refactor: centralize completions and ui helpers

Amolith created

- Move completion functions to internal/completion package
- Add Static() helper for inline static value completions
- Extract ui.Confirm() using charmbracelet/huh
- Implement done command via task.UpdateCmd delegation

Assisted-by: Claude Opus 4.5 via Crush

Change summary

cmd/done.go                       | 12 ++-
cmd/habit/track.go                | 19 ------
cmd/note/add.go                   | 19 ------
cmd/note/delete.go                | 12 ----
cmd/note/list.go                  |  3 
cmd/note/update.go                |  3 
cmd/person/add.go                 | 16 -----
cmd/person/delete.go              | 12 ----
cmd/person/update.go              |  3 
cmd/task/add.go                   | 53 ++-----------------
cmd/task/delete.go                | 12 ----
cmd/task/list.go                  |  7 +-
cmd/task/update.go                | 15 ++---
go.mod                            | 11 ++++
go.sum                            | 23 ++++++++
internal/completion/completion.go | 87 +++++++++++++++++++++++++++++++++
internal/completion/static.go     | 16 ++++++
internal/ui/confirm.go            | 26 +++++++++
18 files changed, 201 insertions(+), 148 deletions(-)

Detailed changes

cmd/done.go 🔗

@@ -5,8 +5,7 @@
 package cmd
 
 import (
-	"fmt"
-
+	"git.secluded.site/lune/cmd/task"
 	"github.com/spf13/cobra"
 )
 
@@ -16,9 +15,12 @@ var doneCmd = &cobra.Command{
 	GroupID: "shortcuts",
 	Args:    cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		// TODO: implement as task update --status completed
-		fmt.Fprintf(cmd.OutOrStdout(), "Marking task %s as done (not yet implemented)\n", args[0])
+		_ = task.UpdateCmd.Flags().Set("status", "completed")
+
+		task.UpdateCmd.SetIn(cmd.InOrStdin())
+		task.UpdateCmd.SetOut(cmd.OutOrStdout())
+		task.UpdateCmd.SetErr(cmd.ErrOrStderr())
 
-		return nil
+		return task.UpdateCmd.RunE(task.UpdateCmd, args)
 	},
 }

cmd/habit/track.go 🔗

@@ -7,7 +7,7 @@ package habit
 import (
 	"fmt"
 
-	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/completion"
 	"github.com/spf13/cobra"
 )
 
