Files
nanobot-runtime/skills/remind/scripts/remind_cli.py
2026-06-24 08:11:12 +02:00

515 lines
17 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 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 _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
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"]
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())