feat(person): implement person module

Amolith created

Fully implement person commands with Lunatask API integration:
- add: create people with relationship strength and source
- list: filter by relationship/source, table and JSON output
- show: display person details (renamed from get)
- update: modify names and relationship strength
- delete: with confirmation prompt
- timeline: add notes to person's memory timeline

Register shell completions for relationship flag.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/person/add.go      |  68 ++++++++++++++++++++-
cmd/person/delete.go   |  53 +++++++++++------
cmd/person/get.go      |  34 -----------
cmd/person/list.go     | 133 ++++++++++++++++++++++++++++++++++++++++++-
cmd/person/person.go   |   2 
cmd/person/show.go     |  94 +++++++++++++++++++++++++++++++
cmd/person/timeline.go |  94 +++++++++++++++++++++++++++---
cmd/person/update.go   |  74 ++++++++++++++++++++---
8 files changed, 464 insertions(+), 88 deletions(-)

Detailed changes

cmd/person/add.go 🔗

@@ -7,7 +7,11 @@ package person
 import (
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/completion"
+	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
 
@@ -15,17 +19,69 @@ import (
 var AddCmd = &cobra.Command{
 	Use:   "add FIRST LAST",
 	Short: "Add a new person",
-	Args:  cobra.ExactArgs(2),
-	RunE: func(cmd *cobra.Command, args []string) error {
-		// TODO: implement person creation
-		fmt.Fprintf(cmd.OutOrStdout(), "Adding person: %s %s (not yet implemented)\n", args[0], args[1])
+	Long: `Add a new person to the Lunatask relationship tracker.
 
-		return nil
-	},
+Names are required. Use flags to set additional properties.`,
+	Args: cobra.ExactArgs(2),
+	RunE: runAdd,
 }
 
 func init() {
 	AddCmd.Flags().StringP("relationship", "r", "", "Relationship strength")
+	AddCmd.Flags().String("source", "", "Source identifier")
+	AddCmd.Flags().String("source-id", "", "Source ID")
 
 	_ = AddCmd.RegisterFlagCompletionFunc("relationship", completion.Relationships)
 }
+
+func runAdd(cmd *cobra.Command, args []string) error {
+	firstName := args[0]
+	lastName := args[1]
+
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	builder := apiClient.NewPerson(firstName, lastName)
+
+	if err := applyRelationship(cmd, builder); err != nil {
+		return err
+	}
+
+	applySource(cmd, builder)
+
+	person, err := builder.Create(cmd.Context())
+	if err != nil {
+		return err
+	}
+
+	fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Created person: "+person.ID))
+
+	return nil
+}
+
+func applyRelationship(cmd *cobra.Command, builder *lunatask.PersonBuilder) error {
+	rel, _ := cmd.Flags().GetString("relationship")
+	if rel == "" {
+		return nil
+	}
+
+	strength, err := validate.RelationshipStrength(rel)
+	if err != nil {
+		return err
+	}
+
+	builder.WithRelationshipStrength(strength)
+
+	return nil
+}
+
+func applySource(cmd *cobra.Command, builder *lunatask.PersonBuilder) {
+	source, _ := cmd.Flags().GetString("source")
+	sourceID, _ := cmd.Flags().GetString("source-id")
+
+	if source != "" && sourceID != "" {
+		builder.FromSource(source, sourceID)
+	}
+}

cmd/person/delete.go 🔗

@@ -7,6 +7,7 @@ package person
 import (
 	"fmt"
 
+	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
@@ -16,29 +17,43 @@ import (
 var DeleteCmd = &cobra.Command{
 	Use:   "delete ID",
 	Short: "Delete a person",
-	Args:  cobra.ExactArgs(1),
-	RunE: func(cmd *cobra.Command, args []string) error {
-		id, err := validate.Reference(args[0])
-		if err != nil {
-			return err
-		}
+	Long: `Delete a person from Lunatask.
+
+Accepts a UUID or lunatask:// deep link.`,
+	Args: cobra.ExactArgs(1),
+	RunE: runDelete,
+}
+
+func init() {
+	DeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
+}
+
+func runDelete(cmd *cobra.Command, args []string) error {
+	id, err := validate.Reference(args[0])
+	if err != nil {
+		return err
+	}
 
-		force, _ := cmd.Flags().GetBool("force")
-		if !force {
-			if !ui.Confirm(fmt.Sprintf("Delete person %s?", id)) {
-				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
+	force, _ := cmd.Flags().GetBool("force")
+	if !force {
+		if !ui.Confirm(fmt.Sprintf("Delete person %s?", id)) {
+			fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
 
-				return nil
-			}
+			return nil
 		}
+	}
 
-		// TODO: implement person deletion
-		fmt.Fprintf(cmd.OutOrStdout(), "Deleting person %s (not yet implemented)\n", id)
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
 
-		return nil
-	},
-}
+	person, err := apiClient.DeletePerson(cmd.Context(), id)
+	if err != nil {
+		return err
+	}
 
-func init() {
-	DeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
+	fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Deleted person: "+person.ID))
+
+	return nil
 }

cmd/person/get.go 🔗

@@ -1,34 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package person
-
-import (
-	"fmt"
-
-	"git.secluded.site/lune/internal/validate"
-	"github.com/spf13/cobra"
-)
-
-// GetCmd retrieves a person by ID. Exported for potential use by shortcuts.
-var GetCmd = &cobra.Command{
-	Use:   "get ID",
-	Short: "Get a person by ID or reference",
-	Args:  cobra.ExactArgs(1),
-	RunE: func(cmd *cobra.Command, args []string) error {
-		id, err := validate.Reference(args[0])
-		if err != nil {
-			return err
-		}
-
-		// TODO: implement person get
-		fmt.Fprintf(cmd.OutOrStdout(), "Getting person %s (not yet implemented)\n", id)
-
-		return nil
-	},
-}
-
-func init() {
-	GetCmd.Flags().Bool("json", false, "Output as JSON")
-}

cmd/person/list.go 🔗

@@ -5,8 +5,15 @@
 package person
 
 import (
+	"encoding/json"
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/completion"
+	"git.secluded.site/lune/internal/ui"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/lipgloss/table"
 	"github.com/spf13/cobra"
 )
 
@@ -18,14 +25,128 @@ var ListCmd = &cobra.Command{
 
 Note: Due to end-to-end encryption, names are not available
 through the API. Only metadata is shown.`,
-	RunE: func(cmd *cobra.Command, _ []string) error {
-		// TODO: implement person listing
-		fmt.Fprintln(cmd.OutOrStdout(), "Person listing not yet implemented")
-
-		return nil
-	},
+	RunE: runList,
 }
 
 func init() {
+	ListCmd.Flags().StringP("relationship", "r", "", "Filter by relationship strength")
+	ListCmd.Flags().String("source", "", "Filter by source")
+	ListCmd.Flags().String("source-id", "", "Filter by source ID")
 	ListCmd.Flags().Bool("json", false, "Output as JSON")
+
+	_ = ListCmd.RegisterFlagCompletionFunc("relationship", completion.Relationships)
+}
+
+func runList(cmd *cobra.Command, _ []string) error {
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	opts := buildListOptions(cmd)
+
+	people, err := apiClient.ListPeople(cmd.Context(), opts)
+	if err != nil {
+		return err
+	}
+
+	relFilter, _ := cmd.Flags().GetString("relationship")
+	if relFilter != "" {
+		people = filterByRelationship(people, relFilter)
+	}
+
+	if len(people) == 0 {
+		fmt.Fprintln(cmd.OutOrStdout(), "No people found")
+
+		return nil
+	}
+
+	if mustGetBoolFlag(cmd, "json") {
+		return outputJSON(cmd, people)
+	}
+
+	return outputTable(cmd, people)
+}
+
+func buildListOptions(cmd *cobra.Command) *lunatask.ListPeopleOptions {
+	source, _ := cmd.Flags().GetString("source")
+	sourceID, _ := cmd.Flags().GetString("source-id")
+
+	if source == "" && sourceID == "" {
+		return nil
+	}
+
+	opts := &lunatask.ListPeopleOptions{}
+	if source != "" {
+		opts.Source = &source
+	}
+
+	if sourceID != "" {
+		opts.SourceID = &sourceID
+	}
+
+	return opts
+}
+
+func filterByRelationship(people []lunatask.Person, rel string) []lunatask.Person {
+	filtered := make([]lunatask.Person, 0, len(people))
+
+	for _, person := range people {
+		if person.RelationshipStrength != nil && string(*person.RelationshipStrength) == rel {
+			filtered = append(filtered, person)
+		}
+	}
+
+	return filtered
+}
+
+func mustGetBoolFlag(cmd *cobra.Command, name string) bool {
+	f := cmd.Flags().Lookup(name)
+	if f == nil {
+		panic("flag not defined: " + name)
+	}
+
+	return f.Value.String() == "true"
+}
+
+func outputJSON(cmd *cobra.Command, people []lunatask.Person) error {
+	enc := json.NewEncoder(cmd.OutOrStdout())
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(people); err != nil {
+		return fmt.Errorf("encoding JSON: %w", err)
+	}
+
+	return nil
+}
+
+func outputTable(cmd *cobra.Command, people []lunatask.Person) error {
+	rows := make([][]string, 0, len(people))
+
+	for _, person := range people {
+		rel := "-"
+		if person.RelationshipStrength != nil {
+			rel = string(*person.RelationshipStrength)
+		}
+
+		created := ui.FormatDate(person.CreatedAt)
+
+		rows = append(rows, []string{person.ID, rel, created})
+	}
+
+	tbl := table.New().
+		Headers("ID", "RELATIONSHIP", "CREATED").
+		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/person/person.go 🔗

@@ -17,7 +17,7 @@ var Cmd = &cobra.Command{
 func init() {
 	Cmd.AddCommand(AddCmd)
 	Cmd.AddCommand(ListCmd)
-	Cmd.AddCommand(GetCmd)
+	Cmd.AddCommand(ShowCmd)
 	Cmd.AddCommand(UpdateCmd)
 	Cmd.AddCommand(DeleteCmd)
 	Cmd.AddCommand(TimelineCmd)

cmd/person/show.go 🔗

@@ -0,0 +1,94 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package person
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/deeplink"
+	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
+	"github.com/spf13/cobra"
+)
+
+// ShowCmd displays a person by ID. Exported for potential use by shortcuts.
+var ShowCmd = &cobra.Command{
+	Use:   "show ID",
+	Short: "Show person details",
+	Long: `Show detailed information for a person.
+
+Accepts a UUID or lunatask:// deep link.
+
+Note: Due to end-to-end encryption, names 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
+	}
+
+	person, err := apiClient.GetPerson(cmd.Context(), id)
+	if err != nil {
+		return err
+	}
+
+	if mustGetBoolFlag(cmd, "json") {
+		return outputPersonJSON(cmd, person)
+	}
+
+	return printPersonDetails(cmd, person)
+}
+
+func outputPersonJSON(cmd *cobra.Command, person *lunatask.Person) error {
+	enc := json.NewEncoder(cmd.OutOrStdout())
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(person); err != nil {
+		return fmt.Errorf("encoding JSON: %w", err)
+	}
+
+	return nil
+}
+
+func printPersonDetails(cmd *cobra.Command, person *lunatask.Person) error {
+	link, _ := deeplink.Build(deeplink.Person, person.ID)
+
+	fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.H1.Render("Person"))
+	fmt.Fprintf(cmd.OutOrStdout(), "  ID:   %s\n", person.ID)
+	fmt.Fprintf(cmd.OutOrStdout(), "  Link: %s\n", link)
+
+	if person.RelationshipStrength != nil {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Relationship: %s\n", *person.RelationshipStrength)
+	}
+
+	if len(person.Sources) > 0 {
+		fmt.Fprintf(cmd.OutOrStdout(), "  Sources:\n")
+
+		for _, src := range person.Sources {
+			fmt.Fprintf(cmd.OutOrStdout(), "    - %s: %s\n", src.Source, src.SourceID)
+		}
+	}
+
+	fmt.Fprintf(cmd.OutOrStdout(), "  Created: %s\n", ui.FormatDate(person.CreatedAt))
+	fmt.Fprintf(cmd.OutOrStdout(), "  Updated: %s\n", ui.FormatDate(person.UpdatedAt))
+
+	return nil
+}

cmd/person/timeline.go 🔗

@@ -6,7 +6,13 @@ package person
 
 import (
 	"fmt"
+	"os"
+	"strings"
 
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/dateutil"
+	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
@@ -19,20 +25,86 @@ var TimelineCmd = &cobra.Command{
 
 Use "-" as content flag value to read from stdin.`,
 	Args: cobra.ExactArgs(1),
-	RunE: func(cmd *cobra.Command, args []string) error {
-		id, err := validate.Reference(args[0])
-		if err != nil {
-			return err
-		}
-
-		// TODO: implement timeline note creation
-		fmt.Fprintf(cmd.OutOrStdout(), "Adding timeline note to person %s (not yet implemented)\n", id)
-
-		return nil
-	},
+	RunE: runTimeline,
 }
 
 func init() {
 	TimelineCmd.Flags().StringP("content", "c", "", "Note content (use - for stdin)")
 	TimelineCmd.Flags().StringP("date", "d", "", "Date of interaction (natural language)")
 }
+
+func runTimeline(cmd *cobra.Command, args []string) error {
+	personID, err := validate.Reference(args[0])
+	if err != nil {
+		return err
+	}
+
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	builder := apiClient.NewTimelineNote(personID)
+
+	if err := applyTimelineContent(cmd, builder); err != nil {
+		return err
+	}
+
+	if err := applyTimelineDate(cmd, builder); err != nil {
+		return err
+	}
+
+	note, err := builder.Create(cmd.Context())
+	if err != nil {
+		return err
+	}
+
+	fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Created timeline note: "+note.ID))
+
+	return nil
+}
+
+func applyTimelineContent(cmd *cobra.Command, builder *lunatask.TimelineNoteBuilder) error {
+	content, _ := cmd.Flags().GetString("content")
+	if content == "" {
+		return nil
+	}
+
+	resolved, err := resolveContent(content)
+	if err != nil {
+		return err
+	}
+
+	builder.WithContent(resolved)
+
+	return nil
+}
+
+func resolveContent(content string) (string, error) {
+	if content != "-" {
+		return content, nil
+	}
+
+	data, err := os.ReadFile("/dev/stdin")
+	if err != nil {
+		return "", fmt.Errorf("reading stdin: %w", err)
+	}
+
+	return strings.TrimSpace(string(data)), nil
+}
+
+func applyTimelineDate(cmd *cobra.Command, builder *lunatask.TimelineNoteBuilder) error {
+	dateStr, _ := cmd.Flags().GetString("date")
+	if dateStr == "" {
+		return nil
+	}
+
+	date, err := dateutil.Parse(dateStr)
+	if err != nil {
+		return err
+	}
+
+	builder.OnDate(date)
+
+	return nil
+}

cmd/person/update.go 🔗

@@ -7,7 +7,10 @@ package person
 import (
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/completion"
+	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
@@ -16,18 +19,12 @@ import (
 var UpdateCmd = &cobra.Command{
 	Use:   "update ID",
 	Short: "Update a person",
-	Args:  cobra.ExactArgs(1),
-	RunE: func(cmd *cobra.Command, args []string) error {
-		id, err := validate.Reference(args[0])
-		if err != nil {
-			return err
-		}
+	Long: `Update an existing person in Lunatask.
 
-		// TODO: implement person update
-		fmt.Fprintf(cmd.OutOrStdout(), "Updating person %s (not yet implemented)\n", id)
-
-		return nil
-	},
+Accepts a UUID or lunatask:// deep link.
+Only specified flags are modified; other fields remain unchanged.`,
+	Args: cobra.ExactArgs(1),
+	RunE: runUpdate,
 }
 
 func init() {
@@ -37,3 +34,58 @@ func init() {
 
 	_ = UpdateCmd.RegisterFlagCompletionFunc("relationship", completion.Relationships)
 }
+
+func runUpdate(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
+	}
+
+	builder := apiClient.NewPersonUpdate(id)
+
+	applyUpdateNames(cmd, builder)
+
+	if err := applyUpdateRelationship(cmd, builder); err != nil {
+		return err
+	}
+
+	person, err := builder.Update(cmd.Context())
+	if err != nil {
+		return err
+	}
+
+	fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Updated person: "+person.ID))
+
+	return nil
+}
+
+func applyUpdateNames(cmd *cobra.Command, builder *lunatask.PersonUpdateBuilder) {
+	if first, _ := cmd.Flags().GetString("first"); first != "" {
+		builder.FirstName(first)
+	}
+
+	if last, _ := cmd.Flags().GetString("last"); last != "" {
+		builder.LastName(last)
+	}
+}
+
+func applyUpdateRelationship(cmd *cobra.Command, builder *lunatask.PersonUpdateBuilder) error {
+	rel, _ := cmd.Flags().GetString("relationship")
+	if rel == "" {
+		return nil
+	}
+
+	strength, err := validate.RelationshipStrength(rel)
+	if err != nil {
+		return err
+	}
+
+	builder.WithRelationshipStrength(strength)
+
+	return nil
+}