426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""Hermes Recipes — shareable setup bundles (export / preview / install).
|
|
|
|
Inspired by Poke Recipes (poke.com/docs/creating-recipes, released
|
|
2026-03-19): one shareable file that bundles a starter prompt, the
|
|
automations, skills, and MCP integrations that make a workflow, so another
|
|
user can adopt the whole setup in one command.
|
|
|
|
Design constraints (deliberate deltas from Poke's hosted marketplace):
|
|
|
|
- **Secrets never travel.** Export strips API keys, headers, env blocks, and
|
|
anything secret-shaped from MCP entries; the recipe records only the *names*
|
|
of secrets the installer must supply (``required_secrets``).
|
|
- **Consent-first install.** ``hermes recipe install`` previews everything the
|
|
recipe would add and asks before writing. Cron jobs are installed *paused*
|
|
by default (enable with ``--enable`` or ``hermes cron resume``).
|
|
- **No code execution from recipes.** stdio MCP servers (``command:``) and
|
|
cron ``script`` / ``monitor_script`` fields are refused on install — a
|
|
recipe is data, not a program. Remote MCP URLs are validated through the
|
|
SSRF guard before being written to config.
|
|
- **File or URL.** Recipes are plain YAML; share them as gists, repo files,
|
|
or any https URL. No marketplace, no payouts — the sharing half only.
|
|
|
|
Zero model-tool footprint: this is a CLI command + docs, per the footprint
|
|
ladder in AGENTS.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import datetime as _dt
|
|
import io
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import yaml
|
|
|
|
RECIPE_VERSION = 1
|
|
|
|
# Fields on a cron job that are safe to carry in a recipe. Everything else
|
|
# (run history, streaks, origin chat ids, scripts) is host-local state.
|
|
_JOB_EXPORT_FIELDS = (
|
|
"name",
|
|
"prompt",
|
|
"schedule",
|
|
"repeat",
|
|
"deliver",
|
|
"skills",
|
|
"enabled_toolsets",
|
|
)
|
|
|
|
# Keys inside an MCP server entry that must never be exported, matched
|
|
# case-insensitively as substrings. ``headers`` and ``env`` are dropped
|
|
# wholesale (they are where credentials live).
|
|
_SECRET_KEY_PATTERN = re.compile(
|
|
r"(api[_-]?key|token|secret|password|credential|authorization|bearer)",
|
|
re.IGNORECASE,
|
|
)
|
|
_MCP_DROP_KEYS = {"headers", "env", "auth", "oauth"}
|
|
# MCP entry keys allowed into a recipe (allowlist beats blocklist for
|
|
# a secret boundary).
|
|
_MCP_EXPORT_FIELDS = ("transport", "url", "description", "enabled")
|
|
|
|
_MAX_RECIPE_BYTES = 256 * 1024 # a recipe is text; 256 KiB is generous
|
|
|
|
|
|
class RecipeError(Exception):
|
|
"""User-facing recipe failure (bad format, unsafe content, IO)."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Export
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _sanitize_mcp_entry(name: str, entry: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
|
|
"""Return (sanitized entry, required secret names) for one MCP server.
|
|
|
|
stdio servers (``command:``) are refused — a recipe must not carry
|
|
executable configuration.
|
|
"""
|
|
if not isinstance(entry, dict):
|
|
raise RecipeError(f"MCP server '{name}' has a non-dict config; cannot export")
|
|
if entry.get("command"):
|
|
raise RecipeError(
|
|
f"MCP server '{name}' is a stdio server (command:). Recipes only "
|
|
"carry remote (http/sse) servers — stdio config is executable and "
|
|
"host-specific."
|
|
)
|
|
if not entry.get("url"):
|
|
raise RecipeError(f"MCP server '{name}' has no url; cannot export")
|
|
|
|
required: List[str] = []
|
|
for key in entry:
|
|
if key in _MCP_DROP_KEYS and entry.get(key):
|
|
if key == "headers" and isinstance(entry[key], dict):
|
|
required.extend(sorted(entry[key].keys()))
|
|
else:
|
|
required.append(key)
|
|
elif _SECRET_KEY_PATTERN.search(key) and entry.get(key):
|
|
required.append(key)
|
|
|
|
clean = {k: copy.deepcopy(entry[k]) for k in _MCP_EXPORT_FIELDS if k in entry}
|
|
return clean, required
|
|
|
|
|
|
def _sanitize_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
|
if job.get("no_agent") or job.get("script") or job.get("monitor_script"):
|
|
raise RecipeError(
|
|
f"cron job '{job.get('name') or job.get('id')}' uses a local script; "
|
|
"script-backed jobs are host-specific and cannot be exported"
|
|
)
|
|
clean: Dict[str, Any] = {}
|
|
for key in _JOB_EXPORT_FIELDS:
|
|
value = job.get(key)
|
|
if value not in (None, "", []):
|
|
clean[key] = copy.deepcopy(value)
|
|
schedule = clean.get("schedule")
|
|
if isinstance(schedule, dict):
|
|
# jobs.json stores the parsed schedule dict; flatten back to the
|
|
# portable string form so the recipe stays human-editable.
|
|
flat = (
|
|
schedule.get("raw")
|
|
or schedule.get("display")
|
|
or schedule.get("expr")
|
|
)
|
|
if not flat:
|
|
raise RecipeError(
|
|
f"cron job '{job.get('name') or job.get('id')}' has a "
|
|
"schedule that cannot be exported as a string"
|
|
)
|
|
clean["schedule"] = flat
|
|
repeat = clean.get("repeat")
|
|
if isinstance(repeat, dict):
|
|
# stored as {"times": N|null, "completed": M}; only the target count
|
|
# is portable.
|
|
times = repeat.get("times")
|
|
if times:
|
|
clean["repeat"] = int(times)
|
|
else:
|
|
clean.pop("repeat", None)
|
|
if not clean.get("prompt"):
|
|
raise RecipeError(
|
|
f"cron job '{job.get('name') or job.get('id')}' has no prompt; "
|
|
"only prompt-based jobs are exportable"
|
|
)
|
|
if not clean.get("schedule"):
|
|
raise RecipeError(f"cron job '{job.get('name') or job.get('id')}' has no schedule")
|
|
# Delivery targets like specific chat ids are host-local; keep only
|
|
# generic targets.
|
|
if clean.get("deliver") not in (None, "local", "origin", "log"):
|
|
clean["deliver"] = "local"
|
|
return clean
|
|
|
|
|
|
def build_recipe(
|
|
*,
|
|
name: str,
|
|
description: str = "",
|
|
author: str = "",
|
|
starter_prompt: str = "",
|
|
job_ids: Optional[List[str]] = None,
|
|
mcp_names: Optional[List[str]] = None,
|
|
skills: Optional[List[str]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Assemble a recipe dict from the current install's state."""
|
|
from cron import jobs as cron_jobs
|
|
from hermes_cli.config import load_config
|
|
|
|
recipe: Dict[str, Any] = {
|
|
"recipe": RECIPE_VERSION,
|
|
"name": name,
|
|
"description": description,
|
|
"author": author,
|
|
"created_at": _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
}
|
|
if starter_prompt:
|
|
recipe["starter_prompt"] = starter_prompt
|
|
if skills:
|
|
recipe["skills"] = list(skills)
|
|
|
|
if job_ids:
|
|
exported = []
|
|
all_jobs = {j.get("id"): j for j in cron_jobs.load_jobs()}
|
|
for jid in job_ids:
|
|
job = all_jobs.get(jid)
|
|
if job is None:
|
|
# allow matching by name too
|
|
matches = [j for j in all_jobs.values() if j.get("name") == jid]
|
|
if len(matches) == 1:
|
|
job = matches[0]
|
|
if job is None:
|
|
raise RecipeError(f"cron job '{jid}' not found")
|
|
exported.append(_sanitize_job(job))
|
|
if exported:
|
|
recipe["cron_jobs"] = exported
|
|
|
|
if mcp_names:
|
|
config = load_config()
|
|
servers = config.get("mcp_servers") or {}
|
|
out: Dict[str, Any] = {}
|
|
all_required: Dict[str, List[str]] = {}
|
|
for sname in mcp_names:
|
|
entry = servers.get(sname)
|
|
if entry is None:
|
|
raise RecipeError(f"MCP server '{sname}' not found in config.yaml")
|
|
clean, required = _sanitize_mcp_entry(sname, entry)
|
|
out[sname] = clean
|
|
if required:
|
|
all_required[sname] = required
|
|
if out:
|
|
recipe["mcp_servers"] = out
|
|
if all_required:
|
|
recipe["required_secrets"] = all_required
|
|
|
|
return recipe
|
|
|
|
|
|
def dump_recipe(recipe: Dict[str, Any]) -> str:
|
|
buf = io.StringIO()
|
|
yaml.safe_dump(recipe, buf, sort_keys=False, allow_unicode=True, width=100)
|
|
return buf.getvalue()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Load / validate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fetch_url(url: str) -> str:
|
|
from tools.url_safety import create_ssrf_safe_client, is_safe_url
|
|
|
|
if not is_safe_url(url):
|
|
raise RecipeError(f"unsafe recipe URL refused: {url}")
|
|
with create_ssrf_safe_client(timeout=20, follow_redirects=False) as client:
|
|
resp = client.get(url)
|
|
if resp.status_code != 200:
|
|
raise RecipeError(f"fetching recipe failed: HTTP {resp.status_code}")
|
|
if len(resp.content) > _MAX_RECIPE_BYTES:
|
|
raise RecipeError("recipe too large (limit 256 KiB)")
|
|
return resp.text
|
|
|
|
|
|
def load_recipe(source: str) -> Dict[str, Any]:
|
|
"""Load and validate a recipe from a local path or an http(s) URL."""
|
|
if source.startswith(("http://", "https://")):
|
|
text = _fetch_url(source)
|
|
else:
|
|
path = Path(source).expanduser()
|
|
if not path.is_file():
|
|
raise RecipeError(f"recipe file not found: {source}")
|
|
if path.stat().st_size > _MAX_RECIPE_BYTES:
|
|
raise RecipeError("recipe too large (limit 256 KiB)")
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
try:
|
|
data = yaml.safe_load(text)
|
|
except yaml.YAMLError as exc:
|
|
raise RecipeError(f"invalid recipe YAML: {exc}") from exc
|
|
return validate_recipe(data)
|
|
|
|
|
|
def validate_recipe(data: Any) -> Dict[str, Any]:
|
|
if not isinstance(data, dict):
|
|
raise RecipeError("recipe must be a YAML mapping")
|
|
version = data.get("recipe")
|
|
if not isinstance(version, int) or version < 1:
|
|
raise RecipeError("missing/invalid 'recipe' version field (expected: recipe: 1)")
|
|
if version > RECIPE_VERSION:
|
|
raise RecipeError(
|
|
f"recipe version {version} is newer than this Hermes understands "
|
|
f"({RECIPE_VERSION}); update Hermes first"
|
|
)
|
|
if not isinstance(data.get("name"), str) or not data["name"].strip():
|
|
raise RecipeError("recipe needs a non-empty 'name'")
|
|
|
|
jobs = data.get("cron_jobs") or []
|
|
if not isinstance(jobs, list):
|
|
raise RecipeError("'cron_jobs' must be a list")
|
|
for job in jobs:
|
|
if not isinstance(job, dict):
|
|
raise RecipeError("each cron job must be a mapping")
|
|
for banned in ("script", "monitor_script", "no_agent", "workdir", "command"):
|
|
if job.get(banned):
|
|
raise RecipeError(
|
|
f"cron job '{job.get('name', '?')}' carries '{banned}' — "
|
|
"recipes must not contain executable/host-specific config"
|
|
)
|
|
if not job.get("prompt") or not job.get("schedule"):
|
|
raise RecipeError("each recipe cron job needs 'prompt' and 'schedule'")
|
|
if not isinstance(job.get("schedule"), str):
|
|
raise RecipeError(
|
|
f"cron job '{job.get('name', '?')}' schedule must be a string "
|
|
"(e.g. 'every 2h' or '0 8 * * *')"
|
|
)
|
|
if job.get("repeat") is not None and not isinstance(job["repeat"], int):
|
|
raise RecipeError(
|
|
f"cron job '{job.get('name', '?')}' repeat must be an integer"
|
|
)
|
|
|
|
servers = data.get("mcp_servers") or {}
|
|
if not isinstance(servers, dict):
|
|
raise RecipeError("'mcp_servers' must be a mapping")
|
|
from tools.url_safety import is_safe_url
|
|
|
|
for sname, entry in servers.items():
|
|
if not isinstance(entry, dict):
|
|
raise RecipeError(f"MCP server '{sname}' must be a mapping")
|
|
if entry.get("command"):
|
|
raise RecipeError(
|
|
f"MCP server '{sname}' is a stdio server (command:) — refused; "
|
|
"recipes may only reference remote http/sse servers"
|
|
)
|
|
url = entry.get("url")
|
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
raise RecipeError(f"MCP server '{sname}' needs an http(s) 'url'")
|
|
for key in entry:
|
|
if key in _MCP_DROP_KEYS or _SECRET_KEY_PATTERN.search(key):
|
|
raise RecipeError(
|
|
f"MCP server '{sname}' carries a secret-shaped field "
|
|
f"('{key}') — recipes must not contain credentials"
|
|
)
|
|
if not is_safe_url(url):
|
|
raise RecipeError(f"MCP server '{sname}' URL refused by the SSRF guard: {url}")
|
|
|
|
skills = data.get("skills") or []
|
|
if not isinstance(skills, list) or not all(isinstance(s, str) for s in skills):
|
|
raise RecipeError("'skills' must be a list of skill identifiers")
|
|
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview / install
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def describe_recipe(recipe: Dict[str, Any]) -> str:
|
|
"""Human-readable preview of what installing the recipe would add."""
|
|
lines: List[str] = []
|
|
lines.append(f"Recipe: {recipe['name']}")
|
|
if recipe.get("author"):
|
|
lines.append(f"Author: {recipe['author']}")
|
|
if recipe.get("description"):
|
|
lines.append(f" {recipe['description']}")
|
|
jobs = recipe.get("cron_jobs") or []
|
|
if jobs:
|
|
lines.append(f"\nCron jobs ({len(jobs)}) — installed PAUSED unless --enable:")
|
|
for job in jobs:
|
|
sched = job.get("schedule")
|
|
if isinstance(sched, dict):
|
|
sched = sched.get("raw") or json.dumps(sched)
|
|
lines.append(f" • {job.get('name') or '(unnamed)'} [{sched}]")
|
|
prompt = str(job.get("prompt", "")).strip().splitlines()[0]
|
|
lines.append(f" {prompt[:100]}{'…' if len(prompt) > 100 else ''}")
|
|
servers = recipe.get("mcp_servers") or {}
|
|
if servers:
|
|
lines.append(f"\nMCP servers ({len(servers)}):")
|
|
for sname, entry in servers.items():
|
|
lines.append(f" • {sname}: {entry.get('url')}")
|
|
required = recipe.get("required_secrets") or {}
|
|
if required:
|
|
lines.append("\nSecrets you must supply after install (never shipped in recipes):")
|
|
for sname, keys in required.items():
|
|
lines.append(f" • {sname}: {', '.join(keys)}")
|
|
skills = recipe.get("skills") or []
|
|
if skills:
|
|
lines.append(f"\nSkills to install ({len(skills)}):")
|
|
for s in skills:
|
|
lines.append(f" • {s}")
|
|
if recipe.get("starter_prompt"):
|
|
lines.append("\nStarter prompt:")
|
|
lines.append(f" {recipe['starter_prompt'][:200]}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def install_recipe(
|
|
recipe: Dict[str, Any],
|
|
*,
|
|
enable_jobs: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""Apply a validated recipe. Returns a summary dict of what was created.
|
|
|
|
Cron jobs are created paused unless ``enable_jobs``. MCP servers are
|
|
merged into config.yaml (existing entries with the same name are NOT
|
|
overwritten). Skills are reported for manual install (the hub flow owns
|
|
quarantine/consent) rather than auto-installed.
|
|
"""
|
|
from cron import jobs as cron_jobs
|
|
from hermes_cli.config import load_config, save_config
|
|
|
|
summary: Dict[str, Any] = {"cron_jobs": [], "mcp_servers": [], "mcp_skipped": [],
|
|
"skills": list(recipe.get("skills") or [])}
|
|
|
|
for job in recipe.get("cron_jobs") or []:
|
|
created = cron_jobs.create_job(
|
|
prompt=job["prompt"],
|
|
schedule=str(job["schedule"]),
|
|
name=job.get("name"),
|
|
repeat=job.get("repeat"),
|
|
deliver=job.get("deliver") or "local",
|
|
skills=job.get("skills"),
|
|
enabled_toolsets=job.get("enabled_toolsets"),
|
|
)
|
|
if not enable_jobs:
|
|
cron_jobs.update_job(created["id"], {"enabled": False})
|
|
summary["cron_jobs"].append({"id": created["id"], "name": created.get("name")})
|
|
|
|
servers = recipe.get("mcp_servers") or {}
|
|
if servers:
|
|
config = load_config()
|
|
existing = config.get("mcp_servers")
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
changed = False
|
|
for sname, entry in servers.items():
|
|
if sname in existing:
|
|
summary["mcp_skipped"].append(sname)
|
|
continue
|
|
existing[sname] = copy.deepcopy(entry)
|
|
summary["mcp_servers"].append(sname)
|
|
changed = True
|
|
if changed:
|
|
config["mcp_servers"] = existing
|
|
save_config(config, merge_existing=True)
|
|
|
|
return summary
|