#!/usr/bin/env python3 """cairn — Jacob's task board from the command line. Claude adds and ticks cards with it; the file is board/tasks.md. cairn the board (Done: the last five) cairn list [#tag] [--all] the same, one category, or every Done card cairn add TITLE [#tag] [due:WHEN] [every:1y] [--doing|--later[=WHEN]|--top] cairn do CARD to the top of Doing cairn done CARD tick it cairn later CARD [WHEN] put it off until WHEN, or to "someday" with no WHEN cairn todo CARD [--bottom] back to the top (or bottom) of Todo cairn move CARD COLUMN [top|bottom|after CARD] cairn edit CARD [TITLE] [#tag] [due:WHEN|due:-] [every:3m|every:-] cairn note CARD TEXT add a line of notes under it cairn rm CARD delete it outright (done is usually what you want) cairn tick wake Later cards whose day has come; archive last month's Done (a timer runs it) cairn shop what to buy, grouped by store (cards with a store:, and #shop ones) cairn json the board as JSON, for the dashboard ⚠ In a shell, an unquoted #tag is a comment and vanishes: write '#home' in quotes, or cat:home. CARD is an id (k3f9 or ^k3f9) or a piece of the title that matches one card only. WHEN is today, tomorrow, mon..sun, +3d, +2w, 10-18 or 2026-10-18, with @9:00 for a time. store: where to buy it: menards, harborfreight, fleetfarm, farmstore, dealer, online, fb (store:- clears it). every: repeats a card: 1w, 2w, 1m, 3m, 6m, 1y, 2y … Ticking it makes the next one, which waits in Later until shortly before it's due. Un-ticking takes that one back. From the TUF or the MSI: ssh pi '~/projects/cairn/cairn add "…" #home'. """ import datetime as dt, json, os, sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import board as B # Away from the Pi (the TUF, the MSI's WSL) there is no board on disk: the same commands go to the Pi's server as ops # over the tailnet, exactly as the app sends them. CAIRN_URL overrides; on the Pi it is unset and the file is used. URL = os.environ.get("CAIRN_URL") or ("" if (B.BOARD / "tasks.md").exists() else "https://board.your-tailnet.ts.net:8443") def remote(): import platform, urllib.request, uuid cats, last = [], [None] def call(path, body=None): req = urllib.request.Request(URL + path, data=json.dumps(body).encode() if body is not None else None, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read()) except OSError as e: sys.exit(f"cairn: can't reach the board at {URL} ({e})") def board_of(data): cats[:] = data["board"]["categories"] last[0] = B.Board([], {col: [B.Card(**c) for c in cards] for col, cards in data["board"]["columns"].items()}) return last[0] def load(): return board_of(call("/api/board")) def change(op, who=""): if op["op"] == "tick": sys.exit("cairn tick runs on the Pi only (its timer does it hourly)") if op["op"] == "add" and not op.get("id"): op["id"] = load().new_id() before = last[0] op["opid"] = uuid.uuid4().hex[:12] data = call("/api/sync", {"ops": [op], "who": platform.node().split(".")[0][:20]}) if op["opid"] in data["dropped"]: sys.exit("cairn: the Pi dropped that change (the card has gone)") board = board_of(data) card_id = op.get("id") for b in (board, before): # a deleted card is only in the board from before hit = [c for _, c in b.cards() if c.id == card_id] if b and card_id else [] if hit: return hit[0] return B.Card(id=card_id or "", title=op.get("title", "")) B.load, B.change, B.categories = load, change, lambda: cats or (load() and cats) if URL: remote() def args_meta(words): """The words of a title with #tag and due: pulled out, wherever they were typed.""" title, meta = B.split_meta(" ".join(words)) if meta.get("due"): meta["due"] = B.when(meta["due"]) if meta.get("store") == "-": meta["store"] = "" elif meta.get("store") and meta["store"] not in B.STORES: sys.exit(f"cairn: store:{meta['store']} — use one of " + ", ".join(B.STORES)) if meta.get("every") == "-": meta["every"] = "" elif meta.get("every") and not B.EVERY.match(meta["every"]): sys.exit(f"cairn: every:{meta['every']} isn't a repeat — use 1w, 2w, 1m, 3m, 6m, 1y, 2y …") if meta.get("cat"): tags = [c["tag"] for c in B.categories()] if meta["cat"] not in tags: sys.exit(f"cairn: no category #{meta['cat']} — categories.md has " + " ".join("#" + t for t in tags)) return title, meta def show(board, only=None, all_done=False): cats = {c["tag"]: c["colour"] for c in B.categories()} tty = sys.stdout.isatty() and not os.environ.get("NO_COLOR") today = dt.date.today() for col in B.COLUMNS: cards = [c for c in board.columns[col] if not only or c.cat == only] shown = cards if col != "Done" or all_done else cards[:5] print(f"\n{col.upper()} ({len(cards)})") for c in shown: tag = f"#{c.cat}" if c.cat else "" if tty and c.cat in cats: r, g, b = (int(cats[c.cat][i:i + 2], 16) for i in (1, 3, 5)) tag = f"\033[38;2;{r};{g};{b}m{tag}\033[0m" extra = [] if c.due: late = col not in ("Done",) and B.due_date(c) < today extra.append(("OVERDUE " if late else "due ") + c.due.replace("T", " ")) if c.every: extra.append(f"every {c.every}") if c.store: extra.append(f"@ {B.STORES.get(c.store, c.store)}") if col == "Later": extra.append(f"until {c.until}" if c.until else "someday") if col == "Done": extra.append(c.done) print(f" ^{c.id} {c.title} {tag} {' '.join(extra)}".rstrip()) for n in c.notes: print(f" {n}") if len(shown) < len(cards): print(f" … {len(cards) - len(shown)} more (cairn list --all)") def main(argv): cmd, rest = (argv[0], argv[1:]) if argv else ("list", []) if cmd in ("-h", "--help", "help"): print(__doc__.strip()) return if cmd == "list": only = next((a[1:] for a in rest if a.startswith("#")), None) show(B.load(), only, "--all" in rest) return if cmd == "shop": board = B.load() buy = [(col, c) for col, c in board.cards() if col != "Done" and (c.store or c.cat == "shop")] for key in list(B.STORES) + [""]: group = [(col, c) for col, c in buy if c.store == key] if group: print(f"\n{B.STORES.get(key, 'No store yet').upper()} ({len(group)})") for col, c in group: when = f" due {c.due[:10]}" if c.due else "" print(f" ^{c.id} {c.title}{when}{' (later)' if col == 'Later' and c.until else ''}") return if cmd == "json": print(json.dumps(B.as_json(B.load()), ensure_ascii=False)) return if cmd == "tick": woke = B.change({"op": "tick"}, "tick") for c in woke: print(f"back on the list: ^{c.id} {c.title}") import notify notify.back_on_list(woke) return if cmd == "add": flags = [a for a in rest if a.startswith("--")] title, meta = args_meta([a for a in rest if not a.startswith("--")]) if not title: sys.exit("cairn add: a title, please") op = {"op": "add", "title": title, "cat": meta.get("cat", ""), "due": meta.get("due", ""), "every": meta.get("every", ""), "store": meta.get("store", "")} for f in flags: if f == "--doing": op.update(column="Doing", where="top") elif f.startswith("--later"): op.update(column="Later", until=B.when(f.partition("=")[2]) if "=" in f else "") elif f == "--top": op["where"] = "top" else: sys.exit(f"cairn add: unknown flag {f}") card = B.change(op) print(f"added ^{card.id} {card.title}") return if not rest: sys.exit(f"cairn {cmd}: which card?") key, more = rest[0], rest[1:] try: _, card = B.load().find(key) except KeyError as e: sys.exit(f"cairn: {e.args[0]}") if cmd == "do": op = {"op": "move", "id": card.id, "column": "Doing", "where": "top"} elif cmd == "done": op = {"op": "done", "id": card.id} elif cmd == "later": op = {"op": "later", "id": card.id, "until": B.when(more[0]) if more else ""} elif cmd == "todo": op = {"op": "move", "id": card.id, "column": "Todo", "where": "bottom" if "--bottom" in more else "top"} elif cmd == "move": if not more or more[0].title() not in B.COLUMNS: sys.exit("cairn move: CARD COLUMN [top|bottom|after CARD], COLUMN one of " + ", ".join(B.COLUMNS)) where = more[1] if len(more) > 1 else "top" if where == "after": where = B.load().find(more[2])[1].id op = {"op": "move", "id": card.id, "column": more[0].title(), "where": where} elif cmd == "edit": title, meta = args_meta(more) op = {"op": "edit", "id": card.id, **({"title": title} if title else {}), **{k: meta[k] for k in ("cat", "due", "every", "store") if k in meta}} if "due:-" in more: op["due"] = "" elif cmd == "note": op = {"op": "edit", "id": card.id, "note": " ".join(more)} elif cmd == "rm": op = {"op": "delete", "id": card.id} else: sys.exit(f"cairn: unknown command {cmd!r} (cairn help)") card = B.change(op) print(f"{cmd} ^{card.id} {card.title}") if __name__ == "__main__": main(sys.argv[1:])