mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
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:
parent
9a09ea69fb
commit
1593ca5406
25 changed files with 1975 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/<key>?slot=val -> host="cron-recipe", path="/<key>"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
|
|
@ -75,6 +75,10 @@ declare global {
|
|||
}
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onOpenUpdatesRequested?: (callback: () => void) => () => void
|
||||
onDeepLink?: (
|
||||
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
|
||||
) => () => void
|
||||
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
|
|
|
|||
4
cli.py
4
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":
|
||||
|
|
|
|||
688
cron/recipe_catalog.py
Normal file
688
cron/recipe_catalog.py
Normal file
|
|
@ -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 <key> 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/<key>?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
|
||||
|
|
@ -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)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <command>).
|
||||
_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):
|
||||
|
|
|
|||
147
hermes_cli/cron_recipe_cmd.py
Normal file
147
hermes_cli/cron_recipe_cmd.py
Normal file
|
|
@ -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 <key> show that recipe's slots + a ready command
|
||||
/cron-recipe <key> slot=val … fill + create the cron job
|
||||
|
||||
Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``)
|
||||
survive. On a fill error the message names the slot, which is exactly what the
|
||||
agent needs to ask a targeted follow-up rather than re-prompting everything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
platform = get_session_env("HERMES_SESSION_PLATFORM")
|
||||
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
|
||||
if platform and chat_id:
|
||||
return {
|
||||
"platform": platform,
|
||||
"chat_id": chat_id,
|
||||
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
|
||||
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
|
||||
"""Split ``slot=value`` tokens from bare tokens. Returns (values, leftovers)."""
|
||||
values: Dict[str, str] = {}
|
||||
leftovers = []
|
||||
for tok in tokens:
|
||||
if "=" in tok:
|
||||
k, _, v = tok.partition("=")
|
||||
k = k.strip()
|
||||
if k:
|
||||
values[k] = v.strip()
|
||||
continue
|
||||
leftovers.append(tok)
|
||||
return values, leftovers
|
||||
|
||||
|
||||
def _fmt_catalog() -> str:
|
||||
from cron.recipe_catalog import CATALOG, recipe_slash_command
|
||||
|
||||
lines = ["Cron Recipes — `/cron-recipe <name>` to set one up:\n"]
|
||||
for r in CATALOG:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append(f" {r.description}")
|
||||
lines.append(f" ↳ {recipe_slash_command(r)}")
|
||||
lines.append("\nEdit the values then send, or just send to use the defaults.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_recipe(recipe) -> str:
|
||||
from cron.recipe_catalog import recipe_slash_command
|
||||
|
||||
lines = [f"{recipe.title} — {recipe.description}\n", "Fields:"]
|
||||
for s in recipe.slots:
|
||||
opts = f" (one of: {', '.join(map(str, s.options))})" if s.options else ""
|
||||
dflt = f" [default: {s.default}]" if s.default not in (None, "") else ""
|
||||
opt = " (optional)" if s.optional else ""
|
||||
lines.append(f" • {s.name}: {s.label}{opts}{dflt}{opt}")
|
||||
lines.append("\nReady-to-edit command:")
|
||||
lines.append(f" {recipe_slash_command(recipe)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def handle_cron_recipe_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Dispatch a ``/cron-recipe`` invocation. Returns text to show the user.
|
||||
|
||||
``args`` is everything after ``/cron-recipe``. ``origin`` lets an accepted
|
||||
recipe's job deliver back to the chat it was created from; resolved from
|
||||
session env when omitted.
|
||||
"""
|
||||
try:
|
||||
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
logger.debug("recipe catalog import failed: %s", e)
|
||||
return "Cron Recipes are unavailable in this build."
|
||||
|
||||
try:
|
||||
tokens = shlex.split(args or "")
|
||||
except ValueError:
|
||||
tokens = (args or "").split()
|
||||
|
||||
# Bare -> list catalog.
|
||||
if not tokens:
|
||||
return _fmt_catalog()
|
||||
|
||||
key = tokens[0]
|
||||
recipe = get_recipe(key)
|
||||
if recipe is None:
|
||||
return (
|
||||
f"No cron recipe named '{key}'. Run /cron-recipe to see the catalog."
|
||||
)
|
||||
|
||||
values, _leftover = _parse_kv(tokens[1:])
|
||||
|
||||
# `<key>` with no slot args -> show the recipe's fields + a ready command.
|
||||
if not values:
|
||||
return _fmt_recipe(recipe)
|
||||
|
||||
# `<key> slot=val …` -> fill + create.
|
||||
try:
|
||||
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
|
||||
except RecipeFillError as e:
|
||||
return f"Can't set up '{recipe.title}': {e}\nRun /cron-recipe {key} to see its fields."
|
||||
|
||||
try:
|
||||
from cron.jobs import create_job
|
||||
|
||||
job = create_job(**spec)
|
||||
except Exception as e:
|
||||
logger.debug("cron-recipe create_job failed: %s", e)
|
||||
return f"Failed to create the job: {e}"
|
||||
|
||||
sched = job.get("schedule_display") or spec.get("schedule", "")
|
||||
return (
|
||||
f"Scheduled '{recipe.title}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron."
|
||||
)
|
||||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
195
tests/cron/test_recipe_catalog.py
Normal file
195
tests/cron/test_recipe_catalog.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
222
web/src/components/CronRecipes.tsx
Normal file
222
web/src/components/CronRecipes.tsx
Normal file
|
|
@ -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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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 (
|
||||
<Select value={value} onValueChange={(v) => onChange(v)}>
|
||||
{field.options.map((opt) => (
|
||||
<SelectOption key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
if (field.type === "time") {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// text
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={field.help || field.label}
|
||||
onChange={(e) => 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<Record<string, string>>(() => initialValues(recipe));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Card className={cn("overflow-hidden", themedBody)}>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 shrink-0 opacity-70" />
|
||||
<span className="font-medium">{recipe.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm opacity-70">{recipe.description}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{recipe.tags.map((t) => (
|
||||
<Badge key={t} tone="secondary">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
ghost={open}
|
||||
size="sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? "Cancel" : "Set up"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{recipe.fields.map((f) => (
|
||||
<div key={f.name} className="space-y-1">
|
||||
<Label htmlFor={`${recipe.key}-${f.name}`}>{f.label}</Label>
|
||||
<FieldInput
|
||||
field={f}
|
||||
value={values[f.name] ?? ""}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, [f.name]: v }))}
|
||||
/>
|
||||
{f.help && f.type !== "text" ? (
|
||||
<p className="text-xs opacity-60">{f.help}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{error ? (
|
||||
<p className="text-sm text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => void submit()} disabled={submitting}>
|
||||
{submitting ? <Spinner className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
|
||||
Schedule it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CronRecipe[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(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 <p className="text-sm text-red-500">Couldn't load recipes: {loadError}</p>;
|
||||
}
|
||||
if (recipes === null) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 opacity-70">
|
||||
<Spinner className="h-4 w-4" /> Loading recipes…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (recipes.length === 0) {
|
||||
return <p className="opacity-70">No cron recipes available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast toast={toast} />
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{recipes.map((r) => (
|
||||
<RecipeCard
|
||||
key={r.key}
|
||||
recipe={r}
|
||||
profile={profile}
|
||||
showToast={showToast}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CronRecipes;
|
||||
|
|
@ -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<string, string> },
|
||||
profile = "default",
|
||||
) =>
|
||||
fetchJSON<CronJob>(`/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;
|
||||
|
|
|
|||
|
|
@ -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<CronJob[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
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() {
|
|||
<PluginSlot name="cron:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={(v) => setView(v as "jobs" | "recipes")}
|
||||
options={[
|
||||
{ value: "jobs", label: "Jobs" },
|
||||
{ value: "recipes", label: "Recipes" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{view === "recipes" && (
|
||||
<CronRecipes
|
||||
profile={selectedProfile === "all" ? "default" : selectedProfile}
|
||||
onCreated={loadJobs}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={jobDelete.isOpen}
|
||||
onCancel={jobDelete.cancel}
|
||||
|
|
@ -746,6 +766,7 @@ export default function CronPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{view === "jobs" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<H2
|
||||
|
|
@ -888,6 +909,7 @@ export default function CronPage() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="cron:bottom" />
|
||||
</div>
|
||||
|
|
|
|||
34
website/docs/reference/cron-recipes-catalog.mdx
Normal file
34
website/docs/reference/cron-recipes-catalog.mdx
Normal file
|
|
@ -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.
|
||||
50
website/scripts/extract-cron-recipes.py
Normal file
50
website/scripts/extract-cron-recipes.py
Normal file
|
|
@ -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',
|
||||
|
|
|
|||
117
website/src/components/CronRecipesCatalog/index.tsx
Normal file
117
website/src/components/CronRecipesCatalog/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
website/src/components/CronRecipesCatalog/styles.module.css
Normal file
114
website/src/components/CronRecipesCatalog/styles.module.css
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue