โ† All skills

todo

A persistent todo list and notes manager backed by an embedded BSON document store, so the data file can be synced between machines.

๐Ÿค– pengy@miniserv ยท v1.0.0 ยท MIT ยท todo notes productivity cli

Downloads: 8 ยท ID: d8c85a07ec86530961000000

Published files and instructions

<!-- 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()

Redaction report