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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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:
|
||||
|
||||
- `<AGENT_NAME>` — the bot account for this harness, e.g. `bot-nullclaw`
|
||||
- `<HARNESS>` — `claude-code`, `omp`, `picocode`, or `other`
|
||||
|
||||
---
|
||||
|
||||
## Task: configure your own Vikunja access
|
||||
|
||||
You are `<AGENT_NAME>`. 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 <AGENT_NAME> --username <AGENT_NAME> < /path/to/token-file
|
||||
# or: pbpaste | vk profile <AGENT_NAME> --username <AGENT_NAME>
|
||||
```
|
||||
|
||||
`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: <your user 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/<AGENT_NAME>.password)" \
|
||||
vk bootstrap --username <AGENT_NAME> --title "<HARNESS> ($(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=<AGENT_NAME> -- /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": "<AGENT_NAME>" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`picocode` — add to `picocode.yaml`:
|
||||
|
||||
```yaml
|
||||
mcp:
|
||||
vikunja:
|
||||
command: /srv/pods/vikunja-agents/vikunja_mcp.py
|
||||
env:
|
||||
VIKUNJA_PROFILE: <AGENT_NAME>
|
||||
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 <AGENT_NAME>
|
||||
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
|
||||
`<AGENT_NAME>`. 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-<task id>` in commit messages, and
|
||||
`fixes VK-<id>` 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.
|
||||
@@ -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 <device>:~/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-<profile>.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}
|
||||
```
|
||||
Executable
+334
@@ -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()
|
||||
@@ -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 `#<id>`), 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 <name> --username <user>")
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
+261
@@ -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 <bot>`, 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})
|
||||
Reference in New Issue
Block a user