refactor(init): deep links, nav, token rename

Amolith created

- Add internal/deeplink package for parsing lunatask:// URLs
- Resource commands (task/note/person) now accept deep links or UUIDs
- Init wizard gains Back/Next navigation between steps
- Rename "API key" to "access token" throughout
- Remove LUNATASK_API_KEY env var fallback; keyring-only auth

Assisted-by: Claude Opus 4.5 via Crush

Change summary

AGENTS.md                          |   8 
cmd/init/apikey.go                 |  89 +++++++----------
cmd/init/areas.go                  | 122 ++++++++++++-----------
cmd/init/defaults.go               |   6 
cmd/init/habits.go                 |  96 +++++++++----------
cmd/init/init.go                   |  97 +++++++++++++------
cmd/init/notebooks.go              |  96 +++++++++----------
cmd/init/steps.go                  | 160 ++++++++++++++++++++++++++++++++
cmd/init/ui.go                     | 155 +++++++++++++++++++++++++-----
cmd/init/ui_test.go                |  68 +++++++++----
cmd/note/delete.go                 |   7 
cmd/note/get.go                    |   7 
cmd/note/update.go                 |   5 
cmd/person/delete.go               |   7 
cmd/person/get.go                  |   7 
cmd/person/timeline.go             |   5 
cmd/person/update.go               |   5 
cmd/ping.go                        |   6 
cmd/root.go                        |   2 
cmd/task/delete.go                 |   7 
cmd/task/get.go                    |   7 
cmd/task/update.go                 |   5 
internal/client/client.go          |  32 ++----
internal/deeplink/deeplink.go      |  92 ++++++++++++++++++
internal/deeplink/deeplink_test.go | 128 +++++++++++++++++++++++++
internal/validate/validate.go      |  18 +++
26 files changed, 888 insertions(+), 349 deletions(-)

Detailed changes

AGENTS.md πŸ”—

@@ -44,7 +44,7 @@ cmd/
   journal/           β†’ journal add
   habit/             β†’ habit track
 internal/
-  client/            β†’ Lunatask API client factory (reads LUNATASK_API_KEY)
+  client/            β†’ Lunatask API client factory (uses system keyring)
   config/            β†’ TOML config at ~/.config/lunatask/config.toml
   ui/                β†’ Lipgloss styles (Success, Warning, Error, Muted, Bold)
   validate/          β†’ Input validation (UUID format)
@@ -109,10 +109,10 @@ The `.golangci.yaml` disables several linters for `cmd/`:
 
 These patterns are intentional.
 
-### API key
+### Access token
 
-Read from `LUNATASK_API_KEY` env var via `internal/client.New()`. No interactive
-promptsβ€”fail fast with `client.ErrNoAPIKey` if unset.
+Read from system keyring via `internal/client.New()`. No interactive
+promptsβ€”fail fast with `client.ErrNoToken` if not configured.
 
 ### Output formatting
 

cmd/init/apikey.go πŸ”—

@@ -9,7 +9,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"os"
 	"time"
 
 	"git.secluded.site/go-lunatask"
@@ -21,31 +20,19 @@ import (
 	"git.secluded.site/lune/internal/ui"
 )
 
