feat(client): add dynamic version and UA option

Amolith created

Version is now determined at runtime via debug.ReadBuildInfo() instead
of being hardcoded. WithUserAgent option allows consumers to set a
custom User-Agent prefix; the library identifier is always appended.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

lunatask.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 52 insertions(+)

Detailed changes

lunatask.go 🔗

@@ -18,6 +18,8 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"runtime/debug"
+	"sync"
 )
 
 // API error types for typed error handling.
@@ -96,14 +98,48 @@ const (
 	DefaultBaseURL = "https://api.lunatask.app/v1"
 	// statusCloudflareTimeout is Cloudflare's "A Timeout Occurred" status.
 	statusCloudflareTimeout = 524
+	// modulePath is the import path used to find this module's version.
+	modulePath = "git.secluded.site/go-lunatask"
 )
 
+var (
+	versionOnce sync.Once //nolint:gochecknoglobals
+	version     = "unknown"
+)
+
+// getVersion returns the library version, determined from build info.
+func getVersion() string {
+	versionOnce.Do(func() {
+		buildInfo, ok := debug.ReadBuildInfo()
+		if !ok {
+			return
+		}
+
+		// When used as a dependency, find our version in the deps list.
+		for _, dep := range buildInfo.Deps {
+			if dep.Path == modulePath {
+				version = dep.Version
+
+				return
+			}
+		}
+
+		// When running as main module (e.g., tests), use main module version.
+		if buildInfo.Main.Path == modulePath && buildInfo.Main.Version != "" && buildInfo.Main.Version != "(devel)" {
+			version = buildInfo.Main.Version
+		}
+	})
+
+	return version
+}
+
 // Client handles communication with the Lunatask API.
 // A Client is safe for concurrent use.
 type Client struct {
 	accessToken string
 	baseURL     string
 	httpClient  *http.Client
+	userAgent   string
 }
 
 // Option configures a Client.
@@ -123,6 +159,14 @@ func WithBaseURL(url string) Option {
 	}
 }
 
+// WithUserAgent sets a custom User-Agent prefix.
+// The library identifier (go-lunatask/version) is always appended.
+func WithUserAgent(ua string) Option {
+	return func(c *Client) {
+		c.userAgent = ua
+	}
+}
+
 // NewClient creates a new Lunatask API client.
 // Generate an access token in the Lunatask desktop app under Settings → Access tokens.
 func NewClient(accessToken string, opts ...Option) *Client {
@@ -130,6 +174,7 @@ func NewClient(accessToken string, opts ...Option) *Client {
 		accessToken: accessToken,
 		baseURL:     DefaultBaseURL,
 		httpClient:  &http.Client{}, //nolint:exhaustruct
+		userAgent:   "",
 	}
 	for _, opt := range opts {
 		opt(client)
@@ -156,6 +201,13 @@ func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
 func (c *Client) doRequest(req *http.Request) ([]byte, int, error) {
 	req.Header.Set("Authorization", "bearer "+c.accessToken)
 
+	ua := "go-lunatask/" + getVersion()
+	if c.userAgent != "" {
+		ua = c.userAgent + " " + ua
+	}
+
+	req.Header.Set("User-Agent", ua)
+
 	resp, err := c.httpClient.Do(req)
 	if err != nil {
 		return nil, 0, fmt.Errorf("failed to send HTTP request: %w", err)