#!/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 log_operation from forecast import fires_in_window, format_upcoming, window_for from random_times import compute_fire_times, minutes_to_hhmm import store 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 _build_random(args: argparse.Namespace) -> dict | None: """Assemble and validate the random schedule block, or None if no --random-* flag given. --random-times-per-day and --random-times-per-week are mutually exclusive; the latter selects the weekly period (count spread across distinct days of the week). """ per_day = args.random_times_per_day per_week = getattr(args, "random_times_per_week", None) if per_day is not None and per_week is not None: raise ValueError( "--random-times-per-day and --random-times-per-week are mutually exclusive" ) period = "week" if per_week is not None else "day" count = per_week if per_week is not None else per_day fields = { "times_per_day": count, "window": args.random_window, "days": args.random_days, "from": args.random_from, "until": args.random_until, } if count is None and all(value is None for value in fields.values()): return None if count is None or fields["window"] is None: raise ValueError( "random schedule needs --random-times-per-day or --random-times-per-week, plus --random-window" ) cfg = {key: value for key, value in fields.items() if value is not None} cfg["period"] = period compute_fire_times(date(2000, 1, 1), "validation", cfg) return cfg def _schedule_lines(conn, reminder_id: int) -> list[str]: """Human-readable schedule descriptions for one reminder, in at/cron/random order.""" schedules = store.schedules_for(conn, reminder_id) lines = [] for r in schedules["at"]: lines.append(f"at: {r['at_datetime']}") for r in schedules["cron"]: lines.append(f"cron: {r['cron_expr']}") for r in schedules["random"]: window = ( f"{minutes_to_hhmm(r['window_start'])}–{minutes_to_hhmm(r['window_end'])}" ) cadence = "weekly" if r["period"] == "week" else "daily" parts = [f"random: {r['times_per_day']}× {cadence} {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 _reject_duplicate_text(conn, text: str, exclude_id: int | None = None) -> bool: """Print an error and return True if an active reminder already has this text. A single reminder text never needs more than one record: multiple times/days belong on one reminder via repeated --at/--cron. This guard turns an accidental split (the agent issuing several `add`s) into a hard error. """ dupes = store.find_active_by_exact_text(conn, text, exclude_id=exclude_id) if not dupes: return False display_id = store.active_display_order(conn).index(dupes[0]["id"]) + 1 print( json.dumps( { "error": "duplicate text", "display_id": display_id, "hint": ( "a reminder with this text already exists; to fire one message at " "several times use a single add with repeated --at/--cron, or run: " f"edit --id {display_id} --replace-schedules --at --at ..." ), }, ensure_ascii=False, ), file=sys.stderr, ) return True def _resolve_one(conn, args: argparse.Namespace) -> dict | None: """Resolve exactly one active reminder by --id (display ID) or --keyword (substring). --id is the display ID shown by `list`/`upcoming` (1-based position among active reminders), not the internal DB id. Prints a JSON error to stderr and returns None when no/ambiguous match. Ambiguous matches include each display ID so the caller can retry with --id. """ display_id = getattr(args, "id", None) if display_id is not None: order = store.active_display_order(conn) idx = display_id - 1 if idx < 0 or idx >= len(order): print( json.dumps({"error": "no match", "display_id": display_id}), file=sys.stderr, ) return None return store.find_active_by_id(conn, order[idx]) keyword = (args.keyword or "").strip().lower() if not keyword: print(json.dumps({"error": "provide --id or --keyword"}), file=sys.stderr) return None matches = store.find_active_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: order = store.active_display_order(conn) display_of = {nid: i + 1 for i, nid in enumerate(order)} print( json.dumps( { "error": "ambiguous", "matches": [ {"display_id": display_of[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: with store.connection(DB_PATH) as conn: rows = store.list_active(conn) if not rows: print("(no active reminders)") return 0 for display_id, row in enumerate(rows, start=1): status = "enabled" if row["enabled"] else "disabled" print(f"#{display_id} {row['text']} [{status}]") for line in _schedule_lines(conn, row["id"]): print(f" {line}") return 0 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 with store.connection(DB_PATH) as conn: if _reject_duplicate_text(conn, text): return 1 try: with store.transaction(DB_PATH) as conn: now = _now() reminder_id = store.insert_reminder(conn, text, now) store.insert_schedules(conn, reminder_id, args.at, args.cron, random_cfg) with store.connection(DB_PATH) as conn: reminder = store.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: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 def cmd_remove(args: argparse.Namespace) -> int: try: with store.connection(DB_PATH) as conn: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] with store.transaction(DB_PATH) as conn: store.soft_delete(conn, rid, _now()) with store.connection(DB_PATH) as conn: reminder = store.fetch_reminder(conn, rid) log_operation("REMOVE", rid, f'text="{target["text"]}"') print(json.dumps({"removed": reminder}, ensure_ascii=False)) return 0 except Exception as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 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_times_per_week 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 try: with store.connection(DB_PATH) as conn: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] if new_text is not None and _reject_duplicate_text(conn, new_text, exclude_id=rid): return 1 with store.transaction(DB_PATH) as conn: now = _now() if new_text is not None: store.update_text(conn, rid, new_text, now) log_operation("EDIT", rid, f'text="{new_text}"') if args.replace_schedules: store.delete_schedules(conn, rid) store.insert_schedules(conn, rid, args.at, args.cron, random_cfg) store.touch(conn, rid, now) log_operation("EDIT", rid, "schedules replaced") with store.connection(DB_PATH) as conn: reminder = store.fetch_reminder(conn, rid) print(json.dumps({"edited": reminder}, ensure_ascii=False)) return 0 except Exception as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 def cmd_enable(args: argparse.Namespace) -> int: try: with store.connection(DB_PATH) as conn: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] store.set_enabled(conn, rid, True, _now()) log_operation("ENABLE", rid, None) reminder = store.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 def cmd_disable(args: argparse.Namespace) -> int: try: with store.connection(DB_PATH) as conn: target = _resolve_one(conn, args) if target is None: return 1 rid = target["id"] store.set_enabled(conn, rid, False, _now()) log_operation("DISABLE", rid, None) reminder = store.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 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). """ with store.connection(DB_PATH) as conn: 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 = store.delivered_since(conn, since) else: today = datetime.now(PRAGUE).date().isoformat() rows = store.delivered_today(conn, today) for row in rows: print(f"{row['delivered_at']} {row['text']}") return 0 def cmd_upcoming(args: argparse.Namespace) -> int: """List scheduled fires in a time window (the plan, not deliveries — see `delivered`).""" try: with store.connection(DB_PATH) as conn: now = datetime.now(PRAGUE).replace(tzinfo=None) start, end = window_for(now, args.date, args.days) id_to_display = { nid: i + 1 for i, nid in enumerate(store.active_display_order(conn)) } for line in format_upcoming( fires_in_window(conn, start, end), id_to_display ): print(line) return 0 except ValueError as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 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 readable text") 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-times-per-week", type=int, dest="random_times_per_week", metavar="N", help="Random schedule: fires per week (distinct days)", ) 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="Display ID from list (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="Display ID from list (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-times-per-week", type=int, dest="random_times_per_week", 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="Display ID from list") 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="Display ID from list") 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", ) upcoming_p = sub.add_parser( "upcoming", help="List scheduled fires in a window (default: rest of today)" ) upcoming_p.add_argument( "--date", metavar="YYYY-MM-DD", help="Show fires for this whole day" ) upcoming_p.add_argument( "--days", type=int, metavar="N", help="Show fires for the next N calendar days (incl. 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, "upcoming": cmd_upcoming, } return dispatch[args.command](args) if __name__ == "__main__": sys.exit(main())