-func configureAPIKey(cmd *cobra.Command) error {
+func configureAccessToken(cmd *cobra.Command) error {
 	out := cmd.OutOrStdout()
 
-	if envKey := os.Getenv(client.EnvAPIKey); envKey != "" {
-		fmt.Fprintln(out, ui.Success.Render("API key found in "+client.EnvAPIKey+" environment variable."))
-
-		if err := validateWithSpinner(envKey); err != nil {
-			return fmt.Errorf("API key validation failed: %w", err)
-		}
-
-		fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
-
-		return nil
-	}
-
-	hasKey, keyringErr := client.HasKeyringKey()
+	hasToken, keyringErr := client.HasKeyringToken()
 	if keyringErr != nil {
 		fmt.Fprintln(out, ui.Error.Render("Failed to access system keyring: "+keyringErr.Error()))
-		fmt.Fprintln(out, ui.Muted.Render("Please resolve the keyring issue and try again."))
+		fmt.Fprintln(out, "Please resolve the keyring issue and try again.")
 
 		return fmt.Errorf("keyring access failed: %w", keyringErr)
 	}
 
-	if hasKey {
-		shouldPrompt, err := handleExistingAPIKey(out)
+	if hasToken {
+		shouldPrompt, err := handleExistingToken(out)
 		if err != nil {
 			return err
 		}
@@ -55,18 +42,18 @@ func configureAPIKey(cmd *cobra.Command) error {
 		}
 	}
 
-	return promptForAPIKey(out)
+	return promptForToken(out)
 }
 
-func handleExistingAPIKey(out io.Writer) (bool, error) {
-	existingKey, err := client.GetAPIKey()
+func handleExistingToken(out io.Writer) (bool, error) {
+	existingToken, err := client.GetToken()
 	if err != nil {
-		return false, fmt.Errorf("reading API key from keyring: %w", err)
+		return false, fmt.Errorf("reading access token from keyring: %w", err)
 	}
 
-	fmt.Fprintln(out, ui.Muted.Render("API key found in system keyring."))
+	fmt.Fprintln(out, "Access token found in system keyring.")
 
-	if err := validateWithSpinner(existingKey); err != nil {
+	if err := validateWithSpinner(existingToken); err != nil {
 		fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error()))
 	} else {
 		fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
@@ -75,17 +62,17 @@ func handleExistingAPIKey(out io.Writer) (bool, error) {
 	var action string
 
 	err = huh.NewSelect[string]().
-		Title("API key is already configured").
+		Title("Access token is already configured").
 		Options(
-			huh.NewOption("Keep existing key", "keep"),
-			huh.NewOption("Replace with new key", "replace"),
+			huh.NewOption("Keep existing token", "keep"),
+			huh.NewOption("Replace with new token", "replace"),
 			huh.NewOption("Delete from keyring", actionDelete),
 		).
 		Value(&action).
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return false, nil
+			return false, errQuit
 		}
 
 		return false, err
@@ -95,11 +82,11 @@ func handleExistingAPIKey(out io.Writer) (bool, error) {
 	case "keep":
 		return false, nil
 	case actionDelete:
-		if err := client.DeleteAPIKey(); err != nil {
-			return false, fmt.Errorf("deleting API key: %w", err)
+		if err := client.DeleteToken(); err != nil {
+			return false, fmt.Errorf("deleting access token: %w", err)
 		}
 
-		fmt.Fprintln(out, ui.Success.Render("API key removed from keyring."))
+		fmt.Fprintln(out, ui.Success.Render("Access token removed from keyring."))
 
 		return false, nil
 	default:
@@ -107,20 +94,20 @@ func handleExistingAPIKey(out io.Writer) (bool, error) {
 	}
 }
 
-func promptForAPIKey(out io.Writer) error {
+func promptForToken(out io.Writer) error {
 	fmt.Fprintln(out)
-	fmt.Fprintln(out, ui.Muted.Render("You can get your API key from:"))
-	fmt.Fprintln(out, ui.Muted.Render("  Lunatask β†’ Settings β†’ Integrations β†’ API"))
+	fmt.Fprintln(out, "You can get your access token from:")
+	fmt.Fprintln(out, "  Lunatask β†’ Settings β†’ Access Tokens")
 	fmt.Fprintln(out)
 
-	var apiKey string
+	var token string
 
 	for {
 		err := huh.NewInput().
-			Title("Lunatask API Key").
-			Description("Paste your API key (it will be stored securely in your system keyring).").
+			Title("Lunatask Access Token").
+			Description("Paste your access token (it will be stored securely in your system keyring).").
 			EchoMode(huh.EchoModePassword).
-			Value(&apiKey).
+			Value(&token).
 			Validate(func(s string) error {
 				if s == "" {
 					return errKeyRequired
@@ -131,20 +118,18 @@ func promptForAPIKey(out io.Writer) error {
 			Run()
 		if err != nil {
 			if errors.Is(err, huh.ErrUserAborted) {
-				fmt.Fprintln(out, ui.Warning.Render("Skipped API key setup."))
-
-				return nil
+				return errQuit
 			}
 
 			return err
 		}
 
-		if err := validateWithSpinner(apiKey); err != nil {
+		if err := validateWithSpinner(token); err != nil {
 			fmt.Fprintln(out, ui.Error.Render("βœ— Authentication failed: "+err.Error()))
-			fmt.Fprintln(out, ui.Muted.Render("Please check your API key and try again."))
+			fmt.Fprintln(out, "Please check your access token and try again.")
 			fmt.Fprintln(out)
 
-			apiKey = ""
+			token = ""
 
 			continue
 		}
@@ -154,22 +139,22 @@ func promptForAPIKey(out io.Writer) error {
 		break
 	}
 
-	if err := client.SetAPIKey(apiKey); err != nil {
-		return fmt.Errorf("saving API key to keyring: %w", err)
+	if err := client.SetToken(token); err != nil {
+		return fmt.Errorf("saving access token to keyring: %w", err)
 	}
 
-	fmt.Fprintln(out, ui.Success.Render("API key saved to system keyring."))
+	fmt.Fprintln(out, ui.Success.Render("Access token saved to system keyring."))
 
 	return nil
 }
 
-func validateWithSpinner(apiKey string) error {
+func validateWithSpinner(token string) error {
 	var validationErr error
 
 	err := spinner.New().
-		Title("Verifying API key...").
+		Title("Verifying access token...").
 		Action(func() {
-			validationErr = validateAPIKeyWithPing(apiKey)
+			validationErr = validateTokenWithPing(token)
 		}).
 		Run()
 	if err != nil {
@@ -179,8 +164,8 @@ func validateWithSpinner(apiKey string) error {
 	return validationErr
 }
 
-func validateAPIKeyWithPing(apiKey string) error {
-	c := lunatask.NewClient(apiKey, lunatask.UserAgent("lune/init"))
+func validateTokenWithPing(token string) error {
+	c := lunatask.NewClient(token, lunatask.UserAgent("lune/init"))
 
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()

cmd/init/areas.go πŸ”—

@@ -13,64 +13,52 @@ import (
 	"git.secluded.site/lune/internal/config"
 )
 
-func maybeConfigureAreas(cfg *config.Config) error {
-	var configure bool
-
-	err := huh.NewConfirm().
-		Title("Configure areas & goals?").
-		Description("Areas organize your life (Work, Health, etc). Goals belong to areas.").
-		Affirmative("Yes").
-		Negative("Not now").
-		Value(&configure).
-		Run()
-	if err != nil {
-		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
-		}
-
-		return err
-	}
-
-	if configure {
-		return manageAreas(cfg)
+func manageAreas(cfg *config.Config) error {
+	nav := manageAreasAsStep(cfg)
+	if nav == navQuit {
+		return errQuit
 	}
 
 	return nil
 }
 
-func manageAreas(cfg *config.Config) error {
+// manageAreasAsStep runs areas management as a wizard step with Back/Next navigation.
+func manageAreasAsStep(cfg *config.Config) wizardNav {
 	for {
-		options := buildAreaOptions(cfg.Areas)
+		options := buildAreaStepOptions(cfg.Areas)
 
-		choice, err := runListSelect("Areas", "Manage your areas and their goals.", options)
+		choice, err := runListSelect(
+			"Areas & Goals",
+			"Areas organize your life (Work, Health, etc). Goals belong to areas.",
+			options,
+		)
 		if err != nil {
-			return err
-		}
-
-		if choice == choiceBack {
-			return nil
+			return navQuit
 		}
 
-		if choice == choiceAdd {
-			if err := addArea(cfg); err != nil {
-				return err
+		switch choice {
+		case choiceBack:
+			return navBack
+		case choiceNext:
+			return navNext
+		case choiceAdd:
+			if err := addArea(cfg); errors.Is(err, errQuit) {
+				return navQuit
+			}
+		default:
+			idx, ok := parseEditIndex(choice)
+			if !ok {
+				continue
 			}
 
-			continue
-		}
-
-		idx, ok := parseEditIndex(choice)
-		if !ok {
-			continue
-		}
-
-		if err := manageAreaActions(cfg, idx); err != nil {
-			return err
+			if err := manageAreaActions(cfg, idx); errors.Is(err, errQuit) {
+				return navQuit
+			}
 		}
 	}
 }
 
-func buildAreaOptions(areas []config.Area) []huh.Option[string] {
+func buildAreaStepOptions(areas []config.Area) []huh.Option[string] {
 	options := []huh.Option[string]{
 		huh.NewOption("Add new area", choiceAdd),
 	}
@@ -81,7 +69,10 @@ func buildAreaOptions(areas []config.Area) []huh.Option[string] {
 		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
 	}
 
-	options = append(options, huh.NewOption("Back", choiceBack))
+	options = append(options,
+		huh.NewOption("← Back", choiceBack),
+		huh.NewOption("Next β†’", choiceNext),
+	)
 
 	return options
 }
@@ -89,7 +80,7 @@ func buildAreaOptions(areas []config.Area) []huh.Option[string] {
 func addArea(cfg *config.Config) error {
 	area, err := editArea(nil, cfg)
 	if err != nil {
-		if errors.Is(err, errUserAborted) {
+		if errors.Is(err, errBack) {
 			return nil
 		}
 
@@ -119,9 +110,16 @@ func manageAreaActions(cfg *config.Config, idx int) error {
 func handleAreaAction(cfg *config.Config, idx int, area *config.Area, action itemAction) error {
 	switch action {
 	case itemActionEdit:
-		if updated, err := editArea(area, cfg); err != nil && !errors.Is(err, errUserAborted) {
+		updated, err := editArea(area, cfg)
+		if err != nil {
+			if errors.Is(err, errBack) {
+				return nil
+			}
+
 			return err
-		} else if updated != nil {
+		}
+
+		if updated != nil {
 			cfg.Areas[idx] = *updated
 		}
 	case itemActionGoals:
@@ -142,10 +140,11 @@ func editArea(existing *config.Area, cfg *config.Config) (*config.Area, error) {
 	}
 
 	err := runItemForm(&area.Name, &area.Key, &area.ID, itemFormConfig{
-		itemType:        "area",
-		namePlaceholder: "Personal",
-		keyPlaceholder:  "personal",
-		keyValidator:    validateAreaKey(cfg, existing),
+		itemType:         "area",
+		namePlaceholder:  "Personal",
+		keyPlaceholder:   "personal",
+		keyValidator:     validateAreaKey(cfg, existing),
+		supportsDeepLink: true,
 	})
 	if err != nil {
 		return nil, err
@@ -185,7 +184,7 @@ func maybeManageGoals(cfg *config.Config, areaIdx int) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err
@@ -258,7 +257,7 @@ func buildGoalOptions(goals []config.Goal) []huh.Option[string] {
 func addGoal(area *config.Area) error {
 	goal, err := editGoal(nil, area)
 	if err != nil {
-		if errors.Is(err, errUserAborted) {
+		if errors.Is(err, errBack) {
 			return nil
 		}
 
@@ -285,7 +284,11 @@ func manageGoalActions(area *config.Area, idx int) error {
 	switch action {
 	case itemActionEdit:
 		updated, err := editGoal(goal, area)
-		if err != nil && !errors.Is(err, errUserAborted) {
+		if err != nil {
+			if errors.Is(err, errBack) {
+				return nil
+			}
+
 			return err
 		}
 
@@ -308,10 +311,11 @@ func editGoal(existing *config.Goal, area *config.Area) (*config.Goal, error) {
 	}
 
 	err := runItemForm(&goal.Name, &goal.Key, &goal.ID, itemFormConfig{
-		itemType:        "goal",
-		namePlaceholder: "Learn Gaelic",
-		keyPlaceholder:  "gaelic",
-		keyValidator:    validateGoalKey(area, existing),
+		itemType:         "goal",
+		namePlaceholder:  "Learn Gaelic",
+		keyPlaceholder:   "gaelic",
+		keyValidator:     validateGoalKey(area, existing),
+		supportsDeepLink: true,
 	})
 	if err != nil {
 		return nil, err
@@ -358,7 +362,7 @@ func deleteArea(cfg *config.Config, idx int) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err
@@ -392,7 +396,7 @@ func deleteGoal(area *config.Area, idx int) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err

cmd/init/defaults.go πŸ”—

@@ -51,7 +51,7 @@ func handleNoDefaultsAvailable(cfg *config.Config) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err
@@ -88,7 +88,7 @@ func selectDefaultArea(cfg *config.Config) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err
@@ -120,7 +120,7 @@ func selectDefaultNotebook(cfg *config.Config) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err

cmd/init/habits.go πŸ”—

@@ -13,64 +13,52 @@ import (
 	"git.secluded.site/lune/internal/config"
 )
 
-func maybeConfigureHabits(cfg *config.Config) error {
-	var configure bool
-
-	err := huh.NewConfirm().
-		Title("Configure habits?").
-		Description("Track habits from the command line.").
-		Affirmative("Yes").
-		Negative("Not now").
-		Value(&configure).
-		Run()
-	if err != nil {
-		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
-		}
-
-		return err
-	}
-
-	if configure {
-		return manageHabits(cfg)
+func manageHabits(cfg *config.Config) error {
+	nav := manageHabitsAsStep(cfg)
+	if nav == navQuit {
+		return errQuit
 	}
 
 	return nil
 }
 
-func manageHabits(cfg *config.Config) error {
+// manageHabitsAsStep runs habits management as a wizard step with Back/Next navigation.
+func manageHabitsAsStep(cfg *config.Config) wizardNav {
 	for {
-		options := buildHabitOptions(cfg.Habits)
+		options := buildHabitStepOptions(cfg.Habits)
 
-		choice, err := runListSelect("Habits", "Manage your trackable habits.", options)
+		choice, err := runListSelect(
+			"Habits",
+			"Track habits from the command line.",
+			options,
+		)
 		if err != nil {
-			return err
+			return navQuit
 		}
 
-		if choice == choiceBack {
-			return nil
-		}
-
-		if choice == choiceAdd {
-			if err := addHabit(cfg); err != nil {
-				return err
+		switch choice {
+		case choiceBack:
+			return navBack
+		case choiceNext:
+			return navNext
+		case choiceAdd:
+			if err := addHabit(cfg); errors.Is(err, errQuit) {
+				return navQuit
+			}
+		default:
+			idx, ok := parseEditIndex(choice)
+			if !ok {
+				continue
 			}
 
-			continue
-		}
-
-		idx, ok := parseEditIndex(choice)
-		if !ok {
-			continue
-		}
-
-		if err := manageHabitActions(cfg, idx); err != nil {
-			return err
+			if err := manageHabitActions(cfg, idx); errors.Is(err, errQuit) {
+				return navQuit
+			}
 		}
 	}
 }
 
-func buildHabitOptions(habits []config.Habit) []huh.Option[string] {
+func buildHabitStepOptions(habits []config.Habit) []huh.Option[string] {
 	options := []huh.Option[string]{
 		huh.NewOption("Add new habit", choiceAdd),
 	}
@@ -80,7 +68,10 @@ func buildHabitOptions(habits []config.Habit) []huh.Option[string] {
 		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
 	}
 
-	options = append(options, huh.NewOption("Back", choiceBack))
+	options = append(options,
+		huh.NewOption("← Back", choiceBack),
+		huh.NewOption("Next β†’", choiceNext),
+	)
 
 	return options
 }
@@ -88,7 +79,7 @@ func buildHabitOptions(habits []config.Habit) []huh.Option[string] {
 func addHabit(cfg *config.Config) error {
 	habit, err := editHabit(nil, cfg)
 	if err != nil {
-		if errors.Is(err, errUserAborted) {
+		if errors.Is(err, errBack) {
 			return nil
 		}
 
@@ -115,7 +106,11 @@ func manageHabitActions(cfg *config.Config, idx int) error {
 	switch action {
 	case itemActionEdit:
 		updated, err := editHabit(habit, cfg)
-		if err != nil && !errors.Is(err, errUserAborted) {
+		if err != nil {
+			if errors.Is(err, errBack) {
+				return nil
+			}
+
 			return err
 		}
 
@@ -138,10 +133,11 @@ func editHabit(existing *config.Habit, cfg *config.Config) (*config.Habit, error
 	}
 
 	err := runItemForm(&habit.Name, &habit.Key, &habit.ID, itemFormConfig{
-		itemType:        "habit",
-		namePlaceholder: "Study Gaelic",
-		keyPlaceholder:  "gaelic",
-		keyValidator:    validateHabitKey(cfg, existing),
+		itemType:         "habit",
+		namePlaceholder:  "Study Gaelic",
+		keyPlaceholder:   "gaelic",
+		keyValidator:     validateHabitKey(cfg, existing),
+		supportsDeepLink: false,
 	})
 	if err != nil {
 		return nil, err
@@ -188,7 +184,7 @@ func deleteHabit(cfg *config.Config, idx int) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err

cmd/init/init.go πŸ”—

@@ -16,6 +16,21 @@ import (
 	"git.secluded.site/lune/internal/ui"
 )
 
+// errQuit signals that the user wants to exit the wizard entirely.
+var errQuit = errors.New("user quit")
+
+// errReset signals that the user reset configuration and should run fresh setup.
+var errReset = errors.New("configuration reset")
+
+// wizardNav represents navigation direction in the wizard.
+type wizardNav int
+
+const (
+	navNext wizardNav = iota
+	navBack
+	navQuit
+)
+
 // Cmd is the init command for interactive setup.
 var Cmd = &cobra.Command{
 	Use:   "init",
@@ -25,7 +40,7 @@ var Cmd = &cobra.Command{
 This command will guide you through:
   - Adding areas, goals, notebooks, and habits from Lunatask
   - Setting default area and notebook
-  - Configuring and verifying your API key`,
+  - Configuring and verifying your access token`,
 	RunE: runInit,
 }
 
@@ -34,41 +49,61 @@ func runInit(cmd *cobra.Command, _ []string) error {
 	if errors.Is(err, config.ErrNotFound) {
 		cfg = &config.Config{}
 
-		return runFreshSetup(cmd, cfg)
+		err = runFreshSetup(cmd, cfg)
+		if errors.Is(err, errQuit) {
+			fmt.Fprintln(cmd.OutOrStdout(), ui.Warning.Render("Setup cancelled."))
+
+			return nil
+		}
+
+		return err
 	}
 
 	if err != nil {
 		return fmt.Errorf("loading config: %w", err)
 	}
 
-	return runReconfigure(cmd, cfg)
-}
-
-func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
-	printWelcome(cmd)
-
-	if err := configureUIPrefs(cfg); err != nil {
-		return err
+	err = runReconfigure(cmd, cfg)
+	if errors.Is(err, errQuit) {
+		return nil
 	}
 
-	if err := maybeConfigureAreas(cfg); err != nil {
-		return err
+	if errors.Is(err, errReset) {
+		return runFreshSetup(cmd, cfg)
 	}
 
-	if err := maybeConfigureNotebooks(cfg); err != nil {
-		return err
-	}
+	return err
+}
 
-	if err := maybeConfigureHabits(cfg); err != nil {
-		return err
-	}
+// wizardStep is a function that runs a wizard step and returns navigation direction.
+type wizardStep func() wizardNav
 
-	if err := configureDefaults(cfg); err != nil {
-		return err
-	}
+func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
+	printWelcome(cmd)
 
-	if err := configureAPIKey(cmd); err != nil {
-		return err
+	steps := []wizardStep{
+		func() wizardNav { return runUIPrefsStep(cfg) },
+		func() wizardNav { return runAreasStep(cfg) },
+		func() wizardNav { return runNotebooksStep(cfg) },
+		func() wizardNav { return runHabitsStep(cfg) },
+		func() wizardNav { return runDefaultsStep(cfg) },
+		func() wizardNav { return runAccessTokenStep(cmd) },
+	}
+
+	step := 0
+	for step < len(steps) {
+		nav := steps[step]()
+
+		switch nav {
+		case navNext:
+			step++
+		case navBack:
+			if step > 0 {
+				step--
+			}
+		case navQuit:
+			return errQuit
+		}
 	}
 
 	return saveWithSummary(cmd, cfg)
@@ -80,14 +115,14 @@ func printWelcome(cmd *cobra.Command) {
 	fmt.Fprintln(out)
 	fmt.Fprintln(out, "This wizard will help you configure lune for use with Lunatask.")
 	fmt.Fprintln(out)
-	fmt.Fprintln(out, ui.Muted.Render("Since Lunatask is end-to-end encrypted, lune can't fetch your"))
-	fmt.Fprintln(out, ui.Muted.Render("areas, goals, notebooks, or habits automatically. You'll need to"))
-	fmt.Fprintln(out, ui.Muted.Render("copy IDs from the Lunatask app."))
+	fmt.Fprintln(out, "Since Lunatask is end-to-end encrypted, lune can't fetch your")
+	fmt.Fprintln(out, "areas, goals, notebooks, or habits automatically. You'll need to")
+	fmt.Fprintln(out, "copy IDs from the Lunatask app.")
 	fmt.Fprintln(out)
 	fmt.Fprintln(out, ui.Bold.Render("Where to find IDs:"))
 	fmt.Fprintln(out, "  Open any item's settings modal β†’ click 'Copy [Item] ID' (bottom left)")
 	fmt.Fprintln(out)
-	fmt.Fprintln(out, ui.Muted.Render("You can run 'lune init' again anytime to add or modify config."))
+	fmt.Fprintln(out, "You can run 'lune init' again anytime to add or modify config.")
 	fmt.Fprintln(out)
 }
 
@@ -101,7 +136,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
 		"habits":    func() error { return manageHabits(cfg) },
 		"defaults":  func() error { return configureDefaults(cfg) },
 		"ui":        func() error { return configureUIPrefs(cfg) },
-		"apikey":    func() error { return configureAPIKey(cmd) },
+		"apikey":    func() error { return configureAccessToken(cmd) },
 		"reset":     func() error { return resetConfig(cmd, cfg) },
 	}
 
@@ -116,7 +151,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
 				huh.NewOption("Manage habits", "habits"),
 				huh.NewOption("Set defaults", "defaults"),
 				huh.NewOption("UI preferences", "ui"),
-				huh.NewOption("API key", "apikey"),
+				huh.NewOption("Access token", "apikey"),
 				huh.NewOption("Reset all configuration", "reset"),
 				huh.NewOption("Done", choiceDone),
 			).
@@ -124,7 +159,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
 			Run()
 		if err != nil {
 			if errors.Is(err, huh.ErrUserAborted) {
-				return nil
+				return errQuit
 			}
 
 			return err
@@ -183,7 +218,7 @@ func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
 		if errors.Is(err, huh.ErrUserAborted) {
 			fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
 
-			return nil
+			return errQuit
 		}
 
 		return err

cmd/init/notebooks.go πŸ”—

@@ -13,64 +13,52 @@ import (
 	"git.secluded.site/lune/internal/config"
 )
 
-func maybeConfigureNotebooks(cfg *config.Config) error {
-	var configure bool
-
-	err := huh.NewConfirm().
-		Title("Configure notebooks?").
-		Description("Notebooks organize your notes in Lunatask.").
-		Affirmative("Yes").
-		Negative("Not now").
-		Value(&configure).
-		Run()
-	if err != nil {
-		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
-		}
-
-		return err
-	}
-
-	if configure {
-		return manageNotebooks(cfg)
+func manageNotebooks(cfg *config.Config) error {
+	nav := manageNotebooksAsStep(cfg)
+	if nav == navQuit {
+		return errQuit
 	}
 
 	return nil
 }
 
-func manageNotebooks(cfg *config.Config) error {
+// manageNotebooksAsStep runs notebooks management as a wizard step with Back/Next navigation.
+func manageNotebooksAsStep(cfg *config.Config) wizardNav {
 	for {
-		options := buildNotebookOptions(cfg.Notebooks)
+		options := buildNotebookStepOptions(cfg.Notebooks)
 
-		choice, err := runListSelect("Notebooks", "Manage your notebooks.", options)
+		choice, err := runListSelect(
+			"Notebooks",
+			"Notebooks organize your notes in Lunatask.",
+			options,
+		)
 		if err != nil {
-			return err
+			return navQuit
 		}
 
-		if choice == choiceBack {
-			return nil
-		}
-
-		if choice == choiceAdd {
-			if err := addNotebook(cfg); err != nil {
-				return err
+		switch choice {
+		case choiceBack:
+			return navBack
+		case choiceNext:
+			return navNext
+		case choiceAdd:
+			if err := addNotebook(cfg); errors.Is(err, errQuit) {
+				return navQuit
+			}
+		default:
+			idx, ok := parseEditIndex(choice)
+			if !ok {
+				continue
 			}
 
-			continue
-		}
-
-		idx, ok := parseEditIndex(choice)
-		if !ok {
-			continue
-		}
-
-		if err := manageNotebookActions(cfg, idx); err != nil {
-			return err
+			if err := manageNotebookActions(cfg, idx); errors.Is(err, errQuit) {
+				return navQuit
+			}
 		}
 	}
 }
 
-func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] {
+func buildNotebookStepOptions(notebooks []config.Notebook) []huh.Option[string] {
 	options := []huh.Option[string]{
 		huh.NewOption("Add new notebook", choiceAdd),
 	}
@@ -80,7 +68,10 @@ func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] {
 		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
 	}
 
-	options = append(options, huh.NewOption("Back", choiceBack))
+	options = append(options,
+		huh.NewOption("← Back", choiceBack),
+		huh.NewOption("Next β†’", choiceNext),
+	)
 
 	return options
 }
@@ -88,7 +79,7 @@ func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] {
 func addNotebook(cfg *config.Config) error {
 	notebook, err := editNotebook(nil, cfg)
 	if err != nil {
-		if errors.Is(err, errUserAborted) {
+		if errors.Is(err, errBack) {
 			return nil
 		}
 
@@ -115,7 +106,11 @@ func manageNotebookActions(cfg *config.Config, idx int) error {
 	switch action {
 	case itemActionEdit:
 		updated, err := editNotebook(notebook, cfg)
-		if err != nil && !errors.Is(err, errUserAborted) {
+		if err != nil {
+			if errors.Is(err, errBack) {
+				return nil
+			}
+
 			return err
 		}
 
@@ -138,10 +133,11 @@ func editNotebook(existing *config.Notebook, cfg *config.Config) (*config.Notebo
 	}
 
 	err := runItemForm(&notebook.Name, &notebook.Key, &notebook.ID, itemFormConfig{
-		itemType:        "notebook",
-		namePlaceholder: "Gaelic Notes",
-		keyPlaceholder:  "gaelic",
-		keyValidator:    validateNotebookKey(cfg, existing),
+		itemType:         "notebook",
+		namePlaceholder:  "Gaelic Notes",
+		keyPlaceholder:   "gaelic",
+		keyValidator:     validateNotebookKey(cfg, existing),
+		supportsDeepLink: true,
 	})
 	if err != nil {
 		return nil, err
@@ -188,7 +184,7 @@ func deleteNotebook(cfg *config.Config, idx int) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err

cmd/init/steps.go πŸ”—

@@ -0,0 +1,160 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package init
+
+import (
+	"errors"
+
+	"github.com/charmbracelet/huh"
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/lune/internal/config"
+)
+
+// runUIPrefsStep runs the UI preferences configuration step.
+func runUIPrefsStep(cfg *config.Config) wizardNav {
+	color := cfg.UI.Color
+	if color == "" {
+		color = "auto"
+	}
+
+	err := huh.NewSelect[string]().
+		Title("Color output").
+		Description("When should lune use colored output?").
+		Options(
+			huh.NewOption("Auto (detect terminal capability)", "auto"),
+			huh.NewOption("Always", "always"),
+			huh.NewOption("Never", "never"),
+		).
+		Value(&color).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return navQuit
+		}
+
+		return navQuit
+	}
+
+	cfg.UI.Color = color
+
+	return navNext
+}
+
+// runAreasStep runs the areas configuration step.
+func runAreasStep(cfg *config.Config) wizardNav {
+	return manageAreasAsStep(cfg)
+}
+
+// runNotebooksStep runs the notebooks configuration step.
+func runNotebooksStep(cfg *config.Config) wizardNav {
+	return manageNotebooksAsStep(cfg)
+}
+
+// runHabitsStep runs the habits configuration step.
+func runHabitsStep(cfg *config.Config) wizardNav {
+	return manageHabitsAsStep(cfg)
+}
+
+// runDefaultsStep runs the defaults configuration step.
+func runDefaultsStep(cfg *config.Config) wizardNav {
+	hasAreas := len(cfg.Areas) > 0
+	hasNotebooks := len(cfg.Notebooks) > 0
+
+	if !hasAreas && !hasNotebooks {
+		return navNext
+	}
+
+	if hasAreas {
+		nav := selectDefaultAreaWithNav(cfg)
+		if nav != navNext {
+			return nav
+		}
+	}
+
+	if hasNotebooks {
+		return selectDefaultNotebookWithNav(cfg)
+	}
+
+	return navNext
+}
+
+// runAccessTokenStep runs the access token configuration step.
+func runAccessTokenStep(cmd *cobra.Command) wizardNav {
+	err := configureAccessToken(cmd)
+	if errors.Is(err, errQuit) {
+		return navQuit
+	}
+
+	if err != nil {
+		return navQuit
+	}
+
+	return navNext
+}
+
+// selectDefaultAreaWithNav selects the default area and returns navigation.
+func selectDefaultAreaWithNav(cfg *config.Config) wizardNav {
+	options := []huh.Option[string]{huh.NewOption("None", "")}
+	for _, area := range cfg.Areas {
+		options = append(options, huh.NewOption(area.Name+" ("+area.Key+")", area.Key))
+	}
+
+	choice, nav := runDefaultSelect("Default area", "Used when no area is specified.", cfg.Defaults.Area, options)
+	if nav != navNext || choice == "" {
+		return nav
+	}
+
+	cfg.Defaults.Area = choice
+
+	return navNext
+}
+
+// selectDefaultNotebookWithNav selects the default notebook and returns navigation.
+func selectDefaultNotebookWithNav(cfg *config.Config) wizardNav {
+	options := []huh.Option[string]{huh.NewOption("None", "")}
+	for _, nb := range cfg.Notebooks {
+		options = append(options, huh.NewOption(nb.Name+" ("+nb.Key+")", nb.Key))
+	}
+
+	desc := "Used when no notebook is specified."
+	choice, nav := runDefaultSelect("Default notebook", desc, cfg.Defaults.Notebook, options)
+
+	if nav != navNext || choice == "" {
+		return nav
+	}
+
+	cfg.Defaults.Notebook = choice
+
+	return navNext
+}
+
+func runDefaultSelect(title, desc, current string, options []huh.Option[string]) (string, wizardNav) {
+	options = append(options,
+		huh.NewOption("← Back", choiceBack),
+		huh.NewOption("Next β†’", choiceNext),
+	)
+
+	value := current
+
+	err := huh.NewSelect[string]().
+		Title(title).
+		Description(desc).
+		Options(options...).
+		Value(&value).
+		Run()
+	if err != nil {
+		return "", navQuit
+	}
+
+	switch value {
+	case choiceBack:
+		return "", navBack
+	case choiceNext:
+		return "", navNext
+	default:
+		return value, navNext
+	}
+}

cmd/init/ui.go πŸ”—

@@ -16,12 +16,13 @@ import (
 	"github.com/spf13/cobra"
 
 	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/deeplink"
 	"git.secluded.site/lune/internal/ui"
-	"git.secluded.site/lune/internal/validate"
 )
 
 const (
 	choiceBack   = "back"
+	choiceNext   = "next"
 	choiceAdd    = "add"
 	choiceDone   = "done"
 	actionEdit   = "edit"
@@ -30,13 +31,14 @@ const (
 )
 
 var (
+	errNameRequired  = errors.New("name is required")
 	errKeyRequired   = errors.New("key is required")
 	errKeyFormat     = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
 	errKeyDuplicate  = errors.New("this key is already in use")
-	errIDRequired    = errors.New("ID is required")
-	errIDFormat      = errors.New("invalid UUID format")
-	errUserAborted   = errors.New("user aborted")
+	errRefRequired   = errors.New("reference is required")
+	errRefFormat     = errors.New("expected UUID or lunatask:// deep link")
 	errIndexOutRange = errors.New("index out of range")
+	errBack          = errors.New("user went back")
 )
 
 func configureUIPrefs(cfg *config.Config) error {
@@ -57,7 +59,7 @@ func configureUIPrefs(cfg *config.Config) error {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err
@@ -78,14 +80,14 @@ func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
 
 	err = huh.NewConfirm().
 		Title("Reset all configuration?").
-		Description(fmt.Sprintf("This will delete %s\nYou'll need to run 'lune init' again.", path)).
+		Description(fmt.Sprintf("This will delete %s and restart the setup wizard.", path)).
 		Affirmative("Reset").
 		Negative("Cancel").
 		Value(&confirm).
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return nil
+			return errQuit
 		}
 
 		return err
@@ -97,8 +99,11 @@ func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
 		}
 
 		fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
+		fmt.Fprintln(cmd.OutOrStdout())
 
 		*cfg = config.Config{}
+
+		return errReset
 	}
 
 	return nil
@@ -118,7 +123,7 @@ func runListSelect(title, description string, options []huh.Option[string]) (str
 
 	if err := selector.Run(); err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return choiceBack, nil
+			return "", errQuit
 		}
 
 		return "", err
@@ -154,23 +159,35 @@ func validateKeyFormat(input string) error {
 	return nil
 }
 
-func validateID(input string) error {
+func validateReference(input string, supportsDeepLink bool) (string, error) {
 	if input == "" {
-		return errIDRequired
+		return "", errRefRequired
 	}
 
-	if err := validate.ID(input); err != nil {
-		return errIDFormat
+	if supportsDeepLink {
+		id, err := deeplink.ParseID(input)
+		if err != nil {
+			return "", errRefFormat
+		}
+
+		return id, nil
 	}
 
-	return nil
+	// Habits don't support deep links, only raw UUIDs
+	id, err := deeplink.ParseID(input)
+	if err != nil {
+		return "", errRefFormat
+	}
+
+	return id, nil
 }
 
 type itemFormConfig struct {
-	itemType        string
-	namePlaceholder string
-	keyPlaceholder  string
-	keyValidator    func(string) error
+	itemType         string
+	namePlaceholder  string
+	keyPlaceholder   string
+	keyValidator     func(string) error
+	supportsDeepLink bool
 }
 
 func titleCase(s string) string {
@@ -215,7 +232,7 @@ func runActionSelect(title string, includeGoals bool) (itemAction, error) {
 		Run()
 	if err != nil {
 		if errors.Is(err, huh.ErrUserAborted) {
-			return itemActionNone, nil
+			return itemActionNone, errQuit
 		}
 
 		return itemActionNone, err
@@ -234,36 +251,116 @@ func runActionSelect(title string, includeGoals bool) (itemAction, error) {
 }
 
 func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
-	form := huh.NewForm(
+	var save bool
+
+	requireNonEmpty := false
+
+	for {
+		form := buildItemFormGroup(name, key, itemID, cfg, &requireNonEmpty, &save)
+
+		if err := form.Run(); err != nil {
+			if errors.Is(err, huh.ErrUserAborted) {
+				return errBack
+			}
+
+			return err
+		}
+
+		if !save {
+			return errBack
+		}
+
+		// If any required field is empty, re-run with strict validation
+		if *name == "" || *key == "" || *itemID == "" {
+			requireNonEmpty = true
+
+			continue
+		}
+
+		// Normalise reference to UUID
+		parsedID, _ := validateReference(*itemID, cfg.supportsDeepLink)
+		*itemID = parsedID
+
+		return nil
+	}
+}
+
+func buildItemFormGroup(
+	name, key, itemID *string,
+	cfg itemFormConfig,
+	requireNonEmpty *bool,
+	save *bool,
+) *huh.Form {
+	refDescription := "Settings β†’ 'Copy " + titleCase(cfg.itemType) + " ID' (bottom left)."
+	if cfg.supportsDeepLink {
+		refDescription = "Paste UUID or lunatask:// deep link."
+	}
+
+	return huh.NewForm(
 		huh.NewGroup(
 			huh.NewInput().
 				Title("Name").
 				Description("Display name for this "+cfg.itemType+".").
 				Placeholder(cfg.namePlaceholder).
 				Value(name).
-				Validate(huh.ValidateNotEmpty()),
+				Validate(makeNameValidator(requireNonEmpty)),
 			huh.NewInput().
 				Title("Key").
 				Description("Short alias for CLI use (lowercase, no spaces).").
 				Placeholder(cfg.keyPlaceholder).
 				Value(key).
-				Validate(cfg.keyValidator),
+				Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)),
 			huh.NewInput().
-				Title("ID").
-				Description("Settings β†’ 'Copy "+titleCase(cfg.itemType)+" ID' (bottom left).").
+				Title("Reference").
+				Description(refDescription).
 				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
 				Value(itemID).
-				Validate(validateID),
+				Validate(makeRefValidator(requireNonEmpty, cfg.supportsDeepLink)),
+			huh.NewConfirm().
+				Title("Save this "+cfg.itemType+"?").
+				Affirmative("Save").
+				Negative("Cancel").
+				Value(save),
 		),
 	)
+}
 
-	if err := form.Run(); err != nil {
-		if errors.Is(err, huh.ErrUserAborted) {
-			return errUserAborted
+func makeNameValidator(requireNonEmpty *bool) func(string) error {
+	return func(input string) error {
+		if *requireNonEmpty && input == "" {
+			return errNameRequired
 		}
 
-		return err
+		return nil
 	}
+}
 
-	return nil
+func makeKeyValidator(requireNonEmpty *bool, formatValidator func(string) error) func(string) error {
+	return func(input string) error {
+		if input == "" {
+			if *requireNonEmpty {
+				return errKeyRequired
+			}
+
+			return nil
+		}
+
+		return formatValidator(input)
+	}
+}
+
+func makeRefValidator(requireNonEmpty *bool, supportsDeepLink bool) func(string) error {
+	return func(input string) error {
+		if input == "" {
+			if *requireNonEmpty {
+				return errRefRequired
+			}
+
+			return nil
+		}
+
+		_, err := validateReference(input, supportsDeepLink)
+
+		return err
+	}
 }

cmd/init/ui_test.go πŸ”—

@@ -62,48 +62,74 @@ func TestValidateKeyFormat_Invalid(t *testing.T) {
 	}
 }
 
-func TestValidateID_Valid(t *testing.T) {
+func TestValidateReference_Valid(t *testing.T) {
 	t.Parallel()
 
+	const (
+		uuidLower = "123e4567-e89b-12d3-a456-426614174000"
+		uuidArea  = "3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
+		uuidGoal  = "9d79e922-9ca8-4b8c-9aa5-dd98bb2492b2"
+		uuidNote  = "e230ef3e-d9a5-4211-9dfb-515cc892d6e5"
+	)
+
 	tests := []struct {
-		name  string
-		input string
+		name             string
+		input            string
+		supportsDeepLink bool
+		wantID           string
 	}{
-		{"valid UUID lowercase", "123e4567-e89b-12d3-a456-426614174000"},
-		{"valid UUID uppercase", "123E4567-E89B-12D3-A456-426614174000"},
+		{"valid UUID lowercase", uuidLower, false, uuidLower},
+		{"valid UUID uppercase", "123E4567-E89B-12D3-A456-426614174000", false, uuidLower},
+		{"deep link area", "lunatask://areas/" + uuidArea, true, uuidArea},
+		{"deep link goal", "lunatask://goals/" + uuidGoal, true, uuidGoal},
+		{"deep link note", "lunatask://notes/" + uuidNote, true, uuidNote},
 	}
 
-	for _, tc := range tests {
-		t.Run(tc.name, func(t *testing.T) {
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
 			t.Parallel()
 
-			if err := validateID(tc.input); err != nil {
-				t.Errorf("validateID(%q) = %v, want nil", tc.input, err)
+			id, err := validateReference(testCase.input, testCase.supportsDeepLink)
+			if err != nil {
+				t.Errorf("validateReference(%q, %v) = %v, want nil",
+					testCase.input, testCase.supportsDeepLink, err)
+			}
+
+			if id != testCase.wantID {
+				t.Errorf("validateReference(%q, %v) = %q, want %q",
+					testCase.input, testCase.supportsDeepLink, id, testCase.wantID)
 			}
 		})
 	}
 }
 
-func TestValidateID_Invalid(t *testing.T) {
+func TestValidateReference_Invalid(t *testing.T) {
 	t.Parallel()
 
+	const validUUID = "123e4567-e89b-12d3-a456-426614174000"
+
 	tests := []struct {
-		name    string
-		input   string
-		wantErr error
+		name             string
+		input            string
+		supportsDeepLink bool
+		wantErr          error
 	}{
-		{"empty ID", "", errIDRequired},
-		{"too short", "123e4567-e89b", errIDFormat},
-		{"random string", "not-a-uuid", errIDFormat},
-		{"wrong characters", "123e4567-e89b-12d3-a456-42661417zzzz", errIDFormat},
+		{"empty reference", "", true, errRefRequired},
+		{"too short", "123e4567-e89b", true, errRefFormat},
+		{"random string", "not-a-uuid", true, errRefFormat},
+		{"wrong characters", "123e4567-e89b-12d3-a456-42661417zzzz", false, errRefFormat},
+		{"invalid scheme", "http://areas/" + validUUID, true, errRefFormat},
+		{"invalid resource", "lunatask://invalid/" + validUUID, true, errRefFormat},
 	}
 
-	for _, tc := range tests {
-		t.Run(tc.name, func(t *testing.T) {
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
 			t.Parallel()
 
-			if err := validateID(tc.input); !errors.Is(err, tc.wantErr) {
-				t.Errorf("validateID(%q) = %v, want %v", tc.input, err, tc.wantErr)
+			_, err := validateReference(testCase.input, testCase.supportsDeepLink)
+			if !errors.Is(err, testCase.wantErr) {
+				t.Errorf("validateReference(%q, %v) = %v, want %v",
+					testCase.input, testCase.supportsDeepLink, err, testCase.wantErr)
 			}
 		})
 	}

cmd/note/delete.go πŸ”—

@@ -18,13 +18,14 @@ var DeleteCmd = &cobra.Command{
 	Short: "Delete a note",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		id, err := validate.Reference(args[0])
+		if err != nil {
 			return err
 		}
 
 		force, _ := cmd.Flags().GetBool("force")
 		if !force {
-			if !ui.Confirm(fmt.Sprintf("Delete note %s?", args[0])) {
+			if !ui.Confirm(fmt.Sprintf("Delete note %s?", id)) {
 				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
 
 				return nil
@@ -32,7 +33,7 @@ var DeleteCmd = &cobra.Command{
 		}
 
 		// TODO: implement note deletion
-		fmt.Fprintf(cmd.OutOrStdout(), "Deleting note %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Deleting note %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/note/get.go πŸ”—

@@ -14,15 +14,16 @@ import (
 // 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",
+	Short: "Get a note by ID or reference",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		id, err := validate.Reference(args[0])
+		if err != nil {
 			return err
 		}
 
 		// TODO: implement note get
-		fmt.Fprintf(cmd.OutOrStdout(), "Getting note %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Getting note %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/note/update.go πŸ”—

@@ -18,12 +18,13 @@ var UpdateCmd = &cobra.Command{
 	Short: "Update a note",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		id, err := validate.Reference(args[0])
+		if err != nil {
 			return err
 		}
 
 		// TODO: implement note update
-		fmt.Fprintf(cmd.OutOrStdout(), "Updating note %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Updating note %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/person/delete.go πŸ”—

@@ -18,13 +18,14 @@ var DeleteCmd = &cobra.Command{
 	Short: "Delete a person",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		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?", args[0])) {
+			if !ui.Confirm(fmt.Sprintf("Delete person %s?", id)) {
 				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
 
 				return nil
@@ -32,7 +33,7 @@ var DeleteCmd = &cobra.Command{
 		}
 
 		// TODO: implement person deletion
-		fmt.Fprintf(cmd.OutOrStdout(), "Deleting person %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Deleting person %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/person/get.go πŸ”—

@@ -14,15 +14,16 @@ import (
 // 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",
+	Short: "Get a person by ID or reference",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		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", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Getting person %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/person/timeline.go πŸ”—

@@ -20,12 +20,13 @@ 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 {
-		if err := validate.ID(args[0]); err != nil {
+		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", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Adding timeline note to person %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/person/update.go πŸ”—

@@ -18,12 +18,13 @@ var UpdateCmd = &cobra.Command{
 	Short: "Update a person",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		id, err := validate.Reference(args[0])
+		if err != nil {
 			return err
 		}
 
 		// TODO: implement person update
-		fmt.Fprintf(cmd.OutOrStdout(), "Updating person %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Updating person %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/ping.go πŸ”—

@@ -15,12 +15,12 @@ import (
 
 var pingCmd = &cobra.Command{
 	Use:   "ping",
-	Short: "Verify your API key is valid",
+	Short: "Verify your access token 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"))
+			if errors.Is(err, client.ErrNoToken) {
+				fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("No access token configured; run 'lune init'"))
 
 				return err
 			}

cmd/root.go πŸ”—

@@ -30,7 +30,7 @@ var rootCmd = &cobra.Command{
 	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.`,
+Run 'lune init' to configure your access token and get started.`,
 	SilenceUsage:  true,
 	SilenceErrors: true,
 }

cmd/task/delete.go πŸ”—

@@ -18,13 +18,14 @@ var DeleteCmd = &cobra.Command{
 	Short: "Delete a task",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		id, err := validate.Reference(args[0])
+		if err != nil {
 			return err
 		}
 
 		force, _ := cmd.Flags().GetBool("force")
 		if !force {
-			if !ui.Confirm(fmt.Sprintf("Delete task %s?", args[0])) {
+			if !ui.Confirm(fmt.Sprintf("Delete task %s?", id)) {
 				fmt.Fprintln(cmd.OutOrStdout(), "Cancelled")
 
 				return nil
@@ -32,7 +33,7 @@ var DeleteCmd = &cobra.Command{
 		}
 
 		// TODO: implement task deletion
-		fmt.Fprintf(cmd.OutOrStdout(), "Deleting task %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Deleting task %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/task/get.go πŸ”—

@@ -14,15 +14,16 @@ import (
 // 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",
+	Short: "Get a task by ID or reference",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		id, err := validate.Reference(args[0])
+		if err != nil {
 			return err
 		}
 
 		// TODO: implement task get
-		fmt.Fprintf(cmd.OutOrStdout(), "Getting task %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Getting task %s (not yet implemented)\n", id)
 
 		return nil
 	},

cmd/task/update.go πŸ”—

@@ -18,12 +18,13 @@ var UpdateCmd = &cobra.Command{
 	Short: "Update a task",
 	Args:  cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := validate.ID(args[0]); err != nil {
+		id, err := validate.Reference(args[0])
+		if err != nil {
 			return err
 		}
 
 		// TODO: implement task update
-		fmt.Fprintf(cmd.OutOrStdout(), "Updating task %s (not yet implemented)\n", args[0])
+		fmt.Fprintf(cmd.OutOrStdout(), "Updating task %s (not yet implemented)\n", id)
 
 		return nil
 	},

internal/client/client.go πŸ”—

@@ -8,34 +8,26 @@ package client
 import (
 	"errors"
 	"fmt"
-	"os"
 	"runtime/debug"
 
 	"git.secluded.site/go-lunatask"
 	"github.com/zalando/go-keyring"
 )
 
-// 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
-
 const (
 	keyringService = "lune"
 	keyringUser    = "api-key"
 )
 
-// ErrNoAPIKey indicates the API key is not available from env or keyring.
-var ErrNoAPIKey = errors.New("LUNATASK_API_KEY not set and no key found in system keyring")
+// ErrNoToken indicates no access token is available in the system keyring.
+var ErrNoToken = errors.New("no access token found in system keyring; run 'lune init' to configure")
 
-// New creates a Lunatask client, checking env var first, then system keyring.
+// New creates a Lunatask client using the access token from system keyring.
 func New() (*lunatask.Client, error) {
-	if token := os.Getenv(EnvAPIKey); token != "" {
-		return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil
-	}
-
 	token, err := keyring.Get(keyringService, keyringUser)
 	if err != nil {
 		if errors.Is(err, keyring.ErrNotFound) {
-			return nil, ErrNoAPIKey
+			return nil, ErrNoToken
 		}
 
 		return nil, fmt.Errorf("accessing system keyring: %w", err)
@@ -44,9 +36,9 @@ func New() (*lunatask.Client, error) {
 	return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil
 }
 
-// GetAPIKey returns the API key from keyring. Returns empty string and nil error
+// GetToken returns the access token from keyring. Returns empty string and nil error
 // if not found; returns error for keyring access problems.
-func GetAPIKey() (string, error) {
+func GetToken() (string, error) {
 	token, err := keyring.Get(keyringService, keyringUser)
 	if err != nil {
 		if errors.Is(err, keyring.ErrNotFound) {
@@ -59,8 +51,8 @@ func GetAPIKey() (string, error) {
 	return token, nil
 }
 
-// SetAPIKey stores the API key in the system keyring.
-func SetAPIKey(token string) error {
+// SetToken stores the access token in the system keyring.
+func SetToken(token string) error {
 	if err := keyring.Set(keyringService, keyringUser, token); err != nil {
 		return fmt.Errorf("keyring set: %w", err)
 	}
@@ -68,8 +60,8 @@ func SetAPIKey(token string) error {
 	return nil
 }
 
-// DeleteAPIKey removes the API key from the system keyring.
-func DeleteAPIKey() error {
+// DeleteToken removes the access token from the system keyring.
+func DeleteToken() error {
 	if err := keyring.Delete(keyringService, keyringUser); err != nil {
 		return fmt.Errorf("keyring delete: %w", err)
 	}
@@ -77,10 +69,10 @@ func DeleteAPIKey() error {
 	return nil
 }
 
-// HasKeyringKey checks if an API key is stored in the keyring.
+// HasKeyringToken checks if an access token is stored in the keyring.
 // Returns (true, nil) if found, (false, nil) if not found,
 // or (false, error) if there was a keyring access problem.
-func HasKeyringKey() (bool, error) {
+func HasKeyringToken() (bool, error) {
 	_, err := keyring.Get(keyringService, keyringUser)
 	if err != nil {
 		if errors.Is(err, keyring.ErrNotFound) {

internal/deeplink/deeplink.go πŸ”—

@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package deeplink parses and builds Lunatask deep links.
+package deeplink
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/google/uuid"
+)
+
+// Resource represents a Lunatask resource type that supports deep linking.
+type Resource string
+
+// Supported Lunatask resource types for deep linking.
+const (
+	Area     Resource = "areas"
+	Goal     Resource = "goals"
+	Task     Resource = "tasks"
+	Note     Resource = "notes"
+	Person   Resource = "people"
+	Notebook Resource = "notebooks"
+)
+
+const (
+	scheme            = "lunatask://"
+	deepLinkPartCount = 2
+)
+
+// ErrInvalidReference indicates the input is neither a valid UUID nor deep link.
+var ErrInvalidReference = errors.New("invalid reference: expected UUID or lunatask:// deep link")
+
+// ErrUnsupportedResource indicates the deep link resource type is not recognised.
+var ErrUnsupportedResource = errors.New("unsupported resource type in deep link")
+
+// ParseID extracts a UUID from either a raw UUID string or a Lunatask deep link.
+// Accepts formats:
+//   - UUID: "3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
+//   - Deep link: "lunatask://areas/3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
+func ParseID(input string) (string, error) {
+	input = strings.TrimSpace(input)
+
+	// Try parsing as UUID first
+	if parsed, err := uuid.Parse(input); err == nil {
+		return parsed.String(), nil
+	}
+
+	// Try parsing as deep link
+	if !strings.HasPrefix(input, scheme) {
+		return "", fmt.Errorf("%w: %s", ErrInvalidReference, input)
+	}
+
+	path := strings.TrimPrefix(input, scheme)
+	parts := strings.Split(path, "/")
+
+	if len(parts) != deepLinkPartCount {
+		return "", fmt.Errorf("%w: %s", ErrInvalidReference, input)
+	}
+
+	resource := parts[0]
+	id := parts[1]
+
+	// Validate resource type
+	switch Resource(resource) {
+	case Area, Goal, Task, Note, Person, Notebook:
+		// Valid resource
+	default:
+		return "", fmt.Errorf("%w: %s", ErrUnsupportedResource, resource)
+	}
+
+	// Validate and normalise the UUID
+	parsed, err := uuid.Parse(id)
+	if err != nil {
+		return "", fmt.Errorf("%w: %s", ErrInvalidReference, input)
+	}
+
+	return parsed.String(), nil
+}
+
+// Build constructs a Lunatask deep link for the given resource and ID.
+func Build(resource Resource, id string) (string, error) {
+	parsed, err := uuid.Parse(id)
+	if err != nil {
+		return "", fmt.Errorf("%w: %s", ErrInvalidReference, id)
+	}
+
+	return fmt.Sprintf("%s%s/%s", scheme, resource, parsed.String()), nil
+}
@@ -0,0 +1,128 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package deeplink_test
+
+import (
+	"errors"
+	"testing"
+
+	"git.secluded.site/lune/internal/deeplink"
+)
+
+func TestParseID_Valid(t *testing.T) {
+	t.Parallel()
+
+	const (
+		uuidLower = "123e4567-e89b-12d3-a456-426614174000"
+		uuidArea  = "3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
+		uuidGoal  = "9d79e922-9ca8-4b8c-9aa5-dd98bb2492b2"
+	)
+
+	tests := []struct {
+		name   string
+		input  string
+		wantID string
+	}{
+		{"UUID lowercase", uuidLower, uuidLower},
+		{"UUID uppercase", "123E4567-E89B-12D3-A456-426614174000", uuidLower},
+		{"deep link areas", "lunatask://areas/" + uuidArea, uuidArea},
+		{"deep link goals", "lunatask://goals/" + uuidGoal, uuidGoal},
+		{"deep link tasks", "lunatask://tasks/" + uuidArea, uuidArea},
+		{"deep link notes", "lunatask://notes/" + uuidArea, uuidArea},
+		{"deep link people", "lunatask://people/" + uuidArea, uuidArea},
+		{"deep link notebooks", "lunatask://notebooks/" + uuidArea, uuidArea},
+		{"with whitespace", "  " + uuidLower + "  ", uuidLower},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			id, err := deeplink.ParseID(testCase.input)
+			if err != nil {
+				t.Errorf("ParseID(%q) error = %v, want nil", testCase.input, err)
+			}
+
+			if id != testCase.wantID {
+				t.Errorf("ParseID(%q) = %q, want %q", testCase.input, id, testCase.wantID)
+			}
+		})
+	}
+}
+
+func TestParseID_Invalid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		wantErr error
+	}{
+		{"empty", "", deeplink.ErrInvalidReference},
+		{"random text", "not-a-uuid", deeplink.ErrInvalidReference},
+		{"invalid UUID", "123e4567-e89b-12d3-a456-42661417zzzz", deeplink.ErrInvalidReference},
+		{"wrong scheme", "http://areas/123e4567-e89b-12d3-a456-426614174000", deeplink.ErrInvalidReference},
+		{"invalid resource", "lunatask://habits/123e4567-e89b-12d3-a456-426614174000", deeplink.ErrUnsupportedResource},
+		{"missing path", "lunatask://", deeplink.ErrInvalidReference},
+		{"extra path", "lunatask://areas/foo/bar", deeplink.ErrInvalidReference},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			_, err := deeplink.ParseID(testCase.input)
+			if !errors.Is(err, testCase.wantErr) {
+				t.Errorf("ParseID(%q) error = %v, want %v", testCase.input, err, testCase.wantErr)
+			}
+		})
+	}
+}
+
+func TestBuild(t *testing.T) {
+	t.Parallel()
+
+	const uuid = "3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
+
+	tests := []struct {
+		name     string
+		resource deeplink.Resource
+		id       string
+		want     string
+	}{
+		{"area", deeplink.Area, uuid, "lunatask://areas/" + uuid},
+		{"goal", deeplink.Goal, uuid, "lunatask://goals/" + uuid},
+		{"task", deeplink.Task, uuid, "lunatask://tasks/" + uuid},
+		{"note", deeplink.Note, uuid, "lunatask://notes/" + uuid},
+		{"person", deeplink.Person, uuid, "lunatask://people/" + uuid},
+		{"notebook", deeplink.Notebook, uuid, "lunatask://notebooks/" + uuid},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := deeplink.Build(testCase.resource, testCase.id)
+			if err != nil {
+				t.Errorf("Build(%v, %q) error = %v, want nil",
+					testCase.resource, testCase.id, err)
+			}
+
+			if got != testCase.want {
+				t.Errorf("Build(%v, %q) = %q, want %q",
+					testCase.resource, testCase.id, got, testCase.want)
+			}
+		})
+	}
+}
+
+func TestBuild_InvalidID(t *testing.T) {
+	t.Parallel()
+
+	_, err := deeplink.Build(deeplink.Area, "not-a-uuid")
+	if !errors.Is(err, deeplink.ErrInvalidReference) {
+		t.Errorf("Build(Area, \"not-a-uuid\") error = %v, want %v", err, deeplink.ErrInvalidReference)
+	}
+}

internal/validate/validate.go πŸ”—

@@ -10,11 +10,16 @@ import (
 	"fmt"
 
 	"github.com/google/uuid"
+
+	"git.secluded.site/lune/internal/deeplink"
 )
 
 // ErrInvalidID indicates an ID is not a valid UUID.
 var ErrInvalidID = errors.New("invalid ID: expected UUID format")
 
+// ErrInvalidReference indicates the input is not a valid UUID or deep link.
+var ErrInvalidReference = errors.New("invalid reference: expected UUID or lunatask:// deep link")
+
 // ID validates that the given string is a valid Lunatask ID (UUID).
 func ID(id string) error {
 	if _, err := uuid.Parse(id); err != nil {
@@ -23,3 +28,16 @@ func ID(id string) error {
 
 	return nil
 }
+
+// Reference parses a UUID or Lunatask deep link and returns the normalised UUID.
+// Accepts formats:
+//   - UUID: "3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
+//   - Deep link: "lunatask://areas/3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
+func Reference(input string) (string, error) {
+	id, err := deeplink.ParseID(input)
+	if err != nil {
+		return "", fmt.Errorf("%w: %s", ErrInvalidReference, input)
+	}
+
+	return id, nil
+}