From 1593ca54066cdec09e986cd3bd45ac876a369463 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:49:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(cron):=20Cron=20Recipes=20=E2=80=94=20para?= =?UTF-8?q?meterized=20automation=20templates=20across=20every=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/?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. --- .gitignore | 3 + apps/desktop/electron/main.cjs | 110 +++ apps/desktop/electron/preload.cjs | 6 + apps/desktop/package.json | 8 + apps/desktop/src/app/desktop-controller.tsx | 26 + apps/desktop/src/global.d.ts | 4 + cli.py | 4 + cron/recipe_catalog.py | 688 ++++++++++++++++++ gateway/run.py | 33 + hermes_cli/cli_commands_mixin.py | 43 ++ hermes_cli/commands.py | 26 + hermes_cli/cron_recipe_cmd.py | 147 ++++ hermes_cli/web_server.py | 47 ++ tests/cron/test_recipe_catalog.py | 195 +++++ tests/hermes_cli/test_web_server.py | 36 + .../{test_recipes.py => test_cron_recipes.py} | 0 web/src/components/CronRecipes.tsx | 222 ++++++ web/src/lib/api.ts | 34 + web/src/pages/CronPage.tsx | 22 + .../docs/reference/cron-recipes-catalog.mdx | 34 + website/scripts/extract-cron-recipes.py | 50 ++ website/scripts/prebuild.mjs | 5 + website/sidebars.ts | 1 + .../components/CronRecipesCatalog/index.tsx | 117 +++ .../CronRecipesCatalog/styles.module.css | 114 +++ 25 files changed, 1975 insertions(+) create mode 100644 cron/recipe_catalog.py create mode 100644 hermes_cli/cron_recipe_cmd.py create mode 100644 tests/cron/test_recipe_catalog.py rename tests/tools/{test_recipes.py => test_cron_recipes.py} (100%) create mode 100644 web/src/components/CronRecipes.tsx create mode 100644 website/docs/reference/cron-recipes-catalog.mdx create mode 100644 website/scripts/extract-cron-recipes.py create mode 100644 website/src/components/CronRecipesCatalog/index.tsx create mode 100644 website/src/components/CronRecipesCatalog/styles.module.css diff --git a/.gitignore b/.gitignore index fa4d64049b7..cd2e9d097c0 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,9 @@ website/static/api/skills-index.json # every build). website/static/api/skills.json website/static/api/skills-meta.json +# cron-recipes-index.json is a build artifact emitted by +# website/scripts/extract-cron-recipes.py during prebuild. +website/static/api/cron-recipes-index.json models-dev-upstream/ # Local editor / agent tooling (machine-specific; keep in global config, not the repo) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 9abfc216e56..eb78830da47 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -6111,6 +6111,111 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla // Search the Marketplace for color-theme extensions (empty query = top installs). ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) +// --------------------------------------------------------------------------- +// hermes:// deep links (e.g. hermes://cron-recipe/morning-brief?time=08:00). +// A docs/dashboard "Send to App" button opens this URL; we route it into the +// running app's chat composer. Three delivery paths: macOS 'open-url', +// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv. +// --------------------------------------------------------------------------- +const HERMES_PROTOCOL = 'hermes' +let _pendingDeepLink = null +let _rendererReadyForDeepLink = false + +function _extractDeepLink(argv) { + if (!Array.isArray(argv)) return null + return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null +} + +function handleDeepLink(url) { + if (!url || typeof url !== 'string') return + let parsed + try { + parsed = new URL(url) + } catch { + rememberLog(`[deeplink] ignoring malformed url: ${url}`) + return + } + // hermes://cron-recipe/?slot=val -> host="cron-recipe", path="/" + const kind = parsed.hostname || '' + const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) + const params = {} + parsed.searchParams.forEach((v, k) => { + params[k] = v + }) + const payload = { kind, name, params } + + if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { + _pendingDeepLink = payload + return + } + try { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + mainWindow.webContents.send('hermes:deep-link', payload) + rememberLog(`[deeplink] delivered ${kind}/${name}`) + } catch (err) { + rememberLog(`[deeplink] delivery failed: ${err.message}`) + } +} + +// Renderer calls this (via IPC) once it has mounted its deep-link listener, so +// a link that arrived during boot/install is flushed exactly once. +ipcMain.handle('hermes:deep-link-ready', () => { + _rendererReadyForDeepLink = true + if (_pendingDeepLink) { + const queued = _pendingDeepLink + _pendingDeepLink = null + handleDeepLink( + `${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` + + (Object.keys(queued.params).length + ? '?' + new URLSearchParams(queued.params).toString() + : ''), + ) + } + return { ok: true } +}) + +function registerDeepLinkProtocol() { + try { + if (process.defaultApp && process.argv.length >= 2) { + // Dev: register with the electron exec path + entry script so the OS can + // relaunch us with the URL. + app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [ + path.resolve(process.argv[1]), + ]) + } else { + app.setAsDefaultProtocolClient(HERMES_PROTOCOL) + } + } catch (err) { + rememberLog(`[deeplink] protocol registration failed: ${err.message}`) + } +} + +// Single-instance lock: deep links on a running app (Win/Linux) arrive as a +// second-instance argv. Without the lock a second `hermes://` launch spawns a +// whole new app instead of routing into the running one. +const _gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!_gotSingleInstanceLock) { + app.quit() +} else { + app.on('second-instance', (_event, argv) => { + const url = _extractDeepLink(argv) + if (url) handleDeepLink(url) + else if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }) +} + +// macOS delivers deep links via 'open-url' — register early (can fire before +// whenReady; handleDeepLink queues until the renderer is ready). +app.on('open-url', (event, url) => { + event.preventDefault() + handleDeepLink(url) +}) + + app.whenReady().then(() => { if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) @@ -6119,11 +6224,16 @@ app.whenReady().then(() => { } installMediaPermissions() registerMediaProtocol() + registerDeepLinkProtocol() ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() createWindow() + // Win/Linux cold start: the launching hermes:// URL is in our own argv. + const _coldStartLink = _extractDeepLink(process.argv) + if (_coldStartLink) handleDeepLink(_coldStartLink) + app.on('activate', () => { // Recreate the primary window if it's gone. Guard on mainWindow directly // (not just total window count) so a dock click still restores the main diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index d39bc88fb68..9880d4bcf58 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -80,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:open-updates', listener) return () => ipcRenderer.removeListener('hermes:open-updates', listener) }, + onDeepLink: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:deep-link', listener) + return () => ipcRenderer.removeListener('hermes:deep-link', listener) + }, + signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), onWindowStateChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:window-state-changed', listener) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d03bd7cd0ad..8df63468f54 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -132,6 +132,14 @@ "appId": "com.nousresearch.hermes", "productName": "Hermes", "executableName": "Hermes", + "protocols": [ + { + "name": "Hermes Protocol", + "schemes": [ + "hermes" + ] + } + ], "artifactName": "Hermes-${version}-${os}-${arch}.${ext}", "icon": "assets/icon", "directories": { diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 0da26639544..43b8ab4c900 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -11,6 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell' import { useMediaQuery } from '@/hooks/use-media-query' import { useSkinCommand } from '@/themes/use-skin-command' +import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' import { formatRefValue } from '../components/assistant-ui/directive-text' import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' @@ -266,6 +267,31 @@ export function DesktopController() { } }, []) + // hermes:// deep links (e.g. a docs "Send to App" button for a cron recipe). + // Build the equivalent /cron-recipe slash command from the payload and drop + // it into the composer — the user reviews/edits, then sends; the agent (or + // the shared command handler) creates the job. Signal readiness so a link + // that arrived during boot is flushed exactly once. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => { + if (!payload || payload.kind !== 'cron-recipe' || !payload.name) { + return + } + const slots = Object.entries(payload.params || {}) + .map(([k, v]) => { + const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v + return `${k}=${sval}` + }) + .join(' ') + const command = `/cron-recipe ${payload.name}${slots ? ' ' + slots : ''}` + requestComposerInsert(command, { mode: 'block', target: 'main' }) + requestComposerFocus('main') + }) + // Tell the main process the renderer is ready to receive deep links. + void window.hermesDesktop?.signalDeepLinkReady?.() + return () => unsubscribe?.() + }, []) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (!$filePreviewTarget.get() && !$previewTarget.get()) { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 68e104212e9..0246df344c5 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -75,6 +75,10 @@ declare global { } onClosePreviewRequested?: (callback: () => void) => () => void onOpenUpdatesRequested?: (callback: () => void) => () => void + onDeepLink?: ( + callback: (payload: { kind: string; name: string; params: Record }) => void, + ) => () => void + signalDeepLinkReady?: () => Promise<{ ok: boolean }> onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void onBackendExit: (callback: (payload: BackendExit) => void) => () => void diff --git a/cli.py b/cli.py index 86875bcb60c..8834f125475 100644 --- a/cli.py +++ b/cli.py @@ -7409,6 +7409,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self.save_conversation() elif canonical == "cron": self._handle_cron_command(cmd_original) + elif canonical == "suggestions": + self._handle_suggestions_command(cmd_original) + elif canonical == "cron-recipe": + self._handle_cron_recipe_command(cmd_original) elif canonical == "curator": self._handle_curator_command(cmd_original) elif canonical == "kanban": diff --git a/cron/recipe_catalog.py b/cron/recipe_catalog.py new file mode 100644 index 00000000000..0e7a3e1280a --- /dev/null +++ b/cron/recipe_catalog.py @@ -0,0 +1,688 @@ +"""Cron Recipes — parameterized automation templates with typed slots. + +A *recipe* is a one-place definition of an automation that every surface +renders natively: + + * Dashboard / GUI app -> a form (one field per slot) + * CLI / TUI / messenger -> a pre-filled ``/cron-recipe`` slash command + * Agent -> a seed prompt; it asks for any blank/ambiguous slot + * Docs catalog -> a copy-paste command + a ``hermes://`` deep-link + +The single source of truth is the slot schema below. ``recipe_form_schema`` +emits what a form renderer needs; ``recipe_slash_command`` emits the flattened +one-line command; ``fill_recipe`` validates user-supplied values and turns a +recipe into a ``cron.jobs.create_job`` kwargs dict (so there is no second job +engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat +split both consume this same module. + +Design choice: users never type raw cron. A recipe carries a fixed recurrence +in ``schedule_template`` and parameterizes only the human-friendly parts +(time-of-day, weekday set). Recipes needing full flexibility expose a ``text`` +slot named ``schedule`` that passes through verbatim. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +__all__ = [ + "RecipeSlot", + "CronRecipe", + "CATALOG", + "get_recipe", + "recipe_form_schema", + "recipe_slash_command", + "recipe_deeplink", + "recipe_catalog_entry", + "fill_recipe", + "RecipeFillError", + "WEEKDAY_PRESETS", +] + + +class RecipeFillError(ValueError): + """Raised when supplied slot values fail validation.""" + + +# Slot types the renderers understand. +_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"}) + +# Named weekday recurrences -> cron day-of-week field. +WEEKDAY_PRESETS: Dict[str, str] = { + "everyday": "*", + "weekdays": "1-5", + "weekends": "0,6", +} + + +@dataclass(frozen=True) +class RecipeSlot: + """A single fillable field on a recipe.""" + + name: str + type: str + label: str + default: Any = None + options: tuple = () # for type="enum": allowed values + optional: bool = False + help: str = "" + + def __post_init__(self) -> None: + if self.type not in _SLOT_TYPES: + raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})") + + +@dataclass(frozen=True) +class CronRecipe: + """A parameterized automation template.""" + + key: str + title: str + description: str + category: str + # Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}". + # Placeholders are filled from resolved slot values (time -> minute/hour, + # weekdays -> dow). A literal cron string with no placeholders = fixed schedule. + schedule_template: str + # Seed instruction for the agent / the cron job prompt; may contain {slot}s. + prompt_template: str + slots: List[RecipeSlot] = field(default_factory=list) + deliver_default: str = "origin" + skills: tuple = () # skills the job loads before running + tags: tuple = () + + +# --------------------------------------------------------------------------- +# Curated in-repo catalog +# --------------------------------------------------------------------------- + +_TIME = lambda default="08:00": RecipeSlot( # noqa: E731 - concise factory + name="time", type="time", label="What time?", default=default, + help="24h local time, e.g. 08:00", +) +_DELIVER = RecipeSlot( + name="deliver", type="enum", label="Where to deliver?", + default="origin", options=("origin", "local", "telegram", "discord", "email"), + help="origin = the chat you set this up from; local = save only, no message", +) + + +CATALOG: List[CronRecipe] = [ + CronRecipe( + key="morning-brief", + title="Morning briefing", + description="A short daily briefing: today's calendar, weather, and " + "anything urgent waiting on you.", + category="daily", + schedule_template="{minute} {hour} * * *", + prompt_template=( + "Produce a concise morning briefing for the user: today's calendar " + "events, the local weather, and any urgent items. Keep it short and " + "scannable. If no data sources are connected, give a brief " + "good-morning with the date and offer to connect calendar/email." + ), + slots=[_TIME("08:00"), _DELIVER], + tags=("daily", "briefing"), + ), + CronRecipe( + key="important-mail", + title="Important-mail monitor", + description="Check your inbox periodically and ping you ONLY about mail " + "that actually needs attention.", + category="email", + schedule_template="*/{interval_min} * * * *", + prompt_template=( + "Check the user's inbox for new messages since the last run. Surface " + "ONLY mail matching: {criteria}. Score candidates with the urgency " + "classifier and deliver only what clears the bar; if nothing does, " + "respond with [SILENT]. Requires a connected mail source; if none is " + "configured, explain how to connect one and stop." + ), + slots=[ + RecipeSlot( + name="interval_min", type="enum", label="How often?", + default="30", options=("15", "30", "60"), + help="minutes between checks", + ), + RecipeSlot( + name="criteria", type="text", + label="Only notify me if the mail…", + default="needs a reply today, is from my manager or family, " + "or mentions a deadline", + ), + _DELIVER, + ], + tags=("email", "monitor"), + ), + CronRecipe( + key="weekly-review", + title="Weekly review", + description="A weekly recap: what got done, what's still open, and " + "what's coming up.", + category="weekly", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Produce a weekly review for the user: what was accomplished this " + "week, still-open items, and next week's calendar. Pull from " + "connected sources. Keep it tight." + ), + slots=[ + _TIME("18:00"), + RecipeSlot( + name="day", type="enum", label="Which day?", + default="sunday", + options=("sunday", "monday", "friday", "saturday"), + ), + _DELIVER, + ], + tags=("weekly", "review"), + ), + CronRecipe( + key="workday-start", + title="Workday start reminder", + description="A weekday nudge with your agenda and top priorities.", + category="daily", + schedule_template="{minute} {hour} * * 1-5", + prompt_template=( + "Give the user a brief weekday start-of-day nudge: today's calendar " + "and the 1-3 highest-priority things to focus on, inferred from " + "recent context and any task tools. Encouraging, short, one message." + ), + slots=[_TIME("09:00"), _DELIVER], + tags=("daily", "focus"), + ), + CronRecipe( + key="custom-reminder", + title="Custom reminder", + description="A recurring reminder in your own words, on your schedule.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template="Remind the user: {what}", + slots=[ + RecipeSlot(name="what", type="text", label="Remind me to…", + default="take a break and stretch"), + _TIME("14:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("reminder",), + ), + CronRecipe( + key="evening-winddown", + title="Evening wind-down", + description="An end-of-day check-in: tomorrow's calendar at a glance " + "and anything you should prep tonight.", + category="daily", + schedule_template="{minute} {hour} * * *", + prompt_template=( + "Give the user a short evening wind-down: tomorrow's calendar, any " + "early commitments to prep for, and one gentle nudge to wrap up " + "loose ends from today. Keep it calm and brief — one message. If no " + "calendar is connected, just offer a friendly sign-off and the " + "weather for tomorrow." + ), + slots=[_TIME("21:00"), _DELIVER], + tags=("daily", "evening"), + ), + CronRecipe( + key="news-digest", + title="Topic news digest", + description="A recurring digest on a topic you care about — deduped " + "against what was already sent, so only genuinely new items land.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Search the web for new and noteworthy items about: {topic}. " + "Dedupe against what you sent in previous runs — only include " + "genuinely new developments. Deliver a tight digest of at most " + "{count} bullets, each one line with a link. If nothing new since " + "last run, respond with [SILENT]." + ), + slots=[ + RecipeSlot( + name="topic", type="text", label="What topic?", + default="AI and technology", + help="a subject, product, person, or search phrase", + ), + _TIME("18:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="weekdays", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + RecipeSlot( + name="count", type="enum", label="How many bullets?", + default="5", options=("3", "5", "8"), + ), + _DELIVER, + ], + tags=("digest", "research"), + ), + CronRecipe( + key="bill-renewal-watch", + title="Bills & renewals reminder", + description="A heads-up before a recurring payment, subscription " + "renewal, or due date — so nothing auto-charges by surprise.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Remind the user about an upcoming payment or renewal: {what}. " + "Phrase it as an actionable heads-up (e.g. 'review or cancel before " + "it renews'), not just a notification. One short message." + ), + slots=[ + RecipeSlot( + name="what", type="text", label="What's due?", + default="my streaming subscription renews soon", + ), + _TIME("10:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("reminder", "finance"), + ), + CronRecipe( + key="habit-checkin", + title="Habit check-in", + description="A recurring nudge to keep a habit on track and reflect " + "on whether you did it.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Nudge the user about their habit: {habit}. Ask whether they did it " + "today, keep it warm and non-judgmental, and offer a one-line word " + "of encouragement. One short message." + ), + slots=[ + RecipeSlot( + name="habit", type="text", label="Which habit?", + default="20 minutes of reading", + ), + _TIME("20:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("habit", "wellbeing"), + ), + CronRecipe( + key="hydration-move", + title="Hydration & movement nudge", + description="A periodic nudge during the day to drink water, stand up, " + "and stretch.", + category="general", + schedule_template="*/{interval_min} {start_hour}-{end_hour} * * 1-5", + prompt_template=( + "Send the user a brief, friendly nudge to drink some water, stand " + "up, and stretch for a moment. Vary the wording each time so it " + "doesn't feel robotic. One short line." + ), + slots=[ + RecipeSlot( + name="interval_min", type="enum", label="How often?", + default="90", options=("60", "90", "120"), + help="minutes between nudges", + ), + RecipeSlot( + name="start_hour", type="enum", label="Start hour", + default="9", options=("7", "8", "9", "10"), + help="first hour of the active window (24h)", + ), + RecipeSlot( + name="end_hour", type="enum", label="End hour", + default="17", options=("16", "17", "18", "19"), + help="last hour of the active window (24h)", + ), + _DELIVER, + ], + tags=("wellbeing", "focus"), + ), + CronRecipe( + key="meal-plan", + title="Weekly meal plan", + description="A weekly meal plan plus a consolidated grocery list, " + "tuned to your diet and how much time you have to cook.", + category="weekly", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Build the user a meal plan for the coming week: {meals} per day, " + "suited to a {diet} diet and roughly {effort} cooking effort. " + "Include a consolidated grocery list grouped by aisle. Keep recipes " + "simple and skimmable." + ), + slots=[ + RecipeSlot( + name="diet", type="enum", label="Diet?", + default="no restrictions", + options=("no restrictions", "vegetarian", "vegan", + "high-protein", "low-carb"), + ), + RecipeSlot( + name="meals", type="enum", label="Meals per day?", + default="dinner only", + options=("dinner only", "lunch and dinner", "all three"), + ), + RecipeSlot( + name="effort", type="enum", label="Cooking effort?", + default="quick", options=("quick", "medium", "ambitious"), + ), + _TIME("17:00"), + RecipeSlot( + name="day", type="enum", label="Which day?", + default="sunday", + options=("sunday", "monday", "friday", "saturday"), + ), + _DELIVER, + ], + tags=("weekly", "food"), + ), + CronRecipe( + key="learn-daily", + title="Daily learning drip", + description="One bite-sized lesson a day on a topic you want to learn, " + "building progressively over time.", + category="daily", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Teach the user one bite-sized lesson about: {topic}. Build on " + "earlier lessons so it progresses rather than repeating. Keep it to " + "a couple of short paragraphs with one concrete example, and end " + "with a single question to check understanding." + ), + slots=[ + RecipeSlot( + name="topic", type="text", label="Learn about…", + default="Spanish vocabulary", + ), + _TIME("08:30"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="weekdays", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("learning", "daily"), + ), + CronRecipe( + key="gratitude-journal", + title="Gratitude & reflection prompt", + description="A gentle evening prompt to reflect on the day and note " + "what went well.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Send the user a short, warm reflection prompt for the end of the " + "day — invite them to note one thing that went well, one thing they " + "are grateful for, and one small win. If they reply, acknowledge it " + "kindly. One message." + ), + slots=[ + _TIME("21:30"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("wellbeing", "reflection"), + ), + CronRecipe( + key="on-this-day", + title="On-this-day discovery", + description="A daily dose of curiosity: a notable historical event, " + "fact, or word for the day.", + category="daily", + schedule_template="{minute} {hour} * * *", + prompt_template=( + "Give the user one interesting '{flavor}' item for today — keep it " + "short, surprising, and genuinely interesting. One or two sentences, " + "no filler." + ), + slots=[ + RecipeSlot( + name="flavor", type="enum", label="What kind?", + default="on this day in history", + options=("on this day in history", "word of the day", + "science fact", "quote of the day"), + ), + _TIME("07:30"), + _DELIVER, + ], + tags=("daily", "curiosity"), + ), +] + +_CATALOG_BY_KEY = {r.key: r for r in CATALOG} + + +def get_recipe(key: str) -> Optional[CronRecipe]: + return _CATALOG_BY_KEY.get(key) + + +# --------------------------------------------------------------------------- +# Renderers +# --------------------------------------------------------------------------- + +def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: + """Emit the JSON a form renderer (dashboard / GUI) needs for this recipe.""" + return { + "key": recipe.key, + "title": recipe.title, + "description": recipe.description, + "category": recipe.category, + "tags": list(recipe.tags), + "fields": [ + { + "name": s.name, + "type": s.type, + "label": s.label, + "default": s.default, + "options": list(s.options), + "optional": s.optional, + "help": s.help, + } + for s in recipe.slots + ], + } + + +def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: + """Build the flattened ``/cron-recipe slot=val …`` command string. + + Uses each slot's default when ``values`` is omitted, so the docs/dashboard + can show a ready-to-paste command. Free-text slots are quoted. + """ + values = values or {} + parts = [f"/cron-recipe {recipe.key}"] + for s in recipe.slots: + val = values.get(s.name, s.default) + if val is None or val == "": + if s.optional: + continue + val = "" + sval = str(val) + if s.type == "text" or " " in sval: + sval = '"' + sval.replace('"', '\\"') + '"' + parts.append(f"{s.name}={sval}") + return " ".join(parts) + + +def recipe_deeplink(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: + """Build the ``hermes://cron-recipe/?slot=val`` deep-link URL.""" + from urllib.parse import quote, urlencode + + values = values or {} + query = {} + for s in recipe.slots: + val = values.get(s.name, s.default) + if val not in (None, ""): + query[s.name] = str(val) + qs = ("?" + urlencode(query)) if query else "" + return f"hermes://cron-recipe/{quote(recipe.key)}{qs}" + + +def _humanize_schedule(recipe: CronRecipe) -> str: + """A short human-readable description of when a recipe runs (defaults).""" + sched = recipe.schedule_template + if sched.startswith("*/"): + iv = next((s for s in recipe.slots if s.name == "interval_min"), None) + every = (iv.default if iv else None) or sched.split("/")[1].split()[0] + return f"every {every} minutes" + time_slot = next((s for s in recipe.slots if s.type == "time"), None) + when = time_slot.default if time_slot else None + if "* * 1-5" in sched: + return f"weekdays at {when}" if when else "every weekday" + if "{dow}" in sched: + day_slot = next((s for s in recipe.slots if s.name in ("day", "recurrence")), None) + scope = (day_slot.default if day_slot else "") or "" + if scope and when: + return f"{scope} at {when}" + return f"at {when}" if when else "on a schedule" + if when: + return f"daily at {when}" + return "on a schedule" + + +def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]: + """Unified serializable shape for a recipe — used by the docs generator + and the dashboard API. Combines the form schema, the ready-to-paste slash + command, the deep-link URL, and a human-readable schedule. + """ + return { + **recipe_form_schema(recipe), + "schedule": recipe.schedule_template, + "scheduleHuman": _humanize_schedule(recipe), + "command": recipe_slash_command(recipe), + "appUrl": recipe_deeplink(recipe), + } + + +# --------------------------------------------------------------------------- +# Fill + validate + translate to a create_job spec +# --------------------------------------------------------------------------- + +_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$") +_DAY_TO_DOW = { + "sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3", + "thursday": "4", "friday": "5", "saturday": "6", +} + + +def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: + """Fill the schedule_template placeholders from resolved slot values.""" + sched = recipe.schedule_template + + # A free-text `schedule` slot passes through verbatim (full flexibility). + if "schedule" in values and values["schedule"]: + return str(values["schedule"]) + + repl: Dict[str, str] = {} + + # time -> minute/hour + time_val = values.get("time") + if "{minute}" in sched or "{hour}" in sched: + if not time_val: + raise RecipeFillError("a time is required") + m = _TIME_RE.match(str(time_val).strip()) + if not m: + raise RecipeFillError(f"invalid time {time_val!r} — use HH:MM (24h)") + repl["hour"] = str(int(m.group(1))) + repl["minute"] = str(int(m.group(2))) + + # weekday set -> dow + if "{dow}" in sched: + if "recurrence" in values: + preset = str(values.get("recurrence", "everyday")).lower() + if preset not in WEEKDAY_PRESETS: + raise RecipeFillError( + f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}" + ) + repl["dow"] = WEEKDAY_PRESETS[preset] + elif "day" in values: + day = str(values.get("day", "")).lower() + if day not in _DAY_TO_DOW: + raise RecipeFillError(f"unknown day {day!r}") + repl["dow"] = _DAY_TO_DOW[day] + else: + repl["dow"] = "*" + + # interval (minutes) for */N schedules + if "{interval_min}" in sched: + iv = str(values.get("interval_min", "")).strip() + if not iv.isdigit() or int(iv) <= 0: + raise RecipeFillError(f"invalid interval {iv!r} — minutes as a positive integer") + repl["interval_min"] = iv + + # Any remaining {slot} placeholders are filled verbatim from validated + # enum/text slot values (e.g. an hour-range window). Enum options have + # already been checked in fill_recipe, so these are safe to interpolate. + for name in re.findall(r"\{(\w+)\}", sched): + if name not in repl and name in values: + repl[name] = str(values[name]) + + try: + return sched.format(**repl) + except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error + raise RecipeFillError(f"schedule template missing value for {e}") from e + + +def fill_recipe( + recipe: CronRecipe, + values: Dict[str, Any], + *, + origin: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Validate ``values`` and return ``cron.jobs.create_job`` kwargs. + + Missing required (non-optional) slots raise RecipeFillError naming the + slot, so a form can show field errors and the agent knows what to ask. + Enum values are checked against their options. The result is passed + straight to ``create_job`` — no second schema. + """ + resolved: Dict[str, Any] = {} + for s in recipe.slots: + raw = values.get(s.name, s.default) + if raw in (None, ""): + if s.optional: + continue + raise RecipeFillError(f"missing required value: {s.name} ({s.label})") + if s.type == "enum" and s.options and str(raw) not in {str(o) for o in s.options}: + raise RecipeFillError( + f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}" + ) + resolved[s.name] = raw + + schedule = _resolve_schedule(recipe, resolved) + + # Render the prompt with whatever slots it references. + try: + prompt = recipe.prompt_template.format(**resolved) + except KeyError as e: + raise RecipeFillError(f"recipe prompt missing value for {e}") from e + + spec: Dict[str, Any] = { + "prompt": prompt, + "schedule": schedule, + "name": recipe.title, + "deliver": resolved.get("deliver", recipe.deliver_default), + } + if recipe.skills: + spec["skills"] = list(recipe.skills) + if origin is not None: + spec["origin"] = origin + return spec diff --git a/gateway/run.py b/gateway/run.py index faab5714079..ff6de3289f7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7174,6 +7174,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "suggestions": return await self._handle_suggestions_command(event) + if canonical == "cron-recipe": + return await self._handle_cron_recipe_command(event) + if canonical == "retry": return await self._handle_retry_command(event) @@ -9270,6 +9273,36 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("suggestions command failed: %s", e) return f"Suggestions command failed: {e}" + async def _handle_cron_recipe_command(self, event: MessageEvent) -> str: + """Handle /cron-recipe in the gateway. + + Delegates to the shared handler so CLI, TUI, and gateway never drift. + Origin is built from the event source so a created recipe job delivers + back to this chat/thread. + """ + args = (event.get_command_args() or "").strip() + source = event.source + origin = None + try: + platform = getattr(source.platform, "value", None) or str(getattr(source, "platform", "") or "") + chat_id = getattr(source, "chat_id", None) + if platform and chat_id: + origin = { + "platform": platform, + "chat_id": str(chat_id), + "chat_name": getattr(source, "chat_name", None), + "thread_id": getattr(source, "thread_id", None), + } + except Exception: + origin = None + try: + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + return handle_cron_recipe_command(args, origin=origin) + except Exception as e: + logger.debug("cron-recipe command failed: %s", e) + return f"Cron recipe command failed: {e}" + # ──────────────────────────────────────────────────────────────── # /goal — persistent cross-turn goals (Ralph-style loop) # ──────────────────────────────────────────────────────────────── diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index ffb39d9e956..97e84d86a0d 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1255,6 +1255,49 @@ class CLICommandsMixin: print(f"(._.) Unknown cron command: {subcommand}") print(" Available: list, add, edit, pause, resume, run, remove") + def _handle_suggestions_command(self, cmd: str): + """Handle /suggestions — review/accept/dismiss suggested automations. + + Delegates to the shared handler so CLI and gateway never drift. CLI + origin is the local platform so an accepted job's "origin" delivery + resolves to a configured home channel. + """ + import shlex + + try: + tokens = shlex.split(cmd)[1:] if cmd else [] + except ValueError: + tokens = (cmd or "").split()[1:] + args = " ".join(tokens) + try: + from hermes_cli.suggestions_cmd import handle_suggestions_command + output = handle_suggestions_command(args) + except Exception as e: + output = f"Suggestions command failed: {e}" + self._console_print(output) + + def _handle_cron_recipe_command(self, cmd: str): + """Handle /cron-recipe — set up an automation from a recipe template. + + Delegates to the shared handler so CLI, TUI, and gateway never drift. + The user pastes a pre-filled command (from the docs/dashboard or a bare + ``/cron-recipe`` listing), edits the slot values, and sends; the handler + validates and creates the cron job, or names the slot that's missing. + """ + import shlex + + try: + tokens = shlex.split(cmd)[1:] if cmd else [] + except ValueError: + tokens = (cmd or "").split()[1:] + args = " ".join(shlex.quote(t) for t in tokens) + try: + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + output = handle_cron_recipe_command(args) + except Exception as e: + output = f"Cron recipe command failed: {e}" + self._console_print(output) + def _handle_curator_command(self, cmd: str): """Handle /curator slash command. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index cff8db21d89..dc931820e6c 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -182,6 +182,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("suggestions", "Review suggested automations (accept/dismiss)", "Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]", subcommands=("accept", "dismiss", "catalog", "clear")), + CommandDef("cron-recipe", "Set up an automation from a recipe template", + "Tools & Skills", aliases=("recipe",), args_hint="[name] [slot=value ...]"), CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)", "Tools & Skills", args_hint="[subcommand]", subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")), @@ -1028,6 +1030,15 @@ _SLACK_RESERVED_COMMANDS = frozenset({ "topic", "mute", "pro", "shortcuts", }) +# High-value aliases that must survive Slack's 50-slash cap even when the +# registry fills up. Without this, adding a new canonical command silently +# clamps off low-priority aliases (they're added in the second pass), so a +# long-standing native slash like /btw could disappear just because an +# unrelated command landed. These claim their slots right after /hermes, +# ahead of both canonical names and the rest of the aliases. Anything not +# listed here still degrades gracefully (reachable via /hermes ). +_SLACK_PRIORITY_ALIASES = ("btw", "bg", "reset") + def _sanitize_slack_name(raw: str) -> str: """Convert a command name to a valid Slack slash command name. @@ -1082,6 +1093,21 @@ def slack_native_slashes() -> list[tuple[str, str, str]]: entries.append((slack_name, desc[:140], hint[:100])) seen.add(slack_name) + # Priority pass: pin high-value aliases (e.g. /btw, /bg, /reset) ahead of + # everything except /hermes, so a new canonical command can never silently + # clamp them off the 50-slash cap. Each alias borrows its parent command's + # description and hint. + _alias_to_cmd = { + alias: cmd + for cmd in COMMAND_REGISTRY + if _is_gateway_available(cmd, overrides) + for alias in cmd.aliases + } + for alias in _SLACK_PRIORITY_ALIASES: + cmd = _alias_to_cmd.get(alias) + if cmd is not None: + _add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "") + # First pass: canonical names (so they win slots if we hit the cap). for cmd in COMMAND_REGISTRY: if not _is_gateway_available(cmd, overrides): diff --git a/hermes_cli/cron_recipe_cmd.py b/hermes_cli/cron_recipe_cmd.py new file mode 100644 index 00000000000..cb542f48622 --- /dev/null +++ b/hermes_cli/cron_recipe_cmd.py @@ -0,0 +1,147 @@ +"""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 show that recipe's slots + a ready command + /cron-recipe 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 ` 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:]) + + # `` with no slot args -> show the recipe's fields + a ready command. + if not values: + return _fmt_recipe(recipe) + + # ` 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." + ) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ad70a46491f..05586231820 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6778,6 +6778,53 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None): return {"ok": True} +# --------------------------------------------------------------------------- +# Cron Recipes — parameterized automation templates. The dashboard renders the +# slot schema as a form; submitting instantiates a real cron job via the same +# create_job path. See cron/recipe_catalog.py for the single source of truth. +# --------------------------------------------------------------------------- +class CronRecipeInstantiate(BaseModel): + recipe: str # recipe key, e.g. "morning-brief" + values: Dict[str, Any] = {} # filled slot values from the form + + +@app.get("/api/cron/recipes") +async def list_cron_recipes(): + """Return the recipe catalog as form schemas for the dashboard gallery.""" + try: + from cron.recipe_catalog import CATALOG, recipe_catalog_entry + + return {"recipes": [recipe_catalog_entry(r) for r in CATALOG]} + except Exception as e: + _log.exception("GET /api/cron/recipes failed") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/cron/recipes/instantiate") +async def instantiate_cron_recipe(body: CronRecipeInstantiate, profile: str = "default"): + """Fill a recipe's slots and create the cron job (form-submit path).""" + try: + from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError + + recipe = get_recipe(body.recipe) + if recipe is None: + raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}") + try: + spec = fill_recipe(recipe, body.values) + except RecipeFillError as exc: + # Field-level validation error — 422 so the form can show it inline. + raise HTTPException(status_code=422, detail=str(exc)) from exc + # Recipe-created jobs deliver to the dashboard's configured target by + # default; the form's deliver slot overrides via spec["deliver"]. + spec.pop("origin", None) + return _call_cron_for_profile(profile, "create_job", **spec) + except HTTPException: + raise + except Exception as e: + _log.exception("POST /api/cron/recipes/instantiate failed") + raise HTTPException(status_code=400, detail=str(e)) + + # --------------------------------------------------------------------------- # MCP server endpoints — list / add / remove / test. # diff --git a/tests/cron/test_recipe_catalog.py b/tests/cron/test_recipe_catalog.py new file mode 100644 index 00000000000..afc4dbeeb86 --- /dev/null +++ b/tests/cron/test_recipe_catalog.py @@ -0,0 +1,195 @@ +"""Tests for Cron Recipes — the parameterized automation template system. + +Covers the core catalog/slot schema/renderers/fill (cron/recipe_catalog.py), +the shared /cron-recipe command handler (hermes_cli/cron_recipe_cmd.py), and +the docs generator. Uses an isolated HERMES_HOME for anything that touches the +cron job store. +""" + +import importlib +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cron.recipe_catalog import ( + CATALOG, + RecipeFillError, + RecipeSlot, + fill_recipe, + get_recipe, + recipe_catalog_entry, + recipe_deeplink, + recipe_form_schema, + recipe_slash_command, +) + + +class TestCatalog: + def test_catalog_nonempty_and_keyed(self): + assert len(CATALOG) >= 1 + for r in CATALOG: + assert get_recipe(r.key) is r + + def test_every_slot_has_known_type(self): + for r in CATALOG: + for s in r.slots: + assert s.type in {"time", "enum", "text", "weekdays"} + + def test_bad_slot_type_rejected(self): + with pytest.raises(ValueError): + RecipeSlot(name="x", type="bogus", label="X") + + +class TestScheduleResolution: + def test_time_to_cron(self): + spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:30"}) + assert spec["schedule"] == "30 8 * * *" + + def test_interval_schedule(self): + spec = fill_recipe( + get_recipe("important-mail"), + {"interval_min": "15", "criteria": "x", "deliver": "origin"}, + ) + assert spec["schedule"] == "*/15 * * * *" + + def test_day_to_dow(self): + spec = fill_recipe( + get_recipe("weekly-review"), + {"time": "18:00", "day": "sunday", "deliver": "origin"}, + ) + assert spec["schedule"] == "0 18 * * 0" + + def test_weekday_preset_to_dow(self): + spec = fill_recipe( + get_recipe("custom-reminder"), + {"what": "stretch", "time": "14:00", "recurrence": "weekdays", "deliver": "origin"}, + ) + assert spec["schedule"] == "0 14 * * 1-5" + + def test_defaults_fill_when_omitted(self): + spec = fill_recipe(get_recipe("morning-brief"), {}) + assert spec["schedule"] == "0 8 * * *" + + +class TestValidation: + def test_invalid_time_rejected(self): + with pytest.raises(RecipeFillError, match="invalid time"): + fill_recipe(get_recipe("morning-brief"), {"time": "25:99"}) + + def test_bad_enum_rejected_and_names_slot(self): + with pytest.raises(RecipeFillError, match="not allowed"): + fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "pigeon"}) + + def test_text_slot_renders_into_prompt(self): + spec = fill_recipe( + get_recipe("important-mail"), + {"interval_min": "30", "criteria": "from my CEO", "deliver": "origin"}, + ) + assert "from my CEO" in spec["prompt"] + + def test_origin_threads_through(self): + spec = fill_recipe( + get_recipe("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"} + ) + assert spec["origin"] == {"platform": "telegram", "chat_id": "9"} + + +class TestRenderers: + def test_form_schema_fields(self): + schema = recipe_form_schema(get_recipe("morning-brief")) + names = [f["name"] for f in schema["fields"]] + assert names == ["time", "deliver"] + assert schema["key"] == "morning-brief" + + def test_slash_command_defaults(self): + cmd = recipe_slash_command(get_recipe("morning-brief")) + assert cmd.startswith("/cron-recipe morning-brief") + assert "time=08:00" in cmd + + def test_slash_command_quotes_freetext(self): + cmd = recipe_slash_command( + get_recipe("custom-reminder"), {"what": "drink water", "time": "10:00"} + ) + assert '"drink water"' in cmd + + def test_deeplink_shape(self): + url = recipe_deeplink(get_recipe("morning-brief"), {"time": "07:15"}) + assert url.startswith("hermes://cron-recipe/morning-brief?") + assert "time=07" in url + + def test_catalog_entry_has_all_surfaces(self): + entry = recipe_catalog_entry(get_recipe("morning-brief")) + assert entry["command"].startswith("/cron-recipe") + assert entry["appUrl"].startswith("hermes://") + assert entry["scheduleHuman"] + assert "fields" in entry + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + import hermes_constants + importlib.reload(hermes_constants) + import cron.jobs as jobs + importlib.reload(jobs) + return jobs + + +class TestCommandHandler: + def test_bare_lists_catalog(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("") + assert "morning-brief" in out and "Cron Recipes" in out + + def test_show_recipe_fields(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("morning-brief") + assert "Fields:" in out and "time" in out + + def test_fill_creates_job(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram") + assert "Scheduled" in out + jobs = isolated_home.load_jobs() + assert len(jobs) == 1 + assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *" + assert jobs[0].get("deliver") == "telegram" + + def test_unknown_recipe(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("does-not-exist") + assert "No cron recipe" in out + + def test_bad_value_names_slot(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("morning-brief time=99:99") + assert "Can't set up" in out and "time" in out + + +class TestDocsGenerator: + def test_generator_emits_valid_index(self, tmp_path): + # The generator imports the catalog and writes a flat JSON array. + import importlib.util + + script = ( + Path(__file__).resolve().parents[2] + / "website" / "scripts" / "extract-cron-recipes.py" + ) + spec = importlib.util.spec_from_file_location("extract_cron_recipes", script) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + index = mod.build_index() + assert isinstance(index, list) and len(index) == len(CATALOG) + # Each entry must round-trip through json and carry the surfaces. + json.dumps(index) + assert all("command" in e and "appUrl" in e for e in index) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index dc92f7cefb4..0a6ba0607e9 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2360,6 +2360,42 @@ class TestNewEndpoints: resp = self.client.get("/api/cron/jobs/nonexistent-id") assert resp.status_code == 404 + # --- Cron Recipes --- + + def test_cron_recipes_list(self): + resp = self.client.get("/api/cron/recipes") + assert resp.status_code == 200 + recipes = resp.json()["recipes"] + assert len(recipes) >= 1 + first = recipes[0] + assert "fields" in first + assert first["command"].startswith("/cron-recipe") + assert first["appUrl"].startswith("hermes://") + + def test_cron_recipe_instantiate_creates_job(self): + resp = self.client.post( + "/api/cron/recipes/instantiate", + json={"recipe": "morning-brief", "values": {"time": "07:30", "deliver": "local"}}, + ) + assert resp.status_code == 200 + job = resp.json() + assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \ + (job.get("schedule", {}) or {}).get("expr") == "30 7 * * *" + + def test_cron_recipe_instantiate_unknown_404(self): + resp = self.client.post( + "/api/cron/recipes/instantiate", + json={"recipe": "does-not-exist", "values": {}}, + ) + assert resp.status_code == 404 + + def test_cron_recipe_instantiate_bad_value_422(self): + resp = self.client.post( + "/api/cron/recipes/instantiate", + json={"recipe": "morning-brief", "values": {"time": "99:99"}}, + ) + assert resp.status_code == 422 + # --- Profiles --- def test_profiles_list_includes_default(self): diff --git a/tests/tools/test_recipes.py b/tests/tools/test_cron_recipes.py similarity index 100% rename from tests/tools/test_recipes.py rename to tests/tools/test_cron_recipes.py diff --git a/web/src/components/CronRecipes.tsx b/web/src/components/CronRecipes.tsx new file mode 100644 index 00000000000..ce9f08b62dc --- /dev/null +++ b/web/src/components/CronRecipes.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useState } from "react"; +import { Clock, Wand2 } from "lucide-react"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Card, CardContent } from "@nous-research/ui/ui/components/card"; +import { Input } from "@nous-research/ui/ui/components/input"; +import { Label } from "@nous-research/ui/ui/components/label"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { useToast } from "@nous-research/ui/hooks/use-toast"; +import { Toast } from "@nous-research/ui/ui/components/toast"; +import { api } from "@/lib/api"; +import type { CronRecipe, CronRecipeField } from "@/lib/api"; +import { cn, themedBody } from "@/lib/utils"; + +interface CronRecipesProps { + profile: string; + /** Called after a recipe is instantiated so the parent can refresh its job list. */ + onCreated?: () => void; +} + +/** Initial form values for a recipe = each field's default (or ""). */ +function initialValues(recipe: CronRecipe): Record { + const out: Record = {}; + for (const f of recipe.fields) out[f.name] = f.default ?? ""; + return out; +} + +function FieldInput({ + field, + value, + onChange, +}: { + field: CronRecipeField; + value: string; + onChange: (v: string) => void; +}) { + if (field.type === "enum" || field.type === "weekdays") { + return ( + + ); + } + if (field.type === "time") { + return ( + onChange(e.target.value)} + /> + ); + } + // text + return ( + onChange(e.target.value)} + /> + ); +} + +function RecipeCard({ + recipe, + profile, + showToast, + onCreated, +}: { + recipe: CronRecipe; + profile: string; + showToast: (message: string, type: "error" | "success") => void; + onCreated?: () => void; +}) { + const [open, setOpen] = useState(false); + const [values, setValues] = useState>(() => initialValues(recipe)); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + setSubmitting(true); + setError(null); + try { + const job = await api.instantiateCronRecipe({ recipe: recipe.key, values }, profile); + const when = job.schedule_display ? ` — ${job.schedule_display}` : ""; + showToast(`${recipe.title} scheduled${when}`, "success"); + setOpen(false); + setValues(initialValues(recipe)); + onCreated?.(); + } catch (e) { + // 422 from the API carries the slot-level validation message. + const msg = e instanceof Error ? e.message : String(e); + setError(msg.replace(/^\d+:\s*/, "")); + } finally { + setSubmitting(false); + } + }, [recipe, values, profile, showToast, onCreated]); + + return ( + + +
+
+
+ + {recipe.title} +
+

