config.go

  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}