feat(auth): add LUNE_ACCESS_TOKEN env var support

Amolith created

Enable authentication via LUNE_ACCESS_TOKEN environment variable as an
alternative to the system keyring. The env var takes precedence over
keyring for explicit override capability.

- GetToken() now checks env var first, then falls back to keyring
- New() delegates to GetToken() for single source of truth
- MCP command simplified to use centralized GetToken()

Change summary

cmd/mcp/mcp.go                          |  8 ++----
internal/client/client.go               | 28 +++++++++++++++++---------
internal/mcp/tools/timestamp/handler.go |  6 ++--
3 files changed, 24 insertions(+), 18 deletions(-)

Detailed changes

cmd/mcp/mcp.go 🔗

@@ -8,7 +8,6 @@ package mcp
 import (
 	"errors"
 	"fmt"
-	"os"
 
 	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/config"
@@ -88,10 +87,9 @@ func runMCP(cmd *cobra.Command, _ []string) error {
 		return err
 	}
 
-	// Try keyring first, fall back to env var if keyring fails or is empty
-	token, _ := client.GetToken() // ignore keyring errors (e.g., no dbus in containers)
-	if token == "" {
-		token = os.Getenv("LUNE_ACCESS_TOKEN")
+	token, err := client.GetToken()
+	if err != nil {
+		return err
 	}
 
 	if token == "" {

internal/client/client.go 🔗

@@ -8,6 +8,7 @@ package client
 import (
 	"errors"
 	"fmt"
+	"os"
 	"runtime/debug"
 
 	"git.secluded.site/go-lunatask"
@@ -19,26 +20,33 @@ const (
 	keyringUser    = "api-key"
 )
 
-// ErrNoToken indicates no access token is available in the system keyring.
-var ErrNoToken = errors.New("no access token found in system keyring; run 'lune init' to configure")
+// ErrNoToken indicates no access token is available.
+var ErrNoToken = errors.New("no access token found; set LUNE_ACCESS_TOKEN or run 'lune init' to configure")
 
-// New creates a Lunatask client using the access token from system keyring.
+// New creates a Lunatask client using the access token from LUNE_ACCESS_TOKEN
+// environment variable or system keyring. Environment variable takes precedence.
 func New() (*lunatask.Client, error) {
-	token, err := keyring.Get(keyringService, keyringUser)
+	token, err := GetToken()
 	if err != nil {
-		if errors.Is(err, keyring.ErrNotFound) {
-			return nil, ErrNoToken
-		}
+		return nil, err
+	}
 
-		return nil, fmt.Errorf("accessing system keyring: %w", err)
+	if token == "" {
+		return nil, ErrNoToken
 	}
 
 	return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil
 }
 
-// GetToken returns the access token from keyring. Returns empty string and nil error
-// if not found; returns error for keyring access problems.
+// GetToken returns the access token from LUNE_ACCESS_TOKEN environment variable
+// or keyring. Returns empty string and nil error if not found in either location;
+// returns error for keyring access problems. Environment variable takes precedence.
 func GetToken() (string, error) {
+	// Env var takes precedence for explicit override
+	if token := os.Getenv("LUNE_ACCESS_TOKEN"); token != "" {
+		return token, nil
+	}
+
 	token, err := keyring.Get(keyringService, keyringUser)
 	if err != nil {
 		if errors.Is(err, keyring.ErrNotFound) {

internal/mcp/tools/timestamp/handler.go 🔗

@@ -27,9 +27,9 @@ Empty input returns today.`
 // ToolAnnotations returns hints about tool behavior.
 func ToolAnnotations() *mcp.ToolAnnotations {
 	return &mcp.ToolAnnotations{
-		ReadOnlyHint:    true,
-		OpenWorldHint:   ptr(true),
-		Title:           "Parse date",
+		ReadOnlyHint:  true,
+		OpenWorldHint: ptr(true),
+		Title:         "Parse date",
 	}
 }