commit 2a73f2ef5e5817f7d2b42e06db8c46e82213779a Author: oc Date: Wed Aug 12 13:09:22 2026 -0700 Vikunja tooling for agent harnesses Standard-library Python only, so any device can run it without a virtualenv: vk CLI -- for harnesses that can shell out vikunja_mcp.py MCP server (stdio) -- for harnesses that speak MCP vkclient.py shared client vk-watch@.service systemd template for the per-device task watcher AGENT-ONBOARDING.md is a prompt you can hand to an agent so it configures its own access and verifies it. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/AGENT-ONBOARDING.md b/AGENT-ONBOARDING.md new file mode 100644 index 0000000..91a3650 --- /dev/null +++ b/AGENT-ONBOARDING.md @@ -0,0 +1,155 @@ +# Vikunja access — agent onboarding prompt + +Hand the block below to any agent that needs Vikunja access. It is written to be +pasted verbatim as a task prompt. Everything after the `---` is the prompt. + +Before pasting, substitute: + +- `` — the bot account for this harness, e.g. `bot-nullclaw` +- `` — `claude-code`, `omp`, `picocode`, or `other` + +--- + +## Task: configure your own Vikunja access + +You are ``. Give yourself working access to Vikunja at +`https://todo.dominat.us`, then verify it and report what you can and cannot see. + +### Facts you need + +- Tooling lives at `/srv/pods/vikunja-agents/` on `reptar`: `vk` (CLI), + `vkclient.py` (shared client), `vikunja_mcp.py` (MCP server, stdio). + Standard-library Python only — no virtualenv, no pip install. +- If you are **not** on `reptar`, copy those three files to the machine you run + on (`~/opt/vikunja-agents/`) and symlink `vk` onto your `PATH`. +- Auth is a Vikunja **API token**, read from `~/.config/vikunja-agents/config.json` + (mode 0600) or `$VIKUNJA_TOKEN`. Select an identity with `$VIKUNJA_PROFILE`. +- Your account must already exist. The normal shape is a **bot user**: a local + account owned by a human, with no password of its own. If it does not exist, + stop and ask — creating one is your owner's job (Vikunja UI: Settings → Bot + Users). MAS/OIDC accounts cannot be used for this (see constraints). + +### Step 1 — get your token (once) + +**If your account is a bot user** (the normal case — it has an owner and no +password), you cannot mint your own token: token creation needs a password login +and bots have none. Your owner mints it for you, then hands it over out of band. +Store it without putting it in argv or shell history: + +```sh +vk profile --username < /path/to/token-file +# or: pbpaste | vk profile --username +``` + +`vk profile` verifies the token against the API before saving, so a bad paste +fails loudly instead of silently writing a dead profile. It lands in +`~/.config/vikunja-agents/config.json` at mode 0600. + +If you have no token, stop and ask your owner for one. They create it from +Vikunja's UI (Settings → API tokens, issued for your bot) or with an +authenticated `PUT /api/v1/tokens` carrying `owner_id: `. + +**If your account is instead a plain user with a password** (legacy), you can +mint your own: + +```sh +VIKUNJA_PASSWORD="$(cat ~/.config/vikunja-agents/.password)" \ + vk bootstrap --username --title " ($(hostname))" +``` + +Either way: **never echo the token, paste it into a chat, or commit it.** It +expires in a year and cannot renew itself — renewal means asking your owner +again. + +### Step 2 — register the MCP server for your harness + +`claude-code`: + +```sh +claude mcp add vikunja -e VIKUNJA_PROFILE= -- /srv/pods/vikunja-agents/vikunja_mcp.py +``` + +`omp` — add to `~/.omp/agent/mcp.json` (create it if absent): + +```json +{ + "mcpServers": { + "vikunja": { + "type": "stdio", + "command": "/srv/pods/vikunja-agents/vikunja_mcp.py", + "env": { "VIKUNJA_PROFILE": "" } + } + } +} +``` + +`picocode` — add to `picocode.yaml`: + +```yaml +mcp: + vikunja: + command: /srv/pods/vikunja-agents/vikunja_mcp.py + env: + VIKUNJA_PROFILE: + auto_allow: + - "^vikunja_list_" + - "^vikunja_get_" +``` + +`other` — the server speaks MCP over stdio (JSON-RPC 2.0, newline-delimited). +Run `/srv/pods/vikunja-agents/vikunja_mcp.py` with `VIKUNJA_PROFILE` set. If your +harness cannot speak MCP, skip this step and use the `vk` CLI instead; it covers +the same ground. + +### Step 3 — verify, and do not skip this + +```sh +vk whoami # must print +vk projects # the projects you can actually see +vk tasks --mine # your queue +``` + +Then confirm the MCP path independently of the CLI — restart your harness if it +binds MCP servers at startup (claude-code does), and call `vikunja_list_projects`. +If the tool is missing, the registration did not take; if it errors, the token +did not take. These are different failures — do not report one as the other. + +### Step 4 — report + +State plainly: + +1. Which projects you can see (likely only your own `Inbox` — see constraints). +2. Whether MCP tools are available, or whether you fell back to the CLI. +3. Anything you had to ask a human for. + +### Constraints that will otherwise cost you an hour + +- **A token cannot mint another token.** Vikunja excludes the `tokens` route + group from anything an API token can be granted. For a bot user that means + only your owner can issue or renew your token; you can never do it yourself, + and there is no password you could use even if you wanted to. +- **You cannot ask the API who you are.** `/user` is unreachable with a token + (`user_*` routes are excluded too). `vk whoami` reads the username from your + config file. Do not try to fix a 401 on `/user` — it is expected. +- **You start with only your own Inbox.** A new account sees nothing else until a + human shares projects with it. If `vk projects` shows one project, that is + normal, not a broken token — say so and ask for the shares you need. +- **Assignment is dispatch.** Work reaches you by being assigned to + ``. Use `vk tasks --mine`. Do not scrape other agents' queues. +- **Username lookup is project-scoped.** Resolving a username to an id uses + `/projects/{id}/projectusers`; the instance-wide `/users` search is not + grantable to tokens. You can only assign to users who can access that project. +- **Updates replace the whole task.** The client merges for you; if you call the + API directly, read-modify-write or you will blank fields. +- **Link your work back.** Put `VK-` in commit messages, and + `fixes VK-` to close a task on push — a Gitea bridge posts the commit link + onto the task. Without it a task says "done" but never says what changed. + +### Rules + +- One account per harness. Do not share a profile with another agent — separate + accounts are what make the task history say who did what. +- Never print, log, or transmit the token or password. If you think one leaked, + say so immediately; rotation needs a human. +- Comment on tasks when you finish work, find something that changes scope, or + are blocked. Do not narrate routine steps. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e45464f --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# vikunja-agents + +Harness-agnostic glue between Vikunja and agent harnesses. Standard library +Python only — no virtualenv on any device. + +This file is the how-to. For why the design is shaped this way — the MAS SSO +constraints, the API-token limits, why polling instead of webhooks, and the +known gaps — see [`/srv/pods/docs/agent-collaboration.md`](../docs/agent-collaboration.md). + +| File | What it is | +|---|---| +| `vkclient.py` | Shared API client. | +| `vk` | CLI. Any harness that can shell out is integrated. | +| `vikunja_mcp.py` | MCP server (stdio). Any harness that speaks MCP is integrated. | +| `vk-watch@.service` | systemd template for the per-device task watcher. | + +## The model + +- **Task** = unit of work. **Assignee** = which agent owns it. **Comments** = + the thread. **Labels** = routing and state. +- **Outbound** (agent → Vikunja): `vk` or the MCP tools, authenticated as that + bot, so its actions are attributed to it in the UI. +- **Inbound** (Vikunja → agent): `vk watch` polls the bot's own assigned queue + and runs a command per new task. + +### Why polling rather than webhooks + +Harnesses live on several machines. A webhook receiver would need an inbound +port on each device (or SSH fan-out from one host), and Vikunja's WebSocket is +not an alternative: it authenticates with a JWT only (API tokens are rejected by +`auth.GetUserIDFromToken`) and its subscribable events are notifications and +timers — there are no task events to subscribe to. Polling needs only outbound +HTTPS, so a device behind NAT or on another subnet works with no extra config. + +## Onboarding an agent + +Hand [`AGENT-ONBOARDING.md`](AGENT-ONBOARDING.md) to the agent as a task prompt — +it covers token bootstrap, MCP registration per harness, verification, and the +constraints that otherwise get rediscovered the hard way. + +## One-time setup per bot + +Agents run as Vikunja **bot users**: local accounts owned by a human, with no +password of their own. Create one in the UI (Settings → Bot Users); the `bot-` +prefix is reserved for them and `vikunja user create` will refuse it. + +A bot cannot mint its own token — `tokens` is not a token-grantable route group, +and there is no password to log in with. The owner issues it instead, with +`owner_id` set to the bot's user id (UI, or `PUT /api/v1/tokens`), and the agent +stores it: + +```sh +vk profile bot-nullclaw --username bot-nullclaw < token.txt # reads stdin, verifies, 0600 +``` + +`vk bootstrap` remains for legacy password accounts only. + +This mints a scoped token and writes `~/.config/vikunja-agents/config.json` +(mode 0600). The username is stored alongside the token because `/user` is +unreachable with an API token, so `vk whoami` and `--mine` read it from there. + +Default grants: tasks (read/create/update), comments, assignees, labels, +projects (read + `projectusers` for username lookup). Override with +`--permissions`; `vk bootstrap` intersects your map against the live `/routes` +and reports what it dropped rather than failing at creation. + +## Deploying a watcher to a device + +Applies equally to this host, the Spark, and any other server. + +```sh +# 1. Copy the tools and put `vk` on PATH +mkdir -p ~/.local/bin ~/opt/vikunja-agents +scp vk vkclient.py vikunja_mcp.py :~/opt/vikunja-agents/ +ln -sf ~/opt/vikunja-agents/vk ~/.local/bin/vk + +# 2. Bootstrap that bot's token on that device +vk bootstrap --username bot-nullclaw + +# 3. Confirm it sees its queue before wiring systemd +vk watch --once --dry-run -- nullclaw --task '{task_id}' + +# 4. Configure and start +cat > ~/.config/vikunja-agents/watch-bot-nullclaw.env <<'EOF' +VK_WATCH_ARGS=--interval 30 -- nullclaw --task {task_id} --url {url} +EOF +cp vk-watch@.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now vk-watch@bot-nullclaw +``` + +**On the Spark, enable linger first** — user services are killed on logout +otherwise, and the watcher will quietly stop: + +```sh +loginctl enable-linger $USER +``` + +### Placeholders + +Available in the command: `{task_id}` `{title}` `{project_id}` `{priority}` +`{labels}` `{url}`. The same values are also exported as `VIKUNJA_TASK_ID`, +`VIKUNJA_TASK_TITLE`, `VIKUNJA_TASK_URL`. + +### Trigger semantics + +A task fires once, when it first appears as assigned-to-me and not-done. +Handled IDs are recorded in `~/.local/state/vikunja-agents/watch-.json` +**before** the command runs — at-most-once, so a harness that wedges the machine +is not relaunched on every poll. Entries expire after 30 days. To replay a task, +delete its ID from that file. Poll failures log and back off to 10 minutes; +the watcher never exits on an API error. + +## MCP + +```sh +claude mcp add vikunja -- ~/opt/vikunja-agents/vikunja_mcp.py +``` + +Auth comes from the same config file / `VIKUNJA_PROFILE`. Seven tools: +list/get/create/update/assign tasks, comment, list projects. + +## Routing work to a specific agent + +Assignment is the dispatch mechanism — assign a task to `bot-picoclaw` and only +picoclaw's watcher picks it up. For finer control, give each watcher a filter: + +```sh +vk watch --project 7 --filter "priority >= 3" -- picoclaw --task {task_id} +``` diff --git a/vikunja_mcp.py b/vikunja_mcp.py new file mode 100755 index 0000000..abe440c --- /dev/null +++ b/vikunja_mcp.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +Vikunja MCP server -- stdio transport, JSON-RPC 2.0, standard library only. + +Deliberately dependency-free: this gets registered in several different agent +harnesses, and requiring each of them to provision a virtualenv for one SDK is +how a "works everywhere" integration stops working anywhere. + +Register it (Claude Code): + claude mcp add vikunja -- /srv/pods/vikunja-agents/vikunja_mcp.py + +Auth comes from the same place the CLI gets it: VIKUNJA_TOKEN / VIKUNJA_PROFILE +in the server's environment, or ~/.config/vikunja-agents/config.json. + +Tool descriptions below state *when* to call each tool, not just what it does -- +that trigger condition is what actually drives correct tool selection. +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from vkclient import Vikunja, VikunjaError # noqa: E402 + +PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", "2025-06-18"} +FALLBACK_PROTOCOL = "2024-11-05" +SERVER_INFO = {"name": "vikunja", "version": "1.0.0"} + +TOOLS = [ + { + "name": "vikunja_list_tasks", + "description": ( + "List tasks from Vikunja, newest-relevant first. Call this at the start of a " + "work session to find out what has been assigned to you, and whenever you need " + "to check whether something is already tracked before creating a duplicate. " + "Defaults to undone tasks assigned to the calling bot account." + ), + "inputSchema": { + "type": "object", + "properties": { + "assigned_to_me": { + "type": "boolean", + "description": "Only tasks assigned to this bot account. Defaults to true.", + }, + "project_id": {"type": "integer", "description": "Restrict to one project."}, + "include_done": { + "type": "boolean", + "description": "Include completed tasks. Defaults to false.", + }, + "filter": { + "type": "string", + "description": "Raw Vikunja filter expression, e.g. \"priority >= 3\". ANDed with the other options.", + }, + "limit": {"type": "integer", "description": "Maximum tasks to return."}, + }, + }, + }, + { + "name": "vikunja_get_task", + "description": ( + "Fetch one task with its full description and comment thread. Call this before " + "starting work on a task -- the comment thread carries the conversation and " + "prior agents' findings, which the task list does not show." + ), + "inputSchema": { + "type": "object", + "properties": {"task_id": {"type": "integer"}}, + "required": ["task_id"], + }, + }, + { + "name": "vikunja_comment_task", + "description": ( + "Post a comment on a task. This is the primary way to report progress, findings, " + "and blockers so other agents and the human can follow along. Call it when you " + "finish a unit of work, when you discover something that changes the task's " + "scope, and when you are blocked -- not for routine narration." + ), + "inputSchema": { + "type": "object", + "properties": { + "task_id": {"type": "integer"}, + "comment": {"type": "string", "description": "Markdown is rendered."}, + }, + "required": ["task_id", "comment"], + }, + }, + { + "name": "vikunja_create_task", + "description": ( + "Create a task. Call this when work is identified that will not be done in the " + "current session -- follow-up items, discovered bugs, work for another agent. " + "Check vikunja_list_tasks first so you do not file a duplicate." + ), + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "integer"}, + "title": {"type": "string"}, + "description": {"type": "string", "description": "Markdown is rendered."}, + "priority": { + "type": "integer", + "description": "0 unset, 1 low, 2 medium, 3 high, 4 urgent, 5 DO NOW.", + }, + "due_date": {"type": "string", "description": "RFC3339 timestamp."}, + "assign_to": {"type": "string", "description": "Username to assign it to."}, + }, + "required": ["project_id", "title"], + }, + }, + { + "name": "vikunja_update_task", + "description": ( + "Update a task's fields, including marking it done. Call this when the work is " + "actually finished and verified -- marking a task done is the signal other " + "agents and the human rely on. Fields you omit are left unchanged." + ), + "inputSchema": { + "type": "object", + "properties": { + "task_id": {"type": "integer"}, + "title": {"type": "string"}, + "description": {"type": "string"}, + "priority": {"type": "integer"}, + "done": {"type": "boolean"}, + }, + "required": ["task_id"], + }, + }, + { + "name": "vikunja_assign_task", + "description": ( + "Assign a task to a user or bot account. Call this to hand work to a specific " + "agent, or to claim a task before starting on it so two agents do not " + "duplicate each other." + ), + "inputSchema": { + "type": "object", + "properties": { + "task_id": {"type": "integer"}, + "username": {"type": "string"}, + }, + "required": ["task_id", "username"], + }, + }, + { + "name": "vikunja_list_projects", + "description": ( + "List projects with their IDs. Call this when you need a project_id to file a " + "task and do not already know it." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +def _slim(task): + """Trim a task to what an agent needs. Full payloads are mostly nulls.""" + return { + "id": task.get("id"), + "title": task.get("title"), + "done": task.get("done"), + "priority": task.get("priority"), + "due_date": task.get("due_date"), + "project_id": task.get("project_id"), + "assignees": [a.get("username") for a in (task.get("assignees") or [])], + "labels": [l.get("title") for l in (task.get("labels") or [])], + } + + +def call_tool(vk, name, args): + if name == "vikunja_list_tasks": + filters = [] + if args.get("assigned_to_me", True): + filters.append(f"assignees in '{vk.whoami()}'") + if not args.get("include_done", False): + filters.append("done = false") + if args.get("filter"): + filters.append(args["filter"]) + tasks = vk.tasks( + project_id=args.get("project_id"), + filter=" && ".join(filters) or None, + limit=args.get("limit"), + ) + return [_slim(t) for t in tasks] + + if name == "vikunja_get_task": + task = vk.task(args["task_id"]) + detail = _slim(task) + detail["description"] = task.get("description") + detail["comments"] = [ + { + "author": (c.get("author") or {}).get("username"), + "created": c.get("created"), + "comment": c.get("comment"), + } + for c in vk.comments(args["task_id"]) + ] + return detail + + if name == "vikunja_comment_task": + vk.comment(args["task_id"], args["comment"]) + return {"ok": True, "task_id": args["task_id"]} + + if name == "vikunja_create_task": + task = vk.create_task( + args["project_id"], args["title"], args.get("description"), + args.get("priority"), args.get("due_date"), + ) + if args.get("assign_to"): + user = vk.find_user(args["assign_to"], args["project_id"]) + if not user: + raise VikunjaError( + f"No user '{args['assign_to']}' with access to project {args['project_id']}") + vk.assign(task["id"], user["id"]) + task = vk.task(task["id"]) + return _slim(task) + + if name == "vikunja_update_task": + fields = {k: args[k] for k in ("title", "description", "priority", "done") if k in args} + if not fields: + raise VikunjaError("Nothing to update -- supply at least one field besides task_id.") + return _slim(vk.update_task(args["task_id"], **fields)) + + if name == "vikunja_assign_task": + 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"]) + return _slim(vk.task(args["task_id"])) + + if name == "vikunja_list_projects": + return [{"id": p["id"], "title": p.get("title")} for p in vk.projects()] + + raise VikunjaError(f"Unknown tool: {name}") + + +def handle(message, state): + """Return a JSON-RPC response dict, or None for notifications.""" + method = message.get("method") + msg_id = message.get("id") + params = message.get("params") or {} + is_notification = msg_id is None + + def ok(result): + return None if is_notification else {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def err(code, msg): + return None if is_notification else { + "jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": msg} + } + + if method == "initialize": + asked = params.get("protocolVersion") + return ok({ + # Echo the client's version when we recognise it; otherwise name the + # one we know it can speak. + "protocolVersion": asked if asked in PROTOCOL_VERSIONS else FALLBACK_PROTOCOL, + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + }) + + if method in ("notifications/initialized", "initialized", "notifications/cancelled"): + return None + + if method == "ping": + return ok({}) + + if method == "tools/list": + return ok({"tools": TOOLS}) + + if method == "tools/call": + name = params.get("name") + args = params.get("arguments") or {} + try: + result = call_tool(state["vk"], name, args) + except VikunjaError as e: + # Tool failures are results with isError, not protocol errors -- the + # model needs to see the message so it can adapt. + return ok({"content": [{"type": "text", "text": f"Error: {e}"}], "isError": True}) + except KeyError as e: + return ok({ + "content": [{"type": "text", "text": f"Error: missing required argument {e}"}], + "isError": True, + }) + return ok({"content": [{"type": "text", "text": json.dumps(result, indent=2, default=str)}]}) + + if method in ("shutdown", "exit"): + state["running"] = False + return ok({}) + + return err(-32601, f"Method not found: {method}") + + +def main(): + state = {"vk": Vikunja(), "running": True} + stdout = sys.stdout + + for line in sys.stdin: + if not state["running"]: + break + line = line.strip() + if not line: + continue + try: + message = json.loads(line) + except ValueError: + stdout.write(json.dumps({ + "jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": "Parse error"}, + }) + "\n") + stdout.flush() + continue + + try: + response = handle(message, state) + except Exception as e: # never let one bad call kill the server + response = { + "jsonrpc": "2.0", "id": message.get("id"), + "error": {"code": -32603, "message": f"Internal error: {e}"}, + } + if message.get("id") is None: + response = None + + if response is not None: + stdout.write(json.dumps(response) + "\n") + stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/vk b/vk new file mode 100755 index 0000000..a813b0c --- /dev/null +++ b/vk @@ -0,0 +1,479 @@ +#!/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): + try: + with open(path) as f: + seen = json.load(f) + except (FileNotFoundError, ValueError): + return {} + cutoff = time.time() - SEEN_TTL + return {k: v for k, v in seen.items() if v > cutoff} + + +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 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) + seen = _load_seen(state_file) + log = lambda msg: print(f"{datetime.now().isoformat(timespec='seconds')} {msg}", flush=True) + + log(f"watching 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, seen) + + 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 + + env = dict(os.environ) + env["VIKUNJA_TASK_ID"] = str(task["id"]) + env["VIKUNJA_TASK_TITLE"] = task.get("title") or "" + env["VIKUNJA_TASK_URL"] = f"{vk.url}/tasks/{task['id']}" + 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"task #{task['id']} finished rc={result.returncode}") + except subprocess.TimeoutExpired: + log(f"task #{task['id']} timed out after {args.timeout}s") + except FileNotFoundError: + log(f"task #{task['id']} FAILED: no such command: {command[0]}") + except Exception as e: + log(f"task #{task['id']} FAILED: {e!r}") + + 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("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() diff --git a/vk-watch@.service b/vk-watch@.service new file mode 100644 index 0000000..7cbb405 --- /dev/null +++ b/vk-watch@.service @@ -0,0 +1,21 @@ +[Unit] +# Per-bot task watcher. Instance name is the bot's profile, e.g. +# systemctl --user enable --now vk-watch@bot-nullclaw +Description=Vikunja task watcher for %i +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Holds VIKUNJA_PROFILE and VK_WATCH_ARGS. See README.md. +EnvironmentFile=%h/.config/vikunja-agents/watch-%i.env +# sh -c so VK_WATCH_ARGS splits into argv; the {task_id} placeholders are +# brace-literals to the shell and pass through untouched. +ExecStart=/bin/sh -c 'exec %h/.local/bin/vk watch --profile %i $VK_WATCH_ARGS' +Restart=always +RestartSec=30 +# The watcher only ever needs outbound HTTPS and whatever the harness needs. +NoNewPrivileges=true + +[Install] +WantedBy=default.target diff --git a/vkclient.py b/vkclient.py new file mode 100644 index 0000000..cef2743 --- /dev/null +++ b/vkclient.py @@ -0,0 +1,261 @@ +""" +Thin Vikunja API client shared by the `vk` CLI and the MCP server. + +Standard library only, on purpose: these tools get invoked from a half-dozen +different agent harnesses, and every one of them would otherwise need its own +virtualenv with the same two dependencies in it. + +Auth is an API token (`Authorization: Bearer tk_...`). Tokens cannot be minted +by a token -- Vikunja excludes the `tokens` route group from the set an API +token can be granted (pkg/models/api_routes.go) -- so `bootstrap()` logs in +with a password to get a JWT and mints the token with that. This is why bots +must be local accounts: an OIDC-only account has no password to log in with, +and would need a browser round-trip to ever get its first token. +""" + +import json +import os +import urllib.error +import urllib.parse +import urllib.request + +DEFAULT_URL = "https://todo.dominat.us" +CONFIG_PATH = os.path.expanduser( + os.environ.get("VIKUNJA_CONFIG", "~/.config/vikunja-agents/config.json") +) + +# Vikunja caps page size at `maxitemsperpage` (50 on this instance), so every +# read-all has to page. Callers get the whole set and never see the cursor. +PAGE_SIZE = 50 + + +class VikunjaError(Exception): + """An API call failed. `status` is None for transport-level failures.""" + + def __init__(self, message, status=None, payload=None): + super().__init__(message) + self.status = status + self.payload = payload + + +def load_config(): + try: + with open(CONFIG_PATH) as f: + return json.load(f) + except FileNotFoundError: + return {} + except ValueError as e: + raise VikunjaError(f"{CONFIG_PATH} is not valid JSON: {e}") + + +def save_config(config): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + # The file holds bot API tokens; don't create it world-readable. + fd = os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(config, f, indent=2, sort_keys=True) + f.write("\n") + + +def resolve(url=None, token=None, profile=None): + """Settle on (url, token, username) from args, then env, then the config file.""" + config = load_config() + profile = profile or os.environ.get("VIKUNJA_PROFILE") or config.get("default_profile") + entry = (config.get("profiles") or {}).get(profile, {}) if profile else {} + + url = url or os.environ.get("VIKUNJA_URL") or entry.get("url") or config.get("url") or DEFAULT_URL + token = token or os.environ.get("VIKUNJA_TOKEN") or entry.get("token") + username = os.environ.get("VIKUNJA_USERNAME") or entry.get("username") + return url.rstrip("/"), token, username + + +class Vikunja: + def __init__(self, url=None, token=None, profile=None, api_version="v1"): + self.url, self.token, self.username = resolve(url, token, profile) + self.api = f"{self.url}/api/{api_version}" + + # -- plumbing --------------------------------------------------------- + + def request(self, method, path, body=None, params=None, auth=None): + target = f"{self.api}{path}" + if params: + clean = {k: v for k, v in params.items() if v is not None} + if clean: + target += "?" + urllib.parse.urlencode(clean) + + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(target, data=data, method=method) + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + + bearer = auth if auth is not None else self.token + if bearer: + req.add_header("Authorization", f"Bearer {bearer}") + elif auth is None: + raise VikunjaError( + "No API token. Run `vk bootstrap --username `, or set VIKUNJA_TOKEN." + ) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read() + except urllib.error.HTTPError as e: + raw = e.read() + try: + payload = json.loads(raw) + detail = payload.get("message") or payload.get("error") or raw.decode() + except ValueError: + payload, detail = None, raw.decode(errors="replace")[:400] + raise VikunjaError(f"{method} {path} -> {e.code}: {detail}", e.code, payload) + except Exception as e: + raise VikunjaError(f"{method} {path} failed: {e}") + + if not raw: + return None + try: + return json.loads(raw) + except ValueError: + return raw.decode(errors="replace") + + def paged(self, path, params=None, limit=None): + """Follow pagination to the end (or until `limit` items).""" + out, page = [], 1 + while True: + batch = self.request( + "GET", path, params={**(params or {}), "page": page, "per_page": PAGE_SIZE} + ) + if not batch: + break + out.extend(batch) + if len(batch) < PAGE_SIZE or (limit and len(out) >= limit): + break + page += 1 + return out[:limit] if limit else out + + # -- bootstrap -------------------------------------------------------- + + def login(self, username, password, totp=None): + body = {"username": username, "password": password} + if totp: + body["totp_passcode"] = totp + result = self.request("POST", "/login", body=body, auth="") + jwt = (result or {}).get("token") + if not jwt: + raise VikunjaError(f"Login succeeded but returned no token: {result}") + return jwt + + def token_permissions(self, jwt, wanted): + """Intersect the permissions we want with what this server actually offers. + + Route-group names are derived from the registered routes at startup, so + they can drift between versions. Asking /routes beats hardcoding names + and getting a 400 at token-creation time. + """ + available = self.request("GET", "/routes", auth=jwt) or {} + granted, missing = {}, [] + for group, verbs in wanted.items(): + if group not in available: + missing.append(group) + continue + ok = [v for v in verbs if v in available[group]] + missing += [f"{group}.{v}" for v in verbs if v not in available[group]] + if ok: + granted[group] = ok + return granted, missing + + def create_token(self, jwt, title, permissions, expires_at): + return self.request( + "PUT", + "/tokens", + body={"title": title, "permissions": permissions, "expires_at": expires_at}, + auth=jwt, + ) + + # -- the operations that carry the workflow --------------------------- + + def whoami(self): + """The account this token belongs to. + + API tokens can never reach /user -- Vikunja excludes every `user_*` + route group from the token-grantable set -- so the username is recorded + in the config at bootstrap time and read back from there. Falling back + to the API keeps this working under a JWT. + """ + if self.username: + return self.username + user = self.request("GET", "/user") + return (user or {}).get("username") + + def projects(self): + return self.paged("/projects") + + def tasks(self, project_id=None, filter=None, sort_by=None, limit=None): + path = f"/projects/{project_id}/tasks" if project_id else "/tasks" + return self.paged( + path, params={"filter": filter, "sort_by": sort_by, "filter_include_nulls": "false"}, limit=limit + ) + + def task(self, task_id): + return self.request("GET", f"/tasks/{task_id}") + + def create_task(self, project_id, title, description=None, priority=None, due_date=None): + body = {"title": title} + if description: + body["description"] = description + if priority is not None: + body["priority"] = priority + if due_date: + body["due_date"] = due_date + return self.request("PUT", f"/projects/{project_id}/tasks", body=body) + + def update_task(self, task_id, **fields): + """Vikunja replaces the whole task on update, so merge onto current state.""" + current = self.task(task_id) + current.update({k: v for k, v in fields.items() if v is not None}) + return self.request("POST", f"/tasks/{task_id}", body=current) + + def comments(self, task_id): + return self.paged(f"/tasks/{task_id}/comments") + + def comment(self, task_id, text): + return self.request("PUT", f"/tasks/{task_id}/comments", body={"comment": text}) + + def assignees(self, task_id): + return self.request("GET", f"/tasks/{task_id}/assignees") + + def assign(self, task_id, user_id): + """Assign, treating an existing assignment as success. + + Agents use assignment to claim work, and a claim that fails because the + claim already happened is not a failure worth propagating. + """ + try: + return self.request("PUT", f"/tasks/{task_id}/assignees", body={"user_id": user_id}) + except VikunjaError as e: + if e.status == 400 and "already assigned" in str(e): + return {"already_assigned": True, "user_id": user_id} + raise + + def unassign(self, task_id, user_id): + return self.request("DELETE", f"/tasks/{task_id}/assignees/{user_id}") + + def find_user(self, username, project_id): + """Resolve a username to a user object, scoped to one project. + + The instance-wide /users search is not in the token-grantable route set, + so this uses /projects/{id}/projectusers instead -- which is arguably the + better check anyway: it only finds users who actually have access to the + project you are about to assign work in. + """ + found = self.request("GET", f"/projects/{project_id}/projectusers", + params={"s": username}) or [] + for u in found: + if u.get("username") == username: + return u + return None + + def labels(self): + return self.paged("/labels") + + def add_label(self, task_id, label_id): + return self.request("PUT", f"/tasks/{task_id}/labels", body={"label_id": label_id})