{recipe.description}

+
+ {recipe.tags.map((t) => ( + + {t} + + ))} +
+
+ +
+ + {open && ( +
+ {recipe.fields.map((f) => ( +
+ + setValues((prev) => ({ ...prev, [f.name]: v }))} + /> + {f.help && f.type !== "text" ? ( +

{f.help}

+ ) : null} +
+ ))} + {error ? ( +

+ {error} +

+ ) : null} +
+ +
+
+ )} +
+
+ ); +} + +/** + * Cron Recipes gallery — the form-where-there's-a-screen surface. Each recipe + * card expands into an inline form (one field per typed slot); submitting POSTs + * to /api/cron/recipes/instantiate which fills the recipe and creates the job + * via the same create_job path as everything else. + */ +export function CronRecipes({ profile, onCreated }: CronRecipesProps) { + const { toast, showToast } = useToast(); + const [recipes, setRecipes] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .getCronRecipes() + .then((r) => { + if (!cancelled) setRecipes(r.recipes); + }) + .catch((e) => { + if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + if (loadError) { + return

Couldn't load recipes: {loadError}

; + } + if (recipes === null) { + return ( +
+ Loading recipes… +
+ ); + } + if (recipes.length === 0) { + return

No cron recipes available.

; + } + + return ( + <> + +
+ {recipes.map((r) => ( + + ))} +
+ + ); +} + +export default CronRecipes; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7349e8596d4..4f9b7461151 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -497,6 +497,19 @@ export const api = { deleteCronJob: (id: string, profile = "default") => fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), + // Cron Recipes — parameterized automation templates + getCronRecipes: () => + fetchJSON<{ recipes: CronRecipe[] }>("/api/cron/recipes"), + instantiateCronRecipe: ( + body: { recipe: string; values: Record }, + profile = "default", + ) => + fetchJSON(`/api/cron/recipes/instantiate?profile=${encodeURIComponent(profile)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + // Profiles getProfiles: () => fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"), @@ -1825,6 +1838,27 @@ export interface CronDeliveryTarget { home_env_var: string | null; } +export interface CronRecipeField { + name: string; + type: "time" | "enum" | "text" | "weekdays"; + label: string; + default: string | null; + options: string[]; + optional: boolean; + help: string; +} + +export interface CronRecipe { + key: string; + title: string; + description: string; + category: string; + tags: string[]; + fields: CronRecipeField[]; + command: string; + appUrl: string; +} + export interface SkillInfo { name: string; description: string; diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 32ed45e783d..23c30f3d109 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -29,6 +29,8 @@ import { Label } from "@nous-research/ui/ui/components/label"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +import { Segmented } from "@nous-research/ui/ui/components/segmented"; +import { CronRecipes } from "@/components/CronRecipes"; import { cn, themedBody } from "@/lib/utils"; function formatTime(iso?: string | null): string { @@ -176,6 +178,7 @@ export default function CronPage() { const [jobs, setJobs] = useState([]); const [profiles, setProfiles] = useState([]); const [selectedProfile, setSelectedProfile] = useState("all"); + const [view, setView] = useState<"jobs" | "recipes">("jobs"); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t, locale } = useI18n(); @@ -507,6 +510,23 @@ export default function CronPage() { + setView(v as "jobs" | "recipes")} + options={[ + { value: "jobs", label: "Jobs" }, + { value: "recipes", label: "Recipes" }, + ]} + /> + + {view === "recipes" && ( + + )} + + )} + {view === "jobs" && (

+ )}

