#!/usr/bin/env python3
"""tasx — file-based task tracker where markdown files are the source of truth.

State = folder location (survives with zero tooling: `ls tasks/in-progress/` is a kanban).
  tasks/            inbox (untriaged / not started)
  tasks/in-progress ... actively worked
  tasks/waiting     ... blocked on someone/something (waiting-on: header says what)
  tasks/done        ... completed (archived monthly by `tasx archive`)
  tasks/cancelled   ... dropped (file says why)
  tasks/decisions   ... open choices (kind=decision, option: lines; decided = has choice:)

Python 3.8+ stdlib only. `tasx help` for commands.
"""
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import zlib
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

STATES = ["inbox", "in-progress", "waiting", "done", "cancelled"]
STATE_DIRS = {"in-progress": "in-progress", "waiting": "waiting", "done": "done",
              "cancelled": "cancelled", "inbox": ""}
DIR_ALIASES = {"blocked": "waiting"}          # legacy folder names read as waiting
STATUS_ALIASES = {                            # legacy frontmatter status -> (state, waiting_on)
    "done": ("done", None), "completed": ("done", None), "complete": ("done", None),
    "resolved": ("done", None), "cancelled": ("cancelled", None),
    "in-progress": ("in-progress", None), "needs-you": ("waiting", "you"),
    "deferred": ("waiting", "later"), "future": ("waiting", "later"),
    "blocked": ("waiting", None), "pending": ("inbox", None),
}
# staleness thresholds (seconds)
STALE_IN_PROGRESS = 24 * 3600
STALE_WAITING_YOU = 3 * 24 * 3600
STALE_INBOX = 14 * 24 * 3600
STALE_DECISION = 3 * 24 * 3600
# words in waiting-on: that mean "the human" — extend with $TASX_USER (your name)
YOU_WORDS = ("you", "user", "human") + tuple(
    w for w in [os.environ.get("TASX_USER", "").lower()] if w)

SKILL_DIR = Path(__file__).resolve().parent

TASKS_README = """# Task tracker (tasx)

File-based tasks: **markdown files are the source of truth, the folder is the state.**
The root of `tasks/` is the INBOX — new/untriaged tasks live here as `*.md`.
Move a file to change its state (`tasx move <id> <state>`, or plain `git mv`):

- `tasks/` (root)   — inbox / not started / untriaged
- `in-progress/`    — actively being worked (set `owner:` to your agent name)
- `waiting/`        — blocked; the `waiting-on:` header says on what
                      (`you` = needs the human's feedback; `decision <id>`; `external: <thing>`)
- `done/`           — completed (keep for the record; `tasx archive` rolls up old months)
- `cancelled/`      — dropped (say why in the file)
- `decisions/`      — open choices with `option:` lines; picking writes `choice:` (decided)

Each task = one md file: optional header lines (`id:`, `owner:`, `group:`, `seq:`,
`title:`), then **What / Why / Next steps / Refs**. Comments append to `## Comments`
with author + timestamp (`tasx comment <id> "..." --as <name>`).

Board UI: `tasx serve` → local page that reads/writes these same files.
Agents: run `tasx doctor` at session start and reconcile anything stale you own.
Keep it lightweight.
"""

AGENTS_POINTER = """
## Tasks (tasx)

File-based task tracker in `tasks/` — see `tasks/README.md` for the convention.
State = folder: root = inbox, `in-progress/`, `waiting/` (+`waiting-on:` header),
`done/`, `cancelled/`, `decisions/` (open choices). Move files with `tasx move` or `git mv`.
CLI: `tasx list | new | move | comment | doctor`. Board for the human: `tasx serve`.
At session start run `tasx doctor` and reconcile stale items you own.
"""

TASK_TEMPLATE = """id: {id}
kind: {kind}
owner: {owner}
group: {group}
title: {title}
{extra}
## What

{body}

## Why


## Next steps


## Refs

"""


# ---------- root discovery ----------

def find_tasks_root(start=None):
    """Nearest tasks/ dir walking up from cwd (or $TASX_TASKS)."""
    env = os.environ.get("TASX_TASKS")
    if env:
        return Path(env).resolve()
    d = Path(start or os.getcwd()).resolve()
    while True:
        if d.name == "tasks" and d.is_dir():
            return d
        if (d / "tasks").is_dir():
            return d / "tasks"
        if d.parent == d:
            return None
        d = d.parent


