refactor(cli): restructure with Cobra subcommands

Amolith created

- Add 'serve' subcommand for starting MCP server (stdio/sse/http)
- Add 'config' subcommand for interactive setup wizard
- Move config loading to internal/config with XDG support
- Add internal/ui for styled terminal output
- Add internal/client for Lunatask API wrapper

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>

Change summary

cmd/config/areas.go        | 405 +++++++++++++++++++++++++++++++++
cmd/config/config.go       | 339 ++++++++++++++++++++++++++++
cmd/config/habits.go       | 197 ++++++++++++++++
cmd/config/steps.go        | 181 +++++++++++++++
cmd/config/token.go        | 267 ++++++++++++++++++++++
cmd/config/ui.go           | 324 ++++++++++++++++++++++++++
cmd/lunatask-mcp-server.go | 481 ----------------------------------------
cmd/root.go                |  57 ++++
cmd/serve.go               | 356 +++++++++++++++++++++++++++++
internal/client/client.go  | 139 +++++++++++
internal/config/config.go  | 260 +++++++++++++++++++++
internal/ui/output.go      |  83 ++++++
internal/ui/spinner.go     |  61 +++++
internal/ui/styles.go      |  41 +++
main.go                    |  14 +
15 files changed, 2,724 insertions(+), 481 deletions(-)

Detailed changes

cmd/config/areas.go πŸ”—

@@ -0,0 +1,405 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package config
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/huh"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/config"
+)
+
+func manageAreas(cfg *config.Config) error {
+	nav := manageAreasAsStep(cfg)
+	if nav == navQuit {
+		return errQuit
+	}
+
+	return nil
+}
+
+// manageAreasAsStep runs areas management as a wizard step with Back/Next navigation.
+func manageAreasAsStep(cfg *config.Config) wizardNav {
+	for {
+		options := buildAreaStepOptions(cfg.Areas)
+
+		choice, err := runListSelect(
+			"Areas & Goals",
+			"Areas organize your life (Work, Health, etc). Goals belong to areas.",
+			options,
+		)
+		if err != nil {
+			return navQuit
+		}
+
+		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
+			}
+
+			if err := manageAreaActions(cfg, idx); errors.Is(err, errQuit) {
+				return navQuit
+			}
+		}
+	}
+}
+
+func buildAreaStepOptions(areas []config.Area) []huh.Option[string] {
+	options := []huh.Option[string]{
+		huh.NewOption("Add new area", choiceAdd),
+	}
+
+	for idx, area := range areas {
+		goalCount := len(area.Goals)
+		label := fmt.Sprintf("%s (%s) - %d goal(s)", area.Name, area.Key, goalCount)
+		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
+	}
+
+	options = append(options,
+		huh.NewOption("← Back", choiceBack),
+		huh.NewOption("Next β†’", choiceNext),
+	)
+
+	return options
+}
+
+func addArea(cfg *config.Config) error {
+	area, err := editArea(nil, cfg)
+	if err != nil {
+		if errors.Is(err, errBack) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.Areas = append(cfg.Areas, *area)
+
+	return maybeManageGoals(cfg, len(cfg.Areas)-1)
+}
+
+func manageAreaActions(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Areas) {
+		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
+	}
+
+	area := &cfg.Areas[idx]
+
+	action, err := runActionSelect(fmt.Sprintf("Area: %s (%s)", area.Name, area.Key), true)
+	if err != nil {
+		return err
+	}
+
+	return handleAreaAction(cfg, idx, area, action)
+}
+
+func handleAreaAction(cfg *config.Config, idx int, area *config.Area, action itemAction) error {
+	switch action {
+	case itemActionEdit:
+		updated, err := editArea(area, cfg)
+		if err != nil {
+			if errors.Is(err, errBack) {
+				return nil
+			}
+
+			return err
+		}
+
+		if updated != nil {
+			cfg.Areas[idx] = *updated
+		}
+	case itemActionGoals:
+		return manageGoals(cfg, idx)
+	case itemActionDelete:
+		return deleteArea(cfg, idx)
+	case itemActionNone:
+		// User cancelled or went back
+	}
+
+	return nil
+}
+
+func editArea(existing *config.Area, cfg *config.Config) (*config.Area, error) {
+	area := config.Area{} //nolint:exhaustruct // fields populated by form
+	if existing != nil {
+		area = *existing
+	}
+
+	err := runItemForm(&area.Name, &area.Key, &area.ID, itemFormConfig{
+		itemType:        "area",
+		namePlaceholder: "Personal",
+		keyPlaceholder:  "personal",
+		keyValidator:    validateAreaKey(cfg, existing),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &area, nil
+}
+
+func validateAreaKey(cfg *config.Config, existing *config.Area) func(string) error {
+	return func(input string) error {
+		if err := validateKeyFormat(input); err != nil {
+			return err
+		}
+
+		for idx := range cfg.Areas {
+			if existing != nil && &cfg.Areas[idx] == existing {
+				continue
+			}
+
+			if cfg.Areas[idx].Key == input {
+				return errKeyDuplicate
+			}
+		}
+
+		return nil
+	}
+}
+
+func maybeManageGoals(cfg *config.Config, areaIdx int) error {
+	var manage bool
+
+	err := huh.NewConfirm().
+		Title("Add goals for this area?").
+		Affirmative("Yes").
+		Negative("Not now").
+		Value(&manage).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return errQuit
+		}
+
+		return err
+	}
+
+	if manage {
+		return manageGoals(cfg, areaIdx)
+	}
+
+	return nil
+}
+
+func manageGoals(cfg *config.Config, areaIdx int) error {
+	if areaIdx < 0 || areaIdx >= len(cfg.Areas) {
+		return fmt.Errorf("%w: area %d", errIndexOutRange, areaIdx)
+	}
+
+	area := &cfg.Areas[areaIdx]
+
+	for {
+		options := buildGoalOptions(area.Goals)
+
+		choice, err := runListSelect(
+			fmt.Sprintf("Goals for: %s (%s)", area.Name, area.Key),
+			"",
+			options,
+		)
+		if err != nil {
+			return err
+		}
+
+		if choice == choiceDone {
+			return nil
+		}
+
+		if choice == choiceAdd {
+			if err := addGoal(area); err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		idx, ok := parseEditIndex(choice)
+		if !ok {
+			continue
+		}
+
+		if err := manageGoalActions(area, idx); err != nil {
+			return err
+		}
+	}
+}
+
+func buildGoalOptions(goals []config.Goal) []huh.Option[string] {
+	options := []huh.Option[string]{
+		huh.NewOption("Add new goal", choiceAdd),
+	}
+
+	for idx, goal := range goals {
+		label := fmt.Sprintf("%s (%s)", goal.Name, goal.Key)
+		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
+	}
+
+	options = append(options, huh.NewOption("Done", choiceDone))
+
+	return options
+}
+
+func addGoal(area *config.Area) error {
+	goal, err := editGoal(nil, area)
+	if err != nil {
+		if errors.Is(err, errBack) {
+			return nil
+		}
+
+		return err
+	}
+
+	area.Goals = append(area.Goals, *goal)
+
+	return nil
+}
+
+func manageGoalActions(area *config.Area, idx int) error {
+	if idx < 0 || idx >= len(area.Goals) {
+		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
+	}
+
+	goal := &area.Goals[idx]
+
+	action, err := runActionSelect(fmt.Sprintf("Goal: %s (%s)", goal.Name, goal.Key), false)
+	if err != nil {
+		return err
+	}
+
+	switch action {
+	case itemActionEdit:
+		updated, err := editGoal(goal, area)
+		if err != nil {
+			if errors.Is(err, errBack) {
+				return nil
+			}
+
+			return err
+		}
+
+		if updated != nil {
+			area.Goals[idx] = *updated
+		}
+	case itemActionDelete:
+		return deleteGoal(area, idx)
+	case itemActionNone, itemActionGoals:
+		// User cancelled or went back; goals not applicable here
+	}
+
+	return nil
+}
+
+func editGoal(existing *config.Goal, area *config.Area) (*config.Goal, error) {
+	goal := config.Goal{} //nolint:exhaustruct // fields populated by form
+	if existing != nil {
+		goal = *existing
+	}
+
+	err := runItemForm(&goal.Name, &goal.Key, &goal.ID, itemFormConfig{
+		itemType:        "goal",
+		namePlaceholder: "Learn Gaelic",
+		keyPlaceholder:  "gaelic",
+		keyValidator:    validateGoalKey(area, existing),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &goal, nil
+}
+
+func validateGoalKey(area *config.Area, existing *config.Goal) func(string) error {
+	return func(input string) error {
+		if err := validateKeyFormat(input); err != nil {
+			return err
+		}
+
+		for idx := range area.Goals {
+			if existing != nil && &area.Goals[idx] == existing {
+				continue
+			}
+
+			if area.Goals[idx].Key == input {
+				return errKeyDuplicate
+			}
+		}
+
+		return nil
+	}
+}
+
+func deleteArea(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Areas) {
+		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
+	}
+
+	area := cfg.Areas[idx]
+
+	var confirm bool
+
+	err := huh.NewConfirm().
+		Title(fmt.Sprintf("Delete area '%s'?", area.Name)).
+		Description(fmt.Sprintf("This will also remove %d goal(s). This cannot be undone.", len(area.Goals))).
+		Affirmative("Delete").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return errQuit
+		}
+
+		return err
+	}
+
+	if confirm {
+		cfg.Areas = append(cfg.Areas[:idx], cfg.Areas[idx+1:]...)
+	}
+
+	return nil
+}
+
+func deleteGoal(area *config.Area, idx int) error {
+	if idx < 0 || idx >= len(area.Goals) {
+		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
+	}
+
+	goal := area.Goals[idx]
+
+	var confirm bool
+
+	err := huh.NewConfirm().
+		Title(fmt.Sprintf("Delete goal '%s'?", goal.Name)).
+		Description("This cannot be undone.").
+		Affirmative("Delete").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return errQuit
+		}
+
+		return err
+	}
+
+	if confirm {
+		area.Goals = append(area.Goals[:idx], area.Goals[idx+1:]...)
+	}
+
+	return nil
+}

