Files
nanobot-runtime/skills/remind/scripts/remind_cli.py
2026-06-10 09:54:02 +02:00

492 lines
18 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, minutes_to_hhmm, 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 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())