Detailed changes
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+ }
+}
@@ -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)
-}
@@ -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
+}
@@ -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)
+}
@@ -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
+}
@@ -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"
+ }
+}
@@ -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()
+}
@@ -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
+}
@@ -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)}
+)
@@ -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)
+}