From 2e69c6fbe52a300394ee0f48ca5704baf2012324 Mon Sep 17 00:00:00 2001 From: lachtan Date: Tue, 8 Sep 2026 19:29:42 +0200 Subject: [PATCH] =?UTF-8?q?cook:=20skill=20final=20design=20=E2=80=94=20sc?= =?UTF-8?q?ript-guarded=20store=20for=20recipes=20and=20tea=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/cook/SKILL.md | 96 +++++++++++++++ skills/cook/scripts/cook.py | 240 ++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 skills/cook/SKILL.md create mode 100755 skills/cook/scripts/cook.py diff --git a/skills/cook/SKILL.md b/skills/cook/SKILL.md new file mode 100644 index 0000000..e26f5ba --- /dev/null +++ b/skills/cook/SKILL.md @@ -0,0 +1,96 @@ +--- +name: cook +description: > + Capture and search recipes and tea notes (brewing parameters, origins, tasting + notes) in the cook/ directory — one markdown file per item. Triggers on: + "cook: X", "zapiš recept X", "save recipe X", "co mám na X" (tea/food lookup), + "čaj X", tea temperature/time questions against stored notes. For growing + personal food & tea knowledge — not a one-off task or a durable user fact. +--- + +# Cook + +Recipes and tea notes in `cook/`, one markdown file per item. No inbox, no +pipeline, no background compile — capture is inline in the same turn. + +**All file mutations go through the safety script.** Frontmatter is generated +only by the script, never by hand — it cannot drift. + +```bash +uv run skills/cook/scripts/cook.py ... +``` + +## Layout + +``` +cook/ +├── recepty/ ← recepty/.md (type: recept) +├── caj/ ← caj/.md (type: caj) +└── assets/ ← cook/assets// (only if a document/photo ever arrives) +``` + +Slugs: kebab-case, descriptive (`gulas-classic.md`, `sencha-japonsko.md`). +Subdirs are created by the script on first `add`. + +## Frontmatter (script-generated) + +```yaml +type: recept | caj # the only fixed value +category: volná hodnota # polévka, hlavní-chod / zelený, černý... +cuisine: volná # recepts only +origin: volná # caj only +tags: [chata, zima] # user hashtags, no '#' +added: 2026-09-08 # auto +``` + +Taxonomy is deliberately free-form — it settles over time, don't force enums. + +## Capture (inline, same turn) + +1. `add --type recept|caj [--category C] [--cuisine C] [--origin O] [--tags a,b]` + with the body on **stdin** (heredoc). The script creates the file with + frontmatter + body and prints the path. Exit 1 if the slug exists. +2. **Slug collision** → `show` the existing item, ask the user: update it via + `edit`, or file as a new slug (`gulas-hrachova`). Never auto-suffix `-2`. +3. Commit: `git add cook/ && git commit -m "cook: add "`. Stage only + `cook/` — never `git add -A`. +4. Confirm the file path and quote what was filed. + +Language: user's (Czech), verbatim where sensible. New content appends **at +the end** of a file; never insert into the middle. + +### Body formats — conventions, not rigid schemas + +Recipe: `# `, **Ingredience**, **Postup**, **Zdroj**, **Poznámky**. +Tea: `# `, **Země/Typ/Teplota/Čas**, **Poznámky** (chuť, vůně, +odkud koupeno). Adapt to what the user sends; don't force empty fields. + +## Search / answer + +1. Narrow via script: `list [--type T] [--category C] [--tag X]` (frontmatter + filters) and/or `search ` (fulltext across bodies). +2. Read the shortlisted files (`show ` or `read_file`), answer **from the + files only** — no confabulation. If not covered, say so. +3. Read-only: never modify files in a search turn. + +## Edit / delete — hard-gated TWO-TURN flow + +- **Turn 1 (no mutation):** `edit ` prints the path + full text. Show the + user the exact verbatim text (or before → after), ask to confirm. STOP. +- **Turn 2 (explicit confirmation only):** apply body changes with a surgical + `edit_file` on the script-printed path (frontmatter stays untouched), or + `delete ` / `rename `. Then commit: + `git add cook/ && git commit -m "cook: edit|delete|rename "`. + +`rename` also moves `cook/assets//` if present. `delete` removes the +assets dir only when empty — non-empty assets block silent data loss. + +## House rules + +- Script guards: `add` on existing slug, `edit`/`delete`/`rename` on missing + slug → exit 1. Never bypass the script by `write_file`-ing into `cook/` + (except surgical body edits via `edit_file`/`apply_patch` after `add`). +- `validate` checks frontmatter integrity — run it if files were touched + by anything other than the script. +- Surgical edits only — never reformat or rewrite stored recipes/notes + unless asked. \ No newline at end of file diff --git a/skills/cook/scripts/cook.py b/skills/cook/scripts/cook.py new file mode 100755 index 0000000..6de47c3 --- /dev/null +++ b/skills/cook/scripts/cook.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""cook — minimal store for recipes and tea notes, one markdown file per item. + +Frontmatter is generated only here (never hand-written) so it never drifts. +All operations exit 1 on missing/already-existing targets instead of guessing. +""" + +import argparse +import re +import sys +from datetime import date +from pathlib import Path + +WORKSPACE = Path(__file__).resolve().parents[3] +COOK = WORKSPACE / "cook" +RECEPTY = COOK / "recepty" +CAJ = COOK / "caj" +ASSETS = COOK / "assets" +FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL) + + +def parse_fm(text: str) -> dict[str, str]: + """Parse the frontmatter block into a flat dict (list values joined by ',').""" + m = FRONTMATTER_RE.match(text) + if not m: + return {} + out: dict[str, str] = {} + for line in m.group(1).splitlines(): + if ":" in line: + k, v = line.split(":", 1) + out[k.strip()] = v.strip().strip("[]") + return out + + +def build_fm(fields: dict[str, str]) -> str: + """Emit frontmatter; 'tags' is a list, everything else a plain scalar.""" + lines = ["---"] + for k, v in fields.items(): + if not v: + continue + if k == "tags": + lines.append(f"tags: [{v}]") + else: + lines.append(f"{k}: {v}") + lines.append("---") + return "\n".join(lines) + + +def die(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + sys.exit(1) + + +def find_file(slug: str) -> Path | None: + for d in (RECEPTY, CAJ): + p = d / f"{slug}.md" + if p.is_file(): + return p + return None + + +def require_file(slug: str) -> Path: + p = find_file(slug) + if not p: + die(f"not found: {slug}") + return p + + +def type_dir(item_type: str) -> Path: + if item_type == "recept": + return RECEPTY + if item_type == "caj": + return CAJ + die(f"invalid type: {item_type} (recept|caj)") + raise AssertionError + + +def load_items() -> list[tuple[Path, dict[str, str]]]: + items = [] + for d in (RECEPTY, CAJ): + if not d.is_dir(): + continue + for p in sorted(d.glob("*.md")): + items.append((p, parse_fm(p.read_text(encoding="utf-8")))) + return items + + +def get_body(text: str) -> str: + m = FRONTMATTER_RE.match(text) + return text[m.end() :] if m else text + + +def cmd_add(args: argparse.Namespace) -> None: + d = type_dir(args.type) + d.mkdir(parents=True, exist_ok=True) + target = d / f"{args.slug}.md" + if target.exists() or find_file(args.slug): + die(f"already exists: {args.slug} (use edit, or pick a new slug)") + + fm: dict[str, str] = { + "type": args.type, + "category": args.category or "", + "added": date.today().isoformat(), + } + if args.type == "recept": + fm["cuisine"] = args.cuisine or "" + else: + fm["origin"] = args.origin or "" + if args.tags: + fm["tags"] = ", ".join(args.tags) + + body = sys.stdin.read() if not sys.stdin.isatty() else "" + if args.body_file: + body = Path(args.body_file).read_text(encoding="utf-8") + if body and not body.startswith("\n"): + body = "\n" + body + target.write_text(build_fm(fm) + "\n" + body, encoding="utf-8") + print(target) + + +def cmd_edit(args: argparse.Namespace) -> None: + p = require_file(args.slug) + print(p) + print("---8<---") + print(p.read_text(encoding="utf-8")) + + +def cmd_list(args: argparse.Namespace) -> None: + for p, fm in load_items(): + if args.type and fm.get("type") != args.type: + continue + if args.category and fm.get("category") != args.category: + continue + if args.tag and args.tag not in [ + t.strip() for t in fm.get("tags", "").split(",") + ]: + continue + title = get_body(p.read_text(encoding="utf-8")).strip().splitlines() + first = title[0].lstrip("# ").strip() if title else "" + print( + f"{p.stem}\t{fm.get('type', '?')}\t{fm.get('category', '?')}\t{fm.get('added', '?')}\t{first}" + ) + + +def cmd_show(args: argparse.Namespace) -> None: + print(require_file(args.slug).read_text(encoding="utf-8")) + + +def cmd_search(args: argparse.Namespace) -> None: + needle = args.text.lower() + for p, _ in load_items(): + body = get_body(p.read_text(encoding="utf-8")) + for i, line in enumerate(body.splitlines(), 1): + if needle in line.lower(): + print(f"{p.stem}:{i}: {line.strip()}") + + +def cmd_rename(args: argparse.Namespace) -> None: + src = require_file(args.old) + if find_file(args.new) or args.old == args.new: + die(f"target exists or same: {args.new}") + dest = src.parent / f"{args.new}.md" + src.rename(dest) + a = ASSETS / args.old + if a.is_dir(): + a.rename(ASSETS / args.new) + print(dest) + + +def cmd_delete(args: argparse.Namespace) -> None: + p = require_file(args.slug) + p.unlink() + a = ASSETS / args.slug + if a.is_dir() and not any(a.iterdir()): + a.rmdir() + print(f"deleted: {p}") + + +def cmd_validate(_: argparse.Namespace) -> None: + ok = True + for p, fm in load_items(): + if not fm: + print(f"FAIL {p}: no frontmatter") + ok = False + elif fm.get("type") not in ("recept", "caj"): + print(f"FAIL {p}: invalid type: {fm.get('type')!r}") + ok = False + print("ok" if ok else "invalid") + sys.exit(0 if ok else 1) + + +def main() -> None: + ap = argparse.ArgumentParser(prog="cook") + sub = ap.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("add") + sp.add_argument("slug") + sp.add_argument("--type", required=True, choices=["recept", "caj"]) + sp.add_argument("--category") + sp.add_argument("--cuisine") + sp.add_argument("--origin") + sp.add_argument("--tags", nargs="+") + sp.add_argument("--body-file") + sp.set_defaults(fn=cmd_add) + + sp = sub.add_parser("edit") + sp.add_argument("slug") + sp.set_defaults(fn=cmd_edit) + + sp = sub.add_parser("list") + sp.add_argument("--type", choices=["recept", "caj"]) + sp.add_argument("--category") + sp.add_argument("--tag") + sp.set_defaults(fn=cmd_list) + + sp = sub.add_parser("show") + sp.add_argument("slug") + sp.set_defaults(fn=cmd_show) + + sub.add_parser("validate").set_defaults(fn=cmd_validate) + + sp = sub.add_parser("search") + sp.add_argument("text") + sp.set_defaults(fn=cmd_search) + + sp = sub.add_parser("rename") + sp.add_argument("old") + sp.add_argument("new") + sp.set_defaults(fn=cmd_rename) + + sp = sub.add_parser("delete") + sp.add_argument("slug") + sp.set_defaults(fn=cmd_delete) + + args = ap.parse_args() + args.fn(args) + + +if __name__ == "__main__": + main()