diff --git a/veans/.golangci.yml b/veans/.golangci.yml
index 2528088aa..c14dab5ab 100644
--- a/veans/.golangci.yml
+++ b/veans/.golangci.yml
@@ -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:
diff --git a/veans/AGENTS.md b/veans/AGENTS.md
index b5ab906c0..3545fa055 100644
--- a/veans/AGENTS.md
+++ b/veans/AGENTS.md
@@ -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 `
` +
+ `…
` 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
diff --git a/veans/README.md b/veans/README.md
index d59929d1b..7c8d1e3fa 100644
--- a/veans/README.md
+++ b/veans/README.md
@@ -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 view a task (--json for raw object)
+veans list filtered list (--ready, --mine, --branch, --filter, --status); emits JSON
+veans show view a task (JSON)
veans create "title" --description, --label, --status, --priority, --parent, --blocked-by
veans update --status, --title, --priority, --label-add/remove,
--description, --description-replace-old/new, --description-append,
diff --git a/veans/e2e/claim_test.go b/veans/e2e/claim_test.go
index a0e7d6695..5056056f9 100644
--- a/veans/e2e/claim_test.go
+++ b/veans/e2e/claim_test.go
@@ -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)
}
diff --git a/veans/e2e/helpers.go b/veans/e2e/helpers.go
index 56943cd4c..99e3527ae 100644
--- a/veans/e2e/helpers.go
+++ b/veans/e2e/helpers.go
@@ -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)
}
diff --git a/veans/e2e/init_test.go b/veans/e2e/init_test.go
index 61dd8b1e4..e529ef8c0 100644
--- a/veans/e2e/init_test.go
+++ b/veans/e2e/init_test.go
@@ -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,
diff --git a/veans/e2e/shared_test.go b/veans/e2e/shared_test.go
index 8dac7ccab..2f6813678 100644
--- a/veans/e2e/shared_test.go
+++ b/veans/e2e/shared_test.go
@@ -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,
diff --git a/veans/e2e/tasks_test.go b/veans/e2e/tasks_test.go
index 4fc114aac..2beced968 100644
--- a/veans/e2e/tasks_test.go
+++ b/veans/e2e/tasks_test.go
@@ -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 {
diff --git a/veans/go.mod b/veans/go.mod
index cf110a333..4b0c3bb5d 100644
--- a/veans/go.mod
+++ b/veans/go.mod
@@ -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
)
diff --git a/veans/go.sum b/veans/go.sum
index cfb90c787..3e0c9d612 100644
--- a/veans/go.sum
+++ b/veans/go.sum
@@ -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=
diff --git a/veans/internal/auth/auth.go b/veans/internal/auth/auth.go
index a55ff8ad0..720fcc87e 100644
--- a/veans/internal/auth/auth.go
+++ b/veans/internal/auth/auth.go
@@ -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
diff --git a/veans/internal/auth/browser.go b/veans/internal/auth/browser.go
deleted file mode 100644
index 7950c3c4b..000000000
--- a/veans/internal/auth/browser.go
+++ /dev/null
@@ -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 .
-
-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()
-}
diff --git a/veans/internal/auth/oauth.go b/veans/internal/auth/oauth.go
index 1c3e87a66..f78e630a4 100644
--- a/veans/internal/auth/oauth.go
+++ b/veans/internal/auth/oauth.go
@@ -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) {