#!/usr/bin/env python3 """ vk -- Vikunja from the command line, for agent harnesses that can shell out. Every subcommand takes --json for machine-readable output. The default is a compact line format that is still trivially parseable (leading `#`), so a harness can grep it without asking for JSON. Auth resolution order: flags, then VIKUNJA_TOKEN / VIKUNJA_URL / VIKUNJA_PROFILE, then ~/.config/vikunja-agents/config.json. See `vk bootstrap` to create a token. """ import argparse import getpass import json import os import subprocess import sys import time from datetime import datetime, timedelta, timezone sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from vkclient import CONFIG_PATH, Vikunja, VikunjaError, load_config, save_config # noqa: E402 # What an agent bot needs and nothing more: it works tasks, talks in comments, # and reads the project list to know where to file things. It cannot touch # users, sharing, or its own account (Vikunja excludes `user_*` and `tokens` # from token-grantable routes anyway). DEFAULT_PERMISSIONS = { "tasks": ["read_all", "read_one", "create", "update"], "tasks_comments": ["read_all", "create"], "tasks_assignees": ["read_all", "create"], "tasks_labels": ["read_all", "create"], # `projectusers` resolves a username to an id when assigning; the # instance-wide /users search is not token-grantable. "projects": ["read_all", "read_one", "projectusers"], "labels": ["read_all"], } def out(args, data, lines): if args.json: json.dump(data, sys.stdout, indent=2, default=str) sys.stdout.write("\n") else: for line in lines: print(line) def task_line(t): mark = "x" if t.get("done") else " " who = ",".join(a.get("username", "?") for a in (t.get("assignees") or [])) labels = ",".join(l.get("title", "") for l in (t.get("labels") or [])) bits = [f"[{mark}] #{t.get('id')}", t.get("title", "")] if who: bits.append(f"@{who}") if labels: bits.append(f"+{labels}") return " ".join(bits) # -- commands ------------------------------------------------------------- def cmd_bootstrap(vk, args): """Password login -> mint an API token -> store it. Run once per bot.""" password = args.password or os.environ.get("VIKUNJA_PASSWORD") or getpass.getpass( f"Password for {args.username}: " ) jwt = vk.login(args.username, password, args.totp) wanted = json.loads(args.permissions) if args.permissions else DEFAULT_PERMISSIONS granted, missing = vk.token_permissions(jwt, wanted) if missing: print(f"note: server does not offer {', '.join(missing)} -- skipped", file=sys.stderr) if not granted: raise VikunjaError("None of the requested permissions exist on this server.") expires = (datetime.now(timezone.utc) + timedelta(days=args.days)).replace(microsecond=0) created = vk.create_token( jwt, args.title or f"{args.username} ({os.uname().nodename})", granted, expires.isoformat().replace("+00:00", "Z"), ) token = created.get("token") if not token: raise VikunjaError(f"Token created but not returned: {created}") profile = args.profile or args.username config = load_config() config.setdefault("url", vk.url) config.setdefault("profiles", {})[profile] = { "url": vk.url, "token": token, # Recorded because /user is unreachable with an API token; `--mine` # and `whoami` read the username from here. "username": args.username, } config.setdefault("default_profile", profile) save_config(config) print(f"Token stored as profile '{profile}' in {CONFIG_PATH}") print(f"Permissions: {json.dumps(granted)}") print(f"Expires: {expires.isoformat()}") print(f"Token: {token}") def cmd_profile(vk, args): """Store a token minted elsewhere, reading it from stdin. Bot users have no password, so `bootstrap` cannot be used for them: their owner mints the token instead (`owner_id` on PUT /tokens) and it arrives out-of-band. Reading from stdin keeps it out of argv and shell history. """ token = sys.stdin.read().strip() if not token: raise VikunjaError("No token on stdin. Try: pbpaste | vk profile --username ") url = args.url or vk.url probe = Vikunja(url=url, token=token) probe.username = args.username # Fail before writing: a token that cannot read projects is not worth saving. projects = probe.projects() config = load_config() config.setdefault("url", url) config.setdefault("profiles", {})[args.name] = { "url": url, "token": token, "username": args.username, } config.setdefault("default_profile", args.name) save_config(config) out(args, {"profile": args.name, "username": args.username, "projects": len(projects)}, [f"stored profile '{args.name}' for {args.username} in {CONFIG_PATH}", f"token verified: {len(projects)} project(s) visible"]) def cmd_whoami(vk, args): who = {"username": vk.whoami(), "url": vk.url} out(args, who, [f"{who['username'] or '(unknown)'} @ {who['url']}"]) def cmd_projects(vk, args): p = vk.projects() out(args, p, [f"#{x['id']} {x.get('title')}" for x in p]) def cmd_tasks(vk, args): filters = list(args.filter or []) if args.mine: filters.append(f"assignees in '{vk.whoami()}'") if not args.all: filters.append("done = false") tasks = vk.tasks( project_id=args.project, filter=" && ".join(filters) or None, sort_by=args.sort, limit=args.limit, ) out(args, tasks, [task_line(t) for t in tasks] or ["(no tasks)"]) def cmd_show(vk, args): t = vk.task(args.task_id) comments = vk.comments(args.task_id) if args.comments else [] lines = [task_line(t), f"project: {t.get('project_id')}"] if t.get("description"): lines += ["", t["description"]] for c in comments: author = (c.get("author") or {}).get("username", "?") lines += ["", f"--- {author} @ {c.get('created')}", c.get("comment", "")] out(args, {"task": t, "comments": comments}, lines) def cmd_create(vk, args): t = vk.create_task(args.project, args.title, args.description, args.priority, args.due) if args.assign: user = vk.find_user(args.assign, args.project) if not user: raise VikunjaError(f"No such user: {args.assign}") vk.assign(t["id"], user["id"]) t = vk.task(t["id"]) out(args, t, [task_line(t)]) def cmd_comment(vk, args): text = args.text if args.text != "-" else sys.stdin.read() c = vk.comment(args.task_id, text) out(args, c, [f"commented on #{args.task_id}"]) def cmd_update(vk, args): fields = {} if args.title: fields["title"] = args.title if args.description: fields["description"] = args.description if args.priority is not None: fields["priority"] = args.priority if args.done: fields["done"] = True if args.undone: fields["done"] = False if not fields: raise VikunjaError("Nothing to update -- pass at least one field.") t = vk.update_task(args.task_id, **fields) out(args, t, [task_line(t)]) def cmd_done(vk, args): t = vk.update_task(args.task_id, done=True) out(args, t, [task_line(t)]) def cmd_assign(vk, args): task = vk.task(args.task_id) user = vk.find_user(args.username, task["project_id"]) if not user: raise VikunjaError(f"No user '{args.username}' with access to project {task['project_id']}") vk.assign(args.task_id, user["id"]) t = vk.task(args.task_id) out(args, t, [task_line(t)]) STATE_DIR = os.path.expanduser( os.environ.get("VIKUNJA_STATE_DIR", "~/.local/state/vikunja-agents") ) SEEN_TTL = 30 * 86400 # forget handled tasks after a month so state stays small def _state_path(profile): return os.path.join(STATE_DIR, f"watch-{profile or 'default'}.json") def _load_seen(path): """State for both triggers. Shape: {"tasks": {task_id: ts}, "comments": {task_id: last_comment_id}}. Older files were a bare {task_id: ts} map; those are migrated in place so an upgrade does not re-fire every task already handled. """ try: with open(path) as f: state = json.load(f) except (FileNotFoundError, ValueError): return {"tasks": {}, "comments": {}} if "tasks" not in state: state = {"tasks": state, "comments": {}} state.setdefault("comments", {}) cutoff = time.time() - SEEN_TTL state["tasks"] = {k: v for k, v in state["tasks"].items() if v > cutoff} return state def _save_seen(path, seen): os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w") as f: json.dump(seen, f) os.replace(tmp, path) # atomic; a crash mid-write can't corrupt the state def _substitute(template, task, vk): values = { "task_id": task.get("id"), "title": task.get("title"), "project_id": task.get("project_id"), "priority": task.get("priority"), "url": f"{vk.url}/tasks/{task.get('id')}", "labels": ",".join(l.get("title", "") for l in (task.get("labels") or [])), } out_str = template for key, value in values.items(): out_str = out_str.replace("{" + key + "}", "" if value is None else str(value)) return out_str def _run_command(args, command, env_extra, log): """Run the configured command once. Never raises.""" env = dict(os.environ) env.update({k: str(v) for k, v in env_extra.items() if v is not None}) if args.profile: env["VIKUNJA_PROFILE"] = args.profile try: result = subprocess.run(command, cwd=args.cwd, env=env, stdin=subprocess.DEVNULL, timeout=args.timeout) log(f"finished rc={result.returncode}") except subprocess.TimeoutExpired: log(f"timed out after {args.timeout}s") except FileNotFoundError: log(f"FAILED: no such command: {command[0]}") except Exception as e: log(f"FAILED: {e!r}") def _poll_comments(vk, args, state, state_file, me, log): """Fire for comments written by someone other than this account. Scans the account's assigned tasks *including done ones* -- a follow-up question almost always lands on a task that was just closed. On first sight of a task the newest comment id is recorded without firing, so enabling this on an existing board does not replay months of history. """ filters = [f"assignees in '{me}'"] + list(args.filter or []) tasks = vk.tasks(project_id=args.project, filter=" && ".join(filters), sort_by="id") for task in tasks: key = str(task["id"]) try: comments = vk.comments(task["id"]) except VikunjaError as e: log(f"comment poll failed for #{task['id']}: {e}") continue if not comments: continue newest = max(int(c["id"]) for c in comments) if key not in state["comments"]: # First time we have looked at this task: adopt, do not replay. state["comments"][key] = newest _save_seen(state_file, state) continue last = int(state["comments"][key]) fresh = [c for c in comments if int(c["id"]) > last and (c.get("author") or {}).get("username") != me] if not fresh: state["comments"][key] = max(last, newest) continue state["comments"][key] = newest _save_seen(state_file, state) # at-most-once, same as task triggers for c in fresh: author = (c.get("author") or {}).get("username", "?") body = c.get("comment", "") log(f"comment on #{task['id']} by {author}: {body[:60]!r}") if args.dry_run: continue command = [_substitute(part, task, vk) for part in args.command] _run_command(args, command, { "VIKUNJA_TASK_ID": task["id"], "VIKUNJA_TASK_TITLE": task.get("title") or "", "VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}", "VIKUNJA_TRIGGER": "comment", "VIKUNJA_COMMENT_ID": c.get("id"), "VIKUNJA_COMMENT_AUTHOR": author, "VIKUNJA_COMMENT": body, }, lambda m: log(f"task #{task['id']} {m}")) def cmd_watch(vk, args): """Poll this bot's assigned queue and run a command for each new task. Pull rather than push, deliberately. Vikunja's WebSocket only accepts a JWT (not an API token) and its subscribable events are notifications and timers -- no task events -- so there is nothing to subscribe to. Polling also means a harness on another machine needs only outbound HTTPS: no inbound port, no SSH key, and nothing to reconfigure when a device moves or sits behind NAT. """ me = vk.whoami() if not me: raise VikunjaError("Cannot determine my own username; re-run `vk bootstrap`.") # argparse.REMAINDER hands back the `--` separator itself; drop it so the # command is what the user actually typed after it. if args.command and args.command[0] == "--": args.command = args.command[1:] if not args.command: raise VikunjaError("No command given. Put it after `--`, e.g. " "`vk watch -- nullclaw --task {task_id}`") state_file = args.state or _state_path(args.profile or me) state = _load_seen(state_file) seen = state["tasks"] log = lambda msg: print(f"{datetime.now().isoformat(timespec='seconds')} {msg}", flush=True) triggers = "new tasks" + (" + comments" if args.comments else "") log(f"watching {triggers} as {me} every {args.interval}s " f"({'dry run' if args.dry_run else ' '.join(args.command)})") backoff = args.interval while True: try: filters = [f"assignees in '{me}'", "done = false"] + list(args.filter or []) tasks = vk.tasks(project_id=args.project, filter=" && ".join(filters), sort_by="id") backoff = args.interval except VikunjaError as e: # Never exit on a transient API failure -- this runs unattended. log(f"poll failed: {e}") time.sleep(min(backoff, 600)) backoff = min(backoff * 2, 600) if args.once: return continue for task in tasks: key = str(task["id"]) if key in seen: continue # Record before running, not after: at-most-once. A harness that # crashes the machine should not be relaunched on every poll. seen[key] = time.time() _save_seen(state_file, state) command = [_substitute(part, task, vk) for part in args.command] log(f"task #{task['id']} {task.get('title')!r} -> {' '.join(command)}") if args.dry_run: continue _run_command(args, command, { "VIKUNJA_TASK_ID": task["id"], "VIKUNJA_TASK_TITLE": task.get("title") or "", "VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}", "VIKUNJA_TRIGGER": "task", }, lambda m: log(f"task #{task['id']} {m}")) if args.comments: try: _poll_comments(vk, args, state, state_file, me, log) except VikunjaError as e: log(f"comment poll failed: {e}") if args.once: return time.sleep(args.interval) def build_parser(): p = argparse.ArgumentParser(prog="vk", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--url") p.add_argument("--token") p.add_argument("--profile", help="Named profile from the config file.") p.add_argument("--json", action="store_true", help="Emit raw JSON.") # The same global flags again, accepted *after* the subcommand -- `vk tasks # --json` is what anyone actually types. SUPPRESS keeps an unspecified flag # here from clobbering one given before the subcommand. common = argparse.ArgumentParser(add_help=False) common.add_argument("--url", default=argparse.SUPPRESS) common.add_argument("--token", default=argparse.SUPPRESS) common.add_argument("--profile", default=argparse.SUPPRESS) common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="Emit raw JSON.") sub = p.add_subparsers(dest="command", required=True) _add = sub.add_parser def add_parser(name, **kw): kw.setdefault("parents", [common]) return _add(name, **kw) sub.add_parser = add_parser b = sub.add_parser("bootstrap", help="Mint and store an API token for a bot account.") b.add_argument("--username", required=True) b.add_argument("--password", help="Prompted for, or $VIKUNJA_PASSWORD, if omitted.") b.add_argument("--totp") b.add_argument("--title", help="Token title shown in Vikunja's UI.") b.add_argument("--days", type=int, default=365) b.add_argument("--permissions", help="JSON permission map; defaults to a task-work set.") b.set_defaults(func=cmd_bootstrap) pr = sub.add_parser( "profile", help="Store a token minted elsewhere (reads it from stdin).", description="For bot users, whose owner mints the token for them.\n" "Example: vk profile bot-nullclaw --username bot-nullclaw < token.txt", formatter_class=argparse.RawDescriptionHelpFormatter, ) pr.add_argument("name", help="Profile name to store it under.") pr.add_argument("--username", required=True, help="The Vikunja account the token belongs to.") pr.set_defaults(func=cmd_profile) sub.add_parser("whoami", help="Show the account this token belongs to.").set_defaults(func=cmd_whoami) sub.add_parser("projects", help="List projects.").set_defaults(func=cmd_projects) t = sub.add_parser("tasks", help="List tasks (undone by default).") t.add_argument("--project", type=int) t.add_argument("--mine", action="store_true", help="Only tasks assigned to me.") t.add_argument("--all", action="store_true", help="Include done tasks.") t.add_argument("--filter", action="append", help="Raw Vikunja filter, repeatable (ANDed).") t.add_argument("--sort", default="due_date") t.add_argument("--limit", type=int) t.set_defaults(func=cmd_tasks) s = sub.add_parser("show", help="Show one task.") s.add_argument("task_id", type=int) s.add_argument("--comments", action="store_true") s.set_defaults(func=cmd_show) c = sub.add_parser("create", help="Create a task.") c.add_argument("--project", type=int, required=True) c.add_argument("--title", required=True) c.add_argument("--description") c.add_argument("--priority", type=int) c.add_argument("--due", help="RFC3339 timestamp.") c.add_argument("--assign", help="Username to assign it to.") c.set_defaults(func=cmd_create) m = sub.add_parser("comment", help="Comment on a task ('-' reads stdin).") m.add_argument("task_id", type=int) m.add_argument("text") m.set_defaults(func=cmd_comment) u = sub.add_parser("update", help="Update task fields.") u.add_argument("task_id", type=int) u.add_argument("--title") u.add_argument("--description") u.add_argument("--priority", type=int) u.add_argument("--done", action="store_true") u.add_argument("--undone", action="store_true") u.set_defaults(func=cmd_update) d = sub.add_parser("done", help="Mark a task done.") d.add_argument("task_id", type=int) d.set_defaults(func=cmd_done) w = sub.add_parser( "watch", help="Poll my assigned queue and run a command per new task.", description="Run on each device that hosts a harness. Outbound HTTPS only.\n" "Example: vk watch --interval 30 -- nullclaw --task {task_id}", formatter_class=argparse.RawDescriptionHelpFormatter, ) w.add_argument("--interval", type=int, default=60, help="Seconds between polls.") w.add_argument("--project", type=int, help="Only watch one project.") w.add_argument("--filter", action="append", help="Extra Vikunja filter, repeatable.") w.add_argument("--cwd", help="Working directory for the command.") w.add_argument("--timeout", type=int, default=3600, help="Per-task timeout in seconds.") w.add_argument("--state", help="Override the handled-task state file.") w.add_argument("--once", action="store_true", help="One pass then exit (for cron).") w.add_argument("--dry-run", action="store_true", help="Log what would run; run nothing.") w.add_argument("--comments", action="store_true", help="Also fire when someone else comments on one of my tasks " "(includes done tasks -- follow-ups usually land there).") w.add_argument("command", nargs=argparse.REMAINDER, help="Command to run, after `--`. Placeholders: {task_id} {title} " "{project_id} {priority} {labels} {url}") w.set_defaults(func=cmd_watch) a = sub.add_parser("assign", help="Assign a task to a user.") a.add_argument("task_id", type=int) a.add_argument("username") a.set_defaults(func=cmd_assign) return p def main(): args = build_parser().parse_args() try: args.func(Vikunja(args.url, args.token, args.profile), args) except VikunjaError as e: print(f"error: {e}", file=sys.stderr) sys.exit(1) except KeyboardInterrupt: sys.exit(130) if __name__ == "__main__": main()