feat(area): add list and show commands

Amolith created

- list: table/JSON output with goal preview
- show: full details with ID, deeplink, goals, task counts

Uses H1/H2 headings for visual hierarchy.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

cmd/area/area.go |  27 +++++++++++
cmd/area/list.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++
cmd/area/show.go | 102 ++++++++++++++++++++++++++++++++++++++++++
cmd/root.go      |   2 
4 files changed, 251 insertions(+)

Detailed changes

cmd/area/area.go 🔗

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

cmd/area/list.go 🔗

@@ -0,0 +1,120 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package area
+
+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 areas and their goals.
+var ListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List configured areas",
+	Long: `List areas configured in lune.
+
+Areas are copied from the Lunatask desktop app during 'lune init'.
+Each area may have goals associated with it.`,
+	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 {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config not found; run 'lune init' to configure areas"))
+
+		return err
+	}
+
+	if len(cfg.Areas) == 0 {
+		fmt.Fprintln(cmd.OutOrStdout(), "No areas configured")
+
+		return nil
+	}
+
+	jsonFlag, _ := cmd.Flags().GetBool("json")
+	if jsonFlag {
+		return outputJSON(cmd, cfg.Areas)
+	}
+
+	return outputTable(cmd, cfg.Areas)
+}
+
+func outputJSON(cmd *cobra.Command, areas []config.Area) error {
+	enc := json.NewEncoder(cmd.OutOrStdout())
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(areas); err != nil {
+		return fmt.Errorf("encoding JSON: %w", err)
+	}
+
+	return nil
+}
+
+func outputTable(cmd *cobra.Command, areas []config.Area) error {
+	rows := make([][]string, 0, len(areas))
+
+	for _, area := range areas {
+		goals := formatGoals(area.Goals)
+		rows = append(rows, []string{area.Key, area.Name, goals})
+	}
+
+	tbl := table.New().
+		Headers("KEY", "NAME", "GOALS").
+		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
+}
+
+func formatGoals(goals []config.Goal) string {
+	if len(goals) == 0 {
+		return "-"
+	}
+
+	keys := make([]string, len(goals))
+	for i, g := range goals {
+		keys[i] = g.Key
+	}
+
+	if len(keys) <= 3 {
+		return joinKeys(keys)
+	}
+
+	return joinKeys(keys[:3]) + fmt.Sprintf(" (+%d)", len(keys)-3)
+}
+
+func joinKeys(keys []string) string {
+	result := ""
+
+	for i, k := range keys {
+		if i > 0 {
+			result += ", "
+		}
+
+		result += k
+	}
+
+	return result
+}

cmd/area/show.go 🔗

@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package area
+
+import (
+	"fmt"
+
+	"git.secluded.site/lune/internal/client"
+	"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"
+)
+
+// ShowCmd displays details for a specific area.
+var ShowCmd = &cobra.Command{
+	Use:   "show KEY",
+	Short: "Show area details",
+	Long: `Show detailed information for a configured area.
+
+Displays the area's name, ID, deeplink, goals, and uncompleted task count.`,
+	Args: cobra.ExactArgs(1),
+	RunE: runShow,
+}
+
+func runShow(cmd *cobra.Command, args []string) error {
+	areaKey := args[0]
+
+	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
+	}
+
+	area := cfg.AreaByKey(areaKey)
+	if area == nil {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Unknown area: "+areaKey))
+
+		return fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
+	}
+
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	counter := stats.NewTaskCounter(apiClient)
+
+	return printAreaDetails(cmd, area, counter)
+}
+
+func printAreaDetails(cmd *cobra.Command, area *config.Area, counter *stats.TaskCounter) error {
+	link, _ := deeplink.Build(deeplink.Area, area.ID)
+
+	fmt.Fprintf(cmd.OutOrStdout(), "%s (%s)\n", ui.H1.Render(area.Name), area.Key)
+	fmt.Fprintf(cmd.OutOrStdout(), "  ID:   %s\n", area.ID)
+	fmt.Fprintf(cmd.OutOrStdout(), "  Link: %s\n", link)
+
+	taskCount, err := counter.UncompletedInArea(cmd.Context(), area.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)
+	}
+
+	if len(area.Goals) == 0 {
+		fmt.Fprintln(cmd.OutOrStdout(), "\n  No goals configured")
+
+		return nil
+	}
+
+	fmt.Fprintln(cmd.OutOrStdout())
+
+	for _, goal := range area.Goals {
+		if err := printGoalSummary(cmd, &goal, counter); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func printGoalSummary(cmd *cobra.Command, goal *config.Goal, counter *stats.TaskCounter) error {
+	goalLink, _ := deeplink.Build(deeplink.Goal, goal.ID)
+
+	fmt.Fprintf(cmd.OutOrStdout(), "  %s (%s)\n", ui.H2.Render(goal.Name), goal.Key)
+	fmt.Fprintf(cmd.OutOrStdout(), "    ID:   %s\n", goal.ID)
+	fmt.Fprintf(cmd.OutOrStdout(), "    Link: %s\n", goalLink)
+
+	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 🔗

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