vikunja/veans/AGENTS.md

249 lines
13 KiB
Markdown

# 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.DoMerge` → `UpdateTask(*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.