cmd/config/config.go πŸ”—

@@ -0,0 +1,339 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package config provides the interactive setup wizard for lunatask-mcp-server.
+package config
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+
+	"github.com/charmbracelet/huh"
+	"github.com/spf13/cobra"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/config"
+	"git.sr.ht/~amolith/lunatask-mcp-server/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")
+
+// errNonInteractive signals that config was run without a terminal.
+var errNonInteractive = errors.New("non-interactive terminal")
+
+// errConfigExists signals that a config file already exists.
+var errConfigExists = errors.New("config already exists")
+
+// wizardNav represents navigation direction in the wizard.
+type wizardNav int
+
+const (
+	navNext wizardNav = iota
+	navBack
+	navQuit
+)
+
+// Cmd is the config command for interactive setup.
+//
+//nolint:exhaustruct // cobra only requires a subset of fields
+var Cmd = &cobra.Command{
+	Use:   "config",
+	Short: "Interactive setup wizard",
+	Long: `Configure lunatask-mcp-server interactively.
+
+This command will guide you through:
+  - Adding areas and goals from Lunatask
+  - Adding habits from Lunatask
+  - Configuring server settings (host, port, transport)
+  - Configuring your access token
+
+Use --generate-config to create an example config file for manual editing
+when running non-interactively.`,
+	RunE: runConfig,
+}
+
+func init() {
+	Cmd.Flags().Bool("generate-config", false, "generate example config for manual editing")
+}
+
+func runConfig(cmd *cobra.Command, _ []string) error {
+	generateConfig, _ := cmd.Flags().GetBool("generate-config")
+	if generateConfig {
+		return runGenerateConfig(cmd)
+	}
+
+	if !ui.IsInteractive() {
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("config requires an interactive terminal."))
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr())
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "To configure non-interactively, run:")
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "  lunatask-mcp-server config --generate-config")
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr())
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Then edit the generated config file manually.")
+
+		return errNonInteractive
+	}
+
+	cfg, err := config.Load()
+	if errors.Is(err, config.ErrNotFound) {
+		cfg = &config.Config{} //nolint:exhaustruct // fresh config, defaults applied later
+
+		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)
+	}
+
+	err = runReconfigure(cmd, cfg)
+	if errors.Is(err, errQuit) {
+		return nil
+	}
+
+	if errors.Is(err, errReset) {
+		return runFreshSetup(cmd, cfg)
+	}
+
+	return err
+}
+
+// wizardStep is a function that runs a wizard step and returns navigation direction.
+type wizardStep func() wizardNav
+
+func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
+	printWelcome(cmd)
+
+	steps := []wizardStep{
+		func() wizardNav { return runServerStep(cfg) },
+		func() wizardNav { return runAreasStep(cfg) },
+		func() wizardNav { return runHabitsStep(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)
+}
+
+func printWelcome(cmd *cobra.Command) {
+	out := cmd.OutOrStdout()
+	_, _ = fmt.Fprintln(out, ui.Bold.Render("Welcome to lunatask-mcp-server!"))
+	_, _ = fmt.Fprintln(out)
+	_, _ = fmt.Fprintln(out, "This wizard will help you configure the MCP server for Lunatask.")
+	_, _ = fmt.Fprintln(out)
+	_, _ = fmt.Fprintln(out, "Since Lunatask is end-to-end encrypted, the server can't fetch your")
+	_, _ = fmt.Fprintln(out, "areas, goals, or habits automatically. You'll need to copy IDs from")
+	_, _ = fmt.Fprintln(out, "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, "You can run 'lunatask-mcp-server config' again anytime to modify settings.")
+	_, _ = fmt.Fprintln(out)
+}
+
+func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.Bold.Render("lunatask-mcp-server configuration"))
+	_, _ = fmt.Fprintln(cmd.OutOrStdout())
+
+	if err := ensureAccessToken(cmd); err != nil {
+		return err
+	}
+
+	handlers := map[string]func() error{
+		"areas":  func() error { return manageAreas(cfg) },
+		"habits": func() error { return manageHabits(cfg) },
+		"server": func() error { return configureServer(cfg) },
+		"token":  func() error { return configureAccessToken(cmd) },
+		"reset":  func() error { return resetConfig(cmd, cfg) },
+	}
+
+	for {
+		var choice string
+
+		err := huh.NewSelect[string]().
+			Title("What would you like to configure?").
+			Options(
+				huh.NewOption("Manage areas & goals", "areas"),
+				huh.NewOption("Manage habits", "habits"),
+				huh.NewOption("Server settings", "server"),
+				huh.NewOption("Access token", "token"),
+				huh.NewOption("Reset all configuration", "reset"),
+				huh.NewOption("Done", choiceDone),
+			).
+			Value(&choice).
+			Run()
+		if err != nil {
+			if errors.Is(err, huh.ErrUserAborted) {
+				return errQuit
+			}
+
+			return err
+		}
+
+		if choice == choiceDone {
+			return saveWithSummary(cmd, cfg)
+		}
+
+		if handler, ok := handlers[choice]; ok {
+			if err := handler(); err != nil {
+				return err
+			}
+		}
+	}
+}
+
+func printConfigSummary(out io.Writer, cfg *config.Config) {
+	goalCount := 0
+	for _, area := range cfg.Areas {
+		goalCount += len(area.Goals)
+	}
+
+	_, _ = fmt.Fprintln(out)
+	_, _ = fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
+	_, _ = fmt.Fprintf(out, "  Areas:     %d (%d goals)\n", len(cfg.Areas), goalCount)
+	_, _ = fmt.Fprintf(out, "  Habits:    %d\n", len(cfg.Habits))
+	_, _ = fmt.Fprintf(out, "  Server:    %s:%d (%s)\n", cfg.Server.Host, cfg.Server.Port, cfg.Server.Transport)
+	_, _ = fmt.Fprintf(out, "  Timezone:  %s\n", cfg.Timezone)
+	_, _ = fmt.Fprintln(out)
+}
+
+func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
+	path, err := config.Path()
+	if err != nil {
+		return err
+	}
+
+	out := cmd.OutOrStdout()
+	printConfigSummary(out, cfg)
+
+	var save bool
+
+	err = huh.NewConfirm().
+		Title("Save configuration?").
+		Affirmative("Save").
+		Negative("Discard").
+		Value(&save).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			_, _ = fmt.Fprintln(out, ui.Warning.Render("Setup cancelled; configuration was not saved."))
+
+			return errQuit
+		}
+
+		return err
+	}
+
+	if save {
+		if err := cfg.Save(); err != nil {
+			return err
+		}
+
+		_, _ = fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
+	} else {
+		_, _ = fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
+	}
+
+	return nil
+}
+
+// exampleConfig is a commented TOML config for manual editing.
+const exampleConfig = `# lunatask-mcp-server configuration file
+# See: https://git.sr.ht/~amolith/lunatask-mcp-server for documentation
+
+# Server settings
+[server]
+host = "localhost"
+port = 8080
+# Transport mode: "stdio" (default), "sse", or "http"
+transport = "stdio"
+
+# Timezone for date parsing (IANA format)
+timezone = "UTC"
+
+# Areas of life from Lunatask
+# Find IDs in Lunatask: Open area settings β†’ "Copy Area ID" (bottom left)
+#
+# [[areas]]
+# id = "00000000-0000-0000-0000-000000000000"
+# name = "Work"
+# key = "work"
+#
+#   # Goals within this area
+#   [[areas.goals]]
+#   id = "00000000-0000-0000-0000-000000000001"
+#   name = "Q1 Project"
+#   key = "q1-project"
+
+# Habits to track
+# Find IDs in Lunatask: Open habit settings β†’ "Copy Habit ID"
+#
+# [[habits]]
+# id = "00000000-0000-0000-0000-000000000000"
+# name = "Exercise"
+# key = "exercise"
+
+# Access token: Set via environment variable LUNATASK_ACCESS_TOKEN
+# or store in system keyring using: lunatask-mcp-server config
+`
+
+func runGenerateConfig(cmd *cobra.Command) error {
+	cfgPath, err := config.Path()
+	if err != nil {
+		return err
+	}
+
+	if _, err := os.Stat(cfgPath); err == nil {
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config already exists: "+cfgPath))
+		_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Remove or rename it first, or edit it directly.")
+
+		return errConfigExists
+	}
+
+	dir := filepath.Dir(cfgPath)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return fmt.Errorf("creating config directory: %w", err)
+	}
+
+	if err := os.WriteFile(cfgPath, []byte(exampleConfig), 0o600); err != nil {
+		return fmt.Errorf("writing config: %w", err)
+	}
+
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Generated example config: "+cfgPath))
+	_, _ = fmt.Fprintln(cmd.OutOrStdout())
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Edit this file to add your Lunatask areas and habits.")
+	_, _ = fmt.Fprintln(cmd.OutOrStdout())
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "To set your access token:")
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "  β€’ Environment variable: export LUNATASK_ACCESS_TOKEN=your-token")
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "  β€’ System keyring: lunatask-mcp-server config (interactive)")
+
+	return nil
+}

cmd/config/habits.go πŸ”—

