steps.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package config
  6
  7import (
  8	"errors"
  9	"fmt"
 10	"strconv"
 11
 12	"github.com/charmbracelet/huh"
 13	"github.com/spf13/cobra"
 14
 15	"git.secluded.site/lunatask-mcp-server/internal/config"
 16	"git.secluded.site/lunatask-mcp-server/internal/ui"
 17)
 18
 19// Port validation errors.
 20var (
 21	errPortNotNumber  = errors.New("port must be a number")
 22	errPortOutOfRange = errors.New("port must be between 1 and 65535")
 23)
 24
 25// runServerStep runs the server configuration step.
 26//
 27//nolint:cyclop,funlen // wizard flow benefits from being in one function
 28func runServerStep(cfg *config.Config) wizardNav {
 29	if cfg.Server.Host == "" {
 30		cfg.Server.Host = "localhost"
 31	}
 32
 33	if cfg.Server.Port == 0 {
 34		cfg.Server.Port = 8080
 35	}
 36
 37	if cfg.Server.Transport == "" {
 38		cfg.Server.Transport = "stdio"
 39	}
 40
 41	if cfg.Timezone == "" {
 42		cfg.Timezone = "UTC"
 43	}
 44
 45	err := huh.NewSelect[string]().
 46		Title("Transport mode").
 47		Description("How will clients connect to the server?").
 48		Options(
 49			huh.NewOption("stdio (for CLI tools like Crush, Claude Code)", "stdio"),
 50			huh.NewOption("SSE (for Home Assistant)", "sse"),
 51			huh.NewOption("HTTP (streamable HTTP transport)", "http"),
 52		).
 53		Value(&cfg.Server.Transport).
 54		Run()
 55	if err != nil {
 56		if errors.Is(err, huh.ErrUserAborted) {
 57			return navQuit
 58		}
 59
 60		return navQuit
 61	}
 62
 63	// Only ask for host/port if not using stdio
 64	if cfg.Server.Transport != "stdio" {
 65		portStr := strconv.Itoa(cfg.Server.Port)
 66
 67		err = huh.NewForm(
 68			huh.NewGroup(
 69				huh.NewInput().
 70					Title("Host").
 71					Description("Address to listen on.").
 72					Placeholder("localhost").
 73					Value(&cfg.Server.Host),
 74				huh.NewInput().
 75					Title("Port").
 76					Description("Port to listen on.").
 77					Placeholder("8080").
 78					Value(&portStr).
 79					Validate(validatePort),
 80			),
 81		).Run()
 82		if err != nil {
 83			if errors.Is(err, huh.ErrUserAborted) {
 84				return navQuit
 85			}
 86
 87			return navQuit
 88		}
 89
 90		if portStr != "" {
 91			cfg.Server.Port, _ = strconv.Atoi(portStr)
 92		}
 93	}
 94
 95	err = huh.NewInput().
 96		Title("Timezone").
 97		Description("IANA timezone for date parsing (e.g. America/New_York, Europe/London).").
 98		Placeholder("UTC").
 99		Value(&cfg.Timezone).
100		Run()
101	if err != nil {
102		if errors.Is(err, huh.ErrUserAborted) {
103			return navQuit
104		}
105
106		return navQuit
107	}
108
109	return navNext
110}
111
112// runAreasStep runs the areas configuration step.
113func runAreasStep(cfg *config.Config) wizardNav {
114	return manageAreasAsStep(cfg)
115}
116
117// runHabitsStep runs the habits configuration step.
118func runHabitsStep(cfg *config.Config) wizardNav {
119	return manageHabitsAsStep(cfg)
120}
121
122// runAccessTokenStep runs the access token configuration step.
123func runAccessTokenStep(cmd *cobra.Command) wizardNav {
124	err := configureAccessToken(cmd)
125	if errors.Is(err, errQuit) {
126		return navQuit
127	}
128
129	if err != nil {
130		out := cmd.OutOrStdout()
131		_, _ = fmt.Fprintln(out, ui.Error.Render("Token configuration failed: "+err.Error()))
132
133		var skip bool
134
135		skipErr := huh.NewConfirm().
136			Title("Skip token configuration?").
137			Description("You can configure it later with 'lunatask-mcp-server config'.").
138			Affirmative("Skip for now").
139			Negative("Go back").
140			Value(&skip).
141			Run()
142		if skipErr != nil {
143			return navQuit
144		}
145
146		if skip {
147			return navNext
148		}
149
150		return navBack
151	}
152
153	return navNext
154}
155
156// configureServer runs the server configuration from the main menu.
157func configureServer(cfg *config.Config) error {
158	nav := runServerStep(cfg)
159	if nav == navQuit {
160		return errQuit
161	}
162
163	return nil
164}
165
166func validatePort(s string) error {
167	if s == "" {
168		return nil
169	}
170
171	p, err := strconv.Atoi(s)
172	if err != nil {
173		return errPortNotNumber
174	}
175
176	if p < 1 || p > 65535 {
177		return errPortOutOfRange
178	}
179
180	return nil
181}