Detailed changes
@@ -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
- },
-}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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(¬ebook.Name, ¬ebook.Key, ¬ebook.ID, itemFormConfig{
+ itemType: "notebook",
+ namePlaceholder: "Gaelic Notes",
+ keyPlaceholder: "gaelic",
+ keyValidator: validateNotebookKey(cfg, existing),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return ¬ebook, 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
+}
@@ -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
+}
@@ -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)
+ }
+ })
+ }
+}
@@ -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)
@@ -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
@@ -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=
@@ -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()