@@ -0,0 +1,197 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package config
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/huh"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/config"
+)
+
+func manageHabits(cfg *config.Config) error {
+	nav := manageHabitsAsStep(cfg)
+	if nav == navQuit {
+		return errQuit
+	}
+
+	return nil
+}
+
+// manageHabitsAsStep runs habits management as a wizard step with Back/Next navigation.
+func manageHabitsAsStep(cfg *config.Config) wizardNav {
+	for {
+		options := buildHabitStepOptions(cfg.Habits)
+
+		choice, err := runListSelect(
+			"Habits",
+			"Track habits via the MCP server.",
+			options,
+		)
+		if err != nil {
+			return navQuit
+		}
+
+		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
+			}
+
+			if err := manageHabitActions(cfg, idx); errors.Is(err, errQuit) {
+				return navQuit
+			}
+		}
+	}
+}
+
+func buildHabitStepOptions(habits []config.Habit) []huh.Option[string] {
+	options := []huh.Option[string]{
+		huh.NewOption("Add new habit", choiceAdd),
+	}
+
+	for idx, habit := range habits {
+		label := fmt.Sprintf("%s (%s)", habit.Name, habit.Key)
+		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
+	}
+
+	options = append(options,
+		huh.NewOption("← Back", choiceBack),
+		huh.NewOption("Next β†’", choiceNext),
+	)
+
+	return options
+}
+
+func addHabit(cfg *config.Config) error {
+	habit, err := editHabit(nil, cfg)
+	if err != nil {
+		if errors.Is(err, errBack) {
+			return nil
+		}
+
+		return err
+	}
+
+	cfg.Habits = append(cfg.Habits, *habit)
+
+	return nil
+}
+
+func manageHabitActions(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Habits) {
+		return fmt.Errorf("%w: habit %d", errIndexOutRange, idx)
+	}
+
+	habit := &cfg.Habits[idx]
+
+	action, err := runActionSelect(fmt.Sprintf("Habit: %s (%s)", habit.Name, habit.Key), false)
+	if err != nil {
+		return err
+	}
+
+	switch action {
+	case itemActionEdit:
+		updated, err := editHabit(habit, cfg)
+		if err != nil {
+			if errors.Is(err, errBack) {
+				return nil
+			}
+
+			return err
+		}
+
+		if updated != nil {
+			cfg.Habits[idx] = *updated
+		}
+	case itemActionDelete:
+		return deleteHabit(cfg, idx)
+	case itemActionNone, itemActionGoals:
+		// User cancelled or went back; goals not applicable here
+	}
+
+	return nil
+}
+
+func editHabit(existing *config.Habit, cfg *config.Config) (*config.Habit, error) {
+	habit := config.Habit{} //nolint:exhaustruct // fields populated by form
+	if existing != nil {
+		habit = *existing
+	}
+
+	err := runItemForm(&habit.Name, &habit.Key, &habit.ID, itemFormConfig{
+		itemType:        "habit",
+		namePlaceholder: "Study Gaelic",
+		keyPlaceholder:  "gaelic",
+		keyValidator:    validateHabitKey(cfg, existing),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &habit, nil
+}
+
+func validateHabitKey(cfg *config.Config, existing *config.Habit) func(string) error {
+	return func(input string) error {
+		if err := validateKeyFormat(input); err != nil {
+			return err
+		}
+
+		for idx := range cfg.Habits {
+			if existing != nil && &cfg.Habits[idx] == existing {
+				continue
+			}
+
+			if cfg.Habits[idx].Key == input {
+				return errKeyDuplicate
+			}
+		}
+
+		return nil
+	}
+}
+
+func deleteHabit(cfg *config.Config, idx int) error {
+	if idx < 0 || idx >= len(cfg.Habits) {
+		return fmt.Errorf("%w: habit %d", errIndexOutRange, idx)
+	}
+
+	habit := cfg.Habits[idx]
+
+	var confirm bool
+
+	err := huh.NewConfirm().
+		Title(fmt.Sprintf("Delete habit '%s'?", habit.Name)).
+		Description("This cannot be undone.").
+		Affirmative("Delete").
+		Negative("Cancel").
+		Value(&confirm).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return errQuit
+		}
+
+		return err
+	}
+
+	if confirm {
+		cfg.Habits = append(cfg.Habits[:idx], cfg.Habits[idx+1:]...)
+	}
+
+	return nil
+}

cmd/config/steps.go πŸ”—

@@ -0,0 +1,181 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package config
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+
+	"github.com/charmbracelet/huh"
+	"github.com/spf13/cobra"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/config"
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/ui"
+)
+
+// Port validation errors.
+var (
+	errPortNotNumber  = errors.New("port must be a number")
+	errPortOutOfRange = errors.New("port must be between 1 and 65535")
+)
+
+// runServerStep runs the server configuration step.
+//
+//nolint:cyclop,funlen // wizard flow benefits from being in one function
+func runServerStep(cfg *config.Config) wizardNav {
+	if cfg.Server.Host == "" {
+		cfg.Server.Host = "localhost"
+	}
+
+	if cfg.Server.Port == 0 {
+		cfg.Server.Port = 8080
+	}
+
+	if cfg.Server.Transport == "" {
+		cfg.Server.Transport = "stdio"
+	}
+
+	if cfg.Timezone == "" {
+		cfg.Timezone = "UTC"
+	}
+
+	err := huh.NewSelect[string]().
+		Title("Transport mode").
+		Description("How will clients connect to the server?").
+		Options(
+			huh.NewOption("stdio (for CLI tools like Crush, Claude Code)", "stdio"),
+			huh.NewOption("SSE (for Home Assistant)", "sse"),
+			huh.NewOption("HTTP (streamable HTTP transport)", "http"),
+		).
+		Value(&cfg.Server.Transport).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return navQuit
+		}
+
+		return navQuit
+	}
+
+	// Only ask for host/port if not using stdio
+	if cfg.Server.Transport != "stdio" {
+		portStr := strconv.Itoa(cfg.Server.Port)
+
+		err = huh.NewForm(
+			huh.NewGroup(
+				huh.NewInput().
+					Title("Host").
+					Description("Address to listen on.").
+					Placeholder("localhost").
+					Value(&cfg.Server.Host),
+				huh.NewInput().
+					Title("Port").
+					Description("Port to listen on.").
+					Placeholder("8080").
+					Value(&portStr).
+					Validate(validatePort),
+			),
+		).Run()
+		if err != nil {
+			if errors.Is(err, huh.ErrUserAborted) {
+				return navQuit
+			}
+
+			return navQuit
+		}
+
+		if portStr != "" {
+			cfg.Server.Port, _ = strconv.Atoi(portStr)
+		}
+	}
+
+	err = huh.NewInput().
+		Title("Timezone").
+		Description("IANA timezone for date parsing (e.g. America/New_York, Europe/London).").
+		Placeholder("UTC").
+		Value(&cfg.Timezone).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return navQuit
+		}
+
+		return navQuit
+	}
+
+	return navNext
+}
+
+// runAreasStep runs the areas configuration step.
+func runAreasStep(cfg *config.Config) wizardNav {
+	return manageAreasAsStep(cfg)
+}
+
+// runHabitsStep runs the habits configuration step.
+func runHabitsStep(cfg *config.Config) wizardNav {
+	return manageHabitsAsStep(cfg)
+}
+
+// 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 {
+		out := cmd.OutOrStdout()
+		_, _ = fmt.Fprintln(out, ui.Error.Render("Token configuration failed: "+err.Error()))
+
+		var skip bool
+
+		skipErr := huh.NewConfirm().
+			Title("Skip token configuration?").
+			Description("You can configure it later with 'lunatask-mcp-server config'.").
+			Affirmative("Skip for now").
+			Negative("Go back").
+			Value(&skip).
+			Run()
+		if skipErr != nil {
+			return navQuit
+		}
+
+		if skip {
+			return navNext
+		}
+
+		return navBack
+	}
+
+	return navNext
+}
+
+// configureServer runs the server configuration from the main menu.
+func configureServer(cfg *config.Config) error {
+	nav := runServerStep(cfg)
+	if nav == navQuit {
+		return errQuit
+	}
+
+	return nil
+}
+
+func validatePort(s string) error {
+	if s == "" {
+		return nil
+	}
+
+	p, err := strconv.Atoi(s)
+	if err != nil {
+		return errPortNotNumber
+	}
+
+	if p < 1 || p > 65535 {
+		return errPortOutOfRange
+	}
+
+	return nil
+}

cmd/config/token.go πŸ”—

