10 KiB
10 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
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:
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.POST /tasks/{id}does NOT move tasks between buckets. The task↔bucket relation is row-shaped; useclient.MoveTaskToBucket()which hitsPOST /projects/{p}/views/{v}/buckets/{b}/tasks. The Update path on the server only auto-moves ondoneflips.- Bot user creation is
PUT /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
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
PUT /user/botsautomatically sets the bot'sbot_owner_idto the calling user. Only the owner can mint tokens for the bot viaPUT /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 is JSON only. Form-encoded POSTs to
/oauth/tokenfail; the standardgolang.org/x/oauth2client speaks form encoding, which is why we have a hand-rolledclient.ExchangeOAuthCode.
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.