refactor(cmd/init): extract to subpackage

Amolith created

- Split monolithic init.go into focused files: areas, notebooks,
habits, defaults, apikey, ui
- Move API key configuration to last step with ping validation
- Add spinner feedback during API key verification
- Make keyring errors blocking instead of silent
- Add bounds checks on slice operations
- Handle ErrUserAborted consistently across all huh forms
- Change parseEditIndex to (int, bool) signature
- Add unit tests for validators

Assisted-by: Claude Opus 4.5 via Amp

Change summary

cmd/init.go               |  28 --
cmd/init/apikey.go        | 191 +++++++++++++++++++
cmd/init/areas.go         | 406 +++++++++++++++++++++++++++++++++++++++++
cmd/init/defaults.go      | 132 +++++++++++++
cmd/init/habits.go        | 202 ++++++++++++++++++++
cmd/init/init.go          | 203 ++++++++++++++++++++
cmd/init/notebooks.go     | 205 ++++++++++++++++++++
cmd/init/ui.go            | 269 +++++++++++++++++++++++++++
cmd/init/ui_test.go       | 194 +++++++++++++++++++
cmd/root.go               |   3 
go.mod                    |   9 
go.sum                    |  28 ++
internal/client/client.go |  76 +++++++
13 files changed, 1,907 insertions(+), 39 deletions(-)

Detailed changes

cmd/init.go 🔗

@@ -1,28 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package cmd
-
-import (
-	"fmt"
-
-	"github.com/spf13/cobra"
-)
-
-var initCmd = &cobra.Command{
-	Use:   "init",
-	Short: "Initialize lune configuration interactively",
-	Long: `Interactively set up your lune configuration.
-
-This command will guide you through:
-  - Verifying your LUNATASK_API_KEY
-  - Adding areas, goals, notebooks, and habits from Lunatask
-  - Setting default area and notebook`,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		// TODO: implement interactive setup with huh
-		fmt.Fprintln(cmd.OutOrStdout(), "Interactive setup not yet implemented")
-
-		return nil
-	},
-}

cmd/init/apikey.go 🔗

@@ -0,0 +1,191 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"time"
+
+	"git.secluded.site/go-lunatask"
+	"github.com/charmbracelet/huh"
+	"github.com/charmbracelet/huh/spinner"
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/ui"
+)
+
+func configureAPIKey(cmd *cobra.Command) error {
+	out := cmd.OutOrStdout()
+
+	if envKey := os.Getenv(client.EnvAPIKey); envKey != "" {
+		fmt.Fprintln(out, ui.Success.Render("API key found in "+client.EnvAPIKey+" environment variable."))
+
+		if err := validateWithSpinner(envKey); err != nil {
+			return fmt.Errorf("API key validation failed: %w", err)
+		}
+
+		fmt.Fprintln(out, ui.Success.Render("✓ Authentication successful"))
+
+		return nil
+	}
+
+	hasKey, keyringErr := client.HasKeyringKey()
+	if keyringErr != nil {
+		fmt.Fprintln(out, ui.Error.Render("Failed to access system keyring: "+keyringErr.Error()))
+		fmt.Fprintln(out, ui.Muted.Render("Please resolve the keyring issue and try again."))
+
+		return fmt.Errorf("keyring access failed: %w", keyringErr)
+	}
+
+	if hasKey {
+		shouldPrompt, err := handleExistingAPIKey(out)
+		if err != nil {
+			return err
+		}
+
+		if !shouldPrompt {
+			return nil
+		}
+	}
+
+	return promptForAPIKey(out)
+}
+
+func handleExistingAPIKey(out io.Writer) (bool, error) {
+	existingKey, err := client.GetAPIKey()
+	if err != nil {
+		return false, fmt.Errorf("reading API key from keyring: %w", err)
+	}
+
+	fmt.Fprintln(out, ui.Muted.Render("API key found in system keyring."))
+
+	if err := validateWithSpinner(existingKey); err != nil {
+		fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error()))
+	} else {
+		fmt.Fprintln(out, ui.Success.Render("✓ Authentication successful"))
+	}
+
+	var action string
+
+	err = huh.NewSelect[string]().
+		Title("API key is already configured").
+		Options(
+			huh.NewOption("Keep existing key", "keep"),
+			huh.NewOption("Replace with new key", "replace"),
+			huh.NewOption("Delete from keyring", actionDelete),
+		).
+		Value(&action).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return false, nil
+		}
+
+		return false, err
+	}
+
+	switch action {
+	case "keep":
+		return false, nil
+	case actionDelete:
+		if err := client.DeleteAPIKey(); err != nil {
+			return false, fmt.Errorf("deleting API key: %w", err)
+		}
+
+		fmt.Fprintln(out, ui.Success.Render("API key removed from keyring."))
+
+		return false, nil
+	default:
+		return true, nil
+	}
+}
+
+func promptForAPIKey(out io.Writer) error {
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, ui.Muted.Render("You can get your API key from:"))
+	fmt.Fprintln(out, ui.Muted.Render("  Lunatask → Settings → Integrations → API"))
+	fmt.Fprintln(out)
+
+	var apiKey string
+
+	for {
+		err := huh.NewInput().
+			Title("Lunatask API Key").
+			Description("Paste your API key (it will be stored securely in your system keyring).").
+			EchoMode(huh.EchoModePassword).
+			Value(&apiKey).
+			Validate(func(s string) error {
+				if s == "" {
+					return errKeyRequired
+				}
+
+				return nil
+			}).
+			Run()
+		if err != nil {
+			if errors.Is(err, huh.ErrUserAborted) {
+				fmt.Fprintln(out, ui.Warning.Render("Skipped API key setup."))
+
+				return nil
+			}
+
+			return err
+		}
+
+		if err := validateWithSpinner(apiKey); err != nil {
+			fmt.Fprintln(out, ui.Error.Render("✗ Authentication failed: "+err.Error()))
+			fmt.Fprintln(out, ui.Muted.Render("Please check your API key and try again."))
+			fmt.Fprintln(out)
+
+			apiKey = ""
+
+			continue
+		}
+
+		fmt.Fprintln(out, ui.Success.Render("✓ Authentication successful"))
+
+		break
+	}
+
+	if err := client.SetAPIKey(apiKey); err != nil {
+		return fmt.Errorf("saving API key to keyring: %w", err)
+	}
+
+	fmt.Fprintln(out, ui.Success.Render("API key saved to system keyring."))
+
+	return nil
+}
+
+func validateWithSpinner(apiKey string) error {
+	var validationErr error
+
+	err := spinner.New().
+		Title("Verifying API key...").
+		Action(func() {
+			validationErr = validateAPIKeyWithPing(apiKey)
+		}).
+		Run()
+	if err != nil {
+		return err
+	}
+
+	return validationErr
+}
+
+func validateAPIKeyWithPing(apiKey string) error {
+	c := lunatask.NewClient(apiKey, lunatask.UserAgent("lune/init"))
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	_, err := c.Ping(ctx)
+
+	return err
+}

cmd/init/areas.go 🔗

@@ -0,0 +1,406 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/huh"
+
+	"git.secluded.site/lune/internal/config"
+)
+
+func maybeConfigureAreas(cfg *config.Config) error {
+	var configure bool
+
+	err := huh.NewConfirm().
+		Title("Configure areas & goals?").
+		Description("Areas organize your life (Work, Health, etc). Goals belong to areas.").
+		Affirmative("Yes").
+		Negative("Not now").
+		Value(&configure).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if configure {
+		return manageAreas(cfg)
+	}
+
+	return nil
+}
+
+func manageAreas(cfg *config.Config) error {
+	for {
+		options := buildAreaOptions(cfg.Areas)
+
+		choice, err := runListSelect("Areas", "Manage your areas and their goals.", options)
+		if err != nil {
+			return err
+		}
+
+		if choice == choiceBack {
+			return nil
+		}
+
+		if choice == choiceAdd {
+			if err := addArea(cfg); err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		idx, ok := parseEditIndex(choice)
+		if !ok {
+			continue
+		}
+
+		if err := manageAreaActions(cfg, idx); err != nil {
+			return err
+		}
+	}
+}
+
+func buildAreaOptions(areas []config.Area) []huh.Option[string] {
+	options := []huh.Option[string]{
+		huh.NewOption("Add new area", choiceAdd),
+	}
+
+	for idx, area := range areas {
+		goalCount := len(area.Goals)
+		label := fmt.Sprintf("%s (%s) - %d goal(s)", area.Name, area.Key, goalCount)
+		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
+	}
+
+	options = append(options, huh.NewOption("Back", choiceBack))
+
+	return options
+}
+
+func addArea(cfg *config.Config) error {
+	area, err := editArea(nil, cfg)
+	if err != nil {
+		if errors.Is(err, errUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.Areas = append(cfg.Areas, *area)
+
+	return maybeManageGoals(cfg, len(cfg.Areas)-1)
+}
+
+func manageAreaActions(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Areas) {
+		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
+	}
+
+	area := &cfg.Areas[idx]
+
+	action, err := runActionSelect(fmt.Sprintf("Area: %s (%s)", area.Name, area.Key), true)
+	if err != nil {
+		return err
+	}
+
+	return handleAreaAction(cfg, idx, area, action)
+}
+
+func handleAreaAction(cfg *config.Config, idx int, area *config.Area, action itemAction) error {
+	switch action {
+	case itemActionEdit:
+		if updated, err := editArea(area, cfg); err != nil && !errors.Is(err, errUserAborted) {
+			return err
+		} else if updated != nil {
+			cfg.Areas[idx] = *updated
+		}
+	case itemActionGoals:
+		return manageGoals(cfg, idx)
+	case itemActionDelete:
+		return deleteArea(cfg, idx)
+	case itemActionNone:
+		// User cancelled or went back
+	}
+
+	return nil
+}
+
+func editArea(existing *config.Area, cfg *config.Config) (*config.Area, error) {
+	area := config.Area{}
+	if existing != nil {
+		area = *existing
+	}
+
+	err := runItemForm(&area.Name, &area.Key, &area.ID, itemFormConfig{
+		itemType:        "area",
+		namePlaceholder: "Personal",
+		keyPlaceholder:  "personal",
+		keyValidator:    validateAreaKey(cfg, existing),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &area, nil
+}
+
+func validateAreaKey(cfg *config.Config, existing *config.Area) func(string) error {
+	return func(input string) error {
+		if err := validateKeyFormat(input); err != nil {
+			return err
+		}
+
+		for idx := range cfg.Areas {
+			if existing != nil && &cfg.Areas[idx] == existing {
+				continue
+			}
+
+			if cfg.Areas[idx].Key == input {
+				return errKeyDuplicate
+			}
+		}
+
+		return nil
+	}
+}
+
+func maybeManageGoals(cfg *config.Config, areaIdx int) error {
+	var manage bool
+
+	err := huh.NewConfirm().
+		Title("Add goals for this area?").
+		Affirmative("Yes").
+		Negative("Not now").
+		Value(&manage).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if manage {
+		return manageGoals(cfg, areaIdx)
+	}
+
+	return nil
+}
+
+func manageGoals(cfg *config.Config, areaIdx int) error {
+	if areaIdx < 0 || areaIdx >= len(cfg.Areas) {
+		return fmt.Errorf("%w: area %d", errIndexOutRange, areaIdx)
+	}
+
+	area := &cfg.Areas[areaIdx]
+
+	for {
+		options := buildGoalOptions(area.Goals)
+
+		choice, err := runListSelect(
+			fmt.Sprintf("Goals for: %s (%s)", area.Name, area.Key),
+			"",
+			options,
+		)
+		if err != nil {
+			return err
+		}
+
+		if choice == choiceDone {
+			return nil
+		}
+
+		if choice == choiceAdd {
+			if err := addGoal(area); err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		idx, ok := parseEditIndex(choice)
+		if !ok {
+			continue
+		}
+
+		if err := manageGoalActions(area, idx); err != nil {
+			return err
+		}
+	}
+}
+
+func buildGoalOptions(goals []config.Goal) []huh.Option[string] {
+	options := []huh.Option[string]{
+		huh.NewOption("Add new goal", choiceAdd),
+	}
+
+	for idx, goal := range goals {
+		label := fmt.Sprintf("%s (%s)", goal.Name, goal.Key)
+		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
+	}
+
+	options = append(options, huh.NewOption("Done", choiceDone))
+
+	return options
+}
+
+func addGoal(area *config.Area) error {
+	goal, err := editGoal(nil, area)
+	if err != nil {
+		if errors.Is(err, errUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	area.Goals = append(area.Goals, *goal)
+
+	return nil
+}
+
+func manageGoalActions(area *config.Area, idx int) error {
+	if idx < 0 || idx >= len(area.Goals) {
+		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
+	}
+
+	goal := &area.Goals[idx]
+
+	action, err := runActionSelect(fmt.Sprintf("Goal: %s (%s)", goal.Name, goal.Key), false)
+	if err != nil {
+		return err
+	}
+
+	switch action {
+	case itemActionEdit:
+		updated, err := editGoal(goal, area)
+		if err != nil && !errors.Is(err, errUserAborted) {
+			return err
+		}
+
+		if updated != nil {
+			area.Goals[idx] = *updated
+		}
+	case itemActionDelete:
+		return deleteGoal(area, idx)
+	case itemActionNone, itemActionGoals:
+		// User cancelled or went back; goals not applicable here
+	}
+
+	return nil
+}
+
+func editGoal(existing *config.Goal, area *config.Area) (*config.Goal, error) {
+	goal := config.Goal{}
+	if existing != nil {
+		goal = *existing
+	}
+
+	err := runItemForm(&goal.Name, &goal.Key, &goal.ID, itemFormConfig{
+		itemType:        "goal",
+		namePlaceholder: "Learn Gaelic",
+		keyPlaceholder:  "gaelic",
+		keyValidator:    validateGoalKey(area, existing),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &goal, nil
+}
+
+func validateGoalKey(area *config.Area, existing *config.Goal) func(string) error {
+	return func(input string) error {
+		if err := validateKeyFormat(input); err != nil {
+			return err
+		}
+
+		for idx := range area.Goals {
+			if existing != nil && &area.Goals[idx] == existing {
+				continue
+			}
+
+			if area.Goals[idx].Key == input {
+				return errKeyDuplicate
+			}
+		}
+
+		return nil
+	}
+}
+
+func deleteArea(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Areas) {
+		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
+	}
+
+	area := cfg.Areas[idx]
+
+	var confirm bool
+
+	err := huh.NewConfirm().
+		Title(fmt.Sprintf("Delete area '%s'?", area.Name)).
+		Description(fmt.Sprintf("This will also remove %d goal(s). This cannot be undone.", len(area.Goals))).
+		Affirmative("Delete").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if confirm {
+		cfg.Areas = append(cfg.Areas[:idx], cfg.Areas[idx+1:]...)
+		if cfg.Defaults.Area == area.Key {
+			cfg.Defaults.Area = ""
+		}
+	}
+
+	return nil
+}
+
+func deleteGoal(area *config.Area, idx int) error {
+	if idx < 0 || idx >= len(area.Goals) {
+		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
+	}
+
+	goal := area.Goals[idx]
+
+	var confirm bool
+
+	err := huh.NewConfirm().
+		Title(fmt.Sprintf("Delete goal '%s'?", goal.Name)).
+		Description("This cannot be undone.").
+		Affirmative("Delete").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if confirm {
+		area.Goals = append(area.Goals[:idx], area.Goals[idx+1:]...)
+	}
+
+	return nil
+}

cmd/init/defaults.go 🔗

@@ -0,0 +1,132 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/huh"
+
+	"git.secluded.site/lune/internal/config"
+)
+
+func configureDefaults(cfg *config.Config) error {
+	hasAreas := len(cfg.Areas) > 0
+	hasNotebooks := len(cfg.Notebooks) > 0
+
+	if !hasAreas && !hasNotebooks {
+		return handleNoDefaultsAvailable(cfg)
+	}
+
+	if hasAreas {
+		if err := selectDefaultArea(cfg); err != nil {
+			return err
+		}
+	}
+
+	if hasNotebooks {
+		if err := selectDefaultNotebook(cfg); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func handleNoDefaultsAvailable(cfg *config.Config) error {
+	var action string
+
+	err := huh.NewSelect[string]().
+		Title("No areas or notebooks configured").
+		Description("You need at least one area or notebook to set defaults.").
+		Options(
+			huh.NewOption("Add an area now", "area"),
+			huh.NewOption("Add a notebook now", "notebook"),
+			huh.NewOption("Back", choiceBack),
+		).
+		Value(&action).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	switch action {
+	case "area":
+		return addArea(cfg)
+	case "notebook":
+		return addNotebook(cfg)
+	}
+
+	return nil
+}
+
+func selectDefaultArea(cfg *config.Config) error {
+	areaOptions := []huh.Option[string]{
+		huh.NewOption("None", ""),
+	}
+	for _, area := range cfg.Areas {
+		areaOptions = append(areaOptions, huh.NewOption(
+			fmt.Sprintf("%s (%s)", area.Name, area.Key),
+			area.Key,
+		))
+	}
+
+	defaultArea := cfg.Defaults.Area
+
+	err := huh.NewSelect[string]().
+		Title("Default area").
+		Description("Used when no area is specified in commands.").
+		Options(areaOptions...).
+		Value(&defaultArea).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.Defaults.Area = defaultArea
+
+	return nil
+}
+
+func selectDefaultNotebook(cfg *config.Config) error {
+	notebookOptions := []huh.Option[string]{
+		huh.NewOption("None", ""),
+	}
+	for _, notebook := range cfg.Notebooks {
+		notebookOptions = append(notebookOptions, huh.NewOption(
+			fmt.Sprintf("%s (%s)", notebook.Name, notebook.Key),
+			notebook.Key,
+		))
+	}
+
+	defaultNotebook := cfg.Defaults.Notebook
+
+	err := huh.NewSelect[string]().
+		Title("Default notebook").
+		Description("Used when no notebook is specified in commands.").
+		Options(notebookOptions...).
+		Value(&defaultNotebook).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.Defaults.Notebook = defaultNotebook
+
+	return nil
+}

cmd/init/habits.go 🔗

@@ -0,0 +1,202 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/huh"
+
+	"git.secluded.site/lune/internal/config"
+)
+
+func maybeConfigureHabits(cfg *config.Config) error {
+	var configure bool
+
+	err := huh.NewConfirm().
+		Title("Configure habits?").
+		Description("Track habits from the command line.").
+		Affirmative("Yes").
+		Negative("Not now").
+		Value(&configure).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if configure {
+		return manageHabits(cfg)
+	}
+
+	return nil
+}
+
+func manageHabits(cfg *config.Config) error {
+	for {
+		options := buildHabitOptions(cfg.Habits)
+
+		choice, err := runListSelect("Habits", "Manage your trackable habits.", options)
+		if err != nil {
+			return err
+		}
+
+		if choice == choiceBack {
+			return nil
+		}
+
+		if choice == choiceAdd {
+			if err := addHabit(cfg); err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		idx, ok := parseEditIndex(choice)
+		if !ok {
+			continue
+		}
+
+		if err := manageHabitActions(cfg, idx); err != nil {
+			return err
+		}
+	}
+}
+
+func buildHabitOptions(habits []config.Habit) []huh.Option[string] {
+	options := []huh.Option[string]{
+		huh.NewOption("Add new habit", choiceAdd),
+	}
+
+	for idx, habit := range habits {
+		label := fmt.Sprintf("%s (%s)", habit.Name, habit.Key)
+		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
+	}
+
+	options = append(options, huh.NewOption("Back", choiceBack))
+
+	return options
+}
+
+func addHabit(cfg *config.Config) error {
+	habit, err := editHabit(nil, cfg)
+	if err != nil {
+		if errors.Is(err, errUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.Habits = append(cfg.Habits, *habit)
+
+	return nil
+}
+
+func manageHabitActions(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Habits) {
+		return fmt.Errorf("%w: habit %d", errIndexOutRange, idx)
+	}
+
+	habit := &cfg.Habits[idx]
+
+	action, err := runActionSelect(fmt.Sprintf("Habit: %s (%s)", habit.Name, habit.Key), false)
+	if err != nil {
+		return err
+	}
+
+	switch action {
+	case itemActionEdit:
+		updated, err := editHabit(habit, cfg)
+		if err != nil && !errors.Is(err, errUserAborted) {
+			return err
+		}
+
+		if updated != nil {
+			cfg.Habits[idx] = *updated
+		}
+	case itemActionDelete:
+		return deleteHabit(cfg, idx)
+	case itemActionNone, itemActionGoals:
+		// User cancelled or went back; goals not applicable here
+	}
+
+	return nil
+}
+
+func editHabit(existing *config.Habit, cfg *config.Config) (*config.Habit, error) {
+	habit := config.Habit{}
+	if existing != nil {
+		habit = *existing
+	}
+
+	err := runItemForm(&habit.Name, &habit.Key, &habit.ID, itemFormConfig{
+		itemType:        "habit",
+		namePlaceholder: "Study Gaelic",
+		keyPlaceholder:  "gaelic",
+		keyValidator:    validateHabitKey(cfg, existing),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &habit, nil
+}
+
+func validateHabitKey(cfg *config.Config, existing *config.Habit) func(string) error {
+	return func(input string) error {
+		if err := validateKeyFormat(input); err != nil {
+			return err
+		}
+
+		for idx := range cfg.Habits {
+			if existing != nil && &cfg.Habits[idx] == existing {
+				continue
+			}
+
+			if cfg.Habits[idx].Key == input {
+				return errKeyDuplicate
+			}
+		}
+
+		return nil
+	}
+}
+
+func deleteHabit(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Habits) {
+		return fmt.Errorf("%w: habit %d", errIndexOutRange, idx)
+	}
+
+	habit := cfg.Habits[idx]
+
+	var confirm bool
+
+	err := huh.NewConfirm().
+		Title(fmt.Sprintf("Delete habit '%s'?", habit.Name)).
+		Description("This cannot be undone.").
+		Affirmative("Delete").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if confirm {
+		cfg.Habits = append(cfg.Habits[:idx], cfg.Habits[idx+1:]...)
+	}
+
+	return nil
+}

cmd/init/init.go 🔗

@@ -0,0 +1,203 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package init provides the interactive setup wizard for lune.
+package init
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/huh"
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/ui"
+)
+
+// Cmd is the init command for interactive setup.
+var Cmd = &cobra.Command{
+	Use:   "init",
+	Short: "Interactive setup wizard",
+	Long: `Configure lune interactively.
+
+This command will guide you through:
+  - Adding areas, goals, notebooks, and habits from Lunatask
+  - Setting default area and notebook
+  - Configuring and verifying your API key`,
+	RunE: runInit,
+}
+
+func runInit(cmd *cobra.Command, _ []string) error {
+	cfg, err := config.Load()
+	if errors.Is(err, config.ErrNotFound) {
+		cfg = &config.Config{}
+
+		return runFreshSetup(cmd, cfg)
+	}
+
+	if err != nil {
+		return fmt.Errorf("loading config: %w", err)
+	}
+
+	return runReconfigure(cmd, cfg)
+}
+
+func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
+	printWelcome(cmd)
+
+	if err := configureUIPrefs(cfg); err != nil {
+		return err
+	}
+
+	if err := maybeConfigureAreas(cfg); err != nil {
+		return err
+	}
+
+	if err := maybeConfigureNotebooks(cfg); err != nil {
+		return err
+	}
+
+	if err := maybeConfigureHabits(cfg); err != nil {
+		return err
+	}
+
+	if err := configureDefaults(cfg); err != nil {
+		return err
+	}
+
+	if err := configureAPIKey(cmd); err != nil {
+		return err
+	}
+
+	return saveWithSummary(cmd, cfg)
+}
+
+func printWelcome(cmd *cobra.Command) {
+	out := cmd.OutOrStdout()
+	fmt.Fprintln(out, ui.Bold.Render("Welcome to lune!"))
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "This wizard will help you configure lune for use with Lunatask.")
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, ui.Muted.Render("Since Lunatask is end-to-end encrypted, lune can't fetch your"))
+	fmt.Fprintln(out, ui.Muted.Render("areas, goals, notebooks, or habits automatically. You'll need to"))
+	fmt.Fprintln(out, ui.Muted.Render("copy IDs from the Lunatask app."))
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, ui.Bold.Render("Where to find IDs:"))
+	fmt.Fprintln(out, "  Open any item's settings modal → click 'Copy [Item] ID' (bottom left)")
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, ui.Muted.Render("You can run 'lune init' again anytime to add or modify config."))
+	fmt.Fprintln(out)
+}
+
+func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
+	fmt.Fprintln(cmd.OutOrStdout(), ui.Bold.Render("lune configuration"))
+	fmt.Fprintln(cmd.OutOrStdout())
+
+	handlers := map[string]func() error{
+		"areas":     func() error { return manageAreas(cfg) },
+		"notebooks": func() error { return manageNotebooks(cfg) },
+		"habits":    func() error { return manageHabits(cfg) },
+		"defaults":  func() error { return configureDefaults(cfg) },
+		"ui":        func() error { return configureUIPrefs(cfg) },
+		"apikey":    func() error { return configureAPIKey(cmd) },
+		"reset":     func() error { return resetConfig(cmd, cfg) },
+	}
+
+	for {
+		var choice string
+
+		err := huh.NewSelect[string]().
+			Title("What would you like to configure?").
+			Options(
+				huh.NewOption("Manage areas & goals", "areas"),
+				huh.NewOption("Manage notebooks", "notebooks"),
+				huh.NewOption("Manage habits", "habits"),
+				huh.NewOption("Set defaults", "defaults"),
+				huh.NewOption("UI preferences", "ui"),
+				huh.NewOption("API key", "apikey"),
+				huh.NewOption("Reset all configuration", "reset"),
+				huh.NewOption("Done", choiceDone),
+			).
+			Value(&choice).
+			Run()
+		if err != nil {
+			if errors.Is(err, huh.ErrUserAborted) {
+				return nil
+			}
+
+			return err
+		}
+
+		if choice == choiceDone {
+			return saveWithSummary(cmd, cfg)
+		}
+
+		if handler, ok := handlers[choice]; ok {
+			if err := handler(); err != nil {
+				return err
+			}
+		}
+	}
+}
+
+func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
+	path, err := config.Path()
+	if err != nil {
+		return err
+	}
+
+	goalCount := 0
+	for _, area := range cfg.Areas {
+		goalCount += len(area.Goals)
+	}
+
+	out := cmd.OutOrStdout()
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
+	fmt.Fprintf(out, "  Areas:     %d (%d goals)\n", len(cfg.Areas), goalCount)
+	fmt.Fprintf(out, "  Notebooks: %d\n", len(cfg.Notebooks))
+	fmt.Fprintf(out, "  Habits:    %d\n", len(cfg.Habits))
+
+	if cfg.Defaults.Area != "" {
+		fmt.Fprintf(out, "  Default area: %s\n", cfg.Defaults.Area)
+	}
+
+	if cfg.Defaults.Notebook != "" {
+		fmt.Fprintf(out, "  Default notebook: %s\n", cfg.Defaults.Notebook)
+	}
+
+	fmt.Fprintf(out, "  Color: %s\n", cfg.UI.Color)
+	fmt.Fprintln(out)
+
+	var save bool
+
+	err = huh.NewConfirm().
+		Title("Save configuration?").
+		Affirmative("Save").
+		Negative("Discard").
+		Value(&save).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
+
+			return nil
+		}
+
+		return err
+	}
+
+	if save {
+		if err := cfg.Save(); err != nil {
+			return err
+		}
+
+		fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
+	} else {
+		fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
+	}
+
+	return nil
+}

cmd/init/notebooks.go 🔗

@@ -0,0 +1,205 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/huh"
+
+	"git.secluded.site/lune/internal/config"
+)
+
+func maybeConfigureNotebooks(cfg *config.Config) error {
+	var configure bool
+
+	err := huh.NewConfirm().
+		Title("Configure notebooks?").
+		Description("Notebooks organize your notes in Lunatask.").
+		Affirmative("Yes").
+		Negative("Not now").
+		Value(&configure).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if configure {
+		return manageNotebooks(cfg)
+	}
+
+	return nil
+}
+
+func manageNotebooks(cfg *config.Config) error {
+	for {
+		options := buildNotebookOptions(cfg.Notebooks)
+
+		choice, err := runListSelect("Notebooks", "Manage your notebooks.", options)
+		if err != nil {
+			return err
+		}
+
+		if choice == choiceBack {
+			return nil
+		}
+
+		if choice == choiceAdd {
+			if err := addNotebook(cfg); err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		idx, ok := parseEditIndex(choice)
+		if !ok {
+			continue
+		}
+
+		if err := manageNotebookActions(cfg, idx); err != nil {
+			return err
+		}
+	}
+}
+
+func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] {
+	options := []huh.Option[string]{
+		huh.NewOption("Add new notebook", choiceAdd),
+	}
+
+	for idx, notebook := range notebooks {
+		label := fmt.Sprintf("%s (%s)", notebook.Name, notebook.Key)
+		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
+	}
+
+	options = append(options, huh.NewOption("Back", choiceBack))
+
+	return options
+}
+
+func addNotebook(cfg *config.Config) error {
+	notebook, err := editNotebook(nil, cfg)
+	if err != nil {
+		if errors.Is(err, errUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.Notebooks = append(cfg.Notebooks, *notebook)
+
+	return nil
+}
+
+func manageNotebookActions(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Notebooks) {
+		return fmt.Errorf("%w: notebook %d", errIndexOutRange, idx)
+	}
+
+	notebook := &cfg.Notebooks[idx]
+
+	action, err := runActionSelect(fmt.Sprintf("Notebook: %s (%s)", notebook.Name, notebook.Key), false)
+	if err != nil {
+		return err
+	}
+
+	switch action {
+	case itemActionEdit:
+		updated, err := editNotebook(notebook, cfg)
+		if err != nil && !errors.Is(err, errUserAborted) {
+			return err
+		}
+
+		if updated != nil {
+			cfg.Notebooks[idx] = *updated
+		}
+	case itemActionDelete:
+		return deleteNotebook(cfg, idx)
+	case itemActionNone, itemActionGoals:
+		// User cancelled or went back; goals not applicable here
+	}
+
+	return nil
+}
+
+func editNotebook(existing *config.Notebook, cfg *config.Config) (*config.Notebook, error) {
+	notebook := config.Notebook{}
+	if existing != nil {
+		notebook = *existing
+	}
+
+	err := runItemForm(&notebook.Name, &notebook.Key, &notebook.ID, itemFormConfig{
+		itemType:        "notebook",
+		namePlaceholder: "Gaelic Notes",
+		keyPlaceholder:  "gaelic",
+		keyValidator:    validateNotebookKey(cfg, existing),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &notebook, nil
+}
+
+func validateNotebookKey(cfg *config.Config, existing *config.Notebook) func(string) error {
+	return func(input string) error {
+		if err := validateKeyFormat(input); err != nil {
+			return err
+		}
+
+		for idx := range cfg.Notebooks {
+			if existing != nil && &cfg.Notebooks[idx] == existing {
+				continue
+			}
+
+			if cfg.Notebooks[idx].Key == input {
+				return errKeyDuplicate
+			}
+		}
+
+		return nil
+	}
+}
+
+func deleteNotebook(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Notebooks) {
+		return fmt.Errorf("%w: notebook %d", errIndexOutRange, idx)
+	}
+
+	notebook := cfg.Notebooks[idx]
+
+	var confirm bool
+
+	err := huh.NewConfirm().
+		Title(fmt.Sprintf("Delete notebook '%s'?", notebook.Name)).
+		Description("This cannot be undone.").
+		Affirmative("Delete").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if confirm {
+		cfg.Notebooks = append(cfg.Notebooks[:idx], cfg.Notebooks[idx+1:]...)
+		if cfg.Defaults.Notebook == notebook.Key {
+			cfg.Defaults.Notebook = ""
+		}
+	}
+
+	return nil
+}

cmd/init/ui.go 🔗

@@ -0,0 +1,269 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/charmbracelet/huh"
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
+)
+
+const (
+	choiceBack   = "back"
+	choiceAdd    = "add"
+	choiceDone   = "done"
+	actionEdit   = "edit"
+	actionDelete = "delete"
+	actionGoals  = "goals"
+)
+
+var (
+	errKeyRequired   = errors.New("key is required")
+	errKeyFormat     = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
+	errKeyDuplicate  = errors.New("this key is already in use")
+	errIDRequired    = errors.New("ID is required")
+	errIDFormat      = errors.New("invalid UUID format")
+	errUserAborted   = errors.New("user aborted")
+	errIndexOutRange = errors.New("index out of range")
+)
+
+func configureUIPrefs(cfg *config.Config) error {
+	color := cfg.UI.Color
+	if color == "" {
+		color = "auto"
+	}
+
+	err := huh.NewSelect[string]().
+		Title("Color output").
+		Description("When should lune use colored output?").
+		Options(
+			huh.NewOption("Auto (detect terminal capability)", "auto"),
+			huh.NewOption("Always", "always"),
+			huh.NewOption("Never", "never"),
+		).
+		Value(&color).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.UI.Color = color
+
+	return nil
+}
+
+func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
+	path, err := config.Path()
+	if err != nil {
+		return err
+	}
+
+	var confirm bool
+
+	err = huh.NewConfirm().
+		Title("Reset all configuration?").
+		Description(fmt.Sprintf("This will delete %s\nYou'll need to run 'lune init' again.", path)).
+		Affirmative("Reset").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return nil
+		}
+
+		return err
+	}
+
+	if confirm {
+		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+			return fmt.Errorf("removing config: %w", err)
+		}
+
+		fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
+
+		*cfg = config.Config{}
+	}
+
+	return nil
+}
+
+func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
+	var choice string
+
+	selector := huh.NewSelect[string]().
+		Title(title).
+		Options(options...).
+		Value(&choice)
+
+	if description != "" {
+		selector = selector.Description(description)
+	}
+
+	if err := selector.Run(); err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return choiceBack, nil
+		}
+
+		return "", err
+	}
+
+	return choice, nil
+}
+
+func parseEditIndex(choice string) (int, bool) {
+	if !strings.HasPrefix(choice, "edit:") {
+		return 0, false
+	}
+
+	idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
+	if err != nil {
+		return 0, false
+	}
+
+	return idx, true
+}
+
+var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
+
+func validateKeyFormat(input string) error {
+	if input == "" {
+		return errKeyRequired
+	}
+
+	if !keyPattern.MatchString(input) {
+		return errKeyFormat
+	}
+
+	return nil
+}
+
+func validateID(input string) error {
+	if input == "" {
+		return errIDRequired
+	}
+
+	if err := validate.ID(input); err != nil {
+		return errIDFormat
+	}
+
+	return nil
+}
+
+type itemFormConfig struct {
+	itemType        string
+	namePlaceholder string
+	keyPlaceholder  string
+	keyValidator    func(string) error
+}
+
+func titleCase(s string) string {
+	if s == "" {
+		return s
+	}
+
+	return strings.ToUpper(s[:1]) + s[1:]
+}
+
+// itemAction represents the result of an action selection dialog.
+type itemAction int
+
+const (
+	itemActionNone itemAction = iota
+	itemActionEdit
+	itemActionDelete
+	itemActionGoals
+)
+
+// runActionSelect shows an action selection dialog for an item and returns the chosen action.
+func runActionSelect(title string, includeGoals bool) (itemAction, error) {
+	var action string
+
+	options := []huh.Option[string]{
+		huh.NewOption("Edit", actionEdit),
+	}
+
+	if includeGoals {
+		options = append(options, huh.NewOption("Manage goals", actionGoals))
+	}
+
+	options = append(options,
+		huh.NewOption("Delete", actionDelete),
+		huh.NewOption("Back", choiceBack),
+	)
+
+	err := huh.NewSelect[string]().
+		Title(title).
+		Options(options...).
+		Value(&action).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return itemActionNone, nil
+		}
+
+		return itemActionNone, err
+	}
+
+	switch action {
+	case actionEdit:
+		return itemActionEdit, nil
+	case actionDelete:
+		return itemActionDelete, nil
+	case actionGoals:
+		return itemActionGoals, nil
+	default:
+		return itemActionNone, nil
+	}
+}
+
+func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
+	form := huh.NewForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title("Name").
+				Description("Display name for this "+cfg.itemType+".").
+				Placeholder(cfg.namePlaceholder).
+				Value(name).
+				Validate(huh.ValidateNotEmpty()),
+			huh.NewInput().
+				Title("Key").
+				Description("Short alias for CLI use (lowercase, no spaces).").
+				Placeholder(cfg.keyPlaceholder).
+				Value(key).
+				Validate(cfg.keyValidator),
+			huh.NewInput().
+				Title("ID").
+				Description("Settings → 'Copy "+titleCase(cfg.itemType)+" ID' (bottom left).").
+				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
+				Value(itemID).
+				Validate(validateID),
+		),
+	)
+
+	if err := form.Run(); err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return errUserAborted
+		}
+
+		return err
+	}
+
+	return nil
+}

cmd/init/ui_test.go 🔗

@@ -0,0 +1,194 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init //nolint:testpackage // testing internal validators
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestValidateKeyFormat_Valid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		input string
+	}{
+		{"simple key", "work"},
+		{"key with hyphen", "q1-goals"},
+		{"key with numbers", "area2"},
+		{"multiple hyphens", "my-special-area"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if err := validateKeyFormat(tc.input); err != nil {
+				t.Errorf("validateKeyFormat(%q) = %v, want nil", tc.input, err)
+			}
+		})
+	}
+}
+
+func TestValidateKeyFormat_Invalid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		wantErr error
+	}{
+		{"empty key", "", errKeyRequired},
+		{"uppercase letters", "Work", errKeyFormat},
+		{"spaces", "my area", errKeyFormat},
+		{"underscores", "my_area", errKeyFormat},
+		{"leading hyphen", "-work", errKeyFormat},
+		{"trailing hyphen", "work-", errKeyFormat},
+		{"double hyphen", "my--area", errKeyFormat},
+		{"special characters", "work@home", errKeyFormat},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if err := validateKeyFormat(tc.input); !errors.Is(err, tc.wantErr) {
+				t.Errorf("validateKeyFormat(%q) = %v, want %v", tc.input, err, tc.wantErr)
+			}
+		})
+	}
+}
+
+func TestValidateID_Valid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		input string
+	}{
+		{"valid UUID lowercase", "123e4567-e89b-12d3-a456-426614174000"},
+		{"valid UUID uppercase", "123E4567-E89B-12D3-A456-426614174000"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if err := validateID(tc.input); err != nil {
+				t.Errorf("validateID(%q) = %v, want nil", tc.input, err)
+			}
+		})
+	}
+}
+
+func TestValidateID_Invalid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		wantErr error
+	}{
+		{"empty ID", "", errIDRequired},
+		{"too short", "123e4567-e89b", errIDFormat},
+		{"random string", "not-a-uuid", errIDFormat},
+		{"wrong characters", "123e4567-e89b-12d3-a456-42661417zzzz", errIDFormat},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if err := validateID(tc.input); !errors.Is(err, tc.wantErr) {
+				t.Errorf("validateID(%q) = %v, want %v", tc.input, err, tc.wantErr)
+			}
+		})
+	}
+}
+
+func TestParseEditIndex_Valid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		wantIdx int
+	}{
+		{"index 0", "edit:0", 0},
+		{"index 5", "edit:5", 5},
+		{"large index", "edit:999", 999},
+		{"negative index", "edit:-1", -1},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			idx, valid := parseEditIndex(testCase.input)
+			if !valid {
+				t.Errorf("parseEditIndex(%q) valid = false, want true", testCase.input)
+			}
+
+			if idx != testCase.wantIdx {
+				t.Errorf("parseEditIndex(%q) idx = %d, want %d", testCase.input, idx, testCase.wantIdx)
+			}
+		})
+	}
+}
+
+func TestParseEditIndex_Invalid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		input string
+	}{
+		{"back choice", "back"},
+		{"add choice", "add"},
+		{"done choice", "done"},
+		{"empty prefix", "edit:"},
+		{"non-numeric", "edit:abc"},
+		{"empty string", ""},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			_, valid := parseEditIndex(testCase.input)
+			if valid {
+				t.Errorf("parseEditIndex(%q) valid = true, want false", testCase.input)
+			}
+		})
+	}
+}
+
+func TestTitleCase(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		input string
+		want  string
+	}{
+		{"area", "Area"},
+		{"goal", "Goal"},
+		{"notebook", "Notebook"},
+		{"habit", "Habit"},
+		{"a", "A"},
+		{"", ""},
+		{"already Capitalized", "Already Capitalized"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.input, func(t *testing.T) {
+			t.Parallel()
+
+			if got := titleCase(tc.input); got != tc.want {
+				t.Errorf("titleCase(%q) = %q, want %q", tc.input, got, tc.want)
+			}
+		})
+	}
+}

cmd/root.go 🔗

@@ -10,6 +10,7 @@ import (
 	"os"
 
 	"git.secluded.site/lune/cmd/habit"
+	initcmd "git.secluded.site/lune/cmd/init"
 	"git.secluded.site/lune/cmd/journal"
 	"git.secluded.site/lune/cmd/note"
 	"git.secluded.site/lune/cmd/person"
@@ -38,7 +39,7 @@ func init() {
 	rootCmd.AddGroup(&cobra.Group{ID: "resources", Title: "Resources"})
 	rootCmd.AddGroup(&cobra.Group{ID: "shortcuts", Title: "Shortcuts"})
 
-	rootCmd.AddCommand(initCmd)
+	rootCmd.AddCommand(initcmd.Cmd)
 	rootCmd.AddCommand(pingCmd)
 
 	rootCmd.AddCommand(task.Cmd)

go.mod 🔗

@@ -6,20 +6,23 @@ require (
 	git.secluded.site/go-lunatask v0.1.0-rc7
 	github.com/BurntSushi/toml v1.6.0
 	github.com/charmbracelet/fang v0.4.4
+	github.com/charmbracelet/huh v0.8.0
+	github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3
 	github.com/charmbracelet/lipgloss v1.1.0
 	github.com/google/uuid v1.6.0
 	github.com/spf13/cobra v1.10.2
+	github.com/zalando/go-keyring v0.2.6
 )
 
 require (
+	al.essio.dev/pkg/shellescape v1.5.1 // indirect
 	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/bubbletea v1.3.10 // 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
@@ -31,8 +34,10 @@ require (
 	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/danieljoos/wincred v1.2.2 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+	github.com/godbus/dbus/v5 v5.1.0 // 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

go.sum 🔗

@@ -1,9 +1,13 @@
+al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
+al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
 git.secluded.site/go-lunatask v0.1.0-rc7 h1:kzwAN9h4zVTo0OBs4B23ba5mAqxus6nYU62LElUkdnw=
 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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 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=
@@ -14,14 +18,16 @@ 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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
 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/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg=
+github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
 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=
@@ -30,6 +36,10 @@ github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9G
 github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
 github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
 github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 h1:xGojlO6kHCDB1k6DolME79LG0u90TzVd8atGhmxFRIo=
 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=
@@ -42,6 +52,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
 github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
 github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
@@ -49,12 +61,20 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
 github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
 github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
+github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
 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/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 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=
@@ -93,10 +113,14 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
+github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=

internal/client/client.go 🔗

@@ -7,28 +7,92 @@ package client
 
 import (
 	"errors"
+	"fmt"
 	"os"
 	"runtime/debug"
 
 	"git.secluded.site/go-lunatask"
+	"github.com/zalando/go-keyring"
 )
 
 // EnvAPIKey is the environment variable name for the Lunatask API key.
 const EnvAPIKey = "LUNATASK_API_KEY" //nolint:gosec // not a credential, just env var name
 
-// ErrNoAPIKey indicates the API key environment variable is not set.
-var ErrNoAPIKey = errors.New("LUNATASK_API_KEY environment variable not set")
+const (
+	keyringService = "lune"
+	keyringUser    = "api-key"
+)
+
+// ErrNoAPIKey indicates the API key is not available from env or keyring.
+var ErrNoAPIKey = errors.New("LUNATASK_API_KEY not set and no key found in system keyring")
 
-// New creates a Lunatask client from the LUNATASK_API_KEY environment variable.
+// New creates a Lunatask client, checking env var first, then system keyring.
 func New() (*lunatask.Client, error) {
-	token := os.Getenv(EnvAPIKey)
-	if token == "" {
-		return nil, ErrNoAPIKey
+	if token := os.Getenv(EnvAPIKey); token != "" {
+		return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil
+	}
+
+	token, err := keyring.Get(keyringService, keyringUser)
+	if err != nil {
+		if errors.Is(err, keyring.ErrNotFound) {
+			return nil, ErrNoAPIKey
+		}
+
+		return nil, fmt.Errorf("accessing system keyring: %w", err)
 	}
 
 	return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil
 }
 
+// GetAPIKey returns the API key from keyring. Returns empty string and nil error
+// if not found; returns error for keyring access problems.
+func GetAPIKey() (string, error) {
+	token, err := keyring.Get(keyringService, keyringUser)
+	if err != nil {
+		if errors.Is(err, keyring.ErrNotFound) {
+			return "", nil
+		}
+
+		return "", fmt.Errorf("accessing system keyring: %w", err)
+	}
+
+	return token, nil
+}
+
+// SetAPIKey stores the API key in the system keyring.
+func SetAPIKey(token string) error {
+	if err := keyring.Set(keyringService, keyringUser, token); err != nil {
+		return fmt.Errorf("keyring set: %w", err)
+	}
+
+	return nil
+}
+
+// DeleteAPIKey removes the API key from the system keyring.
+func DeleteAPIKey() error {
+	if err := keyring.Delete(keyringService, keyringUser); err != nil {
+		return fmt.Errorf("keyring delete: %w", err)
+	}
+
+	return nil
+}
+
+// HasKeyringKey checks if an API key is stored in the keyring.
+// Returns (true, nil) if found, (false, nil) if not found,
+// or (false, error) if there was a keyring access problem.
+func HasKeyringKey() (bool, error) {
+	_, err := keyring.Get(keyringService, keyringUser)
+	if err != nil {
+		if errors.Is(err, keyring.ErrNotFound) {
+			return false, nil
+		}
+
+		return false, fmt.Errorf("accessing system keyring: %w", err)
+	}
+
+	return true, nil
+}
+
 // version returns the module version from build info, or "dev" if unavailable.
 func version() string {
 	info, ok := debug.ReadBuildInfo()