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:
parent
1bc3afa430
commit
814b2a635f
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 `<p>…</p>` 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 (`<`, `>`,
|
||||
`&`). 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 }}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue