@@ -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
+}
@@ -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
+}