feat(cmd): add CLI command structure with stubs

Amolith created

Implements the command hierarchy for lune CLI:
- task: add, list, get, update, delete
- note: add, list, get, update, delete
- person: add, list, get, update, delete, timeline
- journal: add
- habit: track
- Shortcuts: add, done, jrnl

Includes supporting infrastructure:
- internal/client: API client with version from build info
- internal/config: TOML config with ErrNotFound sentinel
- internal/validate: UUID validation for IDs
- internal/ui: lipgloss styles

All commands use RunE flag lookups for testability, dynamic shell
completions for config-based flags, and delete commands require --force
or confirmation.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

cmd/add.go                    |  24 +++++
cmd/done.go                   |  24 +++++
cmd/habit/habit.go            |  19 ++++
cmd/habit/track.go            |  54 +++++++++++
cmd/init.go                   |  28 ++++++
cmd/journal/add.go            |  44 +++++++++
cmd/journal/journal.go        |  19 ++++
cmd/jrnl.go                   |  23 +++++
cmd/note/add.go               |  53 +++++++++++
cmd/note/delete.go            |  53 +++++++++++
cmd/note/get.go               |  33 +++++++
cmd/note/list.go              |  34 +++++++
cmd/note/note.go              |  23 +++++
cmd/note/update.go            |  38 ++++++++
cmd/person/add.go             |  43 +++++++++
cmd/person/delete.go          |  53 +++++++++++
cmd/person/get.go             |  33 +++++++
cmd/person/list.go            |  31 ++++++
cmd/person/person.go          |  24 +++++
cmd/person/timeline.go        |  37 ++++++++
cmd/person/update.go          |  37 ++++++++
cmd/ping.go                   |  42 +++++++++
cmd/root.go                   |  64 ++++++++++++++
cmd/task/add.go               |  92 ++++++++++++++++++++
cmd/task/delete.go            |  53 +++++++++++
cmd/task/get.go               |  33 +++++++
cmd/task/list.go              |  39 ++++++++
cmd/task/task.go              |  23 +++++
cmd/task/update.go            |  53 +++++++++++
go.mod                        |  44 +++++++++
go.sum                        |  90 +++++++++++++++++++
internal/client/client.go     |  40 ++++++++
internal/config/config.go     | 169 +++++++++++++++++++++++++++++++++++++
internal/ui/styles.go         |  17 +++
internal/validate/validate.go |  25 +++++
main.go                       |  19 ++++
36 files changed, 1,530 insertions(+)

Detailed changes

cmd/add.go 🔗

@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cmd
+
+import (
+	"git.secluded.site/lune/cmd/task"
+	"github.com/spf13/cobra"
+)
+
+var addCmd = &cobra.Command{
+	Use:     "add NAME",
+	Short:   "Shortcut for 'task add'",
+	GroupID: "shortcuts",
+	Args:    cobra.MinimumNArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return task.AddCmd.RunE(cmd, args)
+	},
+}
+
+func init() {
+	addCmd.Flags().AddFlagSet(task.AddCmd.Flags())
+}

cmd/done.go 🔗

