feat(habit): add list subcommand

Amolith created

Assisted-by: Claude Opus 4.5 via Crush

Change summary

cmd/habit/habit.go |  1 
cmd/habit/list.go  | 85 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 86 insertions(+)

Detailed changes

cmd/habit/habit.go 🔗

@@ -15,5 +15,6 @@ var Cmd = &cobra.Command{
 }
 
 func init() {
+	Cmd.AddCommand(ListCmd)
 	Cmd.AddCommand(TrackCmd)
 }

cmd/habit/list.go 🔗

@@ -0,0 +1,85 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package habit
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/ui"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/lipgloss/table"
+	"github.com/spf13/cobra"
+)
+
+// ListCmd lists configured habits.
+var ListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List configured habits",
+	Long: `List habits configured in lune.
+
+Habits are copied from the Lunatask desktop app during 'lune init'.`,
+	RunE: runList,
+}
+
+func init() {
+	ListCmd.Flags().Bool("json", false, "Output as JSON")
+}
+
+func runList(cmd *cobra.Command, _ []string) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+
+	if len(cfg.Habits) == 0 {
+		fmt.Fprintln(cmd.OutOrStdout(), "No habits configured")
+
+		return nil
+	}
+
+	jsonFlag, _ := cmd.Flags().GetBool("json")
+	if jsonFlag {
+		return outputJSON(cmd, cfg.Habits)
+	}
+
+	return outputTable(cmd, cfg.Habits)
+}
+
+func outputJSON(cmd *cobra.Command, habits []config.Habit) error {
+	enc := json.NewEncoder(cmd.OutOrStdout())
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(habits); err != nil {
+		return fmt.Errorf("encoding JSON: %w", err)
+	}
+
+	return nil
+}
+
+func outputTable(cmd *cobra.Command, habits []config.Habit) error {
+	rows := make([][]string, 0, len(habits))
+
+	for _, habit := range habits {
+		rows = append(rows, []string{habit.Key, habit.Name})
+	}
+
+	tbl := table.New().
+		Headers("KEY", "NAME").
+		Rows(rows...).
+		StyleFunc(func(row, col int) lipgloss.Style {
+			if row == table.HeaderRow {
+				return ui.TableHeaderStyle()
+			}
+
+			return lipgloss.NewStyle()
+		}).
+		Border(ui.TableBorder())
+
+	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
+
+	return nil
+}