feat(veans): install agent hooks during init instead of just printing

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).
This commit is contained in:
Tink bot 2026-05-15 14:34:13 +00:00 committed by kolaente
parent 1bc3afa430
commit 814b2a635f
39 changed files with 836 additions and 495 deletions

View File

@ -92,11 +92,11 @@ linters:
- gosec
path: e2e/
text: 'G(204|306|703):'
# .veans.yml is committed to the repo and intentionally world-
# readable; 0o644 is correct.
# .veans.yml + agent hook config files are committed to the repo
# and intentionally world-readable; 0o644 is correct.
- linters:
- gosec
path: internal/config/config\.go
path: internal/(config|bootstrap)/.*\.go
text: 'G306:'
formatters:
enable:

View File

@ -60,6 +60,16 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
`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
@ -126,18 +136,44 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
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
- Global `--json` flag flips both successful output (raw object for
`show`, raw array for `list`) and the error envelope.
- Stable error codes in `internal/output/errors.go`. Don't add new
ad-hoc strings — wrap with `output.New` / `output.Wrap`.
- `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

View File

@ -73,8 +73,8 @@ without affecting your own session.
```
veans init OAuth/login → create bot → mint token → write .veans.yml
veans prime emit system prompt for agents (silent if no .veans.yml)
veans list filtered list (--ready, --mine, --branch, --filter, --status, --json)
veans show <id> view a task (--json for raw object)
veans list filtered list (--ready, --mine, --branch, --filter, --status); emits JSON
veans show <id> view a task (JSON)
veans create "title" --description, --label, --status, --priority, --parent, --blocked-by
veans update <id> --status, --title, --priority, --label-add/remove,
--description, --description-replace-old/new, --description-append,

View File

@ -29,7 +29,7 @@ import (
func TestClaim_AssignsBotMovesToInProgressTagsBranch(t *testing.T) {
ws, h := provisionWorkspace(t)
out, _, code := h.Run(t, ws, "--json", "create", "claim me")
out, _, code := h.Run(t, ws, "create", "claim me")
if code != 0 {
t.Fatalf("create exit %d\n%s", code, out)
}

View File

@ -34,20 +34,17 @@ import (
"runtime"
"strings"
"testing"
"time"
"code.vikunja.io/veans/internal/client"
)
// Harness bundles a built veans binary and an authenticated admin client
// for verifying side effects on the server. It also tracks the base env
// (HOME / XDG_CONFIG_HOME overrides) every runVeans invocation inherits.
// for verifying side effects on the server.
type Harness struct {
binary string
apiURL string
adminToken string
adminClient *client.Client
suiteStartTS time.Time
Binary string
APIURL string
AdminToken string
AdminClient *client.Client
}
// SkipIfNotConfigured calls t.Skip if the suite hasn't been pointed at a
@ -89,11 +86,10 @@ func New(t *testing.T) *Harness {
}
return &Harness{
binary: binary,
apiURL: apiURL,
adminToken: tok,
adminClient: client.New(apiURL, tok),
suiteStartTS: time.Now(),
Binary: binary,
APIURL: apiURL,
AdminToken: tok,
AdminClient: client.New(apiURL, tok),
}
}
@ -162,7 +158,7 @@ func (h *Harness) NewWorkspace(t *testing.T) *Workspace {
// stderr, and exit code.
func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stderr string, exitCode int) {
t.Helper()
cmd := exec.CommandContext(t.Context(), h.binary, args...)
cmd := exec.CommandContext(t.Context(), h.Binary, args...)
cmd.Dir = ws.Dir
cmd.Env = append(os.Environ(), envSlice(ws.envOverrides)...)
var so, se bytes.Buffer
@ -179,15 +175,6 @@ func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stde
return so.String(), se.String(), 0
}
// AdminClient returns the admin-authenticated client for verification.
func (h *Harness) AdminClient() *client.Client { return h.adminClient }
// AdminToken returns the admin's bearer token (handy for --token flows).
func (h *Harness) AdminToken() string { return h.adminToken }
// APIURL returns the configured Vikunja base URL.
func (h *Harness) APIURL() string { return h.apiURL }
// CreateProject creates a fresh project owned by the admin user and returns
// it. Tests use a unique title to keep results isolated across parallel runs.
func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.Project {
@ -197,7 +184,7 @@ func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.
body["identifier"] = identifier
}
var out client.Project
if err := h.adminClient.Do(context.Background(), "PUT", "/projects", nil, body, &out); err != nil {
if err := h.AdminClient.Do(context.Background(), "PUT", "/projects", nil, body, &out); err != nil {
t.Fatalf("create project %q: %v", title, err)
}
return &out
@ -207,7 +194,7 @@ func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.
// auto-creates one).
func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectView {
t.Helper()
views, err := h.adminClient.ListProjectViews(context.Background(), projectID)
views, err := h.AdminClient.ListProjectViews(context.Background(), projectID)
if err != nil {
t.Fatalf("list views: %v", err)
}
@ -223,7 +210,7 @@ func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectV
// GetTask fetches a task by ID for verification.
func (h *Harness) GetTask(t *testing.T, id int64) *client.Task {
t.Helper()
task, err := h.adminClient.GetTask(context.Background(), id)
task, err := h.AdminClient.GetTask(context.Background(), id)
if err != nil {
t.Fatalf("get task %d: %v", id, err)
}

View File

@ -47,8 +47,8 @@ func TestInit_HappyPath(t *testing.T) {
stdout, stderr, code := h.Run(t, ws,
"init",
"--server", h.APIURL(),
"--token", h.AdminToken(),
"--server", h.APIURL,
"--token", h.AdminToken,
"--project", fmt.Sprintf("%d", project.ID),
"--view", fmt.Sprintf("%d", view.ID),
"--bot-username", ws.BotUsername,
@ -76,7 +76,7 @@ func TestInit_HappyPath(t *testing.T) {
// Bot token persisted in the file backend (since HOME points at a
// fresh tmpdir, the file backend takes over from the missing keyring).
store := credentials.NewFileBackend(ws.XDGConfig + "/veans/credentials.yml")
tok, err := store.Get(h.APIURL(), ws.BotUsername)
tok, err := store.Get(h.APIURL, ws.BotUsername)
if err != nil {
t.Fatalf("token not persisted: %v", err)
}
@ -85,7 +85,7 @@ func TestInit_HappyPath(t *testing.T) {
}
// Bot exists on the server with the right username.
bots, err := h.AdminClient().ListBotUsers(context.Background())
bots, err := h.AdminClient.ListBotUsers(context.Background())
if err != nil {
t.Fatalf("list bots: %v", err)
}
@ -105,7 +105,7 @@ func TestInit_HappyPath(t *testing.T) {
// Project shared with the bot at write permission.
var shares []map[string]any
_ = h.AdminClient().Do(context.Background(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
_ = h.AdminClient.Do(context.Background(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
shareFound := false
for _, s := range shares {
if u, _ := s["username"].(string); u == ws.BotUsername {
@ -132,8 +132,8 @@ func TestInit_NoIdentifierFallsBackToHashNN(t *testing.T) {
_, stderr, code := h.Run(t, ws,
"init",
"--server", h.APIURL(),
"--token", h.AdminToken(),
"--server", h.APIURL,
"--token", h.AdminToken,
"--project", fmt.Sprintf("%d", project.ID),
"--view", fmt.Sprintf("%d", view.ID),
"--bot-username", ws.BotUsername,

View File

@ -39,8 +39,8 @@ func provisionWorkspace(t *testing.T) (*Workspace, *Harness) {
_, stderr, code := h.Run(t, ws,
"init",
"--server", h.APIURL(),
"--token", h.AdminToken(),
"--server", h.APIURL,
"--token", h.AdminToken,
"--project", iToS(project.ID),
"--view", iToS(view.ID),
"--bot-username", ws.BotUsername,

View File

@ -33,7 +33,7 @@ func TestCreateShowList_RoundTrip(t *testing.T) {
// Create a task with a description and a label.
out, errOut, code := h.Run(t, ws,
"--json", "create", "Test bug fix",
"create", "Test bug fix",
"-d", "## Repro\n- [ ] step 1\n- [ ] step 2",
"--label", "bug",
"--priority", "3",
@ -57,7 +57,7 @@ func TestCreateShowList_RoundTrip(t *testing.T) {
// Show with --json — should be a raw object, not enveloped.
id := fmt.Sprintf("%d", created.Index)
showOut, _, code := h.Run(t, ws, "--json", "show", id)
showOut, _, code := h.Run(t, ws, "show", id)
if code != 0 {
t.Fatalf("show exit %d", code)
}
@ -70,7 +70,7 @@ func TestCreateShowList_RoundTrip(t *testing.T) {
}
// List with --json — should be a raw array.
listOut, _, code := h.Run(t, ws, "--json", "list")
listOut, _, code := h.Run(t, ws, "list")
if code != 0 {
t.Fatalf("list exit %d", code)
}
@ -83,7 +83,7 @@ func TestCreateShowList_RoundTrip(t *testing.T) {
}
// --filter passthrough: only items with priority > 2.
filterOut, _, code := h.Run(t, ws, "--json", "list", "--filter", "priority > 2")
filterOut, _, code := h.Run(t, ws, "list", "--filter", "priority > 2")
if code != 0 {
t.Fatalf("list --filter exit %d\n%s", code, filterOut)
}
@ -104,7 +104,7 @@ func TestCreateShowList_RoundTrip(t *testing.T) {
func TestUpdate_DescriptionReplaceUniqueness(t *testing.T) {
ws, h := provisionWorkspace(t)
out, errOut, code := h.Run(t, ws, "--json", "create", "checkbox task",
out, errOut, code := h.Run(t, ws, "create", "checkbox task",
"-d", "- [ ] step 1\n- [ ] step 1 (again)",
)
if code != 0 {

View File

@ -15,6 +15,7 @@ require (
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.43.0 // indirect
)

View File

@ -11,6 +11,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -25,6 +27,7 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=

View File

@ -16,16 +16,10 @@
// Package auth handles the human's transient authentication during init and
// login. The default interactive flow is OAuth 2.0 Authorization Code + PKCE
// against Vikunja's built-in authorization server (no client registration
// needed; PKCE/S256 mandatory). The user opens the authorize URL in their
// browser, signs in, and pastes the resulting `vikunja-veans-cli://callback`
// URL back into the CLI — that side-steps custom-scheme handler registration
// entirely.
//
// For non-interactive contexts (CI scripts, paste-in tokens, accounts on
// instances without OAuth), pass --token, --username + --password, or
// --use-password. Personal API tokens via --token also let SSO/OIDC users
// onboard without exercising local password login.
// against Vikunja's built-in authorization server. The OAuth dance opens a
// browser at the authorize URL; the user signs in and lands on a localhost
// callback this CLI ran. --token / --use-password / --username + --password
// are escape hatches for non-interactive contexts.
package auth
import (
@ -36,7 +30,6 @@ import (
"io"
"os"
"strings"
"syscall"
"golang.org/x/term"
@ -50,22 +43,17 @@ type Prompter interface {
ReadPassword(prompt string) (string, error)
}
// StdPrompter reads from os.Stdin and uses term.ReadPassword for masked
// input. It's the production default.
type StdPrompter struct {
In io.Reader
Out io.Writer
}
// StdPrompter reads from os.Stdin and writes prompts to os.Stderr; uses
// term.ReadPassword for masked input when on a TTY.
type StdPrompter struct{}
func NewStdPrompter() *StdPrompter {
return &StdPrompter{In: os.Stdin, Out: os.Stderr}
}
func NewStdPrompter() *StdPrompter { return &StdPrompter{} }
func (p *StdPrompter) ReadLine(prompt string) (string, error) {
if _, err := fmt.Fprint(p.Out, prompt); err != nil {
func (*StdPrompter) ReadLine(prompt string) (string, error) {
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
return "", err
}
r := bufio.NewReader(p.In)
r := bufio.NewReader(os.Stdin)
line, err := r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
@ -74,20 +62,19 @@ func (p *StdPrompter) ReadLine(prompt string) (string, error) {
}
func (p *StdPrompter) ReadPassword(prompt string) (string, error) {
if _, err := fmt.Fprint(p.Out, prompt); err != nil {
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
return "", err
}
if f, ok := p.In.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
buf, err := term.ReadPassword(int(f.Fd()))
fmt.Fprintln(p.Out)
if term.IsTerminal(int(os.Stdin.Fd())) {
buf, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
return string(buf), nil
}
// Non-TTY (CI, scripted test) — read a plain line.
line, err := p.ReadLine("")
return line, err
return p.ReadLine("")
}
// LoginOptions controls how AcquireHumanToken obtains a JWT.
@ -171,8 +158,3 @@ func loginWithPassword(ctx context.Context, c *client.Client, opts LoginOptions,
}
return resp.Token, nil
}
// silenceLinter suppresses the unused syscall import on platforms where
// term.ReadPassword inlines its own platform call. We keep the import to
// document that masked input is expected to use POSIX-level terminal modes.
var _ = syscall.Stdin

View File

@ -1,48 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
// osOpen launches the OS's default browser at the given URL. The file is
// kept platform-neutral by delegating to "xdg-open" — the same approach
// most Go CLIs take. macOS, Linux and BSD all ship it (or a compat alias);
// on Windows the ./browser_windows.go shim takes precedence via build tag.
import (
"context"
"os/exec"
"runtime"
"time"
)
func osOpen(ctx context.Context, url string) error {
// Cap the launch attempt so a misbehaving xdg-open shim can't block
// the OAuth flow indefinitely. The browser process itself runs
// independently of this child and survives the timeout.
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.CommandContext(cctx, "open", url)
case "windows":
cmd = exec.CommandContext(cctx, "rundll32", "url.dll,FileProtocolHandler", url)
default:
cmd = exec.CommandContext(cctx, "xdg-open", url)
}
return cmd.Start()
}

View File

@ -21,7 +21,6 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
@ -30,6 +29,8 @@ import (
"strings"
"time"
"github.com/pkg/browser"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/output"
)
@ -130,7 +131,10 @@ func runOAuthFlow(ctx context.Context, c *client.Client, _ Prompter, w io.Writer
authURL := buildAuthorizeURL(c.BaseURL, redirectURI, pkce, state)
announceBrowserStep(w, authURL)
openBrowser(ctx, authURL)
// Best-effort browser launch — the URL is also printed so the user
// can paste it manually if their environment can't auto-open one
// (SSH session, container without DISPLAY, etc.).
_ = browser.OpenURL(authURL)
result, err := waitForCallback(ctx, resultCh)
if err != nil {
@ -258,12 +262,4 @@ func renderCallbackPage(w http.ResponseWriter, err error) {
</body></html>`))
}
// openBrowser tries to launch the user's default browser at `url`. Failure
// is ignored — the calling flow already prints the URL to stderr so the
// user can open it themselves.
func openBrowser(ctx context.Context, url string) {
_ = osOpen(ctx, url)
}
// silence the unused-import linter when errors isn't referenced elsewhere.
var _ = errors.New

View File

@ -26,7 +26,6 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"io"
"sort"
@ -76,9 +75,14 @@ type Options struct {
AutoApproveBuckets bool
SkipBucketBootstrap bool
// Store and Prompter are dependency-injected for testing.
Store credentials.Store
Prompter auth.Prompter
// Agent hook installation. If neither flag is set, the user is prompted
// per-agent at the end of init. NoHooks skips the offering entirely
// and falls back to printing the snippets.
InstallClaudeCode bool
InstallOpenCode bool
ClaudeCodeFlagSet bool
OpenCodeFlagSet bool
NoHooks bool
// Out is where progress is written.
Out io.Writer
@ -87,13 +91,11 @@ type Options struct {
RepoRoot string
}
// Result is returned on success. The caller (cobra command) prints
// hook snippets and the bot username for the user.
// Result is returned on success — just the bits printPostInitSummary reads.
type Result struct {
Config *config.Config
Info *client.Info
BotUser *client.BotUser
Token *client.APIToken
AgentChoices AgentHookChoice
}
// Init runs the full onboarding flow. Steps are deliberately sequential and
@ -104,23 +106,14 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
opts = &Options{}
}
if opts.Out == nil {
opts = &Options{}
}
if opts.Out == nil {
opts = &Options{Out: io.Discard}
opts.Out = io.Discard
}
if opts.ConfigPath == "" {
return nil, output.New(output.CodeValidation, "ConfigPath is required")
}
prompter := opts.Prompter
if prompter == nil {
prompter = auth.NewStdPrompter()
}
store := opts.Store
if store == nil {
store = credentials.Default()
}
prompter := auth.NewStdPrompter()
store := credentials.Default()
// 1. Repo root + suggested bot username.
repoRoot := opts.RepoRoot
@ -253,7 +246,28 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
}
progress(opts.Out, "Wrote %s", opts.ConfigPath)
return &Result{Config: cfg, Info: info, BotUser: bot, Token: mintedToken}, nil
// 13. Offer to install agent hooks. Pre-seeded from flags; the rest
// is prompted unless --no-hooks. Failures here are non-fatal — the
// repo is already configured; the user can install hooks by hand.
choices := AgentHookChoice{
ClaudeCode: opts.InstallClaudeCode,
OpenCode: opts.InstallOpenCode,
}
choices, err = offerAgentHooks(prompter, opts.Out, choices,
opts.ClaudeCodeFlagSet, opts.OpenCodeFlagSet, opts.NoHooks)
if err != nil {
return nil, err
}
if err := installAgentHooks(repoRoot, choices, opts.Out); err != nil {
// Log but don't abort — the repo is configured.
fmt.Fprintf(opts.Out, " ! hook install failed: %v (you can paste the snippets manually)\n", err)
}
return &Result{
Config: cfg,
BotUser: bot,
AgentChoices: choices,
}, nil
}
func normalizeBotUsername(override, suggested string) string {
@ -455,4 +469,3 @@ func progress(w io.Writer, format string, args ...any) {
}
// silence the unused-import linter when errors isn't used elsewhere.
var _ = errors.New

View File

@ -0,0 +1,269 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package bootstrap
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"code.vikunja.io/veans/internal/auth"
"code.vikunja.io/veans/internal/output"
)
// veansPrimeCommand is the literal command line every hook ends up invoking.
// Centralising it here keeps the install logic and the duplicate-detection
// reading the same string.
const veansPrimeCommand = "veans prime"
// AgentHookChoice captures the user's per-agent install decision so the
// orchestration in bootstrap.Init can hand the per-repo set of choices
// off to the install routines below.
type AgentHookChoice struct {
ClaudeCode bool
OpenCode bool
}
// offerAgentHooks asks the user — one yes/no per agent — which integrations
// they want veans to wire up. Callers pre-populate `choices` from CLI flags
// (--install-claude / --install-opencode); only the unset slots get
// prompted. When `noHooks` is true we skip everything and return the empty
// choice, mirroring the old "just print the snippets" behaviour.
func offerAgentHooks(p auth.Prompter, w io.Writer, choices AgentHookChoice, claudeFlagSet, opencodeFlagSet, noHooks bool) (AgentHookChoice, error) {
if noHooks {
return AgentHookChoice{}, nil
}
if !claudeFlagSet {
yes, err := promptYesNo(p, w,
"Wire `veans prime` into Claude Code (.claude/settings.json)?", true)
if err != nil {
return choices, err
}
choices.ClaudeCode = yes
}
if !opencodeFlagSet {
yes, err := promptYesNo(p, w,
"Wire `veans prime` into OpenCode (.opencode/plugin/veans-prime.ts)?", false)
if err != nil {
return choices, err
}
choices.OpenCode = yes
}
return choices, nil
}
// installAgentHooks writes the requested integrations to disk relative to
// repoRoot. Each install is idempotent: if the hook entry is already there,
// it's left alone; if the settings file is missing, it's created with a
// fresh skeleton.
func installAgentHooks(repoRoot string, choices AgentHookChoice, w io.Writer) error {
if choices.ClaudeCode {
path, action, err := installClaudeCodeHook(repoRoot)
if err != nil {
return output.Wrap(output.CodeUnknown, err, "install Claude Code hook: %v", err)
}
progress(w, "%s Claude Code hook in %s", action, path)
}
if choices.OpenCode {
path, action, err := installOpenCodeHook(repoRoot)
if err != nil {
return output.Wrap(output.CodeUnknown, err, "install OpenCode hook: %v", err)
}
progress(w, "%s OpenCode hook in %s", action, path)
}
return nil
}
// installClaudeCodeHook merges (or creates) `<repoRoot>/.claude/settings.json`
// so SessionStart and PreCompact invoke `veans prime`. Returns the path,
// a human verb describing what happened ("Wrote", "Updated", "Already
// configured"), and any error.
func installClaudeCodeHook(repoRoot string) (string, string, error) {
path := filepath.Join(repoRoot, ".claude", "settings.json")
settings, existed, err := readJSONOrEmpty(path)
if err != nil {
return path, "", err
}
changed := false
for _, event := range []string{"SessionStart", "PreCompact"} {
if ensureClaudeHook(settings, event) {
changed = true
}
}
if !changed {
return path, "Already configured", nil
}
if err := writeJSON(path, settings); err != nil {
return path, "", err
}
if existed {
return path, "Updated", nil
}
return path, "Wrote", nil
}
// ensureClaudeHook walks the settings object and appends a `veans prime`
// command entry under hooks.<event> if one isn't already present. Returns
// true iff the structure was modified.
//
// Claude Code's settings shape:
//
// {
// "hooks": {
// "SessionStart": [
// { "hooks": [ { "type": "command", "command": "veans prime" } ] }
// ]
// }
// }
func ensureClaudeHook(settings map[string]any, event string) bool {
hooks := mapAt(settings, "hooks")
entries, _ := hooks[event].([]any)
for _, entry := range entries {
entryMap, ok := entry.(map[string]any)
if !ok {
continue
}
inner, _ := entryMap["hooks"].([]any)
for _, h := range inner {
hmap, ok := h.(map[string]any)
if !ok {
continue
}
if str(hmap, "type") == "command" && str(hmap, "command") == veansPrimeCommand {
return false
}
}
}
entries = append(entries, map[string]any{
"hooks": []any{
map[string]any{"type": "command", "command": veansPrimeCommand},
},
})
hooks[event] = entries
settings["hooks"] = hooks
return true
}
// installOpenCodeHook writes `<repoRoot>/.opencode/plugin/veans-prime.ts`
// if missing. Existing files are left alone (TypeScript merging is out of
// scope; the user can edit by hand).
func installOpenCodeHook(repoRoot string) (string, string, error) {
path := filepath.Join(repoRoot, ".opencode", "plugin", "veans-prime.ts")
if _, err := os.Stat(path); err == nil {
return path, "Already configured", nil
} else if !errors.Is(err, os.ErrNotExist) {
return path, "", err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return path, "", err
}
if err := os.WriteFile(path, []byte(openCodeHookSource), 0o644); err != nil {
return path, "", err
}
return path, "Wrote", nil
}
const openCodeHookSource = `// Auto-generated by 'veans init'. Re-emits the veans agent prompt at the
// start of every OpenCode session and before every compaction. See
// https://github.com/go-vikunja/vikunja/tree/main/veans for context.
export const VeansPrime = {
event: ["session.start", "compact.before"],
handler: async ({ exec }: { exec: (cmd: string) => Promise<unknown> }) =>
exec("veans prime"),
}
`
// readJSONOrEmpty reads `path` as JSON or returns an empty object if the
// file doesn't exist. The `existed` flag tells the caller whether the
// resulting object was loaded from disk (so it can decide between
// "Wrote" and "Updated").
func readJSONOrEmpty(path string) (out map[string]any, existed bool, err error) {
buf, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return map[string]any{}, false, nil
}
return nil, false, err
}
out = map[string]any{}
if len(buf) == 0 {
return out, true, nil
}
if err := json.Unmarshal(buf, &out); err != nil {
return nil, true, fmt.Errorf("parse %s: %w", path, err)
}
return out, true, nil
}
// writeJSON encodes `data` with two-space indent (Claude Code's house
// style) and a trailing newline, creating parent directories as needed.
func writeJSON(path string, data map[string]any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
buf, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
buf = append(buf, '\n')
return os.WriteFile(path, buf, 0o644)
}
// mapAt returns the map at key `k` on `m`, creating it if missing or if
// the existing value is the wrong type. Lets ensureClaudeHook treat the
// JSON object tree as if it were always well-shaped.
func mapAt(m map[string]any, k string) map[string]any {
if v, ok := m[k].(map[string]any); ok {
return v
}
v := map[string]any{}
m[k] = v
return v
}
func str(m map[string]any, k string) string {
s, _ := m[k].(string)
return s
}
// promptYesNo reads a Y/n (or y/N) answer with the given default.
func promptYesNo(p auth.Prompter, w io.Writer, question string, defaultYes bool) (bool, error) {
tag := "[Y/n]"
if !defaultYes {
tag = "[y/N]"
}
fmt.Fprintln(w, question)
ans, err := p.ReadLine(tag + " ")
if err != nil {
return defaultYes, err
}
switch strings.ToLower(strings.TrimSpace(ans)) {
case "":
return defaultYes, nil
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
}
return defaultYes, nil
}

View File

@ -0,0 +1,260 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package bootstrap
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureClaudeHook_FreshFile(t *testing.T) {
s := map[string]any{}
if !ensureClaudeHook(s, "SessionStart") {
t.Fatal("expected change on empty settings")
}
hooks, ok := s["hooks"].(map[string]any)
if !ok {
t.Fatalf("hooks key missing or wrong type: %v", s)
}
ss, ok := hooks["SessionStart"].([]any)
if !ok || len(ss) != 1 {
t.Fatalf("SessionStart shape: %v", hooks["SessionStart"])
}
entry := ss[0].(map[string]any)
inner := entry["hooks"].([]any)
if len(inner) != 1 {
t.Fatalf("inner hooks: %v", inner)
}
h := inner[0].(map[string]any)
if h["command"] != "veans prime" || h["type"] != "command" {
t.Fatalf("hook shape: %v", h)
}
}
func TestEnsureClaudeHook_Idempotent(t *testing.T) {
s := map[string]any{}
if !ensureClaudeHook(s, "SessionStart") {
t.Fatal("first call should change")
}
if ensureClaudeHook(s, "SessionStart") {
t.Fatal("second call should NOT change")
}
ss := s["hooks"].(map[string]any)["SessionStart"].([]any)
if len(ss) != 1 {
t.Fatalf("expected exactly one entry, got %d: %v", len(ss), ss)
}
}
func TestEnsureClaudeHook_PreservesOtherHooks(t *testing.T) {
// Existing settings have an unrelated PreToolUse hook and a SessionStart
// entry running a different command. The veans entry should be appended,
// not replace the existing structure.
raw := []byte(`{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "echo hi" } ] }
],
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "other-tool init" } ] }
]
},
"permissions": { "allow": ["Bash"] }
}`)
var s map[string]any
if err := json.Unmarshal(raw, &s); err != nil {
t.Fatal(err)
}
if !ensureClaudeHook(s, "SessionStart") {
t.Fatal("expected change")
}
// PreToolUse + permissions untouched.
if _, ok := s["permissions"]; !ok {
t.Error("permissions key dropped")
}
if pt := s["hooks"].(map[string]any)["PreToolUse"].([]any); len(pt) != 1 {
t.Errorf("PreToolUse perturbed: %v", pt)
}
// SessionStart now has BOTH the original and the veans entry.
ss := s["hooks"].(map[string]any)["SessionStart"].([]any)
if len(ss) != 2 {
t.Fatalf("SessionStart should have 2 entries, got %d", len(ss))
}
gotVeans := false
for _, e := range ss {
inner := e.(map[string]any)["hooks"].([]any)
for _, h := range inner {
if h.(map[string]any)["command"] == "veans prime" {
gotVeans = true
}
}
}
if !gotVeans {
t.Errorf("veans prime not found in merged SessionStart: %v", ss)
}
}
func TestInstallClaudeCodeHook_CreatesFile(t *testing.T) {
dir := t.TempDir()
path, action, err := installClaudeCodeHook(dir)
if err != nil {
t.Fatal(err)
}
if action != "Wrote" {
t.Errorf("first install should say Wrote, got %q", action)
}
if !strings.HasSuffix(path, ".claude/settings.json") {
t.Errorf("unexpected path: %s", path)
}
buf, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(buf), `"veans prime"`) {
t.Errorf("written file missing veans prime command:\n%s", buf)
}
// Two-space indent + trailing newline.
if !strings.HasSuffix(string(buf), "\n") {
t.Error("written file missing trailing newline")
}
if !strings.Contains(string(buf), " \"hooks\"") {
t.Error("expected 2-space indent")
}
}
func TestInstallClaudeCodeHook_IdempotentRerun(t *testing.T) {
dir := t.TempDir()
if _, _, err := installClaudeCodeHook(dir); err != nil {
t.Fatal(err)
}
path, action, err := installClaudeCodeHook(dir)
if err != nil {
t.Fatal(err)
}
if action != "Already configured" {
t.Errorf("second install should report Already configured, got %q", action)
}
// File hasn't grown duplicate entries.
buf, _ := os.ReadFile(path)
if c := strings.Count(string(buf), `"veans prime"`); c != 2 {
// 2 because both SessionStart and PreCompact reference it once.
t.Errorf("expected exactly 2 references to veans prime, got %d:\n%s", c, buf)
}
}
func TestInstallClaudeCodeHook_MergesWithUserSettings(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, ".claude", "settings.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
existing := `{
"model": "claude-opus-4-7",
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "other-tool" } ] }
]
}
}`
if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
_, action, err := installClaudeCodeHook(dir)
if err != nil {
t.Fatal(err)
}
if action != "Updated" {
t.Errorf("merging into existing file should say Updated, got %q", action)
}
buf, _ := os.ReadFile(settingsPath)
out := string(buf)
for _, want := range []string{`"model": "claude-opus-4-7"`, `"other-tool"`, `"veans prime"`} {
if !strings.Contains(out, want) {
t.Errorf("merged file missing %q:\n%s", want, out)
}
}
}
func TestInstallOpenCodeHook_CreatesAndIdempotent(t *testing.T) {
dir := t.TempDir()
path, action, err := installOpenCodeHook(dir)
if err != nil {
t.Fatal(err)
}
if action != "Wrote" {
t.Errorf("first install should say Wrote, got %q", action)
}
buf, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"VeansPrime", "veans prime", "session.start", "compact.before"} {
if !strings.Contains(string(buf), want) {
t.Errorf("opencode file missing %q:\n%s", want, buf)
}
}
// Re-run leaves the file alone — we don't merge TS by hand.
_, action2, err := installOpenCodeHook(dir)
if err != nil {
t.Fatal(err)
}
if action2 != "Already configured" {
t.Errorf("rerun should say Already configured, got %q", action2)
}
}
func TestOfferAgentHooks_NoHooks(t *testing.T) {
choices, err := offerAgentHooks(nil, nil, AgentHookChoice{}, false, false, true)
if err != nil {
t.Fatal(err)
}
if choices.ClaudeCode || choices.OpenCode {
t.Errorf("NoHooks should return empty: %+v", choices)
}
}
func TestOfferAgentHooks_FlagsBypassPrompt(t *testing.T) {
// Both flags set explicitly — no prompts.
p := &scriptedPrompter{} // would panic with out-of-range on any ReadLine
choices, err := offerAgentHooks(p, nopWriter{}, AgentHookChoice{ClaudeCode: true, OpenCode: false}, true, true, false)
if err != nil {
t.Fatal(err)
}
if !choices.ClaudeCode || choices.OpenCode {
t.Errorf("expected ClaudeCode=true, OpenCode=false; got %+v", choices)
}
}
func TestOfferAgentHooks_PromptsWhenFlagsUnset(t *testing.T) {
// User accepts Claude default (Y), declines OpenCode.
p := &scriptedPrompter{answers: []string{"", "n"}}
choices, err := offerAgentHooks(p, nopWriter{}, AgentHookChoice{}, false, false, false)
if err != nil {
t.Fatal(err)
}
if !choices.ClaudeCode || choices.OpenCode {
t.Errorf("expected ClaudeCode=true OpenCode=false, got %+v", choices)
}
}
// nopWriter discards everything; lets tests run prompts without console noise.
type nopWriter struct{}
func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }

View File

@ -25,8 +25,3 @@ import (
func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
}
// RemoveAssignee unassigns.
func (c *Client) RemoveAssignee(ctx context.Context, taskID, userID int64) error {
return c.Do(ctx, "DELETE", fmt.Sprintf("/tasks/%d/assignees/%d", taskID, userID), nil, nil, nil)
}

View File

@ -28,8 +28,6 @@ type TaskListOptions struct {
Filter string
Page int
PerPage int
SortBy []string
OrderBy []string
Expand []string
}
@ -47,12 +45,6 @@ func (o *TaskListOptions) values() url.Values {
if o.PerPage > 0 {
q.Set("per_page", strconv.Itoa(o.PerPage))
}
for _, s := range o.SortBy {
q.Add("sort_by", s)
}
for _, s := range o.OrderBy {
q.Add("order_by", s)
}
for _, e := range o.Expand {
q.Add("expand", e)
}

View File

@ -16,32 +16,7 @@
package client
import (
"context"
"fmt"
)
// FullPermissions is the broadest set of API token scopes a veans bot needs:
// read+write on every resource it touches. Vikunja's permission map is
// `{resource: [actions]}` shaped; the keys here cover everything the CLI
// calls for normal operation.
//
// We over-grant intentionally — the bot needs to claim, comment, label,
// relate, and update tasks; revoking unused scopes after the fact is cheap.
func FullPermissions() map[string][]string {
return map[string][]string{
"tasks": {"read_one", "read_all", "create", "update", "delete"},
"projects": {"read_one", "read_all", "create", "update", "delete"},
"labels": {"read_one", "read_all", "create", "update", "delete"},
"task_comments": {"read_one", "read_all", "create", "update", "delete"},
"task_assignees": {"create", "delete", "read_all"},
"task_relations": {"create", "delete"},
"task_attachments": {"create", "read_one", "delete"},
"buckets": {"read_all", "create", "update", "delete"},
"project_views": {"read_one", "read_all"},
"users": {"read_all"},
}
}
import "context"
// CreateToken mints an API token. If t.OwnerID is non-zero, the token is
// minted FOR that user — the caller must be the bot's owner (i.e. created
@ -53,17 +28,3 @@ func (c *Client) CreateToken(ctx context.Context, t *APIToken) (*APIToken, error
}
return &out, nil
}
// ListTokens returns every API token the authenticated user can see.
func (c *Client) ListTokens(ctx context.Context) ([]*APIToken, error) {
var out []*APIToken
if err := c.Do(ctx, "GET", "/tokens", nil, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// DeleteToken revokes a token by ID. Used by `veans login` rotation.
func (c *Client) DeleteToken(ctx context.Context, id int64) error {
return c.Do(ctx, "DELETE", fmt.Sprintf("/tokens/%d", id), nil, nil, nil)
}

View File

@ -110,7 +110,6 @@ type Task struct {
DueDate *time.Time `json:"due_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
PercentDone float64 `json:"percent_done,omitempty"`
Reactions interface{} `json:"reactions,omitempty"`
}
// TaskComment matches pkg/models/task_comments.TaskComment.

