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
Config *config.Config
BotUser *client.BotUser
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

@ -102,15 +102,14 @@ type Task struct {
// side endpoint (e.g. the bucket-move POST); reads return it as 0.
// The current bucket(s) — one per Kanban view — are exposed via
// ?expand=buckets in the Buckets slice.
BucketID int64 `json:"bucket_id,omitempty"`
Buckets []*Bucket `json:"buckets,omitempty"`
Assignees []*User `json:"assignees,omitempty"`
Labels []*Label `json:"labels,omitempty"`
StartDate *time.Time `json:"start_date,omitempty"`
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"`
BucketID int64 `json:"bucket_id,omitempty"`
Buckets []*Bucket `json:"buckets,omitempty"`
Assignees []*User `json:"assignees,omitempty"`
Labels []*Label `json:"labels,omitempty"`
StartDate *time.Time `json:"start_date,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
PercentDone float64 `json:"percent_done,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 json.NewEncoder(cmd.OutOrStdout()).Encode(task)
},
}
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
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
},
}
cmd.Flags().StringVarP(&f.description, "description", "d", "", "task description (markdown)")

View File

@ -18,6 +18,7 @@ package commands
import (
"fmt"
"io"
"os"
"path/filepath"
@ -28,18 +29,21 @@ import (
)
type initFlags struct {
server string
token string
username string
password string
totp string
usePassword bool
botUsername string
projectID int64
viewID int64
yesBuckets bool
skipBuckets bool
configPath string
server string
token string
username string
password string
totp string
usePassword bool
botUsername string
projectID int64
viewID int64
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
return json.NewEncoder(cmd.OutOrStdout()).Encode(tasks)
},
}
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 json.NewEncoder(cmd.OutOrStdout()).Encode(task)
},
}
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
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
},
}
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,20 +28,14 @@ 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
}
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"`
Server string `yaml:"server"`
Account string `yaml:"account"`
Token string `yaml:"token"`
}
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
if encErr := json.NewEncoder(w).Encode(e); encErr != nil {
fmt.Fprintf(os.Stderr, "veans: failed to encode error envelope: %v\n", encErr)
}
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