@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+var doneCmd = &cobra.Command{
+	Use:     "done ID",
+	Short:   "Mark a task as completed",
+	GroupID: "shortcuts",
+	Args:    cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		// TODO: implement as task update --status completed
+		fmt.Fprintf(cmd.OutOrStdout(), "Marking task %s as done (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}

cmd/habit/habit.go 🔗

@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package habit provides commands for tracking Lunatask habits.
+package habit
+
+import "github.com/spf13/cobra"
+
+// Cmd is the parent command for habit operations.
+var Cmd = &cobra.Command{
+	Use:     "habit",
+	Short:   "Manage habits",
+	GroupID: "resources",
+}
+
+func init() {
+	Cmd.AddCommand(TrackCmd)
+}

cmd/habit/track.go 🔗

@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package habit
+
+import (
+	"fmt"
+
+	"git.secluded.site/lune/internal/config"
+	"github.com/spf13/cobra"
+)
+
+// TrackCmd tracks a habit activity. Exported for potential use by shortcuts.
+var TrackCmd = &cobra.Command{
+	Use:   "track KEY",
+	Short: "Track a habit activity",
+	Long: `Record that a habit was performed.
+
+KEY is the habit key from your config (not the raw Lunatask ID).
+Tracks for today by default. Use --date to specify another date.`,
+	Args:              cobra.ExactArgs(1),
+	ValidArgsFunction: completeHabits,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		date, _ := cmd.Flags().GetString("date")
+		if date == "" {
+			date = "today"
+		}
+
+		// TODO: implement habit tracking
+		fmt.Fprintf(cmd.OutOrStdout(), "Tracking habit %s for %s (not yet implemented)\n", args[0], date)
+
+		return nil
+	},
+}
+
+func init() {
+	TrackCmd.Flags().StringP("date", "d", "", "Date performed (natural language, default: today)")
+}
+
+// completeHabits returns habit keys from config for shell completion.
+func completeHabits(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	keys := make([]string, len(cfg.Habits))
+	for i, h := range cfg.Habits {
+		keys[i] = h.Key
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}

cmd/init.go 🔗

@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+var initCmd = &cobra.Command{
+	Use:   "init",
+	Short: "Initialize lune configuration interactively",
+	Long: `Interactively set up your lune configuration.
+
+This command will guide you through:
+  - Verifying your LUNATASK_API_KEY
+  - Adding areas, goals, notebooks, and habits from Lunatask
+  - Setting default area and notebook`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		// TODO: implement interactive setup with huh
+		fmt.Fprintln(cmd.OutOrStdout(), "Interactive setup not yet implemented")
+
+		return nil
+	},
+}

cmd/journal/add.go 🔗

@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package journal
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+// AddCmd creates a journal entry. Exported for use by the jrnl shortcut.
+var AddCmd = &cobra.Command{
+	Use:   "add [CONTENT]",
+	Short: "Create a journal entry",
+	Long: `Create a journal entry in Lunatask.
+
+Creates an entry for today by default. Use --date to specify another date.
+Use "-" as CONTENT to read from stdin.`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		date, _ := cmd.Flags().GetString("date")
+		if date == "" {
+			date = "today"
+		}
+
+		// TODO: implement journal entry creation
+		content := ""
+		if len(args) > 0 {
+			content = args[0]
+		}
+		fmt.Fprintf(cmd.OutOrStdout(), "Creating journal entry for %s (not yet implemented)\n", date)
+		if content != "" {
+			fmt.Fprintf(cmd.OutOrStdout(), "Content: %s\n", content)
+		}
+
+		return nil
+	},
+}
+
+func init() {
+	AddCmd.Flags().StringP("date", "d", "", "Entry date (natural language, default: today)")
+	AddCmd.Flags().StringP("name", "n", "", "Entry title (default: weekday name)")
+}

cmd/journal/journal.go 🔗

@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package journal provides commands for managing Lunatask journal entries.
+package journal
+
+import "github.com/spf13/cobra"
+
+// Cmd is the parent command for journal operations.
+var Cmd = &cobra.Command{
+	Use:     "journal",
+	Short:   "Manage journal entries",
+	GroupID: "resources",
+}
+
+func init() {
+	Cmd.AddCommand(AddCmd)
+}

cmd/jrnl.go 🔗

@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cmd
+
+import (
+	"git.secluded.site/lune/cmd/journal"
+	"github.com/spf13/cobra"
+)
+
+var jrnlCmd = &cobra.Command{
+	Use:     "jrnl [CONTENT]",
+	Short:   "Shortcut for 'journal add'",
+	GroupID: "shortcuts",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return journal.AddCmd.RunE(cmd, args)
+	},
+}
+
+func init() {
+	jrnlCmd.Flags().AddFlagSet(journal.AddCmd.Flags())
+}

cmd/note/add.go 🔗