@@ -20,7 +20,7 @@ var TrackCmd = &cobra.Command{
 KEY is the habit key from your config (not the raw Lunatask ID).
 Tracks for today by default. Use --date to specify another date.`,
 	Args:              cobra.ExactArgs(1),
-	ValidArgsFunction: completeHabits,
+	ValidArgsFunction: completion.Habits,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		date, _ := cmd.Flags().GetString("date")
 		if date == "" {
@@ -37,18 +37,3 @@ Tracks for today by default. Use --date to specify another date.`,
 func init() {
 	TrackCmd.Flags().StringP("date", "d", "", "Date performed (natural language, default: today)")
 }
-
-// completeHabits returns habit keys from config for shell completion.
-func completeHabits(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-	cfg, err := config.Load()
-	if err != nil {
-		return nil, cobra.ShellCompDirectiveError
-	}
-
-	keys := make([]string, len(cfg.Habits))
-	for i, h := range cfg.Habits {
-		keys[i] = h.Key
-	}
-
-	return keys, cobra.ShellCompDirectiveNoFileComp
-}

cmd/note/add.go 🔗

@@ -7,7 +7,7 @@ package note
 import (
 	"fmt"
 
-	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/completion"
 	"github.com/spf13/cobra"
 )
 
@@ -34,20 +34,5 @@ func init() {
 	AddCmd.Flags().StringP("notebook", "b", "", "Notebook key (from config)")
 	AddCmd.Flags().StringP("content", "c", "", "Note content (use - for stdin)")
 
-	_ = AddCmd.RegisterFlagCompletionFunc("notebook", completeNotebooks)
-}
-
-// completeNotebooks returns notebook keys from config for shell completion.
-func completeNotebooks(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-	cfg, err := config.Load()
-	if err != nil {
-		return nil, cobra.ShellCompDirectiveError
-	}
-
-	keys := make([]string, len(cfg.Notebooks))
-	for i, n := range cfg.Notebooks {
-		keys[i] = n.Key
-	}
-
-	return keys, cobra.ShellCompDirectiveNoFileComp
+	_ = AddCmd.RegisterFlagCompletionFunc("notebook", completion.Notebooks)
 }

cmd/note/delete.go 🔗

@@ -5,10 +5,7 @@
 package note
 
 import (
-	"bufio"
 	"fmt"
-	"os"
-	"strings"
 
 	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
@@ -27,14 +24,7 @@ var DeleteCmd = &cobra.Command{
 
 		force, _ := cmd.Flags().GetBool("force")
 		if !force {
-			fmt.Fprintf(cmd.OutOrStderr(), "%s Delete note %s? [y/N] ",
-				ui.Warning.Render("Warning:"), args[0])
-
-			reader := bufio.NewReader(os.Stdin)
-			response, _ := reader.ReadString('\n')
-			response = strings.TrimSpace(strings.ToLower(response))
-
-			if response != "y" && response != "yes" {
+			if !ui.Confirm(fmt.Sprintf("Delete note %s?", args[0])) {
 				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
 
 				return nil

cmd/note/list.go 🔗

@@ -7,6 +7,7 @@ package note
 import (
 	"fmt"
 
+	"git.secluded.site/lune/internal/completion"
 	"github.com/spf13/cobra"
 )
 
@@ -30,5 +31,5 @@ func init() {
 	ListCmd.Flags().StringP("notebook", "b", "", "Filter by notebook key")
 	ListCmd.Flags().Bool("json", false, "Output as JSON")
 
-	_ = ListCmd.RegisterFlagCompletionFunc("notebook", completeNotebooks)
+	_ = ListCmd.RegisterFlagCompletionFunc("notebook", completion.Notebooks)
 }

cmd/note/update.go 🔗

@@ -7,6 +7,7 @@ package note
 import (
 	"fmt"
 
+	"git.secluded.site/lune/internal/completion"
 	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
@@ -34,5 +35,5 @@ func init() {
 	UpdateCmd.Flags().StringP("content", "c", "", "Note content (use - for stdin)")
 	UpdateCmd.Flags().StringP("date", "d", "", "Note date (natural language)")
 
-	_ = UpdateCmd.RegisterFlagCompletionFunc("notebook", completeNotebooks)
+	_ = UpdateCmd.RegisterFlagCompletionFunc("notebook", completion.Notebooks)
 }

cmd/person/add.go 🔗

@@ -7,6 +7,7 @@ package person
 import (
 	"fmt"
 
+	"git.secluded.site/lune/internal/completion"
 	"github.com/spf13/cobra"
 )
 
@@ -26,18 +27,5 @@ var AddCmd = &cobra.Command{
 func init() {
 	AddCmd.Flags().StringP("relationship", "r", "", "Relationship strength")
 
-	_ = AddCmd.RegisterFlagCompletionFunc("relationship", completeRelationships)
-}
-
-// completeRelationships returns relationship strength options for shell completion.
-func completeRelationships(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-	return []string{
-		"family",
-		"intimate-friends",
-		"close-friends",
-		"casual-friends",
-		"acquaintances",
-		"business-contacts",
-		"almost-strangers",
-	}, cobra.ShellCompDirectiveNoFileComp
+	_ = AddCmd.RegisterFlagCompletionFunc("relationship", completion.Relationships)
 }

cmd/person/delete.go 🔗

@@ -5,10 +5,7 @@
 package person
 
 import (
-	"bufio"
 	"fmt"
-	"os"
-	"strings"
 
 	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
@@ -27,14 +24,7 @@ var DeleteCmd = &cobra.Command{
 
 		force, _ := cmd.Flags().GetBool("force")
 		if !force {
-			fmt.Fprintf(cmd.OutOrStderr(), "%s Delete person %s? [y/N] ",
-				ui.Warning.Render("Warning:"), args[0])
-
-			reader := bufio.NewReader(os.Stdin)
-			response, _ := reader.ReadString('\n')
-			response = strings.TrimSpace(strings.ToLower(response))
-
-			if response != "y" && response != "yes" {
+			if !ui.Confirm(fmt.Sprintf("Delete person %s?", args[0])) {
 				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
 
 				return nil

cmd/person/update.go 🔗

@@ -7,6 +7,7 @@ package person
 import (
 	"fmt"
 
+	"git.secluded.site/lune/internal/completion"
 	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
@@ -33,5 +34,5 @@ func init() {
 	UpdateCmd.Flags().String("last", "", "Last name")
 	UpdateCmd.Flags().StringP("relationship", "r", "", "Relationship strength")
 
-	_ = UpdateCmd.RegisterFlagCompletionFunc("relationship", completeRelationships)
+	_ = UpdateCmd.RegisterFlagCompletionFunc("relationship", completion.Relationships)
 }

cmd/task/add.go 🔗

@@ -7,7 +7,7 @@ package task
 import (
 	"fmt"
 
-	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/completion"
 	"github.com/spf13/cobra"
 )
 
@@ -40,53 +40,12 @@ func init() {
 	AddCmd.Flags().Int("eisenhower", 0, "Eisenhower quadrant: 1-4")
 	AddCmd.Flags().String("schedule", "", "Schedule date (natural language)")
 
-	_ = AddCmd.RegisterFlagCompletionFunc("area", completeAreas)
-	_ = AddCmd.RegisterFlagCompletionFunc("goal", completeGoals)
+	_ = AddCmd.RegisterFlagCompletionFunc("area", completion.Areas)
+	_ = AddCmd.RegisterFlagCompletionFunc("goal", completion.Goals)
 	_ = AddCmd.RegisterFlagCompletionFunc("status",
-		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-			return []string{"later", "next", "started", "waiting"}, cobra.ShellCompDirectiveNoFileComp
-		})
+		completion.Static("later", "next", "started", "waiting"))
 	_ = AddCmd.RegisterFlagCompletionFunc("motivation",
-		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-			return []string{"must", "should", "want"}, cobra.ShellCompDirectiveNoFileComp
-		})
+		completion.Static("must", "should", "want"))
 	_ = AddCmd.RegisterFlagCompletionFunc("eisenhower",
-		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-			return []string{"1", "2", "3", "4"}, cobra.ShellCompDirectiveNoFileComp
-		})
-}
-
-// completeAreas returns area keys from config for shell completion.
-func completeAreas(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-	cfg, err := config.Load()
-	if err != nil {
-		return nil, cobra.ShellCompDirectiveError
-	}
-
-	keys := make([]string, len(cfg.Areas))
-	for i, a := range cfg.Areas {
-		keys[i] = a.Key
-	}
-
-	return keys, cobra.ShellCompDirectiveNoFileComp
-}
-
-// completeGoals returns goal keys from config for shell completion.
-// Note: This returns all goals across all areas. A smarter completion
-// would filter based on the --area flag value if set.
-func completeGoals(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-	cfg, err := config.Load()
-	if err != nil {
-		return nil, cobra.ShellCompDirectiveError
-	}
-
-	var keys []string
-
-	for _, a := range cfg.Areas {
-		for _, g := range a.Goals {
-			keys = append(keys, g.Key)
-		}
-	}
-
-	return keys, cobra.ShellCompDirectiveNoFileComp
+		completion.Static("1", "2", "3", "4"))
 }

cmd/task/delete.go 🔗

@@ -5,10 +5,7 @@
 package task
 
 import (
-	"bufio"
 	"fmt"
-	"os"
-	"strings"
 
 	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
@@ -27,14 +24,7 @@ var DeleteCmd = &cobra.Command{
 
 		force, _ := cmd.Flags().GetBool("force")
 		if !force {
-			fmt.Fprintf(cmd.OutOrStderr(), "%s Delete task %s? [y/N] ",
-				ui.Warning.Render("Warning:"), args[0])
-
-			reader := bufio.NewReader(os.Stdin)
-			response, _ := reader.ReadString('\n')
-			response = strings.TrimSpace(strings.ToLower(response))
-
-			if response != "y" && response != "yes" {
+			if !ui.Confirm(fmt.Sprintf("Delete task %s?", args[0])) {
 				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
 
 				return nil

cmd/task/list.go 🔗

@@ -7,6 +7,7 @@ package task
 import (
 	"fmt"
 
+	"git.secluded.site/lune/internal/completion"
 	"github.com/spf13/cobra"
 )
 
@@ -31,9 +32,7 @@ func init() {
 	ListCmd.Flags().StringP("status", "s", "", "Filter by status")
 	ListCmd.Flags().Bool("json", false, "Output as JSON")
 
-	_ = ListCmd.RegisterFlagCompletionFunc("area", completeAreas)
+	_ = ListCmd.RegisterFlagCompletionFunc("area", completion.Areas)
 	_ = ListCmd.RegisterFlagCompletionFunc("status",
-		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-			return []string{"later", "next", "started", "waiting", "completed"}, cobra.ShellCompDirectiveNoFileComp
-		})
+		completion.Static("later", "next", "started", "waiting", "completed"))
 }

cmd/task/update.go 🔗

@@ -7,6 +7,7 @@ package task
 import (
 	"fmt"
 
+	"git.secluded.site/lune/internal/completion"
 	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
@@ -40,14 +41,12 @@ func init() {
 	UpdateCmd.Flags().Int("eisenhower", 0, "Eisenhower quadrant: 1-4")
 	UpdateCmd.Flags().String("schedule", "", "Schedule date (natural language)")
 
-	_ = UpdateCmd.RegisterFlagCompletionFunc("area", completeAreas)
-	_ = UpdateCmd.RegisterFlagCompletionFunc("goal", completeGoals)
+	_ = UpdateCmd.RegisterFlagCompletionFunc("area", completion.Areas)
+	_ = UpdateCmd.RegisterFlagCompletionFunc("goal", completion.Goals)
 	_ = UpdateCmd.RegisterFlagCompletionFunc("status",
-		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-			return []string{"later", "next", "started", "waiting", "completed"}, cobra.ShellCompDirectiveNoFileComp
-		})
+		completion.Static("later", "next", "started", "waiting", "completed"))
 	_ = UpdateCmd.RegisterFlagCompletionFunc("motivation",
-		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-			return []string{"must", "should", "want"}, cobra.ShellCompDirectiveNoFileComp
-		})
+		completion.Static("must", "should", "want"))
+	_ = UpdateCmd.RegisterFlagCompletionFunc("eisenhower",
+		completion.Static("1", "2", "3", "4"))
 }

go.mod 🔗

@@ -13,22 +13,33 @@ require (
 
 require (
 	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/catppuccin/go v0.3.0 // indirect
+	github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
+	github.com/charmbracelet/bubbletea v1.3.6 // indirect
 	github.com/charmbracelet/colorprofile v0.4.1 // indirect
+	github.com/charmbracelet/huh v0.8.0 // indirect
 	github.com/charmbracelet/ultraviolet v0.0.0-20251217160852-6b0c0e26fad9 // indirect
 	github.com/charmbracelet/x/ansi v0.11.3 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 // indirect
+	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/clipperhouse/displaywidth v0.6.2 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
 	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.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
+	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/mango v0.2.0 // indirect
 	github.com/muesli/mango-cobra v1.3.0 // indirect

go.sum 🔗

@@ -4,14 +4,24 @@ git.secluded.site/go-lunatask v0.1.0-rc7 h1:kzwAN9h4zVTo0OBs4B23ba5mAqxus6nYU62L
 git.secluded.site/go-lunatask v0.1.0-rc7/go.mod h1:sWUQxme1z7qfsfS59nU5hqPvsRCt+HBmT/yBeIn6Fmc=
 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
+github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
+github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
 github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
+github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
+github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
 github.com/charmbracelet/ultraviolet v0.0.0-20251217160852-6b0c0e26fad9 h1:dsDBRP9Iyco0EjVpCsAzl8VGbxk04fP3sa80ySJSAZw=
@@ -24,6 +34,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 h1:x
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -39,6 +51,10 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -47,8 +63,14 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
@@ -80,6 +102,7 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
 golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

internal/completion/completion.go 🔗

@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package completion provides shell completion helpers for CLI commands.
+package completion
+
+import (
+	"git.secluded.site/lune/internal/config"
+	"github.com/spf13/cobra"
+)
+
+// Areas returns area keys from config for shell completion.
+func Areas(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	keys := make([]string, len(cfg.Areas))
+	for i, a := range cfg.Areas {
+		keys[i] = a.Key
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}
+
+// Goals returns all goal keys across all areas for shell completion.
+func Goals(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	var keys []string
+
+	for _, a := range cfg.Areas {
+		for _, g := range a.Goals {
+			keys = append(keys, g.Key)
+		}
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}
+
+// Notebooks returns notebook keys from config for shell completion.
+func Notebooks(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	keys := make([]string, len(cfg.Notebooks))
+	for i, n := range cfg.Notebooks {
+		keys[i] = n.Key
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}
+
+// Habits returns habit keys from config for shell completion.
+func Habits(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	keys := make([]string, len(cfg.Habits))
+	for i, h := range cfg.Habits {
+		keys[i] = h.Key
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}
+
+// Relationships returns relationship strength options for shell completion.
+func Relationships(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	return []string{
+		"family",
+		"intimate-friends",
+		"close-friends",
+		"casual-friends",
+		"acquaintances",
+		"business-contacts",
+		"almost-strangers",
+	}, cobra.ShellCompDirectiveNoFileComp
+}

internal/completion/static.go 🔗

@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package completion
+
+import "github.com/spf13/cobra"
+
+// Static returns a completion function that provides fixed values.
+func Static(values ...string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
+	vals := append([]string(nil), values...)
+
+	return func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+		return vals, cobra.ShellCompDirectiveNoFileComp
+	}
+}

internal/ui/confirm.go 🔗

@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package ui
+
+import "github.com/charmbracelet/huh"
+
+// Confirm asks the user to confirm an action using an interactive prompt.
+// Returns false on cancellation or error (fail closed).
+func Confirm(title string) bool {
+	var confirmed bool
+
+	err := huh.NewConfirm().
+		Title(title).
+		Affirmative("Yes").
+		Negative("No").
+		Inline(true).
+		Value(&confirmed).
+		Run()
+	if err != nil {
+		return false
+	}
+
+	return confirmed
+}