13 KiB
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 importcode.vikunja.io/api/...— that pulls XORM into the CLI binary. Wire types live ininternal/client/types.goas plain JSON-tagged structs that mirror the parent models.- License headers are enforced by
goheaderinveans/.golangci.yml. Every new.gofile needs the AGPLv3 banner fromveans/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→./veansbinary. TheAliasesmap inmagefile.goroutes bare names likemage testtoTest.All— without aliases, mage rejects namespace invocations ("Unknown target specified").- Unit tests:
mage test(passes-short) orgo test -short ./.... The e2e package'sTestMaingates the suite on-short, mirroring the parent monorepo'spkg/webtestsconvention. Without-shortand withoutVEANS_E2E_API_URLset, the e2e tests fail loudly with a "configure or pass -short" hint. - E2e tests:
mage test:e2e(no-short). Assumes an externally- running Vikunja atVEANS_E2E_API_URL. The harness seeds its own admin user viaPATCH /api/v1/test/users— same mechanism the playwright suite uses — so the API must be booted withVIKUNJA_SERVICE_TESTINGTOKEN=<token>and the same value passed in viaVEANS_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, thenmage test:e2efromveans/withVEANS_E2E_API_URL+VEANS_E2E_TESTING_TOKEN. No manual seeding step — the test harness handles it. - CI: the
test-veans-e2ejob in.github/workflows/test.ymlconsumes the existingvikunja_binartifact fromapi-build; don't recompile the API in a parallel workflow. Theveans-testjob runs unit tests with-shortfor fast feedback, independent ofapi-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 nox-pagination-total-pagesheader anymore. Decode with the genericPaginated[T]helper. Most lists are server-paginated — their model'sReadAllapplies a 50-item page limit: tasks, projects, labels, comments and bots. Page through those withdoListAlluntilpage >= total_pages; returning only page 1 silently truncates (>50 comments on a task is realistic). Buckets and project views are the exception: theirReadAlltakes_ int, _ intand returns every row in one page, so fetch them with a singledoListand 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}withapplication/merge-patch+json(client.DoMerge→UpdateTask(*TaskPatch)). Only the fields present in the body are written; absent fields are left intact. Build the body fromTaskPatch(pointer fields, omitempty) — never a wholeclient.Task, whose no-omitemptydone/titlewould clobber those columns on every call (this was issue #2962). - List search is
q, not v1'ss(ListParams.Q). Task-listfilter/expand/page/per_pagekeep their names. ProjectView.view_kindandbucket_configuration_modeare strings, not ints. The parent enums (ProjectViewKind,BucketConfigurationModeKind) have customMarshalJSONthat emits"kanban"/"manual"etc. Use the string constants ininternal/client/types.go.Task.BucketIDis always 0 inGET /tasks/:id. The model hasxorm:"-"on it — the actual bucket lives in a separatetask_bucketstable. Fetch with?expand=bucketsand usetask.CurrentBucketID(viewID)to read it.- Task updates do NOT move tasks between buckets. The task↔bucket
relation is row-shaped; use
client.MoveTaskToBucket()which hitsPUT /projects/{p}/views/{v}/buckets/{b}/taskswith a{"task_id":N}body (project/view/bucket all come from the URL). The Update path on the server only auto-moves ondoneflips. - Bot user creation is
POST /user/bots, not/bots— the routes are registered under the/usersubgroup. Same prefix forGET /user/bots. APIToken.expires_atis required. The struct field hasvalid:"required"upstream; sending it omitted or zero fails validation. Useclient.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 showdisplays the raw HTML; humans skim it fine.
API token permissions
- Vikunja validates token
permissionsagainstapiTokenRoutes, 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→ groupprojects, actionviews_buckets_tasks/tasks/:task/comments→ grouptasks_comments, actioncreate
- v1 and v2 deliberately share
(group, permission)keys:pkg/models/api_routes.gonormalizes the inverted verbs (v2 POST-create and v1 PUT-create both →create; v2 PUT/PATCH-update and v1 POST-update both →update), andCanDoAPIRouteconsults both route tables, treating PATCH as an alias for the stored PUT. SoPermissionsForBot'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 subkeyviews_buckets_tasks; which one gets the bare key vsviews_buckets_tasks_putdepends on unspecified route-init order, so the bot requests both. client.PermissionsForBot()callsGET /routesat 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/botsautomatically sets the bot'sbot_owner_idto the calling user. Only the owner can mint tokens for the bot viaPOST /tokenswithowner_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 loginre-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_idcan be any consistent string (we useveans-cli). internal/auth/oauth.gobinds a free port on 127.0.0.1, opens the browser, and captures the callback. TheShutdowndefer usescontext.WithoutCancel(ctx)so cancellation at the outer scope still drains the loopback server cleanly.- Token exchange goes out as JSON. v2's
/oauth/tokenaccepts both JSON and form-encoded bodies (Huma picks the decoder off theContent-Typeheader), but the standardgolang.org/x/oauth2client hard-codes form encoding and its own response shape, so we keep the hand-rolledclient.ExchangeOAuthCodethat speaks JSON.
Credential store
- Lookup chain: keychain → env (
VEANS_TOKEN) → file (~/.config/veans/credentials.yml, mode 0600, atomic-write + flock serialization).XDG_CONFIG_HOMEis 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.Setfalls 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, withChmod 0o600re-asserted on the destination inode so a pre-existing wider mode is narrowed. Concurrent writers (e.g. twoveans loginruns) are serialized viaflockon<path>.lock(Unix only; Windows is a no-op stub since the audience is Linux/macOS). - E2e tests override
HOMEper test andfilterEnv(..., "VEANS_")strips any inheritedVEANS_TOKENso 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
Identifierisrunelength(0|10), can be empty. When empty,Config.FormatTaskIDrenders#NN; otherwisePROJ-NN. Both are accepted byruntime.resolveTaskIDalong with bare integers. - Bot username must start with
bot-; the server enforces it. Hyphens, digits, lowercase letters allowed; no spaces, no commas, nolink-share-Npattern.config.SuggestedBotUsernamedoes 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
--jsonflag, no human-formatted variant.listis a raw array;show/create/update/claimreturn 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 ininternal/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 withoutput.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
RunEhandlers that don't useargs []stringshould rename it to_to satisfy revive'sunused-parameterrule.- The bucket-move dance (
MoveTaskToBucket) runs after the field update onupdate, so a status transition can't clobber freshly attached labels. Comments for--status scrappedpost 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 toclient.Taskis an implicit API change — bumpprime's "useful fields" note alongside.
Things to not do
- Don't add an
os/exec.Commandwithout ctx —noctxis enabled. Useexec.CommandContext(ctx, …)and thread the context through. - Don't commit the built binary.
veans/.gitignorecovers./veansand./veans.exe. - Don't write to stdout from
primewhen no.veans.ymlis 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]ininternal/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.