@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package note
+
+import (
+	"fmt"
+
+	"git.secluded.site/lune/internal/config"
+	"github.com/spf13/cobra"
+)
+
+// AddCmd creates a new note. Exported for potential use by shortcuts.
+var AddCmd = &cobra.Command{
+	Use:   "add [NAME]",
+	Short: "Create a new note",
+	Long: `Create a new note in Lunatask.
+
+Use "-" as content flag value to read from stdin.`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		// TODO: implement note creation
+		name := ""
+		if len(args) > 0 {
+			name = args[0]
+		}
+		fmt.Fprintf(cmd.OutOrStdout(), "Creating note: %s (not yet implemented)\n", name)
+
+		return nil
+	},
+}
+
+func init() {
+	AddCmd.Flags().StringP("notebook", "b", "", "Notebook key (from config)")
+	AddCmd.Flags().StringP("content", "c", "", "Note content (use - for stdin)")
+
+	_ = AddCmd.RegisterFlagCompletionFunc("notebook", completeNotebooks)
+}
+
+// completeNotebooks returns notebook keys from config for shell completion.
+func completeNotebooks(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	keys := make([]string, len(cfg.Notebooks))
+	for i, n := range cfg.Notebooks {
+		keys[i] = n.Key
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}

cmd/note/delete.go 🔗

@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package note
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+
+	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
+	"github.com/spf13/cobra"
+)
+
+// DeleteCmd deletes a note. Exported for potential use by shortcuts.
+var DeleteCmd = &cobra.Command{
+	Use:   "delete ID",
+	Short: "Delete a note",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		force, _ := cmd.Flags().GetBool("force")
+		if !force {
+			fmt.Fprintf(cmd.OutOrStderr(), "%s Delete note %s? [y/N] ",
+				ui.Warning.Render("Warning:"), args[0])
+
+			reader := bufio.NewReader(os.Stdin)
+			response, _ := reader.ReadString('\n')
+			response = strings.TrimSpace(strings.ToLower(response))
+
+			if response != "y" && response != "yes" {
+				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
+
+				return nil
+			}
+		}
+
+		// TODO: implement note deletion
+		fmt.Fprintf(cmd.OutOrStdout(), "Deleting note %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	DeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
+}

cmd/note/get.go 🔗

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

cmd/note/list.go 🔗

@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package note
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+// ListCmd lists notes. Exported for potential use by shortcuts.
+var ListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List notes",
+	Long: `List notes from Lunatask.
+
+Note: Due to end-to-end encryption, note names and content
+are not available through the API. Only metadata is shown.`,
+	RunE: func(cmd *cobra.Command, _ []string) error {
+		// TODO: implement note listing
+		fmt.Fprintln(cmd.OutOrStdout(), "Note listing not yet implemented")
+
+		return nil
+	},
+}
+
+func init() {
+	ListCmd.Flags().StringP("notebook", "b", "", "Filter by notebook key")
+	ListCmd.Flags().Bool("json", false, "Output as JSON")
+
+	_ = ListCmd.RegisterFlagCompletionFunc("notebook", completeNotebooks)
+}

cmd/note/note.go 🔗

@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package note provides commands for managing Lunatask notes.
+package note
+
+import "github.com/spf13/cobra"
+
+// Cmd is the parent command for note operations.
+var Cmd = &cobra.Command{
+	Use:     "note",
+	Short:   "Manage notes",
+	GroupID: "resources",
+}
+
+func init() {
+	Cmd.AddCommand(AddCmd)
+	Cmd.AddCommand(ListCmd)
+	Cmd.AddCommand(GetCmd)
+	Cmd.AddCommand(UpdateCmd)
+	Cmd.AddCommand(DeleteCmd)
+}

cmd/note/update.go 🔗

@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package note
+
+import (
+	"fmt"
+
+	"git.secluded.site/lune/internal/validate"
+	"github.com/spf13/cobra"
+)
+
+// UpdateCmd updates a note. Exported for potential use by shortcuts.
+var UpdateCmd = &cobra.Command{
+	Use:   "update ID",
+	Short: "Update a note",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		// TODO: implement note update
+		fmt.Fprintf(cmd.OutOrStdout(), "Updating note %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	UpdateCmd.Flags().String("name", "", "New note name")
+	UpdateCmd.Flags().StringP("notebook", "b", "", "Move to notebook key")
+	UpdateCmd.Flags().StringP("content", "c", "", "Note content (use - for stdin)")
+	UpdateCmd.Flags().StringP("date", "d", "", "Note date (natural language)")
+
+	_ = UpdateCmd.RegisterFlagCompletionFunc("notebook", completeNotebooks)
+}

cmd/person/add.go 🔗

@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package person
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+// AddCmd creates a new person. Exported for potential use by shortcuts.
+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])
+
+		return nil
+	},
+}
+
+func init() {
+	AddCmd.Flags().StringP("relationship", "r", "", "Relationship strength")
+
+	_ = AddCmd.RegisterFlagCompletionFunc("relationship", completeRelationships)
+}
+
+// completeRelationships returns relationship strength options for shell completion.
+func completeRelationships(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	return []string{
+		"family",
+		"intimate-friends",
+		"close-friends",
+		"casual-friends",
+		"acquaintances",
+		"business-contacts",
+		"almost-strangers",
+	}, cobra.ShellCompDirectiveNoFileComp
+}

cmd/person/delete.go 🔗