@@ -0,0 +1,267 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package config
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"time"
+
+	"git.secluded.site/go-lunatask"
+	"github.com/charmbracelet/huh"
+	"github.com/spf13/cobra"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/client"
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/ui"
+)
+
+const tokenValidationTimeout = 10 * time.Second
+
+// handleKeyringError prints keyring error messages and returns a wrapped error.
+func handleKeyringError(out io.Writer, err error) error {
+	_, _ = fmt.Fprintln(out, ui.Error.Render("Failed to access system keyring: "+err.Error()))
+	_, _ = fmt.Fprintln(out, "Please resolve the keyring issue and try again.")
+	_, _ = fmt.Fprintln(out)
+	_, _ = fmt.Fprintln(out, "Alternative: set LUNATASK_ACCESS_TOKEN environment variable.")
+
+	return fmt.Errorf("keyring access failed: %w", err)
+}
+
+//nolint:nestif // wizard flow with multiple paths
+func configureAccessToken(cmd *cobra.Command) error {
+	out := cmd.OutOrStdout()
+
+	// Check for environment variable first
+	if client.HasEnvToken() {
+		_, _ = fmt.Fprintln(out, ui.Success.Render("Access token found in LUNATASK_ACCESS_TOKEN environment variable."))
+
+		token, _, _ := client.GetToken()
+		if err := validateWithSpinner(token); err != nil {
+			_, _ = fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error()))
+		} else {
+			_, _ = fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
+		}
+
+		var action string
+
+		err := huh.NewSelect[string]().
+			Title("Environment variable is set").
+			Description("The env var takes priority over keyring storage.").
+			Options(
+				huh.NewOption("Keep using environment variable", "keep"),
+				huh.NewOption("Also store in keyring (for when env not set)", "store"),
+			).
+			Value(&action).
+			Run()
+		if err != nil {
+			if errors.Is(err, huh.ErrUserAborted) {
+				return errQuit
+			}
+
+			return err
+		}
+
+		if action == "keep" {
+			return nil
+		}
+
+		return promptForToken(out)
+	}
+
+	hasToken, keyringErr := client.HasKeyringToken()
+	if keyringErr != nil {
+		return handleKeyringError(out, keyringErr)
+	}
+
+	if hasToken {
+		shouldPrompt, err := handleExistingToken(out)
+		if err != nil {
+			return err
+		}
+
+		if !shouldPrompt {
+			return nil
+		}
+	}
+
+	return promptForToken(out)
+}
+
+// ensureAccessToken checks for a token and prompts for one if missing or invalid.
+// Used at the start of reconfigure mode.
+func ensureAccessToken(cmd *cobra.Command) error {
+	out := cmd.OutOrStdout()
+
+	// Check environment variable first
+	if client.HasEnvToken() {
+		return nil
+	}
+
+	hasToken, keyringErr := client.HasKeyringToken()
+	if keyringErr != nil {
+		return handleKeyringError(out, keyringErr)
+	}
+
+	if !hasToken {
+		_, _ = fmt.Fprintln(out, ui.Warning.Render("No access token found."))
+		_, _ = fmt.Fprintln(out, "Set LUNATASK_ACCESS_TOKEN or store in system keyring.")
+		_, _ = fmt.Fprintln(out)
+
+		return promptForToken(out)
+	}
+
+	existingToken, _, err := client.GetToken()
+	if err != nil {
+		return fmt.Errorf("reading access token: %w", err)
+	}
+
+	if err := validateWithSpinner(existingToken); err != nil {
+		_, _ = fmt.Fprintln(out, ui.Warning.Render("Existing access token failed validation: "+err.Error()))
+
+		var replace bool
+
+		confirmErr := huh.NewConfirm().
+			Title("Would you like to provide a new access token?").
+			Affirmative("Yes").
+			Negative("No").
+			Value(&replace).
+			Run()
+		if confirmErr != nil {
+			if errors.Is(confirmErr, huh.ErrUserAborted) {
+				return errQuit
+			}
+
+			return confirmErr
+		}
+
+		if replace {
+			return promptForToken(out)
+		}
+	}
+
+	return nil
+}
+
+func handleExistingToken(out io.Writer) (bool, error) {
+	existingToken, _, err := client.GetToken()
+	if err != nil {
+		return false, fmt.Errorf("reading access token: %w", err)
+	}
+
+	_, _ = fmt.Fprintln(out, "Access token found in system keyring.")
+
+	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"))
+	}
+
+	var action string
+
+	err = huh.NewSelect[string]().
+		Title("Access token is already configured").
+		Options(
+			huh.NewOption("Keep existing token", "keep"),
+			huh.NewOption("Replace with new token", "replace"),
+			huh.NewOption("Delete from keyring", "delete"),
+		).
+		Value(&action).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return false, errQuit
+		}
+
+		return false, err
+	}
+
+	switch action {
+	case "keep":
+		return false, nil
+	case "delete":
+		if err := client.DeleteKeyringToken(); err != nil {
+			return false, fmt.Errorf("deleting access token: %w", err)
+		}
+
+		_, _ = fmt.Fprintln(out, ui.Success.Render("Access token removed from keyring."))
+
+		return false, nil
+	default:
+		return true, nil
+	}
+}
+
+func promptForToken(out io.Writer) error {
+	_, _ = fmt.Fprintln(out)
+	_, _ = fmt.Fprintln(out, "You can get your access token from:")
+	_, _ = fmt.Fprintln(out, "  Lunatask β†’ Settings β†’ Access Tokens")
+	_, _ = fmt.Fprintln(out)
+
+	var token string
+
+	for {
+		err := huh.NewInput().
+			Title("Lunatask Access Token").
+			Description("Paste your access token (it will be stored securely in your system keyring).").
+			EchoMode(huh.EchoModePassword).
+			Value(&token).
+			Validate(func(s string) error {
+				if s == "" {
+					return errTokenRequired
+				}
+
+				return nil
+			}).
+			Run()
+		if err != nil {
+			if errors.Is(err, huh.ErrUserAborted) {
+				return errQuit
+			}
+
+			return err
+		}
+
+		if err := validateWithSpinner(token); err != nil {
+			_, _ = fmt.Fprintln(out, ui.Error.Render("βœ— Authentication failed: "+err.Error()))
+			_, _ = fmt.Fprintln(out, "Please check your access token and try again.")
+			_, _ = fmt.Fprintln(out)
+
+			token = ""
+
+			continue
+		}
+
+		_, _ = fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
+
+		break
+	}
+
+	if err := client.SetKeyringToken(token); err != nil {
+		return fmt.Errorf("saving access token to keyring: %w", err)
+	}
+
+	_, _ = fmt.Fprintln(out, ui.Success.Render("Access token saved to system keyring."))
+
+	return nil
+}
+
+func validateWithSpinner(token string) error {
+	return ui.SpinVoid("Verifying access token…", func() error {
+		return validateTokenWithPing(token)
+	})
+}
+
+func validateTokenWithPing(token string) error {
+	c := lunatask.NewClient(token, lunatask.UserAgent("lunatask-mcp-server/config"))
+
+	ctx, cancel := context.WithTimeout(context.Background(), tokenValidationTimeout)
+	defer cancel()
+
+	_, err := c.Ping(ctx)
+
+	return err
+}

cmd/config/ui.go πŸ”—

