feat(goal): add list and show commands

Amolith created

- list: table/JSON output, defaults to config's default area
- show: full details with ID, deeplink, task count
- Auto-detects area if goal key is unique, prompts for --area on
  collision

Assisted-by: Claude Opus 4.5 via Crush

Change summary

cmd/goal/goal.go |  30 ++++++++++
cmd/goal/list.go | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++
cmd/goal/show.go | 127 ++++++++++++++++++++++++++++++++++++++++++
cmd/root.go      |   2 
4 files changed, 308 insertions(+)

Detailed changes

cmd/goal/goal.go 🔗

@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package goal provides commands for viewing configured goals.
+package goal
+
+import (
+	"errors"
+
+	"github.com/spf13/cobra"
+)
+
+// ErrUnknownGoal indicates the specified goal key was not found in config.
+var ErrUnknownGoal = errors.New("unknown goal key")
+
+// ErrUnknownArea indicates the specified area key was not found in config.
+var ErrUnknownArea = errors.New("unknown area key")
+
+// Cmd is the parent command for goal operations.
+var Cmd = &cobra.Command{
+	Use:     "goal",
+	Short:   "View configured goals",
+	GroupID: "resources",
+}
+
+func init() {
+	Cmd.AddCommand(ListCmd)
+	Cmd.AddCommand(ShowCmd)
+}

cmd/goal/list.go 🔗

@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package goal
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"git.secluded.site/lune/internal/completion"
+	"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 goals.
+var ListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List configured goals",
+	Long: `List goals configured in lune.
+
+By default, shows goals from the default area (if configured).
+Use --area to specify a different area, or --all to show all goals.`,
+	RunE: runList,
+}
+
+func init() {
+	ListCmd.Flags().StringP("area", "a", "", "Filter by area key")
+	ListCmd.Flags().Bool("all", false, "Show goals from all areas")
+	ListCmd.Flags().Bool("json", false, "Output as JSON")
+
+	_ = ListCmd.RegisterFlagCompletionFunc("area", completion.Areas)
+}
+
+func runList(cmd *cobra.Command, _ []string) error {
+	cfg, err := config.Load()
+	if err != nil {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config not found; run 'lune init' to configure areas"))
+
+		return err
+	}
+
+	areaKey, _ := cmd.Flags().GetString("area")
+	showAll, _ := cmd.Flags().GetBool("all")
+
+	areas := filterAreas(cfg, areaKey, showAll)
+	if len(areas) == 0 {
+		fmt.Fprintln(cmd.OutOrStdout(), "No areas found")
+
+		return nil
+	}
+
+	goals := collectGoals(areas)
+	if len(goals) == 0 {
+		fmt.Fprintln(cmd.OutOrStdout(), "No goals configured")
+
+		return nil
+	}
+
+	jsonFlag, _ := cmd.Flags().GetBool("json")
+	if jsonFlag {
+		return outputJSON(cmd, goals)
+	}
+
+	return outputTable(cmd, goals)
+}
+
+type goalWithArea struct {
+	Key     string `json:"key"`
+	Name    string `json:"name"`
+	ID      string `json:"id"`
+	AreaKey string `json:"area_key"`
+}
+
+func filterAreas(cfg *config.Config, areaKey string, showAll bool) []config.Area {
+	if showAll {
+		return cfg.Areas
+	}
+
+	if areaKey == "" {
+		areaKey = cfg.Defaults.Area
+	}
+
+	if areaKey == "" {
+		return cfg.Areas
+	}
+
+	area := cfg.AreaByKey(areaKey)
+	if area == nil {
+		return nil
+	}
+
+	return []config.Area{*area}
+}
+
+func collectGoals(areas []config.Area) []goalWithArea {
+	var goals []goalWithArea
+
+	for _, area := range areas {
+		for _, goal := range area.Goals {
+			goals = append(goals, goalWithArea{
+				Key:     goal.Key,
+				Name:    goal.Name,
+				ID:      goal.ID,
+				AreaKey: area.Key,
+			})
+		}
+	}
+
+	return goals
+}
+
+func outputJSON(cmd *cobra.Command, goals []goalWithArea) error {
+	enc := json.NewEncoder(cmd.OutOrStdout())
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(goals); err != nil {
+		return fmt.Errorf("encoding JSON: %w", err)
+	}
+
+	return nil
+}
+
+func outputTable(cmd *cobra.Command, goals []goalWithArea) error {
+	rows := make([][]string, 0, len(goals))
+
+	for _, goal := range goals {
+		rows = append(rows, []string{goal.Key, goal.Name, goal.AreaKey})
+	}
+
+	tbl := table.New().
+		Headers("KEY", "NAME", "AREA").
+		Rows(rows...).
+		StyleFunc(func(row, col int) lipgloss.Style {
+			if row == table.HeaderRow {
+				return ui.Bold
+			}
+
+			return lipgloss.NewStyle()
+		}).
+		Border(lipgloss.HiddenBorder())
+
+	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
+
+	return nil
+}

