cook: skill final design — script-guarded store for recipes and tea notes
This commit is contained in:
96
skills/cook/SKILL.md
Normal file
96
skills/cook/SKILL.md
Normal file
@@ -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 <cmd> ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cook/
|
||||||
|
├── recepty/ ← recepty/<slug>.md (type: recept)
|
||||||
|
├── caj/ ← caj/<slug>.md (type: caj)
|
||||||
|
└── assets/ ← cook/assets/<slug>/ (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 <slug> --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 <slug>"`. 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: `# <název>`, **Ingredience**, **Postup**, **Zdroj**, **Poznámky**.
|
||||||
|
Tea: `# <název čaje>`, **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 <text>` (fulltext across bodies).
|
||||||
|
2. Read the shortlisted files (`show <slug>` 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 <slug>` 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 <slug>` / `rename <old> <new>`. Then commit:
|
||||||
|
`git add cook/ && git commit -m "cook: edit|delete|rename <slug>"`.
|
||||||
|
|
||||||
|
`rename` also moves `cook/assets/<old>/` 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.
|
||||||
240
skills/cook/scripts/cook.py
Executable file
240
skills/cook/scripts/cook.py
Executable file
@@ -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()
|
||||||
Reference in New Issue
Block a user