@@ -0,0 +1,324 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package config
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/charmbracelet/huh"
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/go-lunatask"
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/config"
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/ui"
+)
+
+const (
+	choiceBack   = "back"
+	choiceNext   = "next"
+	choiceAdd    = "add"
+	choiceDone   = "done"
+	actionEdit   = "edit"
+	actionDelete = "delete"
+	actionGoals  = "goals"
+)
+
+var (
+	errNameRequired  = errors.New("name is required")
+	errKeyRequired   = errors.New("key is required")
+	errTokenRequired = errors.New("access token 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")
+	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 resetConfig(cmd *cobra.Command, cfg *config.Config) error {
+	path, err := config.Path()
+	if err != nil {
+		return err
+	}
+
+	var confirm bool
+
+	err = huh.NewConfirm().
+		Title("Reset all configuration?").
+		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 errQuit
+		}
+
+		return err
+	}
+
+	if confirm {
+		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+			return fmt.Errorf("removing config: %w", err)
+		}
+
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
+		_, _ = fmt.Fprintln(cmd.OutOrStdout())
+
+		*cfg = config.Config{} //nolint:exhaustruct // reset to fresh config
+
+		return errReset
+	}
+
+	return nil
+}
+
+func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
+	var choice string
+
+	selector := huh.NewSelect[string]().
+		Title(title).
+		Options(options...).
+		Value(&choice)
+
+	if description != "" {
+		selector = selector.Description(description)
+	}
+
+	if err := selector.Run(); err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return "", errQuit
+		}
+
+		return "", err
+	}
+
+	return choice, nil
+}
+
+func parseEditIndex(choice string) (int, bool) {
+	if !strings.HasPrefix(choice, "edit:") {
+		return 0, false
+	}
+
+	idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
+	if err != nil {
+		return 0, false
+	}
+
+	return idx, true
+}
+
+var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
+
+func validateKeyFormat(input string) error {
+	if input == "" {
+		return errKeyRequired
+	}
+
+	if !keyPattern.MatchString(input) {
+		return errKeyFormat
+	}
+
+	return nil
+}
+
+func validateReference(input string) (string, error) {
+	if input == "" {
+		return "", errRefRequired
+	}
+
+	_, id, err := lunatask.ParseDeepLink(input)
+	if err != nil {
+		return "", errRefFormat
+	}
+
+	return id, nil
+}
+
+type itemFormConfig struct {
+	itemType        string
+	namePlaceholder string
+	keyPlaceholder  string
+	keyValidator    func(string) error
+}
+
+func titleCase(s string) string {
+	if s == "" {
+		return s
+	}
+
+	return strings.ToUpper(s[:1]) + s[1:]
+}
+
+// itemAction represents the result of an action selection dialog.
+type itemAction int
+
+const (
+	itemActionNone itemAction = iota
+	itemActionEdit
+	itemActionDelete
+	itemActionGoals
+)
+
+// runActionSelect shows an action selection dialog for an item and returns the chosen action.
+func runActionSelect(title string, includeGoals bool) (itemAction, error) {
+	var action string
+
+	options := []huh.Option[string]{
+		huh.NewOption("Edit", actionEdit),
+	}
+
+	if includeGoals {
+		options = append(options, huh.NewOption("Manage goals", actionGoals))
+	}
+
+	options = append(options,
+		huh.NewOption("Delete", actionDelete),
+		huh.NewOption("Back", choiceBack),
+	)
+
+	err := huh.NewSelect[string]().
+		Title(title).
+		Options(options...).
+		Value(&action).
+		Run()
+	if err != nil {
+		if errors.Is(err, huh.ErrUserAborted) {
+			return itemActionNone, errQuit
+		}
+
+		return itemActionNone, err
+	}
+
+	switch action {
+	case actionEdit:
+		return itemActionEdit, nil
+	case actionDelete:
+		return itemActionDelete, nil
+	case actionGoals:
+		return itemActionGoals, nil
+	default:
+		return itemActionNone, nil
+	}
+}
+
+func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
+	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)
+		*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)."
+
+	return huh.NewForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title("Name").
+				Description("Display name for this "+cfg.itemType+".").
+				Placeholder(cfg.namePlaceholder).
+				Value(name).
+				Validate(makeNameValidator(requireNonEmpty)),
+			huh.NewInput().
+				Title("Key").
+				Description("Short alias for reference (lowercase, no spaces).").
+				Placeholder(cfg.keyPlaceholder).
+				Value(key).
+				Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)),
+			huh.NewInput().
+				Title("Reference").
+				Description(refDescription).
+				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
+				Value(itemID).
+				Validate(makeRefValidator(requireNonEmpty)),
+			huh.NewConfirm().
+				Title("Save this "+cfg.itemType+"?").
+				Affirmative("Save").
+				Negative("Cancel").
+				Value(save),
+		),
+	)
+}
+
+func makeNameValidator(requireNonEmpty *bool) func(string) error {
+	return func(input string) error {
+		if *requireNonEmpty && input == "" {
+			return errNameRequired
+		}
+
+		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) func(string) error {
+	return func(input string) error {
+		if input == "" {
+			if *requireNonEmpty {
+				return errRefRequired
+			}
+
+			return nil
+		}
+
+		_, err := validateReference(input)
+
+		return err
+	}
+}

cmd/lunatask-mcp-server.go πŸ”—

@@ -1,481 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-// lunatask-mcp-server exposes Lunatask to LLMs via the Model Context Protocol.
-package main
-
-import (
-	"context"
-	"log"
-	"log/slog"
-	"net"
-	"os"
-	"strconv"
-
-	"github.com/BurntSushi/toml"
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"git.sr.ht/~amolith/lunatask-mcp-server/tools/areas"
-	"git.sr.ht/~amolith/lunatask-mcp-server/tools/habits"
-	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
-	"git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks"
-	"git.sr.ht/~amolith/lunatask-mcp-server/tools/timestamp"
-)
-
-// Goal represents a Lunatask goal with its name and ID.
-type Goal struct {
-	Name string `toml:"name"`
-	ID   string `toml:"id"`
-}
-
-// GetName returns the goal's name.
-func (g Goal) GetName() string { return g.Name }
-
-// GetID returns the goal's ID.
-func (g Goal) GetID() string { return g.ID }
-
-// Area represents a Lunatask area with its name, ID, and its goals.
-type Area struct {
-	Name  string `toml:"name"`
-	ID    string `toml:"id"`
-	Goals []Goal `toml:"goal"`
-}
-
-// GetName returns the area's name.
-func (a Area) GetName() string { return a.Name }
-
-// GetID returns the area's ID.
-func (a Area) GetID() string { return a.ID }
-
-// GetGoals returns the area's goals as a slice of shared.GoalProvider.
-func (a Area) GetGoals() []shared.GoalProvider {
-	providers := make([]shared.GoalProvider, len(a.Goals))
-	for i, g := range a.Goals {
-		providers[i] = g
-	}
-
-	return providers
-}
-
-// Habit represents a Lunatask habit with its name and ID.
-type Habit struct {
-	Name string `toml:"name"`
-	ID   string `toml:"id"`
-}
-
-// GetName returns the habit's name.
-func (h Habit) GetName() string { return h.Name }
-
-// GetID returns the habit's ID.
-func (h Habit) GetID() string { return h.ID }
-
-// ServerConfig holds the application's configuration loaded from TOML.
-type ServerConfig struct {
-	Host string `toml:"host"`
-	Port int    `toml:"port"`
-}
-
-// Config holds the application's configuration loaded from TOML.
-type Config struct {
-	AccessToken string       `toml:"access_token"`
-	Areas       []Area       `toml:"area"`
-	Server      ServerConfig `toml:"server"`
-	Timezone    string       `toml:"timezone"`
-	Habit       []Habit      `toml:"habit"`
-}
-
-var version = ""
-
-func main() {
-	configPath := parseArgs()
-	config := loadConfig(configPath)
-	validateConfig(&config)
-
-	mcpServer := NewMCPServer(&config)
-
-	hostPort := net.JoinHostPort(config.Server.Host, strconv.Itoa(config.Server.Port))
-	baseURL := "http://" + hostPort
-	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
-	log.Printf("SSE server listening on %s (baseURL: %s)", hostPort, baseURL)
-
-	if err := sseServer.Start(hostPort); err != nil {
-		log.Fatalf("Server error: %v", err)
-	}
-}
-
-func parseArgs() string {
-	configPath := "./config.toml"
-
-	for argIdx, arg := range os.Args {
-		switch arg {
-		case "-v", "--version":
-			if version == "" {
-				version = "unknown, build with `task build` or see Taskfile.yaml"
-			}
-
-			slog.Info("version", "name", "lunatask-mcp-server", "version", version)
-			os.Exit(0)
-		case "-c", "--config":
-			if argIdx+1 < len(os.Args) {
-				configPath = os.Args[argIdx+1]
-			}
-		}
-	}
-
-	return configPath
-}
-
-func loadConfig(configPath string) Config {
-	if _, err := os.Stat(configPath); os.IsNotExist(err) {
-		createDefaultConfigFile(configPath)
-	}
-
-	var config Config
-	if _, err := toml.DecodeFile(configPath, &config); err != nil {
-		log.Fatalf("Failed to load config file %s: %v", configPath, err)
-	}
-
-	return config
-}
-
-func validateConfig(config *Config) {
-	if config.AccessToken == "" || len(config.Areas) == 0 {
-		log.Fatalf("Config file must provide access_token and at least one area.")
-	}
-
-	for areaIdx, area := range config.Areas {
-		if area.Name == "" || area.ID == "" {
-			log.Fatalf("All areas (areas[%d]) must have both a name and id", areaIdx)
-		}
-
-		for goalIdx, goal := range area.Goals {
-			if goal.Name == "" || goal.ID == "" {
-				log.Fatalf(
-					"All goals (areas[%d].goals[%d]) must have both a name and id",
-					areaIdx,
-					goalIdx,
-				)
-			}
-		}
-	}
-
-	if _, err := shared.LoadLocation(config.Timezone); err != nil {
-		log.Fatalf("Timezone validation failed: %v", err)
-	}
-}
-
-// closeFile properly closes a file, handling any errors.
-func closeFile(f *os.File) {
-	if err := f.Close(); err != nil {
-		log.Printf("Error closing file: %v", err)
-	}
-}
-
-// NewMCPServer creates and configures the MCP server with all tools.
-func NewMCPServer(appConfig *Config) *server.MCPServer {
-	mcpServer := server.NewMCPServer(
-		"Lunatask MCP Server",
-		"0.1.0",
-		server.WithHooks(createHooks()),
-		server.WithToolCapabilities(true),
-	)
-
-	areaProviders := toAreaProviders(appConfig.Areas)
-	habitProviders := toHabitProviders(appConfig.Habit)
-
-	registerTimestampTool(mcpServer, appConfig.Timezone)
-	registerAreasTool(mcpServer, areaProviders)
-	registerTaskTools(mcpServer, appConfig.AccessToken, appConfig.Timezone, areaProviders)
-	registerHabitTools(mcpServer, appConfig.AccessToken, habitProviders)
-
-	return mcpServer
-}
-
-func createHooks() *server.Hooks {
-	hooks := &server.Hooks{}
-
-	hooks.AddBeforeAny(func(_ context.Context, id any, method mcp.MCPMethod, message any) {
-		slog.Debug("beforeAny", "method", method, "id", id, "message", message)
-	})
-	hooks.AddOnSuccess(
-		func(_ context.Context, id any, method mcp.MCPMethod, message any, result any) {
-			slog.Debug("onSuccess",
-				"method", method, "id", id, "message", message, "result", result)
-		},
-	)
-	hooks.AddOnError(
-		func(_ context.Context, id any, method mcp.MCPMethod, message any, err error) {
-			slog.Error("onError", "method", method, "id", id, "message", message, "error", err)
-		},
-	)
-	hooks.AddBeforeInitialize(func(_ context.Context, id any, message *mcp.InitializeRequest) {
-		slog.Debug("beforeInitialize", "id", id, "message", message)
-	})
-	hooks.AddAfterInitialize(
-		func(_ context.Context, id any, msg *mcp.InitializeRequest, res *mcp.InitializeResult) {
-			slog.Debug("afterInitialize", "id", id, "message", msg, "result", res)
-		},
-	)
-	hooks.AddAfterCallTool(
-		func(_ context.Context, id any, msg *mcp.CallToolRequest, res *mcp.CallToolResult) {
-			slog.Debug("afterCallTool", "id", id, "message", msg, "result", res)
-		},
-	)
-	hooks.AddBeforeCallTool(func(_ context.Context, id any, message *mcp.CallToolRequest) {
-		slog.Debug("beforeCallTool", "id", id, "message", message)
-	})
-
-	return hooks
-}
-
-func toAreaProviders(areas []Area) []shared.AreaProvider {
-	providers := make([]shared.AreaProvider, len(areas))
-	for i, area := range areas {
-		providers[i] = area
-	}
-
-	return providers
-}
-
-func toHabitProviders(habits []Habit) []shared.HabitProvider {
-	providers := make([]shared.HabitProvider, len(habits))
-	for i, habit := range habits {
-		providers[i] = habit
-	}
-
-	return providers
-}
-
-func registerTimestampTool(mcpServer *server.MCPServer, timezone string) {
-	handler := timestamp.NewHandler(timezone)
-
-	mcpServer.AddTool(
-		mcp.NewTool("get_timestamp",
-			mcp.WithDescription(timestamp.ToolDescription),
-			mcp.WithString("natural_language_date",
-				mcp.Description(timestamp.ParamNaturalLanguageDate),
-				mcp.Required(),
-			),
-		),
-		handler.Handle,
-	)
-}
-
-func registerAreasTool(mcpServer *server.MCPServer, areaProviders []shared.AreaProvider) {
-	handler := areas.NewHandler(areaProviders)
-
-	mcpServer.AddTool(
-		mcp.NewTool("list_areas_and_goals",
-			mcp.WithDescription(areas.ToolDescription),
-		),
-		handler.Handle,
-	)
-}
-
-func registerTaskTools(
-	mcpServer *server.MCPServer,
-	accessToken string,
-	timezone string,
-	areaProviders []shared.AreaProvider,
-) {
-	handler := tasks.NewHandler(accessToken, timezone, areaProviders)
-	registerCreateTaskTool(mcpServer, handler)
-	registerUpdateTaskTool(mcpServer, handler)
-	registerDeleteTaskTool(mcpServer, handler)
-}
-
-func registerCreateTaskTool(mcpServer *server.MCPServer, handler *tasks.Handler) {
-	mcpServer.AddTool(
-		mcp.NewTool("create_task",
-			mcp.WithDescription(tasks.CreateToolDescription),
-			mcp.WithString("area_id",
-				mcp.Description(tasks.ParamAreaID),
-				mcp.Required(),
-			),
-			mcp.WithString("goal_id",
-				mcp.Description(tasks.ParamGoalID),
-			),
-			mcp.WithString("name",
-				mcp.Description(tasks.ParamName),
-				mcp.Required(),
-			),
-			mcp.WithString("note",
-				mcp.Description(tasks.ParamNote),
-			),
-			mcp.WithNumber("estimate",
-				mcp.Description(tasks.ParamEstimate),
-				mcp.Min(0),
-				mcp.Max(tasks.MaxEstimate),
-			),
-			mcp.WithString("priority",
-				mcp.Description(tasks.ParamPriority),
-				mcp.Enum("lowest", "low", "neutral", "high", "highest"),
-			),
-			mcp.WithString("motivation",
-				mcp.Description(tasks.ParamMotivation),
-				mcp.Enum("must", "should", "want"),
-			),
-			mcp.WithString("eisenhower",
-				mcp.Description(tasks.ParamEisenhower),
-				mcp.Enum(
-					"both urgent and important",
-					"urgent, but not important",
-					"important, but not urgent",
-					"neither urgent nor important",
-					"uncategorised",
-				),
-			),
-			mcp.WithString("status",
-				mcp.Description(tasks.ParamStatus),
-				mcp.Enum("later", "next", "started", "waiting", "completed"),
-			),
-			mcp.WithString("scheduled_on",
-				mcp.Description(tasks.ParamScheduledOn),
-			),
-		),
-		handler.HandleCreate,
-	)
-}
-
-func registerUpdateTaskTool(mcpServer *server.MCPServer, handler *tasks.Handler) {
-	mcpServer.AddTool(
-		mcp.NewTool("update_task",
-			mcp.WithDescription(tasks.UpdateToolDescription),
-			mcp.WithString("task_id",
-				mcp.Description(tasks.ParamTaskID),
-				mcp.Required(),
-			),
-			mcp.WithString("area_id",
-				mcp.Description(tasks.ParamUpdateAreaID),
-			),
-			mcp.WithString("goal_id",
-				mcp.Description(tasks.ParamUpdateGoalID),
-			),
-			mcp.WithString("name",
-				mcp.Description(tasks.ParamUpdateName),
-				mcp.Required(),
-			),
-			mcp.WithString("note",
-				mcp.Description(tasks.ParamUpdateNote),
-			),
-			mcp.WithNumber("estimate",
-				mcp.Description(tasks.ParamUpdateEstimate),
-				mcp.Min(0),
-				mcp.Max(tasks.MaxEstimate),
-			),
-			mcp.WithString("priority",
-				mcp.Description(tasks.ParamUpdatePriority),
-				mcp.Enum("lowest", "low", "neutral", "high", "highest"),
-			),
-			mcp.WithString("motivation",
-				mcp.Description(tasks.ParamUpdateMotivation),
-				mcp.Enum("must", "should", "want", ""),
-			),
-			mcp.WithString("eisenhower",
-				mcp.Description(tasks.ParamUpdateEisenhower),
-				mcp.Enum(
-					"both urgent and important",
-					"urgent, but not important",
-					"important, but not urgent",
-					"neither urgent nor important",
-					"uncategorised",
-				),
-			),
-			mcp.WithString("status",
-				mcp.Description(tasks.ParamUpdateStatus),
-				mcp.Enum("later", "next", "started", "waiting", "completed", ""),
-			),
-			mcp.WithString("scheduled_on",
-				mcp.Description(tasks.ParamUpdateScheduledOn),
-			),
-		),
-		handler.HandleUpdate,
-	)
-}
-
-func registerDeleteTaskTool(mcpServer *server.MCPServer, handler *tasks.Handler) {
-	mcpServer.AddTool(
-		mcp.NewTool("delete_task",
-			mcp.WithDescription(tasks.DeleteToolDescription),
-			mcp.WithString("task_id",
-				mcp.Description(tasks.ParamDeleteTaskID),
-				mcp.Required(),
-			),
-		),
-		handler.HandleDelete,
-	)
-}
-
-func registerHabitTools(
-	mcpServer *server.MCPServer,
-	accessToken string,
-	habitProviders []shared.HabitProvider,
-) {
-	handler := habits.NewHandler(accessToken, habitProviders)
-
-	mcpServer.AddTool(
-		mcp.NewTool("list_habits_and_activities",
-			mcp.WithDescription(habits.ListToolDescription),
-		),
-		handler.HandleList,
-	)
-
-	mcpServer.AddTool(
-		mcp.NewTool("track_habit_activity",
-			mcp.WithDescription(habits.TrackToolDescription),
-			mcp.WithString("habit_id",
-				mcp.Description(habits.ParamHabitID),
-				mcp.Required(),
-			),
-			mcp.WithString("performed_on",
-				mcp.Description(habits.ParamPerformedOn),
-				mcp.Required(),
-			),
-		),
-		handler.HandleTrack,
-	)
-}
-
-func createDefaultConfigFile(configPath string) {
-	defaultConfig := Config{
-		Server: ServerConfig{
-			Host: "localhost",
-			Port: 8080,
-		},
-		AccessToken: "",
-		Timezone:    "UTC",
-		Areas: []Area{{
-			Name: "Example Area",
-			ID:   "area-id-placeholder",
-			Goals: []Goal{{
-				Name: "Example Goal",
-				ID:   "goal-id-placeholder",
-			}},
-		}},
-		Habit: []Habit{{
-			Name: "Example Habit",
-			ID:   "habit-id-placeholder",
-		}},
-	}
-
-	//nolint:gosec // user-provided config path is expected
-	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
-	if err != nil {
-		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
-	}
-
-	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
-		closeFile(file)
-		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
-	}
-
-	closeFile(file)
-
-	slog.Info("default config created",
-		"path", configPath,
-		"next_steps", "edit config with access token, area/goal IDs, and timezone, then restart",
-	)
-	os.Exit(1)
-}

