vikunja/veans/AGENTS.md

13 KiB

AGENT Instructions for veans

Things to know before touching this submodule that aren't obvious from reading the code. The parent repo's CLAUDE.md covers the rest of Vikunja; this file is veans-specific.

Module layout

  • veans/ is its own Go module (code.vikunja.io/veans), separate from the parent. Don't try to import code.vikunja.io/api/... — that pulls XORM into the CLI binary. Wire types live in internal/client/types.go as plain JSON-tagged structs that mirror the parent models.
  • License headers are enforced by goheader in veans/.golangci.yml. Every new .go file needs the AGPLv3 banner from veans/code-header-template.txt (a copy of the parent's, kept local so the linter resolves the path relative to this module).

Building and testing

  • mage build./veans binary. The Aliases map in magefile.go routes bare names like mage test to Test.All — without aliases, mage rejects namespace invocations ("Unknown target specified").
  • Unit tests: mage test (passes -short) or go test -short ./.... The e2e package's TestMain gates the suite on -short, mirroring the parent monorepo's pkg/webtests convention. Without -short and without VEANS_E2E_API_URL set, the e2e tests fail loudly with a "configure or pass -short" hint.
  • E2e tests: mage test:e2e (no -short). Assumes an externally- running Vikunja at VEANS_E2E_API_URL. The harness seeds its own admin user via PATCH /api/v1/test/users — same mechanism the playwright suite uses — so the API must be booted with VIKUNJA_SERVICE_TESTINGTOKEN=<token> and the same value passed in via VEANS_E2E_TESTING_TOKEN. Alternative path: VEANS_E2E_ADMIN_TOKEN=<jwt> skips the seed and uses the given token as-is, for driving a long-lived Vikunja the suite shouldn't mutate user rows on.
  • Local e2e loop: from the parent repo root, build the API (mage build:build), run it with sqlite-memory + a known JWT secret + VIKUNJA_SERVICE_TESTINGTOKEN, then mage test:e2e from veans/ with VEANS_E2E_API_URL + VEANS_E2E_TESTING_TOKEN. No manual seeding step — the test harness handles it.
  • CI: the test-veans-e2e job in .github/workflows/test.yml consumes the existing vikunja_bin artifact from api-build; don't recompile the API in a parallel workflow. The veans-test job runs unit tests with -short for fast feedback, independent of api-build.

Vikunja wire-format gotchas

veans targets the Huma-backed /api/v2 exclusively (apiBasePath in internal/client/client.go). v1 is frozen, and the kanban-bucket CRUD veans relies on only exists on v2. Most failures surface when crossing the JSON boundary. The list below is what's bitten me; if a new endpoint behaves oddly, suspect one of these:

  • Lists come wrapped in the standard envelope. Every v2 list returns {"items":[...],"total":N,"page":N,"per_page":N,"total_pages":N}, not a bare array, and there is no x-pagination-total-pages header anymore. Decode with the generic Paginated[T] helper. Most lists are server-paginated — their model's ReadAll applies a 50-item page limit: tasks, projects, labels, comments and bots. Page through those with doListAll until page >= total_pages; returning only page 1 silently truncates (>50 comments on a task is realistic). Buckets and project views are the exception: their ReadAll takes _ int, _ int and returns every row in one page, so fetch them with a single doList and unwrap .items — paging those would re-fetch the full set and duplicate it. Single-object responses (create/update/read of one entity) stay UNWRAPPED.
  • v2 flips the create/update verbs. Creates are POST (v1 used PUT): projects, labels, tokens, bot users, project shares, task create, comments, relations, assignees, label-attach, bucket create. Task update is PATCH (see below). The bucket-task move is PUT.
  • Task update is PATCH /tasks/{id} with application/merge-patch+json (client.DoMergeUpdateTask(*TaskPatch)). Only the fields present in the body are written; absent fields are left intact. Build the body from TaskPatch (pointer fields, omitempty) — never a whole client.Task, whose no-omitempty done/title would clobber those columns on every call (this was issue #2962).
  • List search is q, not v1's s (ListParams.Q). Task-list filter/expand/page/per_page keep their names.
  • ProjectView.view_kind and bucket_configuration_mode are strings, not ints. The parent enums (ProjectViewKind, BucketConfigurationModeKind) have custom MarshalJSON that emits "kanban" / "manual" etc. Use the string constants in internal/client/types.go.
  • Task.BucketID is always 0 in GET /tasks/:id. The model has xorm:"-" on it — the actual bucket lives in a separate task_buckets table. Fetch with ?expand=buckets and use task.CurrentBucketID(viewID) to read it.
  • Task updates do NOT move tasks between buckets. The task↔bucket relation is row-shaped; use client.MoveTaskToBucket() which hits PUT /projects/{p}/views/{v}/buckets/{b}/tasks with a {"task_id":N} body (project/view/bucket all come from the URL). The Update path on the server only auto-moves on done flips.
  • Bot user creation is POST /user/bots, not /bots — the routes are registered under the /user subgroup. Same prefix for GET /user/bots.
  • APIToken.expires_at is required. The struct field has valid:"required" upstream; sending it omitted or zero fails validation. Use client.FarFuture (year 9999) when you mean "no expiry" — the frontend does the same.
  • Task descriptions and comments are HTML, not markdown. The Vikunja web UI uses TipTap, which calls getHTML() on save. The stored field is therefore HTML. The agent prompt template (internal/commands/prompt.tmpl) teaches agents the canonical TipTap shapes — most importantly <ul data-type="taskList"> + <li data-type="taskItem" data-checked="false"><p>…</p></li> for interactive checkboxes. We deliberately do not convert markdown↔HTML in the CLI; the agent writes HTML directly, which avoids lossy roundtrips on --description-replace-old/new. veans show displays the raw HTML; humans skim it fine.

API token permissions

  • Vikunja validates token permissions against apiTokenRoutes, a map built dynamically from registered routes. Group names are derived from the URL path (params stripped, joined by _). Examples:
    • /projects/:project/views/:view/buckets/:bucket/tasks → group projects, action views_buckets_tasks
    • /tasks/:task/comments → group tasks_comments, action create
  • v1 and v2 deliberately share (group, permission) keys: pkg/models/api_routes.go normalizes the inverted verbs (v2 POST-create and v1 PUT-create both → create; v2 PUT/PATCH-update and v1 POST-update both → update), and CanDoAPIRoute consults both route tables, treating PATCH as an alias for the stored PUT. So PermissionsForBot's scope map authorizes the v2 calls unchanged, including the PATCH task update.
  • The bucket-task MOVE (PUT …/buckets/:bucket/tasks) and the buckets-with-tasks LIST (GET …/buckets/tasks) collide on subkey views_buckets_tasks; which one gets the bare key vs views_buckets_tasks_put depends on unspecified route-init order, so the bot requests both.
  • client.PermissionsForBot() calls GET /routes at runtime and grants only the intersection of what we want and what the server exposes. Don't hard-code permission group names — they drift across Vikunja versions, and discovery keeps the bot's grant valid across upgrades.

Bot ownership and token minting

  • Creating a bot via POST /user/bots automatically sets the bot's bot_owner_id to the calling user. Only the owner can mint tokens for the bot via POST /tokens with owner_id=<bot_id>. The init flow does these as a single human-JWT-authenticated batch.
  • Bots have no password and cannot authenticate via POST /login. After init, veans login re-authenticates as the human (not the bot) and mints a fresh bot token.

OAuth flow

  • Vikunja's authorization server requires PKCE/S256 and accepts either vikunja-…:// custom schemes or RFC 8252 loopback URIs (http://127.0.0.1:NNN/, http://localhost:NNN/, http://[::1]:NNN/). No client registration needed — client_id can be any consistent string (we use veans-cli).
  • internal/auth/oauth.go binds a free port on 127.0.0.1, opens the browser, and captures the callback. The Shutdown defer uses context.WithoutCancel(ctx) so cancellation at the outer scope still drains the loopback server cleanly.
  • Token exchange goes out as JSON. v2's /oauth/token accepts both JSON and form-encoded bodies (Huma picks the decoder off the Content-Type header), but the standard golang.org/x/oauth2 client hard-codes form encoding and its own response shape, so we keep the hand-rolled client.ExchangeOAuthCode that speaks JSON.

Credential store

  • Lookup chain: keychain → env (VEANS_TOKEN) → file (~/.config/veans/credentials.yml, mode 0600, atomic-write + flock serialization). XDG_CONFIG_HOME is deliberately not honored — agent-only audience runs in a known environment, and the env var was a path-traversal seam for no real benefit.
  • Chain.Set falls through to the next backend on error so a missing dbus on a CI runner doesn't block writes — the file backend is the reliable last-resort.
  • File writes go through a tmp file + Rename, with Chmod 0o600 re-asserted on the destination inode so a pre-existing wider mode is narrowed. Concurrent writers (e.g. two veans login runs) are serialized via flock on <path>.lock (Unix only; Windows is a no-op stub since the audience is Linux/macOS).
  • E2e tests override HOME per test and filterEnv(..., "VEANS_") strips any inherited VEANS_TOKEN so the developer's keyring stays untouched. Don't bypass the credentials package in tests — leaks between tests will surface as the wrong bot token.

Project identifiers and bot usernames

  • Project Identifier is runelength(0|10), can be empty. When empty, Config.FormatTaskID renders #NN; otherwise PROJ-NN. Both are accepted by runtime.resolveTaskID along with bare integers.
  • Bot username must start with bot-; the server enforces it. Hyphens, digits, lowercase letters allowed; no spaces, no commas, no link-share-N pattern. config.SuggestedBotUsername does the folding for repo names.
  • E2e tests deriving identifiers from a unique suffix should use the trailing chars of strconv.FormatInt(time.Now().UnixNano(), 36). The leading chars barely change between consecutive runs and will collide if you take [:N].

Audience split

The CLI is agent-only at runtime; humans never use it for day-to-day work (they use Vikunja's web UI). Two commands serve a human running one-off setup:

  • init — bootstrap a repo: pick project + view, create bot, share, mint token, write .veans.yml, install hooks.
  • login — rotate the bot's token.

Everything else (list, show, create, update, claim, api, prime, version) is agent-only:

  • Emits JSON on stdout unconditionally. No --json flag, no human-formatted variant. list is a raw array; show / create / update / claim return a single task object.
  • Errors are JSON on stderr with non-zero exit — same envelope everywhere ({"code": "...", "error": "..."}), regardless of which command ran. Stable codes in internal/output/errors.go: NOT_FOUND, CONFLICT, VALIDATION_ERROR, AUTH_ERROR, RATE_LIMITED, BOT_USERS_UNAVAILABLE, NOT_CONFIGURED, UNKNOWN. Don't add ad-hoc strings — wrap with output.New / output.Wrap.
  • No globals.JSON, no dual rendering paths. If you find yourself reaching for "if interactive, do X" on an agent-facing command, stop — it's not interactive, an agent is on the other end.

Cobra surface conventions

  • RunE handlers that don't use args []string should rename it to _ to satisfy revive's unused-parameter rule.
  • The bucket-move dance (MoveTaskToBucket) runs after the field update on update, so a status transition can't clobber freshly attached labels. Comments for --status scrapped post before the bucket move so the audit trail reads in chronological order.
  • Agent-facing commands return the task via json.NewEncoder(...).Encode(task). Adding new top-level keys to client.Task is an implicit API change — bump prime's "useful fields" note alongside.

Things to not do

  • Don't add an os/exec.Command without ctx — noctx is enabled. Use exec.CommandContext(ctx, …) and thread the context through.
  • Don't commit the built binary. veans/.gitignore covers ./veans and ./veans.exe.
  • Don't write to stdout from prime when no .veans.yml is found. The hook contract is silent + exit 0 so the snippet is safe to install globally in ~/.claude/settings.json.
  • Don't rename canonical bucket titles without updating BucketTitleAliases[s][0] in internal/status/status.go, the prompt template (internal/commands/prompt.tmpl), and the e2e assertions in lockstep — agents and humans both treat them as fixed strings.