cook: skill final design — script-guarded store for recipes and tea notes

This commit is contained in:
lachtan
2026-09-08 19:29:42 +02:00
parent fe2d417c5f
commit 2e69c6fbe5
2 changed files with 336 additions and 0 deletions

240
skills/cook/scripts/cook.py Executable file
View 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()