View File

@ -19,7 +19,6 @@ package client
import (
"context"
"errors"
"net/http"
"code.vikunja.io/veans/internal/output"
)
@ -71,8 +70,3 @@ func (c *Client) FindMyBotByUsername(ctx context.Context, username string) (*Bot
}
return nil, nil
}
// statusCheck pulls the HTTP status off an error for callers that need to
// distinguish 404-on-/bots from other failures. Currently unused outside this
// file, but kept for symmetry.
var _ = http.StatusNotFound

View File

@ -19,7 +19,6 @@ package commands
import (
"encoding/json"
"errors"
"fmt"
"github.com/spf13/cobra"
@ -86,13 +85,7 @@ func newClaimCmd() *cobra.Command {
if err == nil {
task = fresh
}
if globals.JSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
}
fmt.Fprintf(cmd.OutOrStdout(), "Claimed %s %s\n",
rt.cfg.FormatTaskID(task.Index), task.Title)
return nil
},
}
return cmd

View File

@ -19,7 +19,6 @@ package commands
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
@ -54,12 +53,7 @@ func newCreateCmd() *cobra.Command {
if err != nil {
return err
}
if globals.JSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
}
fmt.Fprintf(cmd.OutOrStdout(), "Created %s %s\n",
rt.cfg.FormatTaskID(task.Index), task.Title)
return nil
},
}
cmd.Flags().StringVarP(&f.description, "description", "d", "", "task description (markdown)")