def project_name(root):
    return root.parent.name if root.name == "tasks" else root.name


# ---------- parsing ----------

HEADER_RE = re.compile(r"^([a-z][a-z0-9_-]*):\s*(.*)$")


def parse_doc(path, root):
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return None
    lines = text.split("\n")
    doc = {"path": str(path), "options": [], "header": {}}
    yaml_mode = bool(lines) and lines[0].strip() == "---"
    i = 1 if yaml_mode else 0
    while i < len(lines):
        line = lines[i]
        if yaml_mode and line.strip() in ("---", "..."):
            i += 1
            break
        m = HEADER_RE.match(line)
        if not m:
            if yaml_mode:  # tolerate unparsable lines inside --- fences
                i += 1
                continue
            break
        key, val = m.group(1), m.group(2).strip()
        if key == "option":
            oid, _, label = val.partition("|")
            doc["options"].append({"id": oid.strip(), "label": label.strip()})
        else:
            doc["header"][key] = val
        i += 1
    doc["body"] = "\n".join(lines[i:]).strip()

    h = doc["header"]
    doc["id"] = h.get("id") or path.stem
    doc["kind"] = h.get("kind", "task")
    doc["owner"] = h.get("owner", "")
    doc["group"] = h.get("group", "")
    doc["waiting_on"] = h.get("waiting-on", "")
    doc["choice"] = h.get("choice", "")
    try:
        doc["seq"] = int(h.get("seq", "999"))
    except ValueError:
        doc["seq"] = 999
    title = h.get("title", "")
    if not title:
        m = re.search(r"^#\s+(.+)$", doc["body"], re.M)
        title = m.group(1).strip() if m else doc["id"]
    doc["title"] = title

    # state from folder location
    rel = path.relative_to(root)
    top = rel.parts[0] if len(rel.parts) > 1 else ""
    top = DIR_ALIASES.get(top, top)
    if top == "decisions":
        doc["kind"] = "decision"
        doc["state"] = "done" if doc["choice"] else "inbox"
    elif top in ("in-progress", "waiting", "done", "cancelled"):
        doc["state"] = top
        if top == "waiting" and not doc["waiting_on"]:
            doc["waiting_on"] = "?"
    elif top == "":
        doc["state"] = "inbox"
    else:
        # unknown subfolder (e.g. myhdd tasks/backup/): legacy frontmatter status wins
        st = h.get("status", "").lower()
        state, won = STATUS_ALIASES.get(st, ("inbox", None))
        doc["state"] = state
        if won and not doc["waiting_on"]:
            doc["waiting_on"] = won
        if h.get("needs_you", "").lower() in ("true", "yes", "1") \
                and doc["state"] not in ("done", "cancelled"):
            doc["state"] = "waiting"
            doc["waiting_on"] = doc["waiting_on"] or "you"
        if not doc["group"]:
            doc["group"] = rel.parts[0]

    st = path.stat()
    doc["updated"] = max(st.st_mtime, st.st_ctime)
    doc["age_s"] = max(0, int(time.time() - doc["updated"]))

    doc["needs_you"] = (
        (doc["state"] == "waiting" and any(w in doc["waiting_on"].lower() for w in YOU_WORDS))
        or (doc["kind"] == "decision" and not doc["choice"])
    )
    a = doc["age_s"]
    stale = None
    if doc["state"] == "in-progress" and a > STALE_IN_PROGRESS:
        stale = "in-progress untouched %s" % human_age(a)
    elif doc["needs_you"] and doc["kind"] == "decision" and a > STALE_DECISION:
        stale = "decision open %s" % human_age(a)
    elif doc["needs_you"] and a > STALE_WAITING_YOU:
        stale = "waiting on you %s" % human_age(a)
    elif doc["state"] == "inbox" and doc["kind"] != "decision" and a > STALE_INBOX:
        stale = "inbox %s — triage or cancel" % human_age(a)
    doc["stale"] = stale
    return doc