cmd/root.go πŸ”—

@@ -0,0 +1,57 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package cmd provides the CLI commands for lunatask-mcp-server.
+package cmd
+
+import (
+	"os"
+
+	"github.com/spf13/cobra"
+
+	configcmd "git.sr.ht/~amolith/lunatask-mcp-server/cmd/config"
+)
+
+var version = "dev"
+
+// rootCmd is the base command when called without subcommands.
+//
+//nolint:exhaustruct // cobra only requires a subset of fields
+var rootCmd = &cobra.Command{
+	Use:   "lunatask-mcp-server",
+	Short: "MCP server for Lunatask",
+	Long: `lunatask-mcp-server exposes Lunatask to LLMs via the Model Context Protocol.
+
+Use 'lunatask-mcp-server serve' to start the MCP server.
+Use 'lunatask-mcp-server config' to configure the server interactively.`,
+}
+
+func init() {
+	rootCmd.AddCommand(configcmd.Cmd)
+	rootCmd.AddCommand(serveCmd)
+	rootCmd.AddCommand(versionCmd)
+}
+
+//nolint:exhaustruct // cobra only requires a subset of fields
+var versionCmd = &cobra.Command{
+	Use:   "version",
+	Short: "Print version information",
+	Run: func(cmd *cobra.Command, _ []string) {
+		_, _ = cmd.OutOrStdout().Write([]byte("lunatask-mcp-server " + version + "\n"))
+	},
+}
+
+// Execute runs the root command.
+func Execute(v string) {
+	version = v
+
+	if err := rootCmd.Execute(); err != nil {
+		os.Exit(1)
+	}
+}
+
+// SetVersion sets the version for display.
+func SetVersion(v string) {
+	version = v
+}

cmd/serve.go πŸ”—