View File

@ -18,6 +18,7 @@ package commands
import (
"fmt"
"io"
"os"
"path/filepath"
@ -40,6 +41,9 @@ type initFlags struct {
yesBuckets bool
skipBuckets bool
configPath string
installClaude bool
installOpenCode bool
noHooks bool
}
func newInitCmd() *cobra.Command {
@ -80,6 +84,11 @@ revoke it at any time without affecting your own session.`,
ViewID: f.viewID,
AutoApproveBuckets: f.yesBuckets,
SkipBucketBootstrap: f.skipBuckets,
InstallClaudeCode: f.installClaude,
InstallOpenCode: f.installOpenCode,
ClaudeCodeFlagSet: cmd.Flags().Changed("install-claude"),
OpenCodeFlagSet: cmd.Flags().Changed("install-opencode"),
NoHooks: f.noHooks,
Out: os.Stderr,
})
if err != nil {
@ -102,17 +111,26 @@ revoke it at any time without affecting your own session.`,
cmd.Flags().BoolVar(&f.yesBuckets, "yes-buckets", false, "auto-approve canonical bucket bootstrap")
cmd.Flags().BoolVar(&f.skipBuckets, "skip-buckets", false, "do not prompt or create buckets (assumes they exist)")
cmd.Flags().StringVar(&f.configPath, "config", "", "where to write .veans.yml (defaults to the repo root)")
cmd.Flags().BoolVar(&f.installClaude, "install-claude", false, "wire `veans prime` into .claude/settings.json (skip prompt)")
cmd.Flags().BoolVar(&f.installOpenCode, "install-opencode", false, "wire `veans prime` into .opencode/plugin/veans-prime.ts (skip prompt)")
cmd.Flags().BoolVar(&f.noHooks, "no-hooks", false, "don't offer to install agent hooks; just print the snippets")
return cmd
}
func printPostInitSummary(w fmtWriter, res *bootstrap.Result) {
func printPostInitSummary(w io.Writer, res *bootstrap.Result) {
fmt.Fprintf(w, "\nveans is ready. Bot user: %s\n", res.BotUser.Username)
fmt.Fprintf(w, "Config: %s\n", res.Config.Path())
fmt.Fprintf(w, "Project: #%d %s\n", res.Config.ProjectID, identOrFallback(res.Config.ProjectIdentifier))
// Only fall back to printing the snippets when the user declined or
// skipped the install offer. When at least one hook was installed, the
// install routine already logged what it did to stderr.
if res.AgentChoices.ClaudeCode || res.AgentChoices.OpenCode {
return
}
fmt.Fprintln(w, `
To wire veans into your coding agent, paste one of these snippets:
To wire veans into your coding agent later, paste one of these snippets:
Claude Code (.claude/settings.json):
{
@ -131,9 +149,3 @@ func identOrFallback(s string) string {
}
return s
}
// fmtWriter is what cobra.Cmd.OutOrStdout returns — type aliased to keep the
// import surface minimal.
type fmtWriter = interface {
Write(p []byte) (int, error)
}

View File

@ -19,13 +19,11 @@ package commands
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/config"
"code.vikunja.io/veans/internal/output"
"code.vikunja.io/veans/internal/status"
)
@ -65,11 +63,7 @@ Filters can be combined; they're AND-ed together:
if err != nil {
return err
}
if globals.JSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(tasks)
}
renderTasksHuman(cmd.OutOrStdout(), tasks, rt.cfg)
return nil
},
}
cmd.Flags().BoolVar(&f.ready, "ready", false, "only ready-to-start tasks (Todo bucket, not done)")
@ -159,25 +153,6 @@ func taskHasLabel(t *client.Task, title string) bool {
return false
}
func renderTasksHuman(w fmtWriter, tasks []*client.Task, cfg *config.Config) {
if len(tasks) == 0 {
fmt.Fprintln(w, "(no tasks)")
return
}
for _, t := range tasks {
s := status.FromBucketID(t.CurrentBucketID(cfg.ViewID), cfg.Buckets)
stamp := string(s)
if stamp == "" {
stamp = "-"
}
fmt.Fprintf(w, "%-12s %-10s %s\n",
cfg.FormatTaskID(t.Index),
stamp,
t.Title,
)
}
}
func branchLabel(branch string) string {
return "veans:branch:" + branch
}

View File

@ -20,12 +20,10 @@ import (
_ "embed"
"errors"
"fmt"
"strings"
"text/template"
"github.com/spf13/cobra"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/config"
)
@ -99,5 +97,3 @@ silently with status 0 — that makes the hook safe to install globally.`,
}
// silence linter noise on unused symbols when wiring hooks.
var _ = client.New
var _ = strings.TrimSpace

View File

@ -61,6 +61,13 @@ func TestPrimeTemplate_RendersAnchors(t *testing.T) {
"In Review",
"Done",
"Scrapped",
// HTML format guidance the agent depends on:
"Description format",
"Titles are plaintext",
`data-type="taskList"`,
`data-checked="false"`,
"<h2>",
"<pre><code",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {

View File

@ -16,25 +16,26 @@ Server: {{ .Server }}
- Use `veans list --ready` to find tasks ready to start (Todo + not blocked).
## WHILE you work
- Keep the task's description in sync with what you're doing. Use markdown
checkboxes for the small steps you're ticking off:
`veans update {{ .TaskIDExample }} --description-append "- [ ] step 1"`
- For surgical edits to the description, prefer:
`veans update {{ .TaskIDExample }} --description-replace-old "- [ ] step 1" --description-replace-new "- [x] step 1"`
(errors if the old text isn't unique — mirrors the Edit tool semantics)
- Keep the task's description in sync with what you're doing. Use HTML
(not markdown) — see the "Description format" section below for why
and the canonical TipTap shapes Vikunja's web UI renders nicely:
`veans update {{ .TaskIDExample }} --description-append '<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>step 1</p></li></ul>'`
- For surgical edits, prefer `--description-replace-old` /
`--description-replace-new`. To check off a task item, replace
`data-checked="false"><p>step 1</p>` with `data-checked="true"><p>step 1</p>`
(errors if the old text isn't unique — same semantics as the Edit tool).
- Post a comment on significant decisions, discoveries, or course-changes:
`veans update {{ .TaskIDExample }} --comment "Discovered Y; pivoting to Z because …"`
`veans update {{ .TaskIDExample }} --comment '<p>Discovered Y; pivoting to Z because …</p>'`
- For sub-work that could be assigned separately, create real subtasks
via `--parent`. For incremental check-off lists, use markdown checkboxes
via `--parent`. For incremental check-off lists, use task-list items
in the description instead.
## AFTER you finish work
- Move to `in-review` and post a summary comment. **Never close tasks
yourself** — the human (or the merge hook) closes them.
```
veans update {{ .TaskIDExample }} -s in-review --comment "## Summary of Changes
- …
- …"
veans update {{ .TaskIDExample }} -s in-review \
--comment '<h3>Summary of Changes</h3><ul><li>first thing</li><li>second thing</li></ul>'
```
- If you abandon work, scrap the task with a reason:
`veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: <why>"`
@ -49,6 +50,83 @@ fix: handle empty project identifiers
Refs: {{ .TaskIDExample }}
```
# Description format
**Descriptions and comments are HTML, not markdown.** Vikunja's web UI
renders them through the TipTap rich-text editor, which interprets the
stored field as HTML. Plain markdown saves as literal text and shows up
ugly in the UI; write HTML directly. Tokens are cheap, conversion bugs
aren't.
**Titles are plaintext — not HTML, not markdown.** They show up in
list views, breadcrumbs, and notification subjects; tags would leak
through as `&lt;p&gt;…&lt;/p&gt;` everywhere. Write
`fix the bug` not `<p>fix the bug</p>` or `**fix** the bug`.
Canonical TipTap shapes the web UI renders cleanly:
```html
<h2>Summary</h2>
<p>Two short paragraphs explaining the task.</p>
<h3>Steps</h3>
<ul data-type="taskList">
<li data-type="taskItem" data-checked="false"><p>find the bug</p></li>
<li data-type="taskItem" data-checked="false"><p>write the fix</p></li>
<li data-type="taskItem" data-checked="true"><p>write the test</p></li>
</ul>
<h3>Notes</h3>
<ul>
<li>regular bullet point</li>
<li>another one</li>
</ul>
<p>Inline <code>code</code>, <strong>bold</strong>, <em>italic</em>,
and a <a href="https://example.com">link</a>.</p>
<pre><code class="language-go">if err != nil { return err }</code></pre>
<blockquote><p>A quote, e.g. from a linked issue.</p></blockquote>
<hr>
```
Important details:
- Task-list items use `<ul data-type="taskList">` and
`<li data-type="taskItem" data-checked="true|false">` — that's what
gives users an interactive checkbox. Plain `<ul><li>` lists render as
static bullets.
- Inner text of task items goes inside `<p>` — the editor expects block
content in the `<li>`.
- Don't add `data-task-id` attributes manually; the editor auto-fills
them on first save.
- Escape `<`, `>`, `&` when they appear in literal text (`&lt;`, `&gt;`,
`&amp;`). Inside `<pre><code>` blocks you only need to escape these
three; line breaks and indentation are preserved.
- `--description-replace-old/new` matches raw HTML byte-for-byte. Make
the `old` string unique (include surrounding tags to disambiguate).
# Output
Every `list`, `show`, `create`, `update`, `claim`, `api` call emits JSON
on stdout — no flag needed, no human-formatted variant. Errors land on
stderr as `{"code":"...","error":"..."}` with non-zero exit; branch on
`code` (NOT_FOUND, CONFLICT, VALIDATION_ERROR, AUTH_ERROR,
RATE_LIMITED, NOT_CONFIGURED, BOT_USERS_UNAVAILABLE, UNKNOWN).
`show`/`create`/`update`/`claim` return a single task; `list` returns
an array. Useful fields:
- `id` (numeric, internal) — pass to `api` calls
- `index` (per-project) — what `{{ .TaskIDExample }}` resolves to
- `done`, `priority`, `title`, `description` (HTML)
- `buckets[]` — current bucket per view; match `project_view_id` to
yours from `.veans.yml` and read `id` to know the status
- `assignees[]`, `labels[]` — `[]` if absent
Trust your JSON parser; we won't add new fields without notice.
# Status model
| Status | Bucket name | Done flag | Who moves there? |
@ -67,16 +145,15 @@ veans list --ready # ready to start (Todo + not blocked)
veans list --mine # tasks assigned to you
veans list --branch # tasks tagged with the current git branch
veans list --filter "priority > 3" # raw Vikunja filter expression
veans show {{ .TaskIDExample }} # full task detail
veans show {{ .TaskIDExample }} --json # JSON for parsing
veans show {{ .TaskIDExample }} # full task detail (JSON)
veans create "title" -s in-progress -d "description"
veans create "title" -s in-progress -d "<p>HTML body — see Description format.</p>"
veans create "title" --label bug --priority 4 --parent {{ .TaskIDExample }}
veans create "title" --blocked-by {{ .TaskIDExample }}
veans update {{ .TaskIDExample }} -s in-review --comment "..."
veans update {{ .TaskIDExample }} --description-append "- [ ] new step"
veans update {{ .TaskIDExample }} --description-replace-old "- [ ] x" --description-replace-new "- [x] x"
veans update {{ .TaskIDExample }} -s in-review --comment '<p>Summary of changes…</p>'
veans update {{ .TaskIDExample }} --description-append '<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>new step</p></li></ul>'
veans update {{ .TaskIDExample }} --description-replace-old 'data-checked="false"><p>step 1</p>' --description-replace-new 'data-checked="true"><p>step 1</p>'
veans update {{ .TaskIDExample }} --label-add bug --label-remove flaky
veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: replaced by {{ .TaskIDExample }}"

View File

@ -15,8 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package commands wires the cobra command tree. Each subcommand lives in a
// sibling file; root.go owns shared flags, the global error handler, and the
// JSON output toggle.
// sibling file. The agent-facing commands (list/show/create/update/claim/api)
// emit JSON unconditionally; only init and login speak human prose.
package commands
import (
@ -28,15 +28,6 @@ import (
"code.vikunja.io/veans/internal/output"
)
// Globals carries flags shared across subcommands. The pointer is bound onto
// the root command's persistent flags; subcommands read it via PostRun.
type Globals struct {
JSON bool
Verbose bool
}
var globals Globals
// Root builds the cobra command tree.
func Root(version string) *cobra.Command {
root := &cobra.Command{
@ -46,8 +37,6 @@ func Root(version string) *cobra.Command {
SilenceErrors: true,
Version: version,
}
root.PersistentFlags().BoolVar(&globals.JSON, "json", false, "emit JSON output")
root.PersistentFlags().BoolVar(&globals.Verbose, "verbose", false, "verbose logging to stderr")
root.AddCommand(newVersionCmd(version))
root.AddCommand(newInitCmd())
@ -64,11 +53,13 @@ func Root(version string) *cobra.Command {
}
// Execute runs the cobra tree and converts errors into the structured output
// envelope. It returns the desired exit code.
// envelope. Errors land on stderr as JSON `{code, error}` and the process
// exits non-zero — both agent-facing and human-facing commands share this
// shape so callers can branch on `code` regardless of which command they ran.
func Execute(version string) int {
cmd := Root(version)
if err := cmd.Execute(); err != nil {
output.EmitError(globals.JSON, err, os.Stderr)
output.EmitError(err, os.Stderr)
return 1
}
return 0

View File

@ -18,13 +18,8 @@ package commands
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/config"
"code.vikunja.io/veans/internal/status"
)
func newShowCmd() *cobra.Command {
@ -45,44 +40,8 @@ func newShowCmd() *cobra.Command {
if err != nil {
return err
}
if globals.JSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
}
renderTaskHuman(cmd.OutOrStdout(), task, rt.cfg)
return nil
},
}
return cmd
}
func renderTaskHuman(w fmtWriter, t *client.Task, cfg *config.Config) {
s := status.FromBucketID(t.CurrentBucketID(cfg.ViewID), cfg.Buckets)
fmt.Fprintf(w, "%s %s [%s]\n", cfg.FormatTaskID(t.Index), t.Title, s)
if t.Priority > 0 {
fmt.Fprintf(w, "Priority: %d\n", t.Priority)
}
if len(t.Assignees) > 0 {
fmt.Fprintf(w, "Assignees: ")
for i, a := range t.Assignees {
if i > 0 {
fmt.Fprint(w, ", ")
}
fmt.Fprint(w, a.Username)
}
fmt.Fprintln(w)
}
if len(t.Labels) > 0 {
fmt.Fprintf(w, "Labels: ")
for i, l := range t.Labels {
if i > 0 {
fmt.Fprint(w, ", ")
}
fmt.Fprint(w, l.Title)
}
fmt.Fprintln(w)
}
if t.Description != "" {
fmt.Fprintln(w)
fmt.Fprintln(w, t.Description)
}
}

View File

@ -19,7 +19,6 @@ package commands
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
@ -70,13 +69,7 @@ func newUpdateCmd() *cobra.Command {
if err != nil {
return err
}
if globals.JSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
}
s := status.FromBucketID(task.CurrentBucketID(rt.cfg.ViewID), rt.cfg.Buckets)
fmt.Fprintf(cmd.OutOrStdout(), "Updated %s [%s] %s\n",
rt.cfg.FormatTaskID(task.Index), s, task.Title)
return nil
},
}
cmd.Flags().StringVarP(&f.statusName, "status", "s", "", "transition to a status")

View File

@ -119,25 +119,17 @@ func Load(path string) (*Config, error) {
return &c, nil
}
// Save writes the config to its path (must be set on the struct).
func (c *Config) Save() error {
if c.path == "" {
return errors.New("Save: no path set on Config")
}
// SaveAs writes the config to `path` and remembers it as c.path.
func (c *Config) SaveAs(path string) error {
c.path = path
buf, err := yaml.Marshal(c)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(c.path, buf, 0o644)
}
// SaveAs writes the config to a specific path (and updates c.path).
func (c *Config) SaveAs(path string) error {
c.path = path
return c.Save()
return os.WriteFile(path, buf, 0o644)
}
// RepoRoot returns the root of the git repo containing `start` (defaulting

View File

@ -18,26 +18,20 @@ package credentials
import "os"
// EnvBackend is read-only. VEANS_TOKEN is intended for CI / containers where
// the keychain is unavailable and writing a credentials file is undesirable.
//
// VEANS_TOKEN matches any (server, account) lookup — there's only one slot.
// VEANS_SERVER, when set, additionally pins the server it applies to.
// EnvBackend is read-only. VEANS_TOKEN, when set, satisfies any
// (server, account) lookup — intended for CI / containers where the
// keychain is unavailable and writing a credentials file is undesirable.
type EnvBackend struct{}
func NewEnvBackend() *EnvBackend { return &EnvBackend{} }
func (*EnvBackend) Name() string { return "env" }
func (*EnvBackend) Get(server, _ string) (string, error) {
func (*EnvBackend) Get(_, _ string) (string, error) {
tok := os.Getenv("VEANS_TOKEN")
if tok == "" {
return "", ErrNotFound
}
if pinned := os.Getenv("VEANS_SERVER"); pinned != "" && pinned != server {
return "", ErrNotFound
}
return tok, nil
}
func (*EnvBackend) Set(_, _, _ string) error { return errReadOnly }
func (*EnvBackend) Delete(_, _ string) error { return errReadOnly }

View File

@ -21,7 +21,6 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
@ -29,10 +28,6 @@ import (
// FileBackend persists credentials to ~/.config/veans/credentials.yml at
// mode 0600. It's the fallback when no keychain is available (CI, Docker,
// headless servers) and is the implicit backend e2e tests use.
//
// The schema includes a `scope` field that's always empty in v0 but reserved
// for project-scoped tokens once Vikunja gains them — the same store can
// hold both kinds without migration.
type FileBackend struct {
path string
}
@ -40,9 +35,7 @@ type FileBackend struct {
type fileEntry struct {
Server string `yaml:"server"`
Account string `yaml:"account"`
Scope string `yaml:"scope,omitempty"`
Token string `yaml:"token"`
ExpiresAt *time.Time `yaml:"expires_at,omitempty"`
}
type fileSchema struct {
@ -129,24 +122,3 @@ func (b *FileBackend) Set(server, account, token string) error {
})
return b.save(s)
}
func (b *FileBackend) Delete(server, account string) error {
s, err := b.load()
if err != nil {
return err
}
out := s.Credentials[:0]
removed := false
for _, e := range s.Credentials {
if e.Server == server && e.Account == account {
removed = true
continue
}
out = append(out, e)
}
if !removed {
return ErrNotFound
}
s.Credentials = out
return b.save(s)
}

View File

@ -60,16 +60,6 @@ func TestFileBackend_RoundTrip(t *testing.T) {
if tokBar != "tok-789" {
t.Fatalf("bar got %q", tokBar)
}
if err := b.Delete("https://example.com", "bot-foo"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := b.Get("https://example.com", "bot-foo"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
if _, err := b.Get("https://example.com", "bot-bar"); err != nil {
t.Fatalf("bar should still exist: %v", err)
}
}
func TestChain_FallsThroughOnNotFound(t *testing.T) {
@ -112,11 +102,3 @@ func (s *stubBackend) Set(server, account, token string) error {
s.store[server+"::"+account] = token
return nil
}
func (s *stubBackend) Delete(server, account string) error {
k := server + "::" + account
if _, ok := s.store[k]; !ok {
return ErrNotFound
}
delete(s.store, k)
return nil
}

View File

@ -55,16 +55,6 @@ func (*KeyringBackend) Set(server, account, token string) error {
return nil
}
func (*KeyringBackend) Delete(server, account string) error {
if err := keyring.Delete(service, key(server, account)); err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return ErrNotFound
}
return err
}
return nil
}
func key(server, account string) string {
return server + "::" + account
}

View File

@ -31,7 +31,6 @@ var ErrNotFound = errors.New("credential not found")
type Store interface {
Get(server, account string) (string, error)
Set(server, account, token string) error
Delete(server, account string) error
// Name is used in error messages.
Name() string
}
@ -86,20 +85,6 @@ func (c *Chain) Set(server, account, token string) error {
return errors.New("no writable backend available")
}
// Delete removes from every writable backend (best-effort).
func (c *Chain) Delete(server, account string) error {
var firstErr error
for _, b := range c.Backends {
if _, ok := b.(*EnvBackend); ok {
continue
}
if err := b.Delete(server, account); err != nil && !errors.Is(err, ErrNotFound) && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// errReadOnly is sentinel for backends that refuse writes (env).
var errReadOnly = errors.New("read-only backend")

View File

@ -72,18 +72,15 @@ func AsError(err error) *Error {
return &Error{Code: CodeUnknown, Message: err.Error(), Cause: err}
}
// EmitError writes the JSON envelope when --json is set, or a plain message
// otherwise. Always to stderr so stdout stays parseable.
func EmitError(jsonMode bool, err error, w io.Writer) {
// EmitError encodes the error as a JSON envelope `{code, error}` to w
// (default stderr). All veans commands share this shape so callers can
// branch on `code` without sniffing the output format.
func EmitError(err error, w io.Writer) {
if w == nil {
w = os.Stderr
}
e := AsError(err)
if jsonMode {
if encErr := json.NewEncoder(w).Encode(e); encErr != nil {
fmt.Fprintf(os.Stderr, "veans: failed to encode error envelope: %v\n", encErr)
}
return
}
fmt.Fprintf(w, "veans: %s: %s\n", e.Code, e.Message)
}

View File

@ -26,7 +26,6 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
@ -128,10 +127,3 @@ var Aliases = map[string]any{
"lint": Lint.All,
"lint:fix": Lint.Fix,
}
// trimLast is a tiny helper for prettier path printing in error messages.
func trimLast(p string) string {
return strings.TrimSuffix(p, "/")
}
var _ = trimLast