feat(task): implement show command

Amolith created

Displays task metadata including status, area/goal (resolved to names
from config when available), scheduling dates, and attributes like
priority, estimate, progress, motivation, and eisenhower quadrant.

Adds AreaByID and GoalByID helpers to config package for name
resolution. Upgrades go-lunatask to v0.1.0-rc9 for Priority type
support.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/task/add.go           |   2 
cmd/task/show.go          | 153 ++++++++++++++++++++++++++++++++++++++--
go.mod                    |   2 
go.sum                    |   4 
internal/config/config.go |  29 +++++++
5 files changed, 176 insertions(+), 14 deletions(-)

Detailed changes

cmd/task/add.go 🔗

@@ -172,7 +172,7 @@ func applyOptionalFlags(cmd *cobra.Command, builder *lunatask.TaskBuilder) error
 	}
 
 	if priority, _ := cmd.Flags().GetInt("priority"); priority != 0 {
-		builder.WithPriority(priority)
+		builder.Priority(lunatask.Priority(priority))
 	}
 
 	if estimate, _ := cmd.Flags().GetInt("estimate"); estimate != 0 {

cmd/task/show.go 🔗

@@ -5,8 +5,14 @@
 package task
 
 import (
+	"encoding/json"
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/deeplink"
+	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
@@ -15,20 +21,147 @@ import (
 var ShowCmd = &cobra.Command{
 	Use:   "show ID",
 	Short: "Show task details",
-	Args:  cobra.ExactArgs(1),
-	RunE: func(cmd *cobra.Command, args []string) error {
-		id, err := validate.Reference(args[0])
-		if err != nil {
-			return err
-		}
+	Long: `Show detailed information for a task.
 
-		// TODO: implement task show
-		fmt.Fprintf(cmd.OutOrStdout(), "Showing task %s (not yet implemented)\n", id)
+Accepts a UUID or lunatask:// deep link.
 
-		return nil
-	},
+Note: Due to end-to-end encryption, task name and notes
+are not available through the API. Only metadata is shown.`,
+	Args: cobra.ExactArgs(1),
+	RunE: runShow,
 }
 
 func init() {
 	ShowCmd.Flags().Bool("json", false, "Output as JSON")
 }
+
+func runShow(cmd *cobra.Command, args []string) error {
+	id, err := validate.Reference(args[0])
+	if err != nil {
+		return err
+	}
+
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	task, err := apiClient.GetTask(cmd.Context(), id)
+	if err != nil {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Failed to get task"))
+
+		return err
+	}
+
+	if mustGetBoolFlag(cmd, "json") {
+		return outputTaskJSON(cmd, task)
+	}
+
+	return printTaskDetails(cmd, task)
+}
+
+func outputTaskJSON(cmd *cobra.Command, task *lunatask.Task) error {
+	enc := json.NewEncoder(cmd.OutOrStdout())
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(task); err != nil {
+		return fmt.Errorf("encoding JSON: %w", err)
+	}
+
+	return nil
+}
+
+func printTaskDetails(cmd *cobra.Command, task *lunatask.Task) error {
+	link, _ := deeplink.Build(deeplink.Task, task.ID)
+
+	status := "unknown"
+	if task.Status != nil {
+		status = string(*task.Status)
+	}
+
+	fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.H1.Render("Task: "+status))
+	fmt.Fprintf(cmd.OutOrStdout(), "  ID:   %s\n", task.ID)
+	fmt.Fprintf(cmd.OutOrStdout(), "  Link: %s\n", link)
+
+	printAreaGoal(cmd, task)
+
+	if task.ScheduledOn != nil {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Scheduled: %s\n", ui.FormatDate(task.ScheduledOn.Time))
+	}
+
+	if task.CompletedAt != nil {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Completed: %s\n", ui.FormatDate(*task.CompletedAt))
+	}
+
+	printTaskAttributes(cmd, task)
+
+	fmt.Fprintf(cmd.OutOrStdout(), "  Created: %s\n", ui.FormatDate(task.CreatedAt))
+	fmt.Fprintf(cmd.OutOrStdout(), "  Updated: %s\n", ui.FormatDate(task.UpdatedAt))
+
+	return nil
+}
+
+func printAreaGoal(cmd *cobra.Command, task *lunatask.Task) {
+	cfg, _ := config.Load()
+
+	if task.AreaID != nil {
+		areaDisplay := *task.AreaID
+		if cfg != nil {
+			if area := cfg.AreaByID(*task.AreaID); area != nil {
+				areaDisplay = fmt.Sprintf("%s (%s)", area.Name, area.Key)
+			}
+		}
+
+		fmt.Fprintf(cmd.OutOrStdout(), "  Area: %s\n", areaDisplay)
+	}
+
+	if task.GoalID != nil {
+		goalDisplay := *task.GoalID
+		if cfg != nil {
+			if match := cfg.GoalByID(*task.GoalID); match != nil {
+				goalDisplay = fmt.Sprintf("%s (%s)", match.Goal.Name, match.Goal.Key)
+			}
+		}
+
+		fmt.Fprintf(cmd.OutOrStdout(), "  Goal: %s\n", goalDisplay)
+	}
+}
+
+func printTaskAttributes(cmd *cobra.Command, task *lunatask.Task) {
+	if task.Estimate != nil {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Estimate: %d min\n", *task.Estimate)
+	}
+
+	if task.Priority != nil && *task.Priority != lunatask.PriorityNormal {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Priority: %s\n", task.Priority)
+	}
+
+	if task.Progress != nil {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Progress: %d%%\n", *task.Progress)
+	}
+
+	if task.Motivation != nil && *task.Motivation != lunatask.MotivationUnknown {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Motivation: %s\n", *task.Motivation)
+	}
+
+	if task.Eisenhower != nil && *task.Eisenhower != lunatask.EisenhowerUncategorized {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Eisenhower: %s\n", formatEisenhower(*task.Eisenhower))
+	}
+}
+
+func formatEisenhower(e lunatask.Eisenhower) string {
+	switch e {
+	case lunatask.EisenhowerUncategorized:
+		return "uncategorized"
+	case lunatask.EisenhowerDoNow:
+		return "do now (important + urgent)"
+	case lunatask.EisenhowerDoLater:
+		return "do later (important)"
+	case lunatask.EisenhowerDelegate:
+		return "delegate (urgent)"
+	case lunatask.EisenhowerEliminate:
+		return "eliminate"
+	default:
+		return "unknown"
+	}
+}

go.mod 🔗

@@ -3,7 +3,7 @@ module git.secluded.site/lune
 go 1.25.5
 
 require (
-	git.secluded.site/go-lunatask v0.1.0-rc8
+	git.secluded.site/go-lunatask v0.1.0-rc9
 	github.com/BurntSushi/toml v1.6.0
 	github.com/charmbracelet/fang v0.4.4
 	github.com/charmbracelet/huh v0.8.0

go.sum 🔗

@@ -2,8 +2,8 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy
 al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
-git.secluded.site/go-lunatask v0.1.0-rc8 h1:gfL5VII4KTbv83P87VUhPcN5gmUR7a6XKkokBSgDGRs=
-git.secluded.site/go-lunatask v0.1.0-rc8/go.mod h1:sWUQxme1z7qfsfS59nU5hqPvsRCt+HBmT/yBeIn6Fmc=
+git.secluded.site/go-lunatask v0.1.0-rc9 h1:ri8PDl7Xzg3mGStvHBxvL5PKOlBSZGxKDBzkqurLpEw=
+git.secluded.site/go-lunatask v0.1.0-rc9/go.mod h1:sWUQxme1z7qfsfS59nU5hqPvsRCt+HBmT/yBeIn6Fmc=
 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=

internal/config/config.go 🔗

@@ -194,3 +194,32 @@ func (c *Config) FindGoalsByKey(key string) []GoalMatch {
 
 	return matches
 }
+
+// AreaByID finds an area by its ID.
+func (c *Config) AreaByID(id string) *Area {
+	for i := range c.Areas {
+		if c.Areas[i].ID == id {
+			return &c.Areas[i]
+		}
+	}
+
+	return nil
+}
+
+// GoalByID finds a goal by its ID across all areas.
+func (c *Config) GoalByID(id string) *GoalMatch {
+	for i := range c.Areas {
+		area := &c.Areas[i]
+
+		for j := range area.Goals {
+			if area.Goals[j].ID == id {
+				return &GoalMatch{
+					Goal: &area.Goals[j],
+					Area: area,
+				}
+			}
+		}
+	}
+
+	return nil
+}