def scan(root):
    docs = []
    for path in sorted(root.rglob("*.md")):
        if path.name.upper().startswith("README"):
            continue
        if any(p.startswith(".") for p in path.relative_to(root).parts):
            continue
        d = parse_doc(path, root)
        if d:
            docs.append(d)
    return docs


def find_doc(docs, id_):
    hits = [d for d in docs if d["id"] == id_]
    if not hits:
        hits = [d for d in docs if Path(d["path"]).stem == id_]
    return hits[0] if hits else None


def human_age(s):
    if s < 3600:
        return "%dm" % (s // 60)
    if s < 86400:
        return "%dh" % (s // 3600)
    return "%dd" % (s // 86400)


# ---------- file mutations ----------

def in_git(path):
    try:
        r = subprocess.run(["git", "-C", str(path.parent), "rev-parse", "--is-inside-work-tree"],
                           capture_output=True, text=True, timeout=10)
        return r.returncode == 0 and r.stdout.strip() == "true"
    except Exception:
        return False


def move_file(src, dst_dir):
    dst_dir.mkdir(parents=True, exist_ok=True)
    dst = dst_dir / src.name
    if dst.exists() and dst != src:
        raise RuntimeError("target exists: %s" % dst)
    if in_git(src):
        r = subprocess.run(["git", "-C", str(src.parent), "mv", str(src), str(dst)],
                           capture_output=True, text=True, timeout=15)
        if r.returncode == 0:
            return dst
    shutil.move(str(src), str(dst))
    return dst


def patch_header(path, key, value):
    text = path.read_text(encoding="utf-8")
    re_key = re.compile(r"^%s:.*$" % re.escape(key), re.M)
    if re_key.search(text):
        text = re_key.sub("%s: %s" % (key, value), text, count=1)
    elif text.startswith("---\n"):
        text = "---\n%s: %s\n%s" % (key, value, text[4:])
    elif HEADER_RE.match(text.split("\n", 1)[0]):
        first, _, rest = text.partition("\n")
        text = "%s\n%s: %s\n%s" % (first, key, value, rest)
    else:
        text = "%s: %s\n\n%s" % (key, value, text)
    path.write_text(text, encoding="utf-8")


def remove_header(path, key):
    text = path.read_text(encoding="utf-8")
    new = re.sub(r"^%s:.*\n?" % re.escape(key), "", text, count=1, flags=re.M)
    if new != text:
        path.write_text(new, encoding="utf-8")


def append_comment(path, value, author):
    text = path.read_text(encoding="utf-8")
    stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
    entry = "- **%s** (%s): %s" % (author, stamp, value.replace("\r", "").replace("\n", "\n  "))
    if not re.search(r"^## Comments\s*$", text, re.M):
        text = text.rstrip() + "\n\n## Comments\n"
    path.write_text(text.rstrip() + "\n" + entry + "\n", encoding="utf-8")


def set_state(root, doc, state, waiting_on=None):
    src = Path(doc["path"])
    if state not in STATES:
        raise RuntimeError("unknown state %r (use: %s)" % (state, ", ".join(STATES)))
    if doc["kind"] == "decision":
        raise RuntimeError("decisions don't move; pick a choice instead")
    dst_dir = root / STATE_DIRS[state] if STATE_DIRS[state] else root
    dst = move_file(src, dst_dir)
    if state == "waiting":
        patch_header(dst, "waiting-on", waiting_on or "you")
    else:
        remove_header(dst, "waiting-on")
    return dst


# ---------- agent-chat integration ----------

_status_cache = {"t": 0, "map": {}}


def agent_chat_available():
    return shutil.which("agent-chat") is not None


def agent_statuses(owners):
    now = time.time()
    if now - _status_cache["t"] < 30:
        return _status_cache["map"]
    out = {}
    if agent_chat_available():
        for o in owners:
            try:
                r = subprocess.run(["agent-chat", "status", o],
                                   capture_output=True, text=True, timeout=10)
                txt = (r.stdout + r.stderr).lower()
                for word in ("busy", "idle", "waiting", "dead", "offline"):
                    if word in txt:
                        out[o] = word
                        break
            except Exception:
                pass
    _status_cache["t"] = now
    _status_cache["map"] = out
    return out


def nudge(owner, msg):
    if not owner or not agent_chat_available():
        return

    def run():
        try:
            subprocess.run(["agent-chat", "send", msg[:400], "--to", owner],
                           capture_output=True, timeout=20)
        except Exception:
            pass

    threading.Thread(target=run, daemon=True).start()


# ---------- commands ----------

def slugify(title):
    s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
    return s[:60] or "task"


def cmd_init(args):
    base = Path(os.getcwd())
    root = base / "tasks" if base.name != "tasks" else base
    made = []
    for d in ["", "in-progress", "waiting", "done", "cancelled", "decisions"]:
        p = root / d if d else root
        if not p.exists():
            p.mkdir(parents=True)
            made.append(str(p.relative_to(base)) if p != base else ".")
    readme = root / "README.md"
    if not readme.exists():
        readme.write_text(TASKS_README, encoding="utf-8")
        made.append("tasks/README.md")
    # pointer in agents files
    proj = root.parent
    targets = [p for p in (proj / "CLAUDE.md", proj / "AGENTS.md") if p.exists()]
    if not targets:
        targets = [proj / "AGENTS.md"]
    for t in targets:
        text = t.read_text(encoding="utf-8") if t.exists() else ""
        if "## Tasks (tasx)" not in text and "tasks/README.md" not in text:
            t.write_text(text.rstrip() + "\n" + AGENTS_POINTER, encoding="utf-8")
            made.append("%s (pointer added)" % t.name)
    print("initialized %s" % root)
    for m in made:
        print("  + %s" % m)
    if not made:
        print("  (everything already in place)")


def require_root():
    root = find_tasks_root()
    if not root:
        sys.exit("no tasks/ folder found here or above — run `tasx init` first")
    return root


def cmd_new(args):
    root = require_root()
    kind = "decision" if args.decision else "task"
    slug = args.id or slugify(args.title)
    target_dir = root / "decisions" if kind == "decision" else root
    target_dir.mkdir(parents=True, exist_ok=True)
    path = target_dir / (slug + ".md")
    if path.exists():
        sys.exit("already exists: %s" % path)
    extra = ""
    for opt in args.option or []:
        extra += "option: %s\n" % opt
    path.write_text(TASK_TEMPLATE.format(
        id=slug, kind=kind, owner=args.owner or "", group=args.group or "",
        title=args.title, extra=extra, body=args.body or ""), encoding="utf-8")
    print("created %s" % path)


def cmd_move(args):
    root = require_root()
    doc = find_doc(scan(root), args.id)
    if not doc:
        sys.exit("no task with id %r" % args.id)
    dst = set_state(root, doc, args.state, args.waiting_on)
    print("moved %s -> %s" % (args.id, dst.relative_to(root.parent)))


def cmd_comment(args):
    root = require_root()
    doc = find_doc(scan(root), args.id)
    if not doc:
        sys.exit("no task with id %r" % args.id)
    append_comment(Path(doc["path"]), args.text, args.as_)
    print("commented on %s as %s" % (args.id, args.as_))
    if doc["owner"] and doc["owner"] != args.as_:
        nudge(doc["owner"], "[tasx:%s] %s on %s: %s"
              % (project_name(root), args.as_, doc["id"], args.text))


def fmt_line(d, statuses=None):
    bits = ["  %-34s" % d["id"], "%-4s" % human_age(d["age_s"])]
    if d["owner"]:
        s = (statuses or {}).get(d["owner"], "")
        bits.append("@%s%s" % (d["owner"], " (%s)" % s if s else ""))
    if d["group"]:
        bits.append("[%s]" % d["group"])
    if d["waiting_on"] and d["state"] == "waiting":
        bits.append("waiting-on: %s" % d["waiting_on"])
    if d["stale"]:
        bits.append("⚠ " + d["stale"])
    return " ".join(bits)


def cmd_list(args):
    root = require_root()
    docs = scan(root)
    statuses = agent_statuses({d["owner"] for d in docs if d["owner"] and d["state"] == "in-progress"})
    print("tasx — %s (%s)" % (project_name(root), root))
    sections = [
        ("NEEDS YOU", [d for d in docs if d["needs_you"] and d["state"] != "done"]),
        ("IN PROGRESS", [d for d in docs if d["state"] == "in-progress"]),
        ("INBOX", [d for d in docs if d["state"] == "inbox" and not d["needs_you"]]),
        ("WAITING (external)", [d for d in docs if d["state"] == "waiting" and not d["needs_you"]]),
    ]
    if args.all:
        sections += [
            ("DONE", [d for d in docs if d["state"] == "done"]),
            ("CANCELLED", [d for d in docs if d["state"] == "cancelled"]),
        ]
    for name, items in sections:
        if not items:
            continue
        print("\n%s (%d)" % (name, len(items)))
        items.sort(key=lambda d: (d["seq"], d["age_s"] if name != "IN PROGRESS" else -d["updated"]))
        for d in items:
            print(fmt_line(d, statuses))
    done = [d for d in docs if d["state"] == "done"]
    if not args.all and done:
        print("\n(%d done, %d cancelled — `tasx list --all` to show)"
              % (len(done), len([d for d in docs if d["state"] == "cancelled"])))


def cmd_doctor(args):
    root = require_root()
    docs = scan(root)
    stale = [d for d in docs if d["stale"]]
    ids = {}
    for d in docs:
        ids.setdefault(d["id"], []).append(d["path"])
    dupes = {k: v for k, v in ids.items() if len(v) > 1}
    owners = {d["owner"] for d in docs if d["owner"] and d["state"] == "in-progress"}
    statuses = agent_statuses(owners)
    orphans = [d for d in docs if d["state"] == "in-progress" and d["owner"]
               and statuses.get(d["owner"]) in ("dead", "offline")]
    if not (stale or dupes or orphans):
        print("all fresh — nothing stale in %s" % root)
        return
    print("tasx doctor — %s" % root)
    if stale:
        print("\nSTALE (%d):" % len(stale))
        for d in stale:
            print(fmt_line(d, statuses))
    if orphans:
        print("\nOWNER GONE (agent dead/offline — reclaim or move back to inbox):")
        for d in orphans:
            print(fmt_line(d, statuses))
    if dupes:
        print("\nDUPLICATE IDS:")
        for k, v in dupes.items():
            print("  %s:\n    %s" % (k, "\n    ".join(v)))
    print("\nReconcile what you own: finish -> `tasx move <id> done`, "
          "stalled -> comment why, dead owner -> take over or move to inbox.")


def cmd_archive(args):
    root = require_root()
    done = root / "done"
    if not done.is_dir():
        print("nothing to archive")
        return
    cutoff = time.time() - args.days * 86400
    n = 0
    for f in sorted(done.glob("*.md")):
        st = f.stat()
        updated = max(st.st_mtime, st.st_ctime)
        if updated < cutoff:
            month = datetime.fromtimestamp(updated).strftime("%Y-%m")
            move_file(f, done / month)
            n += 1
            print("  %s -> done/%s/" % (f.name, month))
    print("archived %d file(s) older than %dd" % (n, args.days))


# ---------- server ----------

def default_port(root):
    return 8720 + zlib.crc32(str(root).encode()) % 64


def make_handler(root):
    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *a):
            pass

        def _send(self, code, body, ctype="application/json"):
            data = body if isinstance(body, bytes) else body.encode("utf-8")
            self.send_response(code)
            self.send_header("content-type", ctype)
            self.send_header("cache-control", "no-store")
            self.send_header("content-length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def do_GET(self):
            if self.path in ("/", "/index.html"):
                page = (SKILL_DIR / "board.html").read_text(encoding="utf-8")
                return self._send(200, page, "text/html; charset=utf-8")
            if self.path == "/api/state":
                docs = scan(root)
                owners = {d["owner"] for d in docs if d["owner"] and d["state"] == "in-progress"}
                for d in docs:
                    d.pop("header", None)
                return self._send(200, json.dumps({
                    "project": project_name(root), "root": str(root),
                    "docs": docs, "agents": agent_statuses(owners),
                    "now": int(time.time() * 1000),
                }))
            self._send(404, '{"error":"not found"}')

        def do_POST(self):
            if self.path != "/api/save":
                return self._send(404, '{"error":"not found"}')
            try:
                raw = self.rfile.read(int(self.headers.get("content-length", 0)))
                req = json.loads(raw or b"{}")
                op, id_, value = req.get("op"), req.get("id"), req.get("value", "")
                author = req.get("author") or os.environ.get("TASX_USER") or "user"
                docs = scan(root)
                doc = find_doc(docs, id_)
                if not doc:
                    return self._send(404, '{"error":"unknown id"}')
                path = Path(doc["path"])
                proj = project_name(root)
                if op == "state":
                    set_state(root, doc, str(value), req.get("waiting_on"))
                    if doc["owner"] and doc["owner"] != author:
                        nudge(doc["owner"], "[tasx:%s] %s moved %s to %s"
                              % (proj, author, doc["id"], value))
                elif op == "choice":
                    patch_header(path, "choice", str(value))
                    msg = "[tasx:%s] %s decided %s: picked %r" % (proj, author, doc["id"], value)
                    if doc["owner"] and doc["owner"] != author:
                        nudge(doc["owner"], msg)
                    for dep in docs:  # unblock tasks waiting on this decision
                        if dep["state"] == "waiting" and doc["id"] in dep["waiting_on"] \
                                and dep["owner"] and dep["owner"] != author:
                            nudge(dep["owner"], msg + " — your task %s can resume" % dep["id"])
                elif op == "comment" and str(value).strip():
                    append_comment(path, str(value).strip(), author)
                    resumed = ""
                    if req.get("resume") and doc["kind"] != "decision" \
                            and doc["state"] == "waiting":
                        target = "in-progress" if doc["owner"] else "inbox"
                        set_state(root, doc, target)
                        resumed = " — moved back to %s, over to you" % target
                    if doc["owner"] and doc["owner"] != author:
                        nudge(doc["owner"], "[tasx:%s] %s answered %s: %s%s"
                              % (proj, author, doc["id"], str(value).strip(), resumed)
                              if resumed else
                              "[tasx:%s] %s on %s: %s"
                              % (proj, author, doc["id"], str(value).strip()))
                else:
                    return self._send(400, '{"error":"bad op"}')
                return self._send(200, '{"ok":true}')
            except Exception as e:
                return self._send(500, json.dumps({"error": str(e)}))

    return Handler


def cmd_serve(args):
    root = require_root()
    port = args.port or default_port(root)
    server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(root))
    print("tasx board — %s: http://127.0.0.1:%d/" % (project_name(root), port), flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


# ---------- main ----------

def main():
    import argparse
    p = argparse.ArgumentParser(prog="tasx", description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd")

    sub.add_parser("init", help="create tasks/ tree + README + CLAUDE.md/AGENTS.md pointer")

    n = sub.add_parser("new", help="create a task (or decision) in the inbox")
    n.add_argument("title")
    n.add_argument("--id", help="explicit slug (default: from title)")
    n.add_argument("--owner", help="agent name")
    n.add_argument("--group", help="workstream/area")
    n.add_argument("--body", help="What-section text")
    n.add_argument("--decision", action="store_true", help="create in decisions/ with options")
    n.add_argument("--option", action="append", metavar='"id | label"')

    m = sub.add_parser("move", help="move a task to a state folder")
    m.add_argument("id")
    m.add_argument("state", choices=STATES)
    m.add_argument("--waiting-on", dest="waiting_on",
                   help='what it waits on: you | "decision <id>" | "external: <thing>"')

    c = sub.add_parser("comment", help="append a timestamped comment (nudges the owner)")
    c.add_argument("id")
    c.add_argument("text")
    c.add_argument("--as", dest="as_", required=True, help="author name")

    l = sub.add_parser("list", help="kanban to stdout")
    l.add_argument("--all", action="store_true", help="include done/cancelled")

    sub.add_parser("doctor", help="staleness + hygiene report (run at session start)")

    a = sub.add_parser("archive", help="roll old done/ files into done/YYYY-MM/")
    a.add_argument("--days", type=int, default=30)

    s = sub.add_parser("serve", help="serve the board UI")
    s.add_argument("--port", type=int)

    args = p.parse_args()
    if not args.cmd:
        p.print_help()
        return
    {"init": cmd_init, "new": cmd_new, "move": cmd_move, "comment": cmd_comment,
     "list": cmd_list, "doctor": cmd_doctor, "archive": cmd_archive,
     "serve": cmd_serve}[args.cmd](args)


if __name__ == "__main__":
    main()
