From 7a481ba0799faff254f064f60323d1306fc880fa Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Dec 2025 18:07:30 -0700 Subject: [PATCH] feat(goal): add list and show commands - 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 --- cmd/goal/goal.go | 30 ++++++++++ cmd/goal/list.go | 149 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/goal/show.go | 127 ++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 + 4 files changed, 308 insertions(+) create mode 100644 cmd/goal/goal.go create mode 100644 cmd/goal/list.go create mode 100644 cmd/goal/show.go diff --git a/cmd/goal/goal.go b/cmd/goal/goal.go new file mode 100644 index 0000000000000000000000000000000000000000..10fa041a50a2c1ea0893440396f49c8ad2357130 --- /dev/null +++ b/cmd/goal/goal.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) +} diff --git a/cmd/goal/list.go b/cmd/goal/list.go new file mode 100644 index 0000000000000000000000000000000000000000..26bb81638dec656b09d9619301524c3ef4437aac --- /dev/null +++ b/cmd/goal/list.go @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/cmd/goal/show.go b/cmd/goal/show.go new file mode 100644 index 0000000000000000000000000000000000000000..400bec54433664443e881ec1d8c1cf80a5994d10 --- /dev/null +++ b/cmd/goal/show.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/cmd/root.go b/cmd/root.go index 29069dda356ebb5e627cd103083946fd94a2df27..0741557540376c55c174911565d3950adc956e49 100644 --- a/cmd/root.go +++ b/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)