cmd/goal/show.go 🔗

@@ -0,0 +1,127 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package goal
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/completion"
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/deeplink"
+	"git.secluded.site/lune/internal/stats"
+	"git.secluded.site/lune/internal/ui"
+	"github.com/spf13/cobra"
+)
+
+// ErrAmbiguousGoal indicates the goal key exists in multiple areas.
+var ErrAmbiguousGoal = errors.New("ambiguous goal key")
+
+// ShowCmd displays details for a specific goal.
+var ShowCmd = &cobra.Command{
+	Use:   "show KEY",
+	Short: "Show goal details",
+	Long: `Show detailed information for a configured goal.
+
+If the goal key exists in multiple areas, use --area to disambiguate.
+Displays the goal's name, ID, deeplink, and uncompleted task count.`,
+	Args: cobra.ExactArgs(1),
+	RunE: runShow,
+}
+
+func init() {
+	ShowCmd.Flags().StringP("area", "a", "", "Area key (required if goal key is ambiguous)")
+	_ = ShowCmd.RegisterFlagCompletionFunc("area", completion.Areas)
+}
+
+func runShow(cmd *cobra.Command, args []string) error {
+	goalKey := args[0]
+	areaKey, _ := cmd.Flags().GetString("area")
+
+	cfg, err := config.Load()
+	if err != nil {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config not found; run 'lune init' to configure areas"))
+
+		return err
+	}
+
+	match, err := resolveGoal(cmd, cfg, goalKey, areaKey)
+	if err != nil {
+		return err
+	}
+
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	counter := stats.NewTaskCounter(apiClient)
+
+	return printGoalDetails(cmd, match.Goal, match.Area, counter)
+}
+
+func resolveGoal(cmd *cobra.Command, cfg *config.Config, goalKey, areaKey string) (*config.GoalMatch, error) {
+	if areaKey != "" {
+		area := cfg.AreaByKey(areaKey)
+		if area == nil {
+			fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Unknown area: "+areaKey))
+
+			return nil, fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
+		}
+
+		goal := area.GoalByKey(goalKey)
+		if goal == nil {
+			fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Unknown goal: "+goalKey))
+
+			return nil, fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
+		}
+
+		return &config.GoalMatch{Goal: goal, Area: area}, nil
+	}
+
+	matches := cfg.FindGoalsByKey(goalKey)
+
+	switch len(matches) {
+	case 0:
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Unknown goal: "+goalKey))
+
+		return nil, fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
+
+	case 1:
+		return &matches[0], nil
+
+	default:
+		areas := make([]string, len(matches))
+		for i, m := range matches {
+			areas[i] = m.Area.Key
+		}
+
+		fmt.Fprintf(cmd.ErrOrStderr(), "%s\n",
+			ui.Error.Render("Goal '"+goalKey+"' exists in multiple areas: "+strings.Join(areas, ", ")))
+		fmt.Fprintln(cmd.ErrOrStderr(), "Use --area to specify which one")
+
+		return nil, fmt.Errorf("%w: %s exists in %s", ErrAmbiguousGoal, goalKey, strings.Join(areas, ", "))
+	}
+}
+
+func printGoalDetails(cmd *cobra.Command, goal *config.Goal, area *config.Area, counter *stats.TaskCounter) error {
+	link, _ := deeplink.Build(deeplink.Goal, goal.ID)
+
+	fmt.Fprintf(cmd.OutOrStdout(), "%s (%s)\n", ui.H1.Render(goal.Name), goal.Key)
+	fmt.Fprintf(cmd.OutOrStdout(), "  ID:   %s\n", goal.ID)
+	fmt.Fprintf(cmd.OutOrStdout(), "  Area: %s (%s)\n", area.Name, area.Key)
+	fmt.Fprintf(cmd.OutOrStdout(), "  Link: %s\n", link)
+
+	taskCount, err := counter.UncompletedInGoal(cmd.Context(), goal.ID)
+	if err != nil {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Tasks: %s\n", ui.Warning.Render("unable to fetch"))
+	} else {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Tasks: %d uncompleted\n", taskCount)
+	}
+
+	return nil
+}

cmd/root.go 🔗

@@ -10,6 +10,7 @@ import (
 	"os"
 
 	"git.secluded.site/lune/cmd/area"
+	"git.secluded.site/lune/cmd/goal"
 	"git.secluded.site/lune/cmd/habit"
 	initcmd "git.secluded.site/lune/cmd/init"
 	"git.secluded.site/lune/cmd/journal"
@@ -44,6 +45,7 @@ func init() {
 	rootCmd.AddCommand(pingCmd)
 
 	rootCmd.AddCommand(area.Cmd)
+	rootCmd.AddCommand(goal.Cmd)
 	rootCmd.AddCommand(task.Cmd)
 	rootCmd.AddCommand(note.Cmd)
 	rootCmd.AddCommand(person.Cmd)