@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package person
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+
+	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
+	"github.com/spf13/cobra"
+)
+
+// DeleteCmd deletes a person. Exported for potential use by shortcuts.
+var DeleteCmd = &cobra.Command{
+	Use:   "delete ID",
+	Short: "Delete a person",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		force, _ := cmd.Flags().GetBool("force")
+		if !force {
+			fmt.Fprintf(cmd.OutOrStderr(), "%s Delete person %s? [y/N] ",
+				ui.Warning.Render("Warning:"), args[0])
+
+			reader := bufio.NewReader(os.Stdin)
+			response, _ := reader.ReadString('\n')
+			response = strings.TrimSpace(strings.ToLower(response))
+
+			if response != "y" && response != "yes" {
+				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
+
+				return nil
+			}
+		}
+
+		// TODO: implement person deletion
+		fmt.Fprintf(cmd.OutOrStdout(), "Deleting person %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	DeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
+}

cmd/person/get.go 🔗

@@ -0,0 +1,33 @@
+// 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",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		// TODO: implement person get
+		fmt.Fprintf(cmd.OutOrStdout(), "Getting person %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	GetCmd.Flags().Bool("json", false, "Output as JSON")
+}

cmd/person/list.go 🔗

@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package person
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+// ListCmd lists people. Exported for potential use by shortcuts.
+var ListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List people",
+	Long: `List people from Lunatask.
+
+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
+	},
+}
+
+func init() {
+	ListCmd.Flags().Bool("json", false, "Output as JSON")
+}

cmd/person/person.go 🔗

@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package person provides commands for managing Lunatask relationships.
+package person
+
+import "github.com/spf13/cobra"
+
+// Cmd is the parent command for person operations.
+var Cmd = &cobra.Command{
+	Use:     "person",
+	Short:   "Manage people (relationships)",
+	GroupID: "resources",
+}
+
+func init() {
+	Cmd.AddCommand(AddCmd)
+	Cmd.AddCommand(ListCmd)
+	Cmd.AddCommand(GetCmd)
+	Cmd.AddCommand(UpdateCmd)
+	Cmd.AddCommand(DeleteCmd)
+	Cmd.AddCommand(TimelineCmd)
+}

cmd/person/timeline.go 🔗

@@ -0,0 +1,37 @@
+// 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"
+)
+
+// TimelineCmd adds a timeline note to a person. Exported for potential use by shortcuts.
+var TimelineCmd = &cobra.Command{
+	Use:   "timeline ID",
+	Short: "Add a timeline note to a person",
+	Long: `Add a timeline note to a person's memory timeline.
+
+Use "-" as content flag value to read from stdin.`,
+	Args: cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		// TODO: implement timeline note creation
+		fmt.Fprintf(cmd.OutOrStdout(), "Adding timeline note to person %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	TimelineCmd.Flags().StringP("content", "c", "", "Note content (use - for stdin)")
+	TimelineCmd.Flags().StringP("date", "d", "", "Date of interaction (natural language)")
+}

cmd/person/update.go 🔗

@@ -0,0 +1,37 @@
+// 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"
+)
+
+// UpdateCmd updates a person. Exported for potential use by shortcuts.
+var UpdateCmd = &cobra.Command{
+	Use:   "update ID",
+	Short: "Update a person",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		// TODO: implement person update
+		fmt.Fprintf(cmd.OutOrStdout(), "Updating person %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	UpdateCmd.Flags().String("first", "", "First name")
+	UpdateCmd.Flags().String("last", "", "Last name")
+	UpdateCmd.Flags().StringP("relationship", "r", "", "Relationship strength")
+
+	_ = UpdateCmd.RegisterFlagCompletionFunc("relationship", completeRelationships)
+}

cmd/ping.go 🔗

@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cmd
+
+import (
+	"errors"
+	"fmt"
+
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/ui"
+	"github.com/spf13/cobra"
+)
+
+var pingCmd = &cobra.Command{
+	Use:   "ping",
+	Short: "Verify your API key is valid",
+	RunE: func(cmd *cobra.Command, _ []string) error {
+		c, err := client.New()
+		if err != nil {
+			if errors.Is(err, client.ErrNoAPIKey) {
+				fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("LUNATASK_API_KEY not set"))
+
+				return err
+			}
+
+			return err
+		}
+
+		resp, err := c.Ping(cmd.Context())
+		if err != nil {
+			fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Authentication failed"))
+
+			return err
+		}
+
+		fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("✓ "+resp.Message))
+
+		return nil
+	},
+}

cmd/root.go 🔗

@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package cmd provides the lune CLI commands.
+package cmd
+
+import (
+	"context"
+	"os"
+
+	"git.secluded.site/lune/cmd/habit"
+	"git.secluded.site/lune/cmd/journal"
+	"git.secluded.site/lune/cmd/note"
+	"git.secluded.site/lune/cmd/person"
+	"git.secluded.site/lune/cmd/task"
+	"github.com/charmbracelet/fang"
+	"github.com/spf13/cobra"
+)
+
+var (
+	version = "dev"
+	commit  = "none"
+)
+
+var rootCmd = &cobra.Command{
+	Use:   "lune",
+	Short: "A delightful CLI for Lunatask",
+	Long: `lune is a command-line interface for Lunatask, the encrypted
+all-in-one productivity app for tasks, habits, journaling, and more.
+
+Set LUNATASK_API_KEY in your environment to authenticate.`,
+	SilenceUsage:  true,
+	SilenceErrors: true,
+}
+
+func init() {
+	rootCmd.AddGroup(&cobra.Group{ID: "resources", Title: "Resources"})
+	rootCmd.AddGroup(&cobra.Group{ID: "shortcuts", Title: "Shortcuts"})
+
+	rootCmd.AddCommand(initCmd)
+	rootCmd.AddCommand(pingCmd)
+
+	rootCmd.AddCommand(task.Cmd)
+	rootCmd.AddCommand(note.Cmd)
+	rootCmd.AddCommand(person.Cmd)
+	rootCmd.AddCommand(journal.Cmd)
+	rootCmd.AddCommand(habit.Cmd)
+
+	rootCmd.AddCommand(addCmd)
+	rootCmd.AddCommand(doneCmd)
+	rootCmd.AddCommand(jrnlCmd)
+}
+
+// Execute runs the root command with Fang styling.
+func Execute(ctx context.Context) error {
+	return fang.Execute(
+		ctx,
+		rootCmd,
+		fang.WithVersion(version),
+		fang.WithCommit(commit),
+		fang.WithNotifySignal(os.Interrupt),
+	)
+}

cmd/task/add.go 🔗

@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"fmt"
+
+	"git.secluded.site/lune/internal/config"
+	"github.com/spf13/cobra"
+)
+
+// AddCmd creates a new task. Exported for use by the add shortcut.
+var AddCmd = &cobra.Command{
+	Use:   "add NAME",
+	Short: "Create a new task",
+	Long: `Create a new task in Lunatask.
+
+The task name is required. Use flags to set additional properties.
+Use "-" as NAME to read the task name from stdin.`,
+	Args: cobra.MinimumNArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		// TODO: implement task creation
+		name := args[0]
+		fmt.Fprintf(cmd.OutOrStdout(), "Creating task: %s (not yet implemented)\n", name)
+
+		return nil
+	},
+}
+
+func init() {
+	AddCmd.Flags().StringP("area", "a", "", "Area key (from config)")
+	AddCmd.Flags().StringP("goal", "g", "", "Goal key (from config)")
+	AddCmd.Flags().StringP("status", "s", "", "Status: later, next, started, waiting")
+	AddCmd.Flags().StringP("note", "n", "", "Task note (use - for stdin)")
+	AddCmd.Flags().IntP("priority", "p", 0, "Priority: -2 to 2")
+	AddCmd.Flags().IntP("estimate", "e", 0, "Estimate in minutes (0-720)")
+	AddCmd.Flags().StringP("motivation", "m", "", "Motivation: must, should, want")
+	AddCmd.Flags().Int("eisenhower", 0, "Eisenhower quadrant: 1-4")
+	AddCmd.Flags().String("schedule", "", "Schedule date (natural language)")
+
+	_ = AddCmd.RegisterFlagCompletionFunc("area", completeAreas)
+	_ = AddCmd.RegisterFlagCompletionFunc("goal", completeGoals)
+	_ = AddCmd.RegisterFlagCompletionFunc("status",
+		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+			return []string{"later", "next", "started", "waiting"}, cobra.ShellCompDirectiveNoFileComp
+		})
+	_ = AddCmd.RegisterFlagCompletionFunc("motivation",
+		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+			return []string{"must", "should", "want"}, cobra.ShellCompDirectiveNoFileComp
+		})
+	_ = AddCmd.RegisterFlagCompletionFunc("eisenhower",
+		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+			return []string{"1", "2", "3", "4"}, cobra.ShellCompDirectiveNoFileComp
+		})
+}
+
+// completeAreas returns area keys from config for shell completion.
+func completeAreas(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	keys := make([]string, len(cfg.Areas))
+	for i, a := range cfg.Areas {
+		keys[i] = a.Key
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}
+
+// completeGoals returns goal keys from config for shell completion.
+// Note: This returns all goals across all areas. A smarter completion
+// would filter based on the --area flag value if set.
+func completeGoals(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, cobra.ShellCompDirectiveError
+	}
+
+	var keys []string
+
+	for _, a := range cfg.Areas {
+		for _, g := range a.Goals {
+			keys = append(keys, g.Key)
+		}
+	}
+
+	return keys, cobra.ShellCompDirectiveNoFileComp
+}

cmd/task/delete.go 🔗

@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+
+	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
+	"github.com/spf13/cobra"
+)
+
+// DeleteCmd deletes a task. Exported for potential use by shortcuts.
+var DeleteCmd = &cobra.Command{
+	Use:   "delete ID",
+	Short: "Delete a task",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		force, _ := cmd.Flags().GetBool("force")
+		if !force {
+			fmt.Fprintf(cmd.OutOrStderr(), "%s Delete task %s? [y/N] ",
+				ui.Warning.Render("Warning:"), args[0])
+
+			reader := bufio.NewReader(os.Stdin)
+			response, _ := reader.ReadString('\n')
+			response = strings.TrimSpace(strings.ToLower(response))
+
+			if response != "y" && response != "yes" {
+				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
+
+				return nil
+			}
+		}
+
+		// TODO: implement task deletion
+		fmt.Fprintf(cmd.OutOrStdout(), "Deleting task %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	DeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
+}

cmd/task/get.go 🔗

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

cmd/task/list.go 🔗

@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+// ListCmd lists tasks. Exported for potential use by shortcuts.
+var ListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List tasks",
+	Long: `List tasks from Lunatask.
+
+Note: Due to end-to-end encryption, task names and notes
+are not available through the API. Only metadata is shown.`,
+	RunE: func(cmd *cobra.Command, _ []string) error {
+		// TODO: implement task listing
+		fmt.Fprintln(cmd.OutOrStdout(), "Task listing not yet implemented")
+
+		return nil
+	},
+}
+
+func init() {
+	ListCmd.Flags().StringP("area", "a", "", "Filter by area key")
+	ListCmd.Flags().StringP("status", "s", "", "Filter by status")
+	ListCmd.Flags().Bool("json", false, "Output as JSON")
+
+	_ = ListCmd.RegisterFlagCompletionFunc("area", completeAreas)
+	_ = ListCmd.RegisterFlagCompletionFunc("status",
+		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+			return []string{"later", "next", "started", "waiting", "completed"}, cobra.ShellCompDirectiveNoFileComp
+		})
+}

cmd/task/task.go 🔗

@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package task provides commands for managing Lunatask tasks.
+package task
+
+import "github.com/spf13/cobra"
+
+// Cmd is the parent command for task operations.
+var Cmd = &cobra.Command{
+	Use:     "task",
+	Short:   "Manage tasks",
+	GroupID: "resources",
+}
+
+func init() {
+	Cmd.AddCommand(AddCmd)
+	Cmd.AddCommand(ListCmd)
+	Cmd.AddCommand(GetCmd)
+	Cmd.AddCommand(UpdateCmd)
+	Cmd.AddCommand(DeleteCmd)
+}

cmd/task/update.go 🔗

@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"fmt"
+
+	"git.secluded.site/lune/internal/validate"
+	"github.com/spf13/cobra"
+)
+
+// UpdateCmd updates a task. Exported for potential use by shortcuts.
+var UpdateCmd = &cobra.Command{
+	Use:   "update ID",
+	Short: "Update a task",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := validate.ID(args[0]); err != nil {
+			return err
+		}
+
+		// TODO: implement task update
+		fmt.Fprintf(cmd.OutOrStdout(), "Updating task %s (not yet implemented)\n", args[0])
+
+		return nil
+	},
+}
+
+func init() {
+	UpdateCmd.Flags().String("name", "", "New task name")
+	UpdateCmd.Flags().StringP("area", "a", "", "Move to area key")
+	UpdateCmd.Flags().StringP("goal", "g", "", "Move to goal key")
+	UpdateCmd.Flags().StringP("status", "s", "", "Status: later, next, started, waiting, completed")
+	UpdateCmd.Flags().StringP("note", "n", "", "Task note (use - for stdin)")
+	UpdateCmd.Flags().IntP("priority", "p", 0, "Priority: -2 to 2")
+	UpdateCmd.Flags().IntP("estimate", "e", 0, "Estimate in minutes (0-720)")
+	UpdateCmd.Flags().StringP("motivation", "m", "", "Motivation: must, should, want")
+	UpdateCmd.Flags().Int("eisenhower", 0, "Eisenhower quadrant: 1-4")
+	UpdateCmd.Flags().String("schedule", "", "Schedule date (natural language)")
+
+	_ = UpdateCmd.RegisterFlagCompletionFunc("area", completeAreas)
+	_ = UpdateCmd.RegisterFlagCompletionFunc("goal", completeGoals)
+	_ = UpdateCmd.RegisterFlagCompletionFunc("status",
+		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+			return []string{"later", "next", "started", "waiting", "completed"}, cobra.ShellCompDirectiveNoFileComp
+		})
+	_ = UpdateCmd.RegisterFlagCompletionFunc("motivation",
+		func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+			return []string{"must", "should", "want"}, cobra.ShellCompDirectiveNoFileComp
+		})
+}

go.mod 🔗

@@ -0,0 +1,44 @@
+module git.secluded.site/lune
+
+go 1.25.5
+
+require (
+	git.secluded.site/go-lunatask v0.1.0-rc7
+	github.com/BurntSushi/toml v1.6.0
+	github.com/charmbracelet/fang v0.4.4
+	github.com/charmbracelet/lipgloss v1.1.0
+	github.com/google/uuid v1.6.0
+	github.com/spf13/cobra v1.10.2
+)
+
+require (
+	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/charmbracelet/colorprofile v0.4.1 // indirect
+	github.com/charmbracelet/ultraviolet v0.0.0-20251217160852-6b0c0e26fad9 // indirect
+	github.com/charmbracelet/x/ansi v0.11.3 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
+	github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 // indirect
+	github.com/charmbracelet/x/term v0.2.2 // indirect
+	github.com/charmbracelet/x/termios v0.1.1 // indirect
+	github.com/charmbracelet/x/windows v0.2.2 // indirect
+	github.com/clipperhouse/displaywidth v0.6.2 // indirect
+	github.com/clipperhouse/stringish v0.1.1 // indirect
+	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.19 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/muesli/mango v0.2.0 // indirect
+	github.com/muesli/mango-cobra v1.3.0 // indirect
+	github.com/muesli/mango-pflag v0.2.0 // indirect
+	github.com/muesli/roff v0.1.0 // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/spf13/pflag v1.0.10 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	golang.org/x/sync v0.19.0 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+	golang.org/x/text v0.32.0 // indirect
+)

go.sum 🔗

@@ -0,0 +1,90 @@
+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-rc7 h1:kzwAN9h4zVTo0OBs4B23ba5mAqxus6nYU62LElUkdnw=
+git.secluded.site/go-lunatask v0.1.0-rc7/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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
+github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/ultraviolet v0.0.0-20251217160852-6b0c0e26fad9 h1:dsDBRP9Iyco0EjVpCsAzl8VGbxk04fP3sa80ySJSAZw=
+github.com/charmbracelet/ultraviolet v0.0.0-20251217160852-6b0c0e26fad9/go.mod h1:Ns3cOzzY9hEFFeGxB6VpfgRnqOJZJFhQAPfRxPqflQs=
+github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
+github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
+github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
+github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 h1:xGojlO6kHCDB1k6DolME79LG0u90TzVd8atGhmxFRIo=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
+github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
+github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
+github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec=
+github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
+github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k=
+github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y=
+github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
+github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/client/client.go 🔗

@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package client provides a configured Lunatask API client.
+package client
+
+import (
+	"errors"
+	"os"
+	"runtime/debug"
+
+	"git.secluded.site/go-lunatask"
+)
+
+// EnvAPIKey is the environment variable name for the Lunatask API key.
+const EnvAPIKey = "LUNATASK_API_KEY" //nolint:gosec // not a credential, just env var name
+
+// ErrNoAPIKey indicates the API key environment variable is not set.
+var ErrNoAPIKey = errors.New("LUNATASK_API_KEY environment variable not set")
+
+// New creates a Lunatask client from the LUNATASK_API_KEY environment variable.
+func New() (*lunatask.Client, error) {
+	token := os.Getenv(EnvAPIKey)
+	if token == "" {
+		return nil, ErrNoAPIKey
+	}
+
+	return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil
+}
+
+// version returns the module version from build info, or "dev" if unavailable.
+func version() string {
+	info, ok := debug.ReadBuildInfo()
+	if !ok || info.Main.Version == "" || info.Main.Version == "(devel)" {
+		return "dev"
+	}
+
+	return info.Main.Version
+}

internal/config/config.go 🔗

@@ -0,0 +1,169 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package config handles loading and saving lune configuration from TOML.
+package config
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/BurntSushi/toml"
+)
+
+// ErrNotFound indicates the config file doesn't exist.
+var ErrNotFound = errors.New("config file not found")
+
+// Config represents the lune configuration file structure.
+type Config struct {
+	UI        UIConfig   `toml:"ui"`
+	Defaults  Defaults   `toml:"defaults"`
+	Areas     []Area     `toml:"areas"`
+	Notebooks []Notebook `toml:"notebooks"`
+	Habits    []Habit    `toml:"habits"`
+}
+
+// UIConfig holds user interface preferences.
+type UIConfig struct {
+	Color string `toml:"color"` // "always", "never", "auto"
+}
+
+// Defaults holds default selections for commands.
+type Defaults struct {
+	Area     string `toml:"area"`
+	Notebook string `toml:"notebook"`
+}
+
+// Area represents a Lunatask area of life with its goals.
+type Area struct {
+	ID    string `toml:"id"`
+	Name  string `toml:"name"`
+	Key   string `toml:"key"`
+	Goals []Goal `toml:"goals"`
+}
+
+// Goal represents a goal within an area.
+type Goal struct {
+	ID   string `toml:"id"`
+	Name string `toml:"name"`
+	Key  string `toml:"key"`
+}
+
+// Notebook represents a Lunatask notebook for notes.
+type Notebook struct {
+	ID   string `toml:"id"`
+	Name string `toml:"name"`
+	Key  string `toml:"key"`
+}
+
+// Habit represents a trackable habit.
+type Habit struct {
+	ID   string `toml:"id"`
+	Name string `toml:"name"`
+	Key  string `toml:"key"`
+}
+
+// Path returns the path to the config file.
+func Path() (string, error) {
+	configDir, err := os.UserConfigDir()
+	if err != nil {
+		return "", fmt.Errorf("getting config dir: %w", err)
+	}
+
+	return filepath.Join(configDir, "lunatask", "config.toml"), nil
+}
+
+// Load reads the config file. Returns ErrNotFound if the file doesn't exist.
+func Load() (*Config, error) {
+	path, err := Path()
+	if err != nil {
+		return nil, err
+	}
+
+	var cfg Config
+	if _, err := toml.DecodeFile(path, &cfg); err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil, ErrNotFound
+		}
+
+		return nil, fmt.Errorf("decoding config: %w", err)
+	}
+
+	return &cfg, nil
+}
+
+// Save writes the config to disk.
+func (c *Config) Save() error {
+	path, err := Path()
+	if err != nil {
+		return err
+	}
+
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return fmt.Errorf("creating config dir: %w", err)
+	}
+
+	f, err := os.Create(path) //nolint:gosec // path is from user config dir
+	if err != nil {
+		return fmt.Errorf("creating config file: %w", err)
+	}
+
+	if err := toml.NewEncoder(f).Encode(c); err != nil {
+		_ = f.Close()
+
+		return fmt.Errorf("encoding config: %w", err)
+	}
+
+	if err := f.Close(); err != nil {
+		return fmt.Errorf("closing config file: %w", err)
+	}
+
+	return nil
+}
+
+// AreaByKey finds an area by its key.
+func (c *Config) AreaByKey(key string) *Area {
+	for i := range c.Areas {
+		if c.Areas[i].Key == key {
+			return &c.Areas[i]
+		}
+	}
+
+	return nil
+}
+
+// NotebookByKey finds a notebook by its key.
+func (c *Config) NotebookByKey(key string) *Notebook {
+	for i := range c.Notebooks {
+		if c.Notebooks[i].Key == key {
+			return &c.Notebooks[i]
+		}
+	}
+
+	return nil
+}
+
+// HabitByKey finds a habit by its key.
+func (c *Config) HabitByKey(key string) *Habit {
+	for i := range c.Habits {
+		if c.Habits[i].Key == key {
+			return &c.Habits[i]
+		}
+	}
+
+	return nil
+}
+
+// GoalByKey finds a goal within this area by its key.
+func (a *Area) GoalByKey(key string) *Goal {
+	for i := range a.Goals {
+		if a.Goals[i].Key == key {
+			return &a.Goals[i]
+		}
+	}
+
+	return nil
+}

internal/ui/styles.go 🔗

@@ -0,0 +1,17 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package ui provides lipgloss styles for terminal output.
+package ui
+
+import "github.com/charmbracelet/lipgloss"
+
+// Terminal output styles using ANSI colors for broad compatibility.
+var (
+	Success = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
+	Warning = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
+	Error   = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red
+	Muted   = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray
+	Bold    = lipgloss.NewStyle().Bold(true)
+)

internal/validate/validate.go 🔗

@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package validate provides input validation helpers.
+package validate
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/google/uuid"
+)
+
+// ErrInvalidID indicates an ID is not a valid UUID.
+var ErrInvalidID = errors.New("invalid ID: expected UUID format")
+
+// ID validates that the given string is a valid Lunatask ID (UUID).
+func ID(id string) error {
+	if _, err := uuid.Parse(id); err != nil {
+		return fmt.Errorf("%w: %s", ErrInvalidID, id)
+	}
+
+	return nil
+}

main.go 🔗

@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package main is the entry point for the lune CLI.
+package main
+
+import (
+	"context"
+	"os"
+
+	"git.secluded.site/lune/cmd"
+)
+
+func main() {
+	if err := cmd.Execute(context.Background()); err != nil {
+		os.Exit(1)
+	}
+}