feat(ui): support non-interactive mode, NO_COLOR

Amolith created

Detect non-interactive environments (piped output, scripts, CI) and
disable colors, styles, spinners, and table borders automatically.

Detection priority:
1. NO_COLOR env var (non-empty = plain)
2. FORCE_COLOR env var (explicit override)
3. TERM=dumb (plain)
4. TTY detection via isatty (non-TTY = plain)

Config ui.color setting ("auto", "always", "never") is respected.

Also adds `lune init --generate-config` for non-interactive setup, which
writes a commented example config for manual editing.

References: lunatask://tasks/7c9f3a83-db39-4bb4-afcb-bb73646e91bb
Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/area/list.go       |   4 
cmd/goal/list.go       |   4 
cmd/init/init.go       | 114 +++++++++++++++++++++++++++++++++++++++++++
cmd/note/list.go       |   4 
cmd/person/list.go     |   4 
cmd/root.go            |  17 ++++++
cmd/task/list.go       |   4 
go.mod                 |   2 
internal/ui/output.go  |  81 +++++++++++++++++++++++++++++++
internal/ui/spinner.go |  12 ++++
internal/ui/styles.go  |  60 ++++++++++++++++++++---
11 files changed, 286 insertions(+), 20 deletions(-)

Detailed changes

cmd/area/list.go 🔗