@@ -0,0 +1,356 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cmd
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log"
+	"log/slog"
+	"net"
+	"net/http"
+	"strconv"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+	"github.com/spf13/cobra"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/client"
+	"git.sr.ht/~amolith/lunatask-mcp-server/internal/config"
+	"git.sr.ht/~amolith/lunatask-mcp-server/tools/areas"
+	"git.sr.ht/~amolith/lunatask-mcp-server/tools/habits"
+	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+	"git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks"
+	"git.sr.ht/~amolith/lunatask-mcp-server/tools/timestamp"
+)
+
+// Serve errors.
+var (
+	errNoConfig         = errors.New("config file not found; run 'lunatask-mcp-server config'")
+	errNoAreas          = errors.New("config must have at least one area; run 'lunatask-mcp-server config'")
+	errInvalidArea      = errors.New("area must have both name and id")
+	errInvalidGoal      = errors.New("goal must have both name and id")
+	errUnknownTransport = errors.New("unknown transport (valid: stdio, sse, http)")
+)
+
+// Transport modes for the MCP server.
+const (
+	TransportStdio = "stdio"
+	TransportSSE   = "sse"
+	TransportHTTP  = "http"
+)
+
+//nolint:exhaustruct // cobra only requires a subset of fields
+var serveCmd = &cobra.Command{
+	Use:   "serve",
+	Short: "Start the MCP server",
+	Long: `Start the MCP server using the configured transport mode.
+
+Transport modes:
+  stdio   Communicate over stdin/stdout (default, for CLI tools like Crush)
+  sse     Server-Sent Events over HTTP (for Home Assistant)
+  http    Streamable HTTP transport
+
+The server uses configuration from:
+  1. Config file (~/.config/lunatask-mcp-server/config.toml)
+  2. Access token from LUNATASK_ACCESS_TOKEN env var or system keyring
+
+Run 'lunatask-mcp-server config' to set up configuration interactively.`,
+	RunE: runServe,
+}
+
+var (
+	configPath string
+	transport  string
+)
+
+func init() {
+	serveCmd.Flags().StringVarP(
+		&configPath, "config", "c", "",
+		"path to config file (default: ~/.config/lunatask-mcp-server/config.toml)",
+	)
+	serveCmd.Flags().StringVarP(
+		&transport, "transport", "t", "",
+		"transport mode: stdio, sse, http (overrides config)",
+	)
+}
+
+func runServe(_ *cobra.Command, _ []string) error {
+	cfg, err := loadServerConfig()
+	if err != nil {
+		return err
+	}
+
+	accessToken, source, err := client.GetToken()
+	if err != nil {
+		return err
+	}
+
+	slog.Debug("access token loaded", "source", source.String())
+
+	if err := validateServerConfig(cfg); err != nil {
+		return err
+	}
+
+	mcpServer := newMCPServer(cfg, accessToken)
+
+	effectiveTransport := cfg.Server.Transport
+	if transport != "" {
+		effectiveTransport = transport
+	}
+
+	switch effectiveTransport {
+	case TransportStdio:
+		return runStdio(mcpServer)
+	case TransportSSE:
+		return runSSE(mcpServer, cfg)
+	case TransportHTTP:
+		return runHTTP(mcpServer, cfg)
+	default:
+		return errUnknownTransport
+	}
+}
+
+func loadServerConfig() (*config.Config, error) {
+	var cfg *config.Config
+
+	var err error
+
+	if configPath != "" {
+		cfg, err = config.LoadFrom(configPath)
+	} else {
+		cfg, err = config.Load()
+	}
+
+	if err != nil {
+		if errors.Is(err, config.ErrNotFound) {
+			return nil, errNoConfig
+		}
+
+		return nil, err
+	}
+
+	return cfg, nil
+}
+
+func validateServerConfig(cfg *config.Config) error {
+	if len(cfg.Areas) == 0 {
+		return errNoAreas
+	}
+
+	for _, area := range cfg.Areas {
+		if area.Name == "" || area.ID == "" {
+			return errInvalidArea
+		}
+
+		for _, goal := range area.Goals {
+			if goal.Name == "" || goal.ID == "" {
+				return errInvalidGoal
+			}
+		}
+	}
+
+	if _, err := shared.LoadLocation(cfg.Timezone); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server {
+	mcpServer := mcp.NewServer(
+		&mcp.Implementation{
+			Name:    "Lunatask MCP Server",
+			Version: version,
+		},
+		nil,
+	)
+
+	mcpServer.AddReceivingMiddleware(loggingMiddleware)
+
+	areaProviders := toAreaProviders(cfg.Areas)
+	habitProviders := toHabitProviders(cfg.Habits)
+
+	registerResources(mcpServer, areaProviders, habitProviders)
+	registerTimestampTool(mcpServer, cfg.Timezone)
+	registerTaskTools(mcpServer, accessToken, cfg.Timezone, areaProviders)
+	registerHabitTool(mcpServer, accessToken, habitProviders)
+
+	return mcpServer
+}
+
+func loggingMiddleware(next mcp.MethodHandler) mcp.MethodHandler {
+	return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
+		slog.Debug("request", "method", method)
+
+		result, err := next(ctx, method, req)
+		if err != nil {
+			slog.Error("request failed", "method", method, "error", err)
+		} else {
+			slog.Debug("request succeeded", "method", method)
+		}
+
+		return result, err
+	}
+}
+
+func runStdio(mcpServer *mcp.Server) error {
+	if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
+		return fmt.Errorf("stdio server error: %w", err)
+	}
+
+	return nil
+}
+
+func runSSE(mcpServer *mcp.Server, cfg *config.Config) error {
+	hostPort := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
+	handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
+		return mcpServer
+	}, nil)
+
+	log.Printf("SSE server listening on %s", hostPort)
+
+	if err := http.ListenAndServe(hostPort, handler); err != nil { //nolint:gosec // config-provided host:port
+		return fmt.Errorf("SSE server error: %w", err)
+	}
+
+	return nil
+}
+
+func runHTTP(mcpServer *mcp.Server, cfg *config.Config) error {
+	hostPort := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
+	handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
+		return mcpServer
+	}, nil)
+
+	log.Printf("HTTP server listening on %s", hostPort)
+
+	if err := http.ListenAndServe(hostPort, handler); err != nil { //nolint:gosec // config-provided host:port
+		return fmt.Errorf("HTTP server error: %w", err)
+	}
+
+	return nil
+}
+
+// configArea wraps config.Area to implement shared.AreaProvider.
+type configArea struct {
+	area config.Area
+}
+
+func (a configArea) GetName() string { return a.area.Name }
+func (a configArea) GetID() string   { return a.area.ID }
+func (a configArea) GetKey() string  { return a.area.Key }
+func (a configArea) GetGoals() []shared.GoalProvider {
+	providers := make([]shared.GoalProvider, len(a.area.Goals))
+	for i, g := range a.area.Goals {
+		providers[i] = configGoal{goal: g}
+	}
+
+	return providers
+}
+
+// configGoal wraps config.Goal to implement shared.GoalProvider.
+type configGoal struct {
+	goal config.Goal
+}
+
+func (g configGoal) GetName() string { return g.goal.Name }
+func (g configGoal) GetID() string   { return g.goal.ID }
+func (g configGoal) GetKey() string  { return g.goal.Key }
+
+// configHabit wraps config.Habit to implement shared.HabitProvider.
+type configHabit struct {
+	habit config.Habit
+}
+
+func (h configHabit) GetName() string { return h.habit.Name }
+func (h configHabit) GetID() string   { return h.habit.ID }
+func (h configHabit) GetKey() string  { return h.habit.Key }
+
+func toAreaProviders(configAreas []config.Area) []shared.AreaProvider {
+	providers := make([]shared.AreaProvider, len(configAreas))
+	for idx, area := range configAreas {
+		providers[idx] = configArea{area: area}
+	}
+
+	return providers
+}
+
+func toHabitProviders(configHabits []config.Habit) []shared.HabitProvider {
+	providers := make([]shared.HabitProvider, len(configHabits))
+	for idx, habit := range configHabits {
+		providers[idx] = configHabit{habit: habit}
+	}
+
+	return providers
+}
+
+func registerResources(
+	mcpServer *mcp.Server,
+	areaProviders []shared.AreaProvider,
+	habitProviders []shared.HabitProvider,
+) {
+	areasHandler := areas.NewHandler(areaProviders)
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "areas",
+		URI:         areas.ResourceURI,
+		Description: areas.ResourceDescription,
+		MIMEType:    "application/json",
+	}, areasHandler.HandleRead)
+
+	habitsResourceHandler := habits.NewResourceHandler(habitProviders)
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "habits",
+		URI:         habits.ResourceURI,
+		Description: habits.ResourceDescription,
+		MIMEType:    "application/json",
+	}, habitsResourceHandler.HandleRead)
+}
+
+func registerTimestampTool(mcpServer *mcp.Server, timezone string) {
+	handler := timestamp.NewHandler(timezone)
+
+	mcp.AddTool(mcpServer, &mcp.Tool{
+		Name:        "get_timestamp",
+		Description: timestamp.ToolDescription,
+	}, handler.Handle)
+}
+
+func registerTaskTools(
+	mcpServer *mcp.Server,
+	accessToken string,
+	timezone string,
+	areaProviders []shared.AreaProvider,
+) {
+	handler := tasks.NewHandler(accessToken, timezone, areaProviders)
+
+	mcp.AddTool(mcpServer, &mcp.Tool{
+		Name:        "create_task",
+		Description: tasks.CreateToolDescription,
+	}, handler.HandleCreate)
+
+	mcp.AddTool(mcpServer, &mcp.Tool{
+		Name:        "update_task",
+		Description: tasks.UpdateToolDescription,
+	}, handler.HandleUpdate)
+
+	mcp.AddTool(mcpServer, &mcp.Tool{
+		Name:        "delete_task",
+		Description: tasks.DeleteToolDescription,
+	}, handler.HandleDelete)
+}
+
+func registerHabitTool(
+	mcpServer *mcp.Server,
+	accessToken string,
+	habitProviders []shared.HabitProvider,
+) {
+	handler := habits.NewHandler(accessToken, habitProviders)
+
+	mcp.AddTool(mcpServer, &mcp.Tool{
+		Name:        "track_habit_activity",
+		Description: habits.TrackToolDescription,
+	}, handler.HandleTrack)
+}

internal/client/client.go πŸ”—