diff --git a/website/docs/reference/cron-recipes-catalog.mdx b/website/docs/reference/cron-recipes-catalog.mdx new file mode 100644 index 00000000000..988b976b579 --- /dev/null +++ b/website/docs/reference/cron-recipes-catalog.mdx @@ -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`. + + + +## 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. diff --git a/website/scripts/extract-cron-recipes.py b/website/scripts/extract-cron-recipes.py new file mode 100644 index 00000000000..5c833f8b930 --- /dev/null +++ b/website/scripts/extract-cron-recipes.py @@ -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()) diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index 11f5e07521e..5ea9982d2df 100644 --- a/website/scripts/prebuild.mjs +++ b/website/scripts/prebuild.mjs @@ -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"); diff --git a/website/sidebars.ts b/website/sidebars.ts index b6eccc27a41..8e49567291b 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -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', diff --git a/website/src/components/CronRecipesCatalog/index.tsx b/website/src/components/CronRecipesCatalog/index.tsx new file mode 100644 index 00000000000..3482754f1dd --- /dev/null +++ b/website/src/components/CronRecipesCatalog/index.tsx @@ -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 ( + + ); +} + +function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element { + return ( +
+
+

{recipe.title}

+ {recipe.scheduleHuman} +
+

{recipe.description}

+ +
+ {recipe.tags.map((t) => ( + + {t} + + ))} +
+ +
+ {recipe.command} + +
+ +
+ + Send to App ↗ + + + or paste the command into the CLI, TUI, or any messenger + +
+
+ ); +} + +export default function CronRecipesCatalog(): JSX.Element { + const [recipes, setRecipes] = useState(null); + const [error, setError] = useState(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

Couldn't load the recipe catalog: {error}

; + } + if (recipes === null) { + return

Loading recipes…

; + } + if (recipes.length === 0) { + return

No cron recipes are available.

; + } + + return ( +
+ {recipes.map((r) => ( + + ))} +
+ ); +} diff --git a/website/src/components/CronRecipesCatalog/styles.module.css b/website/src/components/CronRecipesCatalog/styles.module.css new file mode 100644 index 00000000000..1da29d5b06d --- /dev/null +++ b/website/src/components/CronRecipesCatalog/styles.module.css @@ -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); +}