249 lines
13 KiB
Markdown
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.
|