@@ -0,0 +1,139 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package client provides access token management and Lunatask API client creation.
+package client
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"runtime/debug"
+
+	"git.secluded.site/go-lunatask"
+	"github.com/zalando/go-keyring"
+)
+
+const (
+	keyringService = "lunatask-mcp-server"
+	keyringUser    = "access-token"
+
+	// EnvAccessToken is the environment variable for the access token.
+	EnvAccessToken = "LUNATASK_ACCESS_TOKEN"
+)
+
+// TokenSource indicates where the access token was loaded from.
+type TokenSource int
+
+// Token source constants.
+const (
+	TokenSourceNone    TokenSource = iota // No token found
+	TokenSourceEnv                        // From environment variable
+	TokenSourceKeyring                    // From system keyring
+)
+
+func (s TokenSource) String() string {
+	switch s {
+	case TokenSourceEnv:
+		return "environment variable"
+	case TokenSourceKeyring:
+		return "system keyring"
+	case TokenSourceNone:
+		return "none"
+	default:
+		return "none"
+	}
+}
+
+// ErrNoToken indicates no access token is available.
+var ErrNoToken = errors.New(
+	"no access token found; run 'lunatask-mcp-server config' to configure, " +
+		"or set LUNATASK_ACCESS_TOKEN",
+)
+
+// New creates a Lunatask client using the access token from available sources.
+// Token priority: environment variable > system keyring.
+func New() (*lunatask.Client, error) {
+	token, _, err := GetToken()
+	if err != nil {
+		return nil, err
+	}
+
+	return lunatask.NewClient(token, lunatask.UserAgent("lunatask-mcp-server/"+version())), nil
+}
+
+// GetToken returns the access token and its source.
+// Token priority: environment variable > system keyring.
+// Returns ErrNoToken if no token is found.
+func GetToken() (string, TokenSource, error) {
+	// 1. Environment variable (highest priority for container deployments)
+	if token := os.Getenv(EnvAccessToken); token != "" {
+		return token, TokenSourceEnv, nil
+	}
+
+	// 2. System keyring
+	token, err := keyring.Get(keyringService, keyringUser)
+	if err == nil && token != "" {
+		return token, TokenSourceKeyring, nil
+	}
+
+	if err != nil && !errors.Is(err, keyring.ErrNotFound) {
+		return "", TokenSourceNone, fmt.Errorf("accessing system keyring: %w", err)
+	}
+
+	return "", TokenSourceNone, ErrNoToken
+}
+
+// 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 HasKeyringToken() (bool, error) {
+	_, err := keyring.Get(keyringService, keyringUser)
+	if err != nil {
+		if errors.Is(err, keyring.ErrNotFound) {
+			return false, nil
+		}
+
+		return false, fmt.Errorf("accessing system keyring: %w", err)
+	}
+
+	return true, nil
+}
+
+// SetKeyringToken stores the access token in the system keyring.
+func SetKeyringToken(token string) error {
+	if err := keyring.Set(keyringService, keyringUser, token); err != nil {
+		return fmt.Errorf("saving to keyring: %w", err)
+	}
+
+	return nil
+}
+
+// DeleteKeyringToken removes the access token from the system keyring.
+func DeleteKeyringToken() error {
+	if err := keyring.Delete(keyringService, keyringUser); err != nil {
+		if errors.Is(err, keyring.ErrNotFound) {
+			return nil
+		}
+
+		return fmt.Errorf("deleting from keyring: %w", err)
+	}
+
+	return nil
+}
+
+// HasEnvToken checks if an access token is set via environment variable.
+func HasEnvToken() bool {
+	return os.Getenv(EnvAccessToken) != ""
+}
+
+// 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,260 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package config handles loading and saving lunatask-mcp-server configuration.
+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 lunatask-mcp-server configuration file structure.
+type Config struct {
+	Server   ServerConfig `toml:"server"`
+	Timezone string       `toml:"timezone"`
+	Areas    []Area       `toml:"areas"`
+	Habits   []Habit      `toml:"habits"`
+}
+
+// ServerConfig holds server-related settings.
+type ServerConfig struct {
+	Host      string `toml:"host"`
+	Port      int    `toml:"port"`
+	Transport string `toml:"transport"`
+}
+
+// Area represents a Lunatask area of life with its goals.
+type Area struct {
+	ID    string `json:"id"    toml:"id"`
+	Name  string `json:"name"  toml:"name"`
+	Key   string `json:"key"   toml:"key"`
+	Goals []Goal `json:"goals" toml:"goals"`
+}
+
+// Goal represents a goal within an area.
+type Goal struct {
+	ID   string `json:"id"   toml:"id"`
+	Name string `json:"name" toml:"name"`
+	Key  string `json:"key"  toml:"key"`
+}
+
+// Habit represents a trackable habit.
+type Habit struct {
+	ID   string `json:"id"   toml:"id"`
+	Name string `json:"name" toml:"name"`
+	Key  string `json:"key"  toml:"key"`
+}
+
+// Defaults holds default selections.
+type Defaults struct {
+	Area string `toml:"area"`
+}
+
+// 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-mcp-server", "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
+	}
+
+	return LoadFrom(path)
+}
+
+// LoadFrom reads the config from the specified path.
+func LoadFrom(path string) (*Config, error) {
+	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)
+	}
+
+	cfg.applyDefaults()
+
+	return &cfg, nil
+}
+
+// Save writes the config to the default path.
+func (c *Config) Save() error {
+	path, err := Path()
+	if err != nil {
+		return err
+	}
+
+	return c.SaveTo(path)
+}
+
+// SaveTo writes the config to the specified path.
+func (c *Config) SaveTo(path string) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return fmt.Errorf("creating config dir: %w", err)
+	}
+
+	//nolint:gosec,mnd // path is from user config dir; 0o600 is intentional
+	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
+	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
+}
+
+// AreaByID finds an area by its ID.
+func (c *Config) AreaByID(id string) *Area {
+	for i := range c.Areas {
+		if c.Areas[i].ID == id {
+			return &c.Areas[i]
+		}
+	}
+
+	return nil
+}
+
+// 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
+}
+
+// HabitByID finds a habit by its ID.
+func (c *Config) HabitByID(id string) *Habit {
+	for i := range c.Habits {
+		if c.Habits[i].ID == id {
+			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
+}
+
+// GoalByID finds a goal within this area by its ID.
+func (a *Area) GoalByID(id string) *Goal {
+	for i := range a.Goals {
+		if a.Goals[i].ID == id {
+			return &a.Goals[i]
+		}
+	}
+
+	return nil
+}
+
+// GoalMatch pairs a goal with its parent area.
+type GoalMatch struct {
+	Goal *Goal
+	Area *Area
+}
+
+// FindGoalsByKey returns all goals matching the given key across all areas.
+func (c *Config) FindGoalsByKey(key string) []GoalMatch {
+	var matches []GoalMatch
+
+	for i := range c.Areas {
+		area := &c.Areas[i]
+
+		for j := range area.Goals {
+			if area.Goals[j].Key == key {
+				matches = append(matches, GoalMatch{
+					Goal: &area.Goals[j],
+					Area: area,
+				})
+			}
+		}
+	}
+
+	return matches
+}
+
+// GoalByID finds a goal by its ID across all areas.
+func (c *Config) GoalByID(id string) *GoalMatch {
+	for i := range c.Areas {
+		area := &c.Areas[i]
+
+		for j := range area.Goals {
+			if area.Goals[j].ID == id {
+				return &GoalMatch{
+					Goal: &area.Goals[j],
+					Area: area,
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+// applyDefaults sets default values for unset fields.
+func (c *Config) applyDefaults() {
+	if c.Server.Host == "" {
+		c.Server.Host = "localhost"
+	}
+
+	if c.Server.Port == 0 {
+		c.Server.Port = 8080
+	}
+
+	if c.Server.Transport == "" {
+		c.Server.Transport = "stdio"
+	}
+
+	if c.Timezone == "" {
+		c.Timezone = "UTC"
+	}
+}

internal/ui/output.go πŸ”—

@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package ui provides terminal output utilities and styles.
+package ui
+
+import (
+	"os"
+	"strings"
+
+	"github.com/mattn/go-isatty"
+)
+
+// ColorMode represents the color output mode.
+type ColorMode int
+
+// Color mode constants controlling terminal output styling.
+const (
+	ColorAuto   ColorMode = iota // Detect from environment/TTY
+	ColorAlways                  // Force colors on
+	ColorNever                   // Force colors off
+)
+
+//nolint:gochecknoglobals // intentional global for output mode
+var colorMode = ColorAuto
+
+// SetColorMode sets the global color mode.
+func SetColorMode(mode ColorMode) {
+	colorMode = mode
+}
+
+// IsPlain returns true when output should be unstyled plain text.
+// Detection priority:
+//  1. Explicit ColorNever/ColorAlways mode
+//  2. NO_COLOR env var (non-empty = plain)
+//  3. FORCE_COLOR env var (non-empty = styled)
+//  4. TERM=dumb (plain)
+//  5. TTY detection (non-TTY = plain)
+func IsPlain() bool {
+	switch colorMode {
+	case ColorNever:
+		return true
+	case ColorAlways:
+		return false
+	case ColorAuto:
+		return detectPlain()
+	}
+
+	return detectPlain()
+}
+
+// IsInteractive returns true when running in an interactive terminal.
+// This checks TTY status regardless of color settings.
+func IsInteractive() bool {
+	return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
+}
+
+func detectPlain() bool {
+	// NO_COLOR takes precedence (https://no-color.org/)
+	if noColor := os.Getenv("NO_COLOR"); noColor != "" {
+		return true
+	}
+
+	// FORCE_COLOR overrides TTY detection
+	if forceColor := os.Getenv("FORCE_COLOR"); forceColor != "" {
+		// FORCE_COLOR=0 or FORCE_COLOR=false means no color
+		lower := strings.ToLower(forceColor)
+		if lower == "0" || lower == "false" {
+			return true
+		}
+
+		return false
+	}
+
+	// TERM=dumb indicates minimal terminal
+	if term := os.Getenv("TERM"); term == "dumb" {
+		return true
+	}
+
+	// Fall back to TTY detection
+	return !IsInteractive()
+}

internal/ui/spinner.go πŸ”—

@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package ui
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/huh/spinner"
+)
+
+// Spin executes fn while displaying a spinner with the given title.
+// Uses generics to preserve the return type of the wrapped function.
+// In non-interactive mode, the function runs directly without spinner UI.
+//
+//nolint:ireturn // generic return by design
+func Spin[T any](title string, fn func() (T, error)) (T, error) {
+	if IsPlain() {
+		return fn()
+	}
+
+	var result T
+
+	var fnErr error
+
+	spinErr := spinner.New().
+		Title(title).
+		Action(func() {
+			result, fnErr = fn()
+		}).
+		Run()
+	if spinErr != nil {
+		return result, fmt.Errorf("spinner: %w", spinErr)
+	}
+
+	return result, fnErr
+}
+
+// SpinVoid executes fn while displaying a spinner with the given title.
+// Use for functions that only return an error.
+// In non-interactive mode, the function runs directly without spinner UI.
+func SpinVoid(title string, fn func() error) error {
+	if IsPlain() {
+		return fn()
+	}
+
+	var fnErr error
+
+	spinErr := spinner.New().
+		Title(title).
+		Action(func() {
+			fnErr = fn()
+		}).
+		Run()
+	if spinErr != nil {
+		return fmt.Errorf("spinner: %w", spinErr)
+	}
+
+	return fnErr
+}

internal/ui/styles.go πŸ”—

@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package ui
+
+import "github.com/charmbracelet/lipgloss"
+
+// Style wraps lipgloss.Style to conditionally render based on output mode.
+type Style struct {
+	style lipgloss.Style
+}
+
+// Render applies the style to the given text, or returns plain text in plain mode.
+func (s Style) Render(strs ...string) string {
+	if IsPlain() {
+		result := ""
+		for _, str := range strs {
+			result += str
+		}
+
+		return result
+	}
+
+	return s.style.Render(strs...)
+}
+
+// Style returns the underlying lipgloss.Style for advanced use.
+func (s Style) Style() lipgloss.Style {
+	return s.style
+}
+
+// Terminal output styles using ANSI colors for broad compatibility.
+//
+//nolint:gochecknoglobals // intentional globals for styled output
+var (
+	Success = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("2"))} // green
+	Warning = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))} // yellow
+	Error   = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("1"))} // red
+	Bold    = Style{lipgloss.NewStyle().Bold(true)}
+)

main.go πŸ”—

@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// lunatask-mcp-server exposes Lunatask to LLMs via the Model Context Protocol.
+package main
+
+import "git.sr.ht/~amolith/lunatask-mcp-server/cmd"
+
+var version = "dev"
+
+func main() {
+	cmd.Execute(version)
+}