#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = ["croniter"] # /// """Deterministic CRUD for reminders backed by SQLite. CLI tool for LLM skills to create, list, edit, enable, disable, and remove reminders. All mutations are atomic SQLite transactions with audit logging. """ from __future__ import annotations import argparse import json import os import sys from datetime import date, datetime, timezone from pathlib import Path from zoneinfo import ZoneInfo from croniter import croniter from db import get_db, init_db, log_operation from random_times import compute_fire_times, parse_window WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH))) PRAGUE = ZoneInfo("Europe/Prague") def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def _ensure_db() -> None: if not DB_PATH.exists(): init_db(DB_PATH) def _build_random(args: argparse.Namespace) -> dict | None: """Assemble and validate the random schedule block, or None if no --random-* flag given.""" fields = { "times_per_day": args.random_times_per_day, "window": args.random_window, "days": args.random_days, "from": args.random_from, "until": args.random_until, } if all(value is None for value in fields.values()): return None if fields["times_per_day"] is None or fields["window"] is None: raise ValueError("random schedule needs --random-times-per-day and --random-window") cfg = {key: value for key, value in fields.items() if value is not None} compute_fire_times(date(2000, 1, 1), "validation", cfg) return cfg def _insert_schedules(conn, reminder_id: int, args: argparse.Namespace, random_cfg: dict | None) -> None: if args.at: for at_str in args.at: conn.execute( "INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (reminder_id, at_str), ) if args.cron: for expr in args.cron: conn.execute( "INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (reminder_id, expr), ) if random_cfg: start, end = parse_window(random_cfg["window"]) conn.execute( """ INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end, days_filter, from_date, until_date) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( reminder_id, random_cfg["times_per_day"], start, end, random_cfg.get("days"), random_cfg.get("from"), random_cfg.get("until"), ), ) def _fetch_reminder(conn, reminder_id: int) -> dict: row = conn.execute( "SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE id = ?", (reminder_id,), ).fetchone() if row is None: raise ValueError(f"reminder {reminder_id} not found") reminder = dict(row) reminder["at"] = [ dict(r) for r in conn.execute( "SELECT id, at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,) ).fetchall() ] reminder["cron"] = [ dict(r) for r in conn.execute( "SELECT id, cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,) ).fetchall() ] reminder["random"] = [ dict(r) for r in conn.execute( "SELECT id, times_per_day, window_start, window_end, days_filter, from_date, until_date FROM schedule_random WHERE reminder_id = ?", (reminder_id,), ).fetchall() ] return reminder def _find_by_keyword(conn, keyword: str) -> list[dict]: escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") rows = conn.execute( "SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at " "FROM reminders WHERE text LIKE ? ESCAPE '\\' AND deleted_at IS NULL", (f"%{escaped}%",), ).fetchall() return [dict(r) for r in rows] def _resolve_one(conn, args: argparse.Namespace) -> dict | None: """Resolve exactly one active reminder by --id (exact) or --keyword (substring). Prints a JSON error to stderr and returns None when no/ambiguous match. Ambiguous matches include each id so the caller can retry with --id. """ rid = getattr(args, "id", None) if rid is not None: row = conn.execute( "SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at " "FROM reminders WHERE id = ? AND deleted_at IS NULL", (rid,), ).fetchone() if row is None: print(json.dumps({"error": "no match", "id": rid}), file=sys.stderr) return None return dict(row) keyword = (args.keyword or "").strip().lower() if not keyword: print(json.dumps({"error": "provide --id or --keyword"}), file=sys.stderr) return None matches = _find_by_keyword(conn, keyword) if len(matches) == 0: print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) return None if len(matches) > 1: print( json.dumps( {"error": "ambiguous", "matches": [{"id": m["id"], "text": m["text"]} for m in matches]}, ensure_ascii=False, ), file=sys.stderr, ) return None return matches[0] def cmd_list(_args: argparse.Namespace) -> int: _ensure_db() conn = get_db(DB_PATH) try: rows = conn.execute( "SELECT id, text, enabled FROM reminders WHERE deleted_at IS NULL ORDER BY id" ).fetchall() if not rows: print("(no active reminders)") return 0 for row in rows: rid = row["id"] status = "enabled" if row["enabled"] else "disabled" print(f"#{rid} {row['text']} [{status}]") for line in _schedule_lines(conn, rid): print(f" {line}") return 0 finally: conn.close() def _schedule_lines(conn, reminder_id: int) -> list[str]: """Human-readable schedule descriptions for one reminder, in at/cron/random order.""" lines = [] for r in conn.execute("SELECT at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)): lines.append(f"at: {r['at_datetime']}") for r in conn.execute("SELECT cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)): lines.append(f"cron: {r['cron_expr']}") random_rows = conn.execute( "SELECT times_per_day, window_start, window_end, days_filter, from_date, until_date " "FROM schedule_random WHERE reminder_id = ?", (reminder_id,), ) for r in random_rows: window = f"{_minutes_to_hhmm(r['window_start'])}–{_minutes_to_hhmm(r['window_end'])}" parts = [f"random: {r['times_per_day']}× daily {window}"] if r["days_filter"]: parts.append(f"({r['days_filter']})") if r["from_date"]: parts.append(f"from {r['from_date']}") if r["until_date"]: parts.append(f"until {r['until_date']}") lines.append(" ".join(parts)) return lines def _minutes_to_hhmm(total: int) -> str: return f"{total // 60:02d}:{total % 60:02d}" def cmd_add(args: argparse.Namespace) -> int: text = (args.text or "").strip() if not text: print(json.dumps({"error": "text must not be empty"}), file=sys.stderr) return 1 try: random_cfg = _build_random(args) except ValueError as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 if not args.at and not args.cron and not random_cfg: print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr) return 1 if args.at: for at_str in args.at: try: datetime.fromisoformat(at_str) except ValueError as exc: print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr) return 1 if args.cron: for expr in args.cron: if not croniter.is_valid(expr): print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr) return 1 _ensure_db() conn = get_db(DB_PATH) try: conn.execute("BEGIN") now = _now() cur = conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', ?, ?)", (text, now, now), ) reminder_id = cur.lastrowid _insert_schedules(conn, reminder_id, args, random_cfg) conn.execute("COMMIT") reminder = _fetch_reminder(conn, reminder_id) log_operation("ADD", reminder_id, f'text="{text}"') print(json.dumps({"added": reminder}, ensure_ascii=False)) return 0 except Exception as exc: conn.execute("ROLLBACK") print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 finally: conn.close() def cmd_remove(args: argparse.Namespace) -> int: _ensure_db() conn = get_db(DB_PATH) try: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] conn.execute("BEGIN") conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid)) conn.execute("COMMIT") log_operation("REMOVE", rid, f'text="{target["text"]}"') reminder = _fetch_reminder(conn, rid) print(json.dumps({"removed": reminder}, ensure_ascii=False)) return 0 except Exception as exc: conn.execute("ROLLBACK") print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 finally: conn.close() def cmd_edit(args: argparse.Namespace) -> int: if args.replace_schedules and not (args.at or args.cron or args.random_times_per_day or args.random_window): print(json.dumps({"error": "--replace-schedules requires at least one --cron/--at/--random-* option"}), file=sys.stderr) return 1 new_text = None if args.text: new_text = args.text.strip() if not new_text: print(json.dumps({"error": "text must not be empty"}), file=sys.stderr) return 1 try: random_cfg = _build_random(args) except ValueError as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 _ensure_db() conn = get_db(DB_PATH) try: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] conn.execute("BEGIN") now = _now() if new_text is not None: conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (new_text, now, rid)) log_operation("EDIT", rid, f'text="{new_text}"') if args.replace_schedules: conn.execute("DELETE FROM schedule_at WHERE reminder_id = ?", (rid,)) conn.execute("DELETE FROM schedule_cron WHERE reminder_id = ?", (rid,)) conn.execute("DELETE FROM schedule_random WHERE reminder_id = ?", (rid,)) _insert_schedules(conn, rid, args, random_cfg) conn.execute("UPDATE reminders SET updated_at = ? WHERE id = ?", (now, rid)) log_operation("EDIT", rid, "schedules replaced") conn.execute("COMMIT") reminder = _fetch_reminder(conn, rid) print(json.dumps({"edited": reminder}, ensure_ascii=False)) return 0 except Exception as exc: conn.execute("ROLLBACK") print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 finally: conn.close() def cmd_enable(args: argparse.Namespace) -> int: _ensure_db() conn = get_db(DB_PATH) try: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid)) log_operation("ENABLE", rid, None) reminder = _fetch_reminder(conn, rid) print(json.dumps({"enabled": reminder}, ensure_ascii=False)) return 0 except Exception as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 finally: conn.close() def cmd_disable(args: argparse.Namespace) -> int: _ensure_db() conn = get_db(DB_PATH) try: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid)) log_operation("DISABLE", rid, None) reminder = _fetch_reminder(conn, rid) print(json.dumps({"disabled": reminder}, ensure_ascii=False)) return 0 except Exception as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 finally: conn.close() def cmd_delivered(args: argparse.Namespace) -> int: """List reminders actually delivered to the user, newest last. Answers 'what reminders arrived today?'. fire_time/delivered_at are stored in Prague local time, so no conversion is needed. Defaults to today (Prague). """ _ensure_db() conn = get_db(DB_PATH) try: since = (args.since or "").strip() if since: try: date.fromisoformat(since) except ValueError as exc: print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr) return 1 rows = conn.execute( """ SELECT f.delivered_at, r.text FROM reminder_fires f JOIN reminders r ON r.id = f.reminder_id WHERE f.status = 'delivered' AND f.fire_time >= ? ORDER BY f.delivered_at """, (since,), ).fetchall() else: today = datetime.now(PRAGUE).date().isoformat() rows = conn.execute( """ SELECT f.delivered_at, r.text FROM reminder_fires f JOIN reminders r ON r.id = f.reminder_id WHERE f.status = 'delivered' AND substr(f.fire_time, 1, 10) = ? ORDER BY f.delivered_at """, (today,), ).fetchall() for row in rows: print(f"{row['delivered_at']} {row['text']}") return 0 finally: conn.close() def main() -> None: parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)") sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("list", help="List all active reminders as JSON") add_p = sub.add_parser("add", help="Add a new reminder") add_p.add_argument("--text", required=True, help="Reminder text") add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)") add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable)") add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day") add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window") add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter") add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date") add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date") remove_p = sub.add_parser("remove", help="Remove a reminder by keyword or id (soft delete)") remove_p.add_argument("--keyword", help="Substring to match against reminder text") remove_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)") edit_p = sub.add_parser("edit", help="Edit a reminder by keyword or id") edit_p.add_argument("--keyword", help="Substring to match against reminder text") edit_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)") edit_p.add_argument("--text", help="New reminder text") edit_p.add_argument("--replace-schedules", action="store_true", help="Replace all schedules with new ones") edit_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)") edit_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime (repeatable)") edit_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N") edit_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM") edit_p.add_argument("--random-days", dest="random_days", metavar="DOW") edit_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD") edit_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD") enable_p = sub.add_parser("enable", help="Enable a reminder by keyword or id") enable_p.add_argument("--keyword") enable_p.add_argument("--id", type=int, help="Exact reminder id") disable_p = sub.add_parser("disable", help="Disable a reminder by keyword or id") disable_p.add_argument("--keyword") disable_p.add_argument("--id", type=int, help="Exact reminder id") delivered_p = sub.add_parser("delivered", help="List reminders delivered to the user (default: today)") delivered_p.add_argument("--since", metavar="YYYY-MM-DD", help="List deliveries on/after this date instead of today") args = parser.parse_args() dispatch = { "list": cmd_list, "add": cmd_add, "remove": cmd_remove, "edit": cmd_edit, "enable": cmd_enable, "disable": cmd_disable, "delivered": cmd_delivered, } return dispatch[args.command](args) if __name__ == "__main__": sys.exit(main())