Detailed changes
@@ -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())
@@ -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())
@@ -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
+}
@@ -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())
@@ -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())
@@ -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() {
@@ -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())
@@ -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
@@ -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()
+}
@@ -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().
@@ -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()
+}