1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package config provides the interactive setup wizard for lunatask-mcp-server.
6package config
7
8import (
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "path/filepath"
14
15 "github.com/charmbracelet/huh"
16 "github.com/spf13/cobra"
17
18 "git.secluded.site/lunatask-mcp-server/internal/config"
19 "git.secluded.site/lunatask-mcp-server/internal/ui"
20)
21
22// errQuit signals that the user wants to exit the wizard entirely.
23var errQuit = errors.New("user quit")
24
25// errReset signals that the user reset configuration and should run fresh setup.
26var errReset = errors.New("configuration reset")
27
28// errNonInteractive signals that config was run without a terminal.
29var errNonInteractive = errors.New("non-interactive terminal")
30
31// errConfigExists signals that a config file already exists.
32var errConfigExists = errors.New("config already exists")
33
34// wizardNav represents navigation direction in the wizard.
35type wizardNav int
36
37const (
38 navNext wizardNav = iota
39 navBack
40 navQuit
41)
42
43// Cmd is the config command for interactive setup.
44//
45//nolint:exhaustruct // cobra only requires a subset of fields
46var Cmd = &cobra.Command{
47 Use: "config",
48 Short: "Interactive setup wizard",
49 Long: `Configure lunatask-mcp-server interactively.
50
51This command will guide you through:
52 - Adding areas and goals from Lunatask
53 - Adding habits from Lunatask
54 - Configuring server settings (host, port, transport)
55 - Configuring your access token
56
57Use --generate-config to create an example config file for manual editing
58when running non-interactively.`,
59 RunE: runConfig,
60}
61
62func init() {
63 Cmd.Flags().Bool("generate-config", false, "generate example config for manual editing")
64}
65
66func runConfig(cmd *cobra.Command, _ []string) error {
67 generateConfig, _ := cmd.Flags().GetBool("generate-config")
68 if generateConfig {
69 return runGenerateConfig(cmd)
70 }
71
72 if !ui.IsInteractive() {
73 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("config requires an interactive terminal."))
74 _, _ = fmt.Fprintln(cmd.ErrOrStderr())
75 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "To configure non-interactively, run:")
76 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), " lunatask-mcp-server config --generate-config")
77 _, _ = fmt.Fprintln(cmd.ErrOrStderr())
78 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Then edit the generated config file manually.")
79
80 return errNonInteractive
81 }
82
83 cfg, err := config.Load()
84 if errors.Is(err, config.ErrNotFound) {
85 cfg = &config.Config{} //nolint:exhaustruct // fresh config, defaults applied later
86
87 err = runFreshSetup(cmd, cfg)
88 if errors.Is(err, errQuit) {
89 _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.Warning.Render("Setup cancelled."))
90
91 return nil
92 }
93
94 return err
95 }
96
97 if err != nil {
98 return fmt.Errorf("loading config: %w", err)
99 }
100
101 err = runReconfigure(cmd, cfg)
102 if errors.Is(err, errQuit) {
103 return nil
104 }
105
106 if errors.Is(err, errReset) {
107 return runFreshSetup(cmd, cfg)
108 }
109
110 return err
111}
112
113// wizardStep is a function that runs a wizard step and returns navigation direction.
114type wizardStep func() wizardNav
115
116func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
117 printWelcome(cmd)
118
119 steps := []wizardStep{
120 func() wizardNav { return runServerStep(cfg) },
121 func() wizardNav { return runToolsStep(cfg) },
122 func() wizardNav { return runAreasStep(cfg) },
123 func() wizardNav { return runHabitsStep(cfg) },
124 func() wizardNav { return runAccessTokenStep(cmd) },
125 }
126
127 step := 0
128 for step < len(steps) {
129 nav := steps[step]()
130
131 switch nav {
132 case navNext:
133 step++
134 case navBack:
135 if step > 0 {
136 step--
137 }
138 case navQuit:
139 return errQuit
140 }
141 }
142
143 return saveWithSummary(cmd, cfg)
144}
145
146func printWelcome(cmd *cobra.Command) {
147 out := cmd.OutOrStdout()
148 _, _ = fmt.Fprintln(out, ui.Bold.Render("Welcome to lunatask-mcp-server!"))
149 _, _ = fmt.Fprintln(out)
150 _, _ = fmt.Fprintln(out, "This wizard will help you configure the MCP server for Lunatask.")
151 _, _ = fmt.Fprintln(out)
152 _, _ = fmt.Fprintln(out, "Since Lunatask is end-to-end encrypted, the server can't fetch your")
153 _, _ = fmt.Fprintln(out, "areas, goals, or habits automatically. You'll need to copy IDs from")
154 _, _ = fmt.Fprintln(out, "the Lunatask app.")
155 _, _ = fmt.Fprintln(out)
156 _, _ = fmt.Fprintln(out, ui.Bold.Render("Where to find IDs:"))
157 _, _ = fmt.Fprintln(out, " Open any item's settings modal → click 'Copy [Item] ID' (bottom left)")
158 _, _ = fmt.Fprintln(out)
159 _, _ = fmt.Fprintln(out, "You can run 'lunatask-mcp-server config' again anytime to modify settings.")
160 _, _ = fmt.Fprintln(out)
161}
162
163func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
164 _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.Bold.Render("lunatask-mcp-server configuration"))
165 _, _ = fmt.Fprintln(cmd.OutOrStdout())
166
167 if err := ensureAccessToken(cmd); err != nil {
168 return err
169 }
170
171 handlers := map[string]func() error{
172 "areas": func() error { return manageAreas(cfg) },
173 "habits": func() error { return manageHabits(cfg) },
174 "server": func() error { return configureServer(cfg) },
175 "tools": func() error { return configureTools(cfg) },
176 "token": func() error { return configureAccessToken(cmd) },
177 "reset": func() error { return resetConfig(cmd, cfg) },
178 }
179
180 for {
181 var choice string
182
183 err := huh.NewSelect[string]().
184 Title("What would you like to configure?").
185 Options(
186 huh.NewOption("Manage areas & goals", "areas"),
187 huh.NewOption("Manage habits", "habits"),
188 huh.NewOption("Server settings", "server"),
189 huh.NewOption("Enabled tools", "tools"),
190 huh.NewOption("Access token", "token"),
191 huh.NewOption("Reset all configuration", "reset"),
192 huh.NewOption("Done", choiceDone),
193 ).
194 Value(&choice).
195 Run()
196 if err != nil {
197 if errors.Is(err, huh.ErrUserAborted) {
198 return errQuit
199 }
200
201 return err
202 }
203
204 if choice == choiceDone {
205 return saveWithSummary(cmd, cfg)
206 }
207
208 if handler, ok := handlers[choice]; ok {
209 if err := handler(); err != nil {
210 return err
211 }
212 }
213 }
214}
215
216func printConfigSummary(out io.Writer, cfg *config.Config) {
217 goalCount := 0
218 for _, area := range cfg.Areas {
219 goalCount += len(area.Goals)
220 }
221
222 _, _ = fmt.Fprintln(out)
223 _, _ = fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
224 _, _ = fmt.Fprintf(out, " Areas: %d (%d goals)\n", len(cfg.Areas), goalCount)
225 _, _ = fmt.Fprintf(out, " Habits: %d\n", len(cfg.Habits))
226 _, _ = fmt.Fprintf(out, " Tools: %s\n", formatToolsSummary(&cfg.Tools))
227 _, _ = fmt.Fprintf(out, " Server: %s:%d (%s)\n", cfg.Server.Host, cfg.Server.Port, cfg.Server.Transport)
228 _, _ = fmt.Fprintf(out, " Timezone: %s\n", cfg.Timezone)
229 _, _ = fmt.Fprintln(out)
230}
231
232func formatToolsSummary(tools *config.ToolsConfig) string {
233 enabled := 0
234 total := 5
235
236 if tools.GetTimestamp {
237 enabled++
238 }
239
240 if tools.CreateTask {
241 enabled++
242 }
243
244 if tools.UpdateTask {
245 enabled++
246 }
247
248 if tools.DeleteTask {
249 enabled++
250 }
251
252 if tools.TrackHabitActivity {
253 enabled++
254 }
255
256 return fmt.Sprintf("%d/%d enabled", enabled, total)
257}
258
259func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
260 path, err := config.Path()
261 if err != nil {
262 return err
263 }
264
265 out := cmd.OutOrStdout()
266 printConfigSummary(out, cfg)
267
268 var save bool
269
270 err = huh.NewConfirm().
271 Title("Save configuration?").
272 Affirmative("Save").
273 Negative("Discard").
274 Value(&save).
275 Run()
276 if err != nil {
277 if errors.Is(err, huh.ErrUserAborted) {
278 _, _ = fmt.Fprintln(out, ui.Warning.Render("Setup cancelled; configuration was not saved."))
279
280 return errQuit
281 }
282
283 return err
284 }
285
286 if save {
287 if err := cfg.Save(); err != nil {
288 return err
289 }
290
291 _, _ = fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
292 } else {
293 _, _ = fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
294 }
295
296 return nil
297}
298
299// exampleConfig is a commented TOML config for manual editing.
300const exampleConfig = `# lunatask-mcp-server configuration file
301# See: https://git.secluded.site/lunatask-mcp-server for documentation
302
303# Server settings
304[server]
305host = "localhost"
306port = 8080
307# Transport mode: "stdio" (default), "sse", or "http"
308transport = "stdio"
309
310# Timezone for date parsing (IANA format)
311timezone = "UTC"
312
313# Enable or disable individual MCP tools (all enabled by default)
314[tools]
315get_timestamp = true
316create_task = true
317update_task = true
318delete_task = true
319track_habit_activity = true
320
321# Areas of life from Lunatask
322# Find IDs in Lunatask: Open area settings → "Copy Area ID" (bottom left)
323#
324# [[areas]]
325# id = "00000000-0000-0000-0000-000000000000"
326# name = "Work"
327# key = "work"
328#
329# # Goals within this area
330# [[areas.goals]]
331# id = "00000000-0000-0000-0000-000000000001"
332# name = "Q1 Project"
333# key = "q1-project"
334
335# Habits to track
336# Find IDs in Lunatask: Open habit settings → "Copy Habit ID"
337#
338# [[habits]]
339# id = "00000000-0000-0000-0000-000000000000"
340# name = "Exercise"
341# key = "exercise"
342
343# Access token: Set via environment variable LUNATASK_ACCESS_TOKEN
344# or store in system keyring using: lunatask-mcp-server config
345`
346
347func runGenerateConfig(cmd *cobra.Command) error {
348 cfgPath, err := config.Path()
349 if err != nil {
350 return err
351 }
352
353 if _, err := os.Stat(cfgPath); err == nil {
354 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config already exists: "+cfgPath))
355 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Remove or rename it first, or edit it directly.")
356
357 return errConfigExists
358 }
359
360 dir := filepath.Dir(cfgPath)
361 if err := os.MkdirAll(dir, 0o700); err != nil {
362 return fmt.Errorf("creating config directory: %w", err)
363 }
364
365 if err := os.WriteFile(cfgPath, []byte(exampleConfig), 0o600); err != nil {
366 return fmt.Errorf("writing config: %w", err)
367 }
368
369 _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Generated example config: "+cfgPath))
370 _, _ = fmt.Fprintln(cmd.OutOrStdout())
371 _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Edit this file to add your Lunatask areas and habits.")
372 _, _ = fmt.Fprintln(cmd.OutOrStdout())
373 _, _ = fmt.Fprintln(cmd.OutOrStdout(), "To set your access token:")
374 _, _ = fmt.Fprintln(cmd.OutOrStdout(), " • Environment variable: export LUNATASK_ACCESS_TOKEN=your-token")
375 _, _ = fmt.Fprintln(cmd.OutOrStdout(), " • System keyring: lunatask-mcp-server config (interactive)")
376
377 return nil
378}