@@ -74,12 +74,12 @@ func outputTable(cmd *cobra.Command, areas []config.Area) error {
 		Rows(rows...).
 		StyleFunc(func(row, col int) lipgloss.Style {
 			if row == table.HeaderRow {
-				return ui.Bold
+				return ui.TableHeaderStyle()
 			}
 
 			return lipgloss.NewStyle()
 		}).
-		Border(lipgloss.HiddenBorder())
+		Border(ui.TableBorder())
 
 	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
 

cmd/goal/list.go 🔗

@@ -139,12 +139,12 @@ func outputTable(cmd *cobra.Command, goals []goalWithArea) error {
 		Rows(rows...).
 		StyleFunc(func(row, col int) lipgloss.Style {
 			if row == table.HeaderRow {
-				return ui.Bold
+				return ui.TableHeaderStyle()
 			}
 
 			return lipgloss.NewStyle()
 		}).
-		Border(lipgloss.HiddenBorder())
+		Border(ui.TableBorder())
 
 	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
 

cmd/init/init.go 🔗

@@ -9,6 +9,8 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"os"
+	"path/filepath"
 
 	"github.com/charmbracelet/huh"
 	"github.com/spf13/cobra"
@@ -23,6 +25,12 @@ var errQuit = errors.New("user quit")
 // errReset signals that the user reset configuration and should run fresh setup.
 var errReset = errors.New("configuration reset")
 
+// errNonInteractive signals that init was run without a terminal.
+var errNonInteractive = errors.New("non-interactive terminal")
+
+// errConfigExists signals that a config file already exists.
+var errConfigExists = errors.New("config already exists")
+
 // wizardNav represents navigation direction in the wizard.
 type wizardNav int
 
@@ -41,11 +49,34 @@ var Cmd = &cobra.Command{
 This command will guide you through:
   - Adding areas, goals, notebooks, and habits from Lunatask
   - Setting default area and notebook
-  - Configuring and verifying your access token`,
+  - Configuring and verifying your access token
+
+Use --generate-config to create an example config file for manual editing
+when running non-interactively.`,
 	RunE: runInit,
 }
 
+func init() {
+	Cmd.Flags().Bool("generate-config", false, "generate example config for manual editing")
+}
+
 func runInit(cmd *cobra.Command, _ []string) error {
+	generateConfig, _ := cmd.Flags().GetBool("generate-config")
+	if generateConfig {
+		return runGenerateConfig(cmd)
+	}
+
+	if !ui.IsInteractive() {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("lune init requires an interactive terminal."))
+		fmt.Fprintln(cmd.ErrOrStderr())
+		fmt.Fprintln(cmd.ErrOrStderr(), "To configure lune non-interactively, run:")
+		fmt.Fprintln(cmd.ErrOrStderr(), "  lune init --generate-config")
+		fmt.Fprintln(cmd.ErrOrStderr())
+		fmt.Fprintln(cmd.ErrOrStderr(), "Then edit the generated config file manually.")
+
+		return errNonInteractive
+	}
+
 	cfg, err := config.Load()
 	if errors.Is(err, config.ErrNotFound) {
 		cfg = &config.Config{}
@@ -250,3 +281,84 @@ func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
 
 	return nil
 }
+
+// exampleConfig is a commented TOML config for manual editing.
+const exampleConfig = `# lune configuration file
+# See: https://git.secluded.site/lune for documentation
+
+# UI preferences
+[ui]
+# Color output: "auto" (default), "always", or "never"
+color = "auto"
+
+# Default selections for commands
+[defaults]
+# Default area key (must match an area defined below)
+# area = "work"
+
+# Default notebook key (must match a notebook defined below)
+# notebook = "journal"
+
+# Areas of life from Lunatask
+# Find IDs in Lunatask: Open area settings → "Copy Area ID" (bottom left)
+#
+# [[areas]]
+# id = "00000000-0000-0000-0000-000000000000"
+# name = "Work"
+# key = "work"
+#
+#   # Goals within this area
+#   [[areas.goals]]
+#   id = "00000000-0000-0000-0000-000000000001"
+#   name = "Q1 Project"
+#   key = "q1-project"
+
+# Notebooks for notes
+# Find IDs in Lunatask: Open notebook settings → "Copy Notebook ID"
+#
+# [[notebooks]]
+# id = "00000000-0000-0000-0000-000000000000"
+# name = "Journal"
+# key = "journal"
+
+# Habits to track
+# Find IDs in Lunatask: Open habit settings → "Copy Habit ID"
+#
+# [[habits]]
+# id = "00000000-0000-0000-0000-000000000000"
+# name = "Exercise"
+# key = "exercise"
+`
+
+func runGenerateConfig(cmd *cobra.Command) error {
+	cfgPath, err := config.Path()
+	if err != nil {
+		return err
+	}
+
+	if _, err := os.Stat(cfgPath); err == nil {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config already exists: "+cfgPath))
+		fmt.Fprintln(cmd.ErrOrStderr(), "Remove or rename it first, or edit it directly.")
+
+		return errConfigExists
+	}
+
+	dir := filepath.Dir(cfgPath)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return fmt.Errorf("creating config directory: %w", err)
+	}
+
+	if err := os.WriteFile(cfgPath, []byte(exampleConfig), 0o600); err != nil {
+		return fmt.Errorf("writing config: %w", err)
+	}
+
+	fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Generated example config: "+cfgPath))
+	fmt.Fprintln(cmd.OutOrStdout())
+	fmt.Fprintln(cmd.OutOrStdout(), "Edit this file to add your Lunatask areas, notebooks, and habits.")
+	fmt.Fprintln(cmd.OutOrStdout(), "Then configure your access token with: lune init --generate-config")
+	fmt.Fprintln(cmd.OutOrStdout())
+	fmt.Fprintln(cmd.OutOrStdout(), "To set your access token non-interactively, use your system keyring.")
+	fmt.Fprintln(cmd.OutOrStdout(), "The service is 'lune' and the key is 'access_token'.")
+
+	return nil
+}

cmd/note/list.go 🔗

@@ -195,12 +195,12 @@ func outputTable(cmd *cobra.Command, notes []lunatask.Note) error {
 		Rows(rows...).
 		StyleFunc(func(row, col int) lipgloss.Style {
 			if row == table.HeaderRow {
-				return ui.Bold
+				return ui.TableHeaderStyle()
 			}
 
 			return lipgloss.NewStyle()
 		}).
-		Border(lipgloss.HiddenBorder())
+		Border(ui.TableBorder())
 
 	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
 

cmd/person/list.go 🔗

@@ -141,12 +141,12 @@ func outputTable(cmd *cobra.Command, people []lunatask.Person) error {
 		Rows(rows...).
 		StyleFunc(func(row, col int) lipgloss.Style {
 			if row == table.HeaderRow {
-				return ui.Bold
+				return ui.TableHeaderStyle()
 			}
 
 			return lipgloss.NewStyle()
 		}).
-		Border(lipgloss.HiddenBorder())
+		Border(ui.TableBorder())
 
 	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
 

cmd/root.go 🔗

@@ -17,6 +17,8 @@ import (
 	"git.secluded.site/lune/cmd/note"
 	"git.secluded.site/lune/cmd/person"
 	"git.secluded.site/lune/cmd/task"
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/ui"
 	"github.com/charmbracelet/fang"
 	"github.com/spf13/cobra"
 )
@@ -35,6 +37,21 @@ all-in-one productivity app for tasks, habits, journaling, and more.
 Run 'lune init' to configure your access token and get started.`,
 	SilenceUsage:  true,
 	SilenceErrors: true,
+	PersistentPreRun: func(_ *cobra.Command, _ []string) {
+		cfg, err := config.Load()
+		if err != nil {
+			return
+		}
+
+		switch cfg.UI.Color {
+		case "always":
+			ui.SetColorMode(ui.ColorAlways)
+		case "never":
+			ui.SetColorMode(ui.ColorNever)
+		default:
+			ui.SetColorMode(ui.ColorAuto)
+		}
+	},
 }
 
 func init() {

cmd/task/list.go 🔗

@@ -232,12 +232,12 @@ func outputTable(cmd *cobra.Command, tasks []lunatask.Task) error {
 		Rows(rows...).
 		StyleFunc(func(row, col int) lipgloss.Style {
 			if row == table.HeaderRow {
-				return ui.Bold
+				return ui.TableHeaderStyle()
 			}
 
 			return lipgloss.NewStyle()
 		}).
-		Border(lipgloss.HiddenBorder())
+		Border(ui.TableBorder())
 
 	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
 

go.mod 🔗

@@ -15,6 +15,7 @@ require (
 	github.com/charmbracelet/lipgloss v1.1.0
 	github.com/klauspost/lctime v0.1.0
 	github.com/markusmobius/go-dateparser v1.2.4
+	github.com/mattn/go-isatty v0.0.20
 	github.com/spf13/cobra v1.10.2
 	github.com/zalando/go-keyring v0.2.6
 )
@@ -50,7 +51,6 @@ require (
 	github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/magefile/mage v1.14.0 // indirect
-	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.19 // indirect
 	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect

internal/ui/output.go 🔗

@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package ui
+
+import (
+	"os"
+	"strings"
+
+	"github.com/mattn/go-isatty"
+)
+
+// ColorMode represents the color output mode.
+type ColorMode int
+
+// Color mode constants controlling terminal output styling.
+const (
+	ColorAuto   ColorMode = iota // Detect from environment/TTY
+	ColorAlways                  // Force colors on
+	ColorNever                   // Force colors off
+)
+
+var colorMode = ColorAuto
+
+// SetColorMode sets the global color mode.
+func SetColorMode(mode ColorMode) {
+	colorMode = mode
+}
+
+// IsPlain returns true when output should be unstyled plain text.
+// Detection priority:
+//  1. Explicit ColorNever/ColorAlways mode
+//  2. NO_COLOR env var (non-empty = plain)
+//  3. FORCE_COLOR env var (non-empty = styled)
+//  4. TERM=dumb (plain)
+//  5. TTY detection (non-TTY = plain)
+func IsPlain() bool {
+	switch colorMode {
+	case ColorNever:
+		return true
+	case ColorAlways:
+		return false
+	case ColorAuto:
+		return detectPlain()
+	}
+
+	return detectPlain()
+}
+
+// IsInteractive returns true when running in an interactive terminal.
+// This checks TTY status regardless of color settings.
+func IsInteractive() bool {
+	return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
+}
+
+func detectPlain() bool {
+	// NO_COLOR takes precedence (https://no-color.org/)
+	if noColor := os.Getenv("NO_COLOR"); noColor != "" {
+		return true
+	}
+
+	// FORCE_COLOR overrides TTY detection
+	if forceColor := os.Getenv("FORCE_COLOR"); forceColor != "" {
+		// FORCE_COLOR=0 or FORCE_COLOR=false means no color
+		lower := strings.ToLower(forceColor)
+		if lower == "0" || lower == "false" {
+			return true
+		}
+
+		return false
+	}
+
+	// TERM=dumb indicates minimal terminal
+	if term := os.Getenv("TERM"); term == "dumb" {
+		return true
+	}
+
+	// Fall back to TTY detection
+	return !IsInteractive()
+}

internal/ui/spinner.go 🔗

@@ -12,7 +12,13 @@ import (
 
 // Spin executes fn while displaying a spinner with the given title.
 // Uses generics to preserve the return type of the wrapped function.
+// In non-interactive mode, the function runs directly without spinner UI.
 func Spin[T any](title string, fn func() (T, error)) (T, error) {
+	// In plain/non-interactive mode, just run the function directly
+	if IsPlain() {
+		return fn()
+	}
+
 	var result T
 
 	var fnErr error
@@ -32,7 +38,13 @@ func Spin[T any](title string, fn func() (T, error)) (T, error) {
 
 // SpinVoid executes fn while displaying a spinner with the given title.
 // Use for functions that only return an error.
+// In non-interactive mode, the function runs directly without spinner UI.
 func SpinVoid(title string, fn func() error) error {
+	// In plain/non-interactive mode, just run the function directly
+	if IsPlain() {
+		return fn()
+	}
+
 	var fnErr error
 
 	spinErr := spinner.New().

internal/ui/styles.go 🔗

@@ -12,28 +12,52 @@ import (
 	"github.com/klauspost/lctime"
 )
 
+// Style wraps lipgloss.Style to conditionally render based on output mode.
+type Style struct {
+	style lipgloss.Style
+}
+
+// Render applies the style to the given text, or returns plain text in plain mode.
+func (s Style) Render(strs ...string) string {
+	if IsPlain() {
+		result := ""
+		for _, str := range strs {
+			result += str
+		}
+
+		return result
+	}
+
+	return s.style.Render(strs...)
+}
+
+// Style returns the underlying lipgloss.Style for advanced use.
+func (s Style) Style() lipgloss.Style {
+	return s.style
+}
+
 // Terminal output styles using ANSI colors for broad compatibility.
 var (
-	Success = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
-	Warning = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
-	Error   = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red
-	Bold    = lipgloss.NewStyle().Bold(true)
+	Success = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("2"))} // green
+	Warning = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))} // yellow
+	Error   = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("1"))} // red
+	Bold    = Style{lipgloss.NewStyle().Bold(true)}
 )
 
 // Heading styles with backgrounds for contrast on any theme.
 var (
 	// H1 is the primary heading style (top-level items).
-	H1 = lipgloss.NewStyle().
+	H1 = Style{lipgloss.NewStyle().
 		Bold(true).
 		Foreground(lipgloss.Color("0")).
 		Background(lipgloss.Color("4")).
-		Padding(0, 1)
+		Padding(0, 1)}
 	// H2 is the secondary heading style (nested items).
-	H2 = lipgloss.NewStyle().
+	H2 = Style{lipgloss.NewStyle().
 		Bold(true).
 		Foreground(lipgloss.Color("0")).
 		Background(lipgloss.Color("6")).
-		Padding(0, 1)
+		Padding(0, 1)}
 )
 
 // FormatDate formats a time.Time as a date string using the user's locale.
@@ -41,3 +65,23 @@ var (
 func FormatDate(t time.Time) string {
 	return lctime.Strftime("%x", t)
 }
+
+// TableHeaderStyle returns the style for table header rows.
+// Returns bold style in interactive mode, plain in non-interactive.
+func TableHeaderStyle() lipgloss.Style {
+	if IsPlain() {
+		return lipgloss.NewStyle()
+	}
+
+	return lipgloss.NewStyle().Bold(true)
+}
+
+// TableBorder returns the border style for tables.
+// Returns a normal border in interactive mode, hidden in non-interactive.
+func TableBorder() lipgloss.Border {
+	if IsPlain() {
+		return lipgloss.HiddenBorder()
+	}
+
+	return lipgloss.NormalBorder()
+}