internal/client/ had no coverage for the helpers that turn HTTP
responses into the stable error envelope. e2e exercises happy paths
but never asserts the envelope's Code field for each status, so a
refactor of mapHTTPError could silently drift.
Seven tests in client_test.go:
- TestMapHTTPError_StatusCodeMapping table-drives 401/403/404/409/
429/400/422/500 → the right output.Code constants.
- TestMapHTTPError_RetryAfterAppendedToMessage asserts the
(retry-after <dur>) suffix on 429s.
- TestMapHTTPError_BodyTruncation pumps 600 bytes and asserts the
message ends with …(truncated) and Cause stays nil (per the earlier
'drop synthetic Cause' change).
- TestMapHTTPError_VikunjaJSONTakesPrecedenceOverRawBody asserts the
parsed {code,message} payload wins over the raw body for the
embedded message text.
- TestParseRetryAfter handles delta-seconds, HTTP-date forms (with a
tolerance window because the parser uses time.Until), and the
unparseable/empty/negative/past-date cases.
- TestPaginationDone covers the header-authoritative and len-heuristic
branches across full, short, and empty pages.
- TestCreateBotUser_404TranslatesToBotUsersUnavailable drives a
fake httptest server returning 404 on PUT /api/v1/user/bots and
asserts the error code is BOT_USERS_UNAVAILABLE (the translation
lives in users.go:37-42).
The 30s timeout on the client.New HTTPClient was hard-coded and
opaque. Long-running paginated reads against slow networks were
tripping it with no escape hatch.
Lift the value into a named constant and let .veans.yml override it
via a new optional http_timeout field (Go duration syntax, e.g.
"60s", "5m"). The field has omitempty so a freshly-written
.veans.yml from `veans init` doesn't surface the knob — operators
who need to tune it can hand-edit, but it stays out of the way for
the common case.
Runtime loader applies the override after client.New if set;
bootstrap- and login-time clients (built before .veans.yml exists)
keep the default.
The project picker used to require at least one pre-existing project
and would otherwise hard-error: "no projects visible to this user —
create one in the Vikunja UI first". Now it always offers an extra
numbered entry "Create a new project" and, when the user picks it,
prompts for a title (required) + identifier (optional). Empty-list
case routes straight to creation.
Backed by a new client.CreateProject(ctx, *Project) method (`PUT
/projects`); the e2e harness now uses that instead of the raw c.Do
call it did before.
Also fixed a latent bufio bug in StdPrompter.ReadLine that this work
surfaced: every call created a fresh bufio.Reader, which read-ahead a
buffer and threw it away on return. Second+ prompts read empty. Reuse
one buffered reader on the StdPrompter instance.
The previous init flow took whatever the user typed for --server and
called GET <url>/api/v1/info on it. If the user typed
"vikunja.example.com" (no scheme), or pasted the URL with /api/v1 in
it (double-suffix), or pointed at a localhost install on the default
:3456 port without typing the port, we'd hand back a raw HTTP error.
New `client.DiscoverServer` ports the frontend's
helpers/checkAndSetApiUrl.ts discovery: probe a small ordered set of
plausible bases for /api/v1/info, return the first one that returns
parseable Info. Candidate order:
1. scheme://host[:port]/path (as the user typed it)
2. scheme://host:3456/path (default API port)
3. opposite scheme of (1)
4. opposite scheme of (2)
Heuristics:
- Missing scheme → https for public hosts, http for localhost /
127.0.0.1 / [::1] (matches most CLIs' behaviour)
- Trailing /api/v1 from a pasted URL is stripped before probing, so
we don't double up to /api/v1/api/v1/info
- Trailing slashes normalized
Errors now list everything we tried + the last underlying network
error, so the user can see why a URL failed instead of just
"GET /info: connection refused":
veans: VALIDATION_ERROR: couldn't find a Vikunja instance reachable
from "vikunja.example.com" — tried:
- https://vikunja.example.com/api/v1/info
- https://vikunja.example.com:3456/api/v1/info
- http://vikunja.example.com/api/v1/info
- http://vikunja.example.com:3456/api/v1/info
last error: dial tcp: lookup vikunja.example.com: no such host
bootstrap.Init now defers URL canonicalisation to DiscoverServer and
caches the matched info from the probe (no second /info round-trip).
Unit tests cover the candidate-builder across the common shapes:
bare hostname, localhost, /api/v1-suffixed paste, explicit port,
subpath install, 127.0.0.1:3456, trailing slash. e2e green.
Adds a final step to bootstrap.Init that offers to wire `veans prime`
into Claude Code and OpenCode automatically. Per-agent yes/no prompts
default to "yes" for Claude Code and "no" for OpenCode; --install-claude
/ --install-opencode flags skip the prompt for scripted contexts;
--no-hooks falls back to the previous behaviour of just printing the
snippets.
Claude Code:
- Writes/merges .claude/settings.json
- JSON merge preserves existing keys (model, permissions, other hooks)
and only appends a `veans prime` command entry under SessionStart
and PreCompact if one isn't already there
- Idempotent: re-running reports "Already configured" without
duplicating entries
OpenCode:
- Writes .opencode/plugin/veans-prime.ts with the standard handler
skeleton
- Existing files are left alone (no TS-merge story for v0)
Failures during hook install are non-fatal: the repo is already
configured, so the user gets a warning + the printed snippets as a
fallback path.
Unit tests cover the merge logic (fresh file, idempotent rerun,
preserving user's other hooks/keys), the install actions
("Wrote"/"Updated"/"Already configured"), and the offer flow
(flags-bypass-prompt vs prompt-when-unset vs no-hooks).
Vikunja's built-in OAuth server (Vikunja 2.3+) does not require client
registration and accepts arbitrary client_ids — it just enforces PKCE
(S256) and constrains redirect URIs to the vikunja- scheme. Earlier I
deferred OAuth on the assumption it needed a registered client; that
was wrong, and the docs make the path much smoother than POST /login.
The custom-scheme constraint (no http:// loopback) is side-stepped by
manual paste-back: veans prints the authorize URL, the user signs in,
their browser fails to open vikunja-veans-cli://callback?code=... and
shows an error, the user copies the URL from the address bar and
pastes it back. CLI extracts code + state, verifies state for CSRF,
exchanges via POST /api/v1/oauth/token (JSON body — Vikunja rejects
form-encoded), and returns the access token.
Resolution order in AcquireHumanToken:
1. --token (paste-in JWT or personal API token; SSO/OIDC users)
2. --use-password / --username + --password (POST /login)
3. OAuth flow (interactive default)
login command supports the same --use-password / --token escape hatches
for token rotation on instances with OAuth disabled.
Includes unit tests for the PKCE generator (verifier shape per RFC 7636,
challenge = SHA256(verifier) base64url-no-pad), authorize-URL
construction, and the lenient callback parser (full URL / query-only /
bare code).