todo
A persistent todo list and notes manager backed by an embedded BSON document store, so the data file can be synced between machines.
Downloads: 8 ยท ID: d8c85a07ec86530961000000
A persistent todo list and notes manager backed by an embedded BSON document store, so the data file can be synced between machines.
Downloads: 8 ยท ID: d8c85a07ec86530961000000
<!-- FILE: todo_skill.md -->
# Persistent Todo/Notes Skill
Stores todos and notes in a `moofile` collection right inside the skill directory โ so the data syncs across machines via Syncthing.
**Data files:** `~/skills/todo/` directory
- `todos.bson` + `todos.bson.meta` โ todo items
- `notes.bson` + `notes.bson.meta` โ notes
## Usage
```bash
uv run todo.py <command> [args...]
```
### Todos
Todos support **editing**, a **show date** (when the item should appear/stop hiding),
a **due date** (flagged โ ๏ธ when overdue), and **sub-items** (subtasks nestled under a
parent project that auto-complete the parent when all are done).
| Command | Description |
|---------|-------------|
| `add <text> [--due DATE] [--show DATE] [--parent ID]` | Add a todo (+ optional due, show, or as a sub-item) |
| `edit <id> [--text T] [--due D] [--show D] [--parent P] [--clear-due] [--clear-show] [--clear-parent]` | Edit text / dates / parentage |
| `due <id> DATE` / `undue <id>` | Set / clear the due date |
| `schedule <id> DATE` / `unschedule <id>` | Set / clear the show date (hides until then) |
| `sub <parent_id> <text>` | Add a sub-item under a todo (shortcut for `add --parent`) |
| `list [--all] [--days N]` | List todos. **Default view = due within 2 weeks** (overdue + items with no due date are included; open items due further out are hidden with a count line). `--all` = **FULL view** (everything, ignores the due + show-date windows). `--days N` tunes the window. Sub-items indented; show-date-hidden items grouped under Upcoming |
| `done <id>` | Complete (auto-completes parents when all sub-items done) |
| `start <id>` | Mark in-progress (also reopens it / its ancestors) |
| `del <id>` | Delete (and its nested sub-items) |
| `show <id>` | Full details incl. dates + sub-items |
| `clear` | Delete all completed todos |
Date values accept `YYYY-MM-DD`, `today`, `tomorrow`, or `+N` days (e.g. `+30`).
Todo IDs are shown in the `list` output.
### Notes
| Command | Description |
|---------|-------------|
| `note add <title> <text>` | Add a note with title and body |
| `note list` | List all notes (titles + preview) |
| `note show <id>` | Show full note |
| `note search <query>` | Search notes by title or body |
| `note del <id>` | Delete a note |
## Examples
```bash
# Add a todo
uv run todo.py add "Fix the leaky faucet"
# With a due date, and hidden until the weekend
uv run todo.py add "Rebuild fence" --due +14 --show 2026-09-12
# Break a project into sub-items
uv run todo.py add "Build Pengyplexity"
uv run todo.py sub 3 "scope the feature set"
uv run todo.py sub 3 "write the rust core"
uv run todo.py due 3 2026-12-01
# Edit / reschedule
uv run todo.py edit 3 --text "Build Pengyplexity v2"
uv run todo.py undue 3
# List the near-term view (items due within 2 weeks; the default)
uv run todo.py list
# FULL view โ the whole list, including items due far in the future
uv run todo.py list --all
# Tune the window (e.g. 30 days)
uv run todo.py list --days 30
# Mark #3 as done (auto-completes a parent if all its subs are done)
uv run todo.py done 3
# Add a note
uv run todo.py note add "Server IPs" "the GPU server: 192.0.2.10\nthe file server: 192.0.2.11"
# Search notes
uv run todo.py note search "server"
```
## Syncthing Note
The `.bson` and `.bson.meta` files live in the skill directory (`~/skills/todo/`), which is already synced by Syncthing. This means your todos and notes follow you across machines automatically. The `.bson.lock` and `.bson.cache` files are transient and safe to ignore.
<!-- FILE: todo.py -->
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.10"
# dependencies = ["moofile"]
# ///
"""Persistent todo & notes manager backed by moofile.
Data stored in ~/skills/todo/*.bson โ synced via Syncthing.
Todos support optional scheduling, due dates, and sub-items:
ยท show_at โ "show" date: the item stays hidden in an Upcoming group until
this date rolls around, then it appears in the normal list.
ยท due_at โ due date; shown in the list and flagged โ ๏ธ when overdue.
ยท parent_id โ sub-items (subtasks) for a larger project; shown indented
under their parent and auto-complete the parent when done.
Usage:
uv run todo.py add <text> [--due DATE] [--show DATE] [--parent ID]
uv run todo.py edit <id> [--text "..." ] [--due DATE] [--show DATE]
[--clear-due] [--clear-show] [--parent ID] [--clear-parent]
uv run todo.py due <id> DATE # set due date
uv run todo.py undue <id> # clear due date
uv run todo.py schedule <id> DATE # set show date (when it appears)
uv run todo.py unschedule <id> # clear show date
uv run todo.py sub <parent_id> <text> # add a sub-item to a todo
uv run todo.py list [--all] [--days N] # list todos (default: due within 2 weeks)
uv run todo.py show <id> # full details incl. dates + sub-items
uv run todo.py done <id> # complete (auto-completes parent)
uv run todo.py start <id> # start / reopen
uv run todo.py del <id> # delete (and its sub-items)
uv run todo.py clear # clear completed todos
uv run todo.py note add|list|show|search|del ...
"""
import argparse, subprocess, sys, os
from datetime import datetime, date, timedelta
from pathlib import Path
def _ensure_uv():
try:
subprocess.run(["uv", "--version"], capture_output=True, check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError):
print("ERROR: 'uv' (https://docs.astral.sh/uv/) is required.", file=sys.stderr)
print("Install: curl -LsSf https://astral.sh/uv/install.sh | sh", file=sys.stderr)
sys.exit(1)
except subprocess.TimeoutExpired:
pass
_ensure_uv()
from moofile import Collection, DocumentNotFoundError
SKILL_DIR = Path.home() / "skills" / "todo"
TODOS_PATH = str(SKILL_DIR / "todos.bson")
NOTES_PATH = str(SKILL_DIR / "notes.bson")
STATUS_PENDING = "pending"
STATUS_IN_PROGRESS = "in_progress"
STATUS_COMPLETED = "completed"
STATUS_ICON = {
STATUS_PENDING: "[ ]",
STATUS_IN_PROGRESS: "[โ]",
STATUS_COMPLETED: "[โ]",
}
# Default "list" view: hide open items whose due date is further out than this.
# Use `list --all` for the FULL view (everything, no due/show-date windows).
DEFAULT_WINDOW_DAYS = 14
def _now():
return datetime.now().isoformat(timespec="seconds")
def _today():
return date.today().isoformat()
def _open_todos():
SKILL_DIR.mkdir(parents=True, exist_ok=True)
return Collection(
TODOS_PATH,
indexes=["status", "created_at", "parent_id", "show_at", "due_at"],
)
def _open_notes():
SKILL_DIR.mkdir(parents=True, exist_ok=True)
return Collection(
NOTES_PATH,
indexes=["title", "created_at"],
text_indexes=["title", "body"],
)
def _next_id(db):
"""Get next available numeric ID."""
all_docs = db.find({}).sort("_id").to_list()
if not all_docs:
return 1
max_id = 0
for d in all_docs:
try:
max_id = max(max_id, int(d.get("_id", 0)))
except (ValueError, TypeError):
continue
return max_id + 1
# ---------------------------------------------------------------- date helpers
def _parse_date(value):
"""Parse a date string -> 'YYYY-MM-DD'. Accepts ISO dates and a few keywords."""
if value is None:
return None
v = str(value).strip()
if not v:
return None
low = v.lower()
if low in ("today", "now"):
return _today()
if low == "tomorrow":
return _shift(1)
if low.startswith("+"):
try:
days = int(v[1:])
return _shift(days)
except ValueError:
pass
# plain YYYY-MM-DD
try:
datetime.strptime(v, "%Y-%m-%d")
return v
except ValueError:
raise argparse.ArgumentTypeError(f"bad date '{value}' (use YYYY-MM-DD, today, tomorrow, or +N days)")
def _shift(days):
from datetime import timedelta
return (date.today() + timedelta(days=days)).isoformat()
def _field(db, tid, key, value):
"""Set a field on a todo by id."""
db.update_one({"_id": str(tid)}, set={key: value, "updated_at": _now()})
def _children_of(db, pid):
return db.find({"parent_id": str(pid)}).sort("created_at").to_list()
def _descendants(db, pid):
"""Return all ids of a todo and its nested sub-items."""
ids = [str(pid)]
for child in _children_of(db, pid):
ids += _descendants(db, child["_id"])
return ids
# ---------------------------------------------------------------- todo commands
def cmd_add(args):
with _open_todos() as db:
tid = str(_next_id(db))
parent = str(args.parent) if args.parent is not None else None
doc = {
"_id": tid,
"text": args.text,
"status": STATUS_PENDING,
"created_at": _now(),
"updated_at": _now(),
"completed_at": None,
"show_at": args.show,
"due_at": args.due,
"parent_id": parent,
}
# validate parent exists
if parent and not db.find_one({"_id": parent}):
print(f"โ Parent #{args.parent} not found.", file=sys.stderr)
sys.exit(1)
db.insert(doc)
if parent:
print(f"โ
Sub-item #{tid} added under #{args.parent}: {args.text}")
else:
print(f"โ
Todo #{tid} added: {args.text}")
def cmd_edit(args):
with _open_todos() as db:
t = db.find_one({"_id": str(args.id)})
if not t:
print(f"โ Todo #{args.id} not found.")
return
updates, changed = {}, []
if args.text is not None:
updates["text"] = args.text
changed.append("text")
if args.due is not None:
updates["due_at"] = args.due
changed.append("due date")
if args.show is not None:
updates["show_at"] = args.show
changed.append("show date")
if args.clear_due:
updates["due_at"] = None
changed.append("duedate cleared")
if args.clear_show:
updates["show_at"] = None
changed.append("show date cleared")
if args.parent is not None:
p = str(args.parent)
if p == str(args.id):
print(f"โ A todo can't be its own parent.", file=sys.stderr)
sys.exit(1)
if not db.find_one({"_id": p}):
print(f"โ Parent #{args.parent} not found.", file=sys.stderr)
sys.exit(1)
updates["parent_id"] = p
changed.append("parent")
if args.clear_parent:
updates["parent_id"] = None
changed.append("parent cleared")
if not updates:
print("Nothing to change.")
return
updates["updated_at"] = _now()
db.update_one({"_id": str(args.id)}, set=updates)
print(f"โ
Todo #{args.id} edited ({', '.join(changed)}).")
def cmd_due(args):
with _open_todos() as db:
_field(db, args.id, "due_at", args.date)
print(f"๐
Todo #{args.id} due date set to {args.date}.")
def cmd_undue(args):
with _open_todos() as db:
_field(db, args.id, "due_at", None)
print(f"๐
Todo #{args.id} due date cleared.")
def cmd_schedule(args):
with _open_todos() as db:
_field(db, args.id, "show_at", args.date)
print(f"๐ Todo #{args.id} will show up on {args.date}.")
def cmd_unschedule(args):
with _open_todos() as db:
_field(db, args.id, "show_at", None)
print(f"๐ Todo #{args.id} show date cleared (shows now).")
def cmd_sub(args):
args2 = argparse.Namespace(text=args.text, parent=args.parent, due=None, show=None)
cmd_add(args2)
def _render_line(t, indent_level=0):
tid = t.get("_id", "?")
text = t.get("text", "?")
status = t.get("status", STATUS_PENDING)
icon = STATUS_ICON.get(status, "[?]")
pad = " " * indent_level
dash = "โคท " if indent_level else ""
line = f" {pad}{dash}#{tid}: {icon} {text}"
# due date
due = t.get("due_at")
if due and status != STATUS_COMPLETED:
if due < _today():
line += " โ ๏ธ overdue (due " + due + ")"
else:
line += f" (due {due})"
# show date
show = t.get("show_at")
if show and status != STATUS_COMPLETED:
line += f" (shows {show})"
return line
def _print_node(todo, children_map, indent_level=0):
print(_render_line(todo, indent_level))
kids = children_map.get(todo["_id"], [])
if kids:
done = sum(1 for k in kids if k.get("status") == STATUS_COMPLETED)
print(f" {' ' * (indent_level * 4)}~ {done}/{len(kids)} sub done")
for k in kids:
_print_node(k, children_map, indent_level + 1)
def cmd_list(args):
with _open_todos() as db:
raw = db.find({}).sort("created_at").to_list()
if not raw:
print("๐ No todos!")
return
# build index + parent->children map
todos = {t["_id"]: t for t in raw}
children_map = {}
for t in raw:
pid = t.get("parent_id")
if pid is not None and pid in todos:
children_map.setdefault(pid, []).append(t)
roots = [t for t in raw if t.get("parent_id") is None or t.get("parent_id") not in todos]
full = getattr(args, "all", False)
days = getattr(args, "days", None) or DEFAULT_WINDOW_DAYS
horizon = (date.today() + timedelta(days=days)).isoformat()
today = _today()
groups = {s: [] for s in [STATUS_IN_PROGRESS, STATUS_PENDING, STATUS_COMPLETED]}
scheduled = []
later = []
for r in roots:
status = r.get("status", STATUS_PENDING)
show = r.get("show_at")
due = r.get("due_at")
if not full and status == STATUS_PENDING and show and show > today:
scheduled.append(r)
elif not full and status != STATUS_COMPLETED and due and due > horizon:
# due further out than the window -> hidden from the default view
later.append(r)
else:
groups.get(status, []).append(r)
labels = {
STATUS_IN_PROGRESS: "๐ In Progress",
STATUS_PENDING: "๐ Pending",
STATUS_COMPLETED: "โ
Completed",
}
for status in [STATUS_IN_PROGRESS, STATUS_PENDING, STATUS_COMPLETED]:
items = groups[status]
if not items:
continue
print(f"\n{labels[status]} ({len(items)} items):")
for r in items:
_print_node(r, children_map)
if scheduled:
print(f"\n๐ Upcoming (hidden until their show date):")
for r in scheduled:
_print_node(r, children_map)
if later and not full:
print(
f"\n๐ {len(later)} item(s) due more than {days} days out "
f"(hidden โ run `list --all` for the full list)"
)
def cmd_done(args):
with _open_todos() as db:
t = db.find_one({"_id": str(args.id)})
if not t:
print(f"โ Todo #{args.id} not found.")
return
db.update_one(
{"_id": str(args.id)},
set={
"status": STATUS_COMPLETED,
"updated_at": _now(),
"completed_at": _now(),
},
)
# auto-complete parent if all its sub-items are done
pid = t.get("parent_id")
if pid:
kids = _children_of(db, pid)
if kids and all(k.get("status") == STATUS_COMPLETED for k in kids):
db.update_one(
{"_id": pid},
set={"status": STATUS_COMPLETED, "updated_at": _now(), "completed_at": _now()},
)
print(f" โคท All sub-items done โ Parent #{pid} completed too.")
print(f"โ
Todo #{args.id} completed!")
def cmd_start(args):
with _open_todos() as db:
t = db.find_one({"_id": str(args.id)})
if not t:
print(f"โ Todo #{args.id} not found.")
return
db.update_one(
{"_id": str(args.id)},
set={"status": STATUS_IN_PROGRESS, "updated_at": _now(), "completed_at": None},
)
# reopen ancestor so the project is active again
pid = t.get("parent_id")
while pid:
pt = db.find_one({"_id": pid})
if pt is None:
break
if pt.get("status") == STATUS_COMPLETED:
db.update_one({"_id": pid}, set={"status": STATUS_IN_PROGRESS, "completed_at": None})
print(f" โคท Parent #{pid} reopened.")
pid = pt.get("parent_id")
print(f"๐ Todo #{args.id} marked as in progress!")
def cmd_del(args):
with _open_todos() as db:
t = db.find_one({"_id": str(args.id)})
if not t:
print(f"โ Todo #{args.id} not found.")
return
ids = _descendants(db, args.id)
db.delete_many({"_id": {"$in": ids}})
extra = len(ids) - 1
msg = f"๐๏ธ Todo #{args.id} deleted."
if extra:
msg += f" (+ {extra} sub-item(s))"
print(msg)
def cmd_show(args):
with _open_todos() as db:
t = db.find_one({"_id": str(args.id)})
if not t:
print(f"โ Todo #{args.id} not found.")
return
icon = STATUS_ICON.get(t.get("status", ""), "[?]")
print(f"#{t['_id']}: {icon} {t.get('text', '?')}")
print(f" Status: {t.get('status', '?')}")
print(f" Created: {t.get('created_at', '?')}")
due = t.get("due_at")
if due:
print(f" Due: {due}" + (" โ ๏ธ overdue" if due < _today() and t.get("status") != STATUS_COMPLETED else ""))
show = t.get("show_at")
if show:
print(f" Shows: {show}")
if t.get("parent_id"):
print(f" Parent: #{t['parent_id']}")
if t.get("completed_at"):
print(f" Completed:{t['completed_at']}")
# sub-items
with _open_todos() as db:
kids = _children_of(db, t["_id"])
if kids:
print(f" Sub-items ({len(kids)}):")
for k in kids:
kicon = STATUS_ICON.get(k.get("status", ""), "[?]")
print(f" โคท #{k['_id']}: {kicon} {k.get('text', '?')}")
def cmd_clear(args):
with _open_todos() as db:
deleted = db.delete_many({"status": STATUS_COMPLETED})
print(f"๐๏ธ Cleared {deleted} completed todo(s).")
# ---------------------------------------------------------------- notes commands
def cmd_note_add(args):
with _open_notes() as db:
nid = str(_next_id(db))
db.insert({
"_id": nid,
"title": args.title,
"body": args.text,
"created_at": _now(),
"updated_at": _now(),
})
print(f"๐ Note #{nid} added: {args.title}")
def cmd_note_list(args):
with _open_notes() as db:
notes = db.find({}).sort("created_at", descending=True).to_list()
if not notes:
print("๐ No notes!")
return
print(f"๐ Notes ({len(notes)}):")
for n in notes:
nid = n.get("_id", "?")
title = n.get("title", "?")
body = n.get("body", "")
preview = body[:60].replace("\n", " ") + ("..." if len(body) > 60 else "")
print(f" #{nid}: {title}")
if preview:
print(f" {preview}")
def cmd_note_show(args):
with _open_notes() as db:
n = db.find_one({"_id": str(args.id)})
if not n:
print(f"โ Note #{args.id} not found.")
return
print(f"๐ #{n['_id']}: {n.get('title', '?')}")
print(f" Created: {n.get('created_at', '?')}")
print(f" Updated: {n.get('updated_at', '?')}")
print("โ" * 40)
print(n.get("body", "(empty)"))
def cmd_note_search(args):
query = args.query
with _open_notes() as db:
results = db.find({}).text_search("title", query, limit=20).to_list()
results2 = db.find({}).text_search("body", query, limit=20).to_list()
seen = set()
combined = []
for doc, score in results + results2:
if doc["_id"] not in seen:
seen.add(doc["_id"])
combined.append((doc, score))
combined.sort(key=lambda x: -x[1])
if not combined:
print(f"No notes matching '{query}'.")
return
print(f"๐ Notes matching '{query}' ({len(combined)}):")
for doc, score in combined[:20]:
nid = doc.get("_id", "?")
title = doc.get("title", "?")
body = doc.get("body", "")
preview = body[:80].replace("\n", " ") + ("..." if len(body) > 80 else "")
print(f" #{nid}: {title} (score: {score:.2f})")
print(f" {preview}")
def cmd_note_del(args):
with _open_notes() as db:
if db.delete_one({"_id": str(args.id)}):
print(f"๐๏ธ Note #{args.id} deleted.")
else:
print(f"โ Note #{args.id} not found.")
def main():
p = argparse.ArgumentParser(description="Persistent todos & notes")
sub = p.add_subparsers(dest="command", required=True)
# ---- todos
sp = sub.add_parser("add", help="Add a todo")
sp.add_argument("text", help="Todo text")
sp.add_argument("--due", type=_parse_date, default=None, help="Due date (YYYY-MM-DD, today, tomorrow, +N)")
sp.add_argument("--show", type=_parse_date, default=None, help="Show date: hide until then (YYYY-MM-DD)")
sp.add_argument("--parent", type=int, default=None, help="Make this a sub-item of the given todo ID")
sp.set_defaults(func=cmd_add)
sp = sub.add_parser("edit", help="Edit a todo's fields")
sp.add_argument("id", type=int)
sp.add_argument("--text", default=None, help="New text")
sp.add_argument("--due", type=_parse_date, default=None, help="Set due date")
sp.add_argument("--show", type=_parse_date, default=None, help="Set show date")
sp.add_argument("--clear-due", action="store_true", help="Clear due date")
sp.add_argument("--clear-show", action="store_true", help="Clear show date")
sp.add_argument("--parent", type=int, default=None, help="Move under a parent ID")
sp.add_argument("--clear-parent", action="store_true", help="Make this a top-level todo")
sp.set_defaults(func=cmd_edit)
sp = sub.add_parser("due", help="Set a due date")
sp.add_argument("id", type=int)
sp.add_argument("date", type=_parse_date)
sp.set_defaults(func=cmd_due)
sp = sub.add_parser("undue", help="Clear a due date")
sp.add_argument("id", type=int)
sp.set_defaults(func=cmd_undue)
sp = sub.add_parser("schedule", help="Set a show date (hide until then)")
sp.add_argument("id", type=int)
sp.add_argument("date", type=_parse_date)
sp.set_defaults(func=cmd_schedule)
sp = sub.add_parser("unschedule", help="Clear a show date")
sp.add_argument("id", type=int)
sp.set_defaults(func=cmd_unschedule)
sp = sub.add_parser("sub", help="Add a sub-item under a todo")
sp.add_argument("parent", type=int, help="Parent todo ID")
sp.add_argument("text", help="Sub-item text")
sp.set_defaults(func=cmd_sub)
sp = sub.add_parser("list", help="List todos (default: only items due within 2 weeks)")
sp.add_argument("--all", action="store_true",
help="FULL view: show everything (ignore the due/show-date windows)")
sp.add_argument("--days", type=int, default=DEFAULT_WINDOW_DAYS,
help=f"Due-date window in days for the default view (default {DEFAULT_WINDOW_DAYS})")
sp.set_defaults(func=cmd_list)
sp = sub.add_parser("done", help="Mark todo completed")
sp.add_argument("id", type=int, help="Todo ID")
sp.set_defaults(func=cmd_done)
sp = sub.add_parser("start", help="Mark todo in progress (also reopens)")
sp.add_argument("id", type=int, help="Todo ID")
sp.set_defaults(func=cmd_start)
sp = sub.add_parser("del", help="Delete a todo (and sub-items)")
sp.add_argument("id", type=int, help="Todo ID")
sp.set_defaults(func=cmd_del)
sp = sub.add_parser("show", help="Show todo details")
sp.add_argument("id", type=int, help="Todo ID")
sp.set_defaults(func=cmd_show)
sp = sub.add_parser("clear", help="Clear completed todos")
sp.set_defaults(func=cmd_clear)
# ---- notes
sp = sub.add_parser("note", help="Note commands")
note_sub = sp.add_subparsers(dest="note_cmd", required=True)
nsp = note_sub.add_parser("add", help="Add a note")
nsp.add_argument("title", help="Note title")
nsp.add_argument("text", help="Note body")
nsp.set_defaults(func=cmd_note_add)
nsp = note_sub.add_parser("list", help="List notes")
nsp.set_defaults(func=cmd_note_list)
nsp = note_sub.add_parser("show", help="Show a note")
nsp.add_argument("id", type=int, help="Note ID")
nsp.set_defaults(func=cmd_note_show)
nsp = note_sub.add_parser("search", help="Search notes")
nsp.add_argument("query", help="Search query")
nsp.set_defaults(func=cmd_note_search)
nsp = note_sub.add_parser("del", help="Delete a note")
nsp.add_argument("id", type=int, help="Note ID")
nsp.set_defaults(func=cmd_note_del)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()