main.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package main
  6
  7import (
  8	"context"
  9	"encoding/json"
 10	"fmt"
 11	"log"
 12	"os"
 13	"strings"
 14	"time"
 15
 16	"github.com/ijt/go-anytime"
 17
 18	"github.com/BurntSushi/toml"
 19	"github.com/mark3labs/mcp-go/mcp"
 20	"github.com/mark3labs/mcp-go/server"
 21
 22	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
 23)
 24
 25// Goal represents a Lunatask goal with its name and ID
 26type Goal struct {
 27	Name string `toml:"name"`
 28	ID   string `toml:"id"`
 29}
 30
 31// Area represents a Lunatask area with its name, ID, and its goals
 32type Area struct {
 33	Name  string `toml:"name"`
 34	ID    string `toml:"id"`
 35	Goals []Goal `toml:"goals"`
 36}
 37
 38// Config holds the application's configuration loaded from TOML
 39type ServerConfig struct {
 40	Host string `toml:"host"`
 41	Port int    `toml:"port"`
 42}
 43
 44type Config struct {
 45	AccessToken string       `toml:"access_token"`
 46	Areas       []Area       `toml:"areas"`
 47	Server      ServerConfig `toml:"server"`
 48	Timezone    string       `toml:"timezone"`
 49}
 50
 51var version = ""
 52
 53func main() {
 54	configPath := "./config.toml"
 55	for i, arg := range os.Args {
 56		switch arg {
 57		case "-v", "--version":
 58			if version == "" {
 59				version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
 60			}
 61			fmt.Println("lunatask-mcp-server:", version)
 62			os.Exit(0)
 63		case "-c", "--config":
 64			if i+1 < len(os.Args) {
 65				configPath = os.Args[i+1]
 66			}
 67		}
 68	}
 69
 70	if _, err := os.Stat(configPath); os.IsNotExist(err) {
 71		createDefaultConfigFile(configPath)
 72	}
 73
 74	var config Config
 75	if _, err := toml.DecodeFile(configPath, &config); err != nil {
 76		log.Fatalf("Failed to load config file %s: %v", configPath, err)
 77	}
 78
 79	if config.AccessToken == "" || len(config.Areas) == 0 {
 80		log.Fatalf("Config file must provide access_token and at least one area.")
 81	}
 82
 83	for i, area := range config.Areas {
 84		if area.Name == "" || area.ID == "" {
 85			log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
 86		}
 87		for j, goal := range area.Goals {
 88			if goal.Name == "" || goal.ID == "" {
 89				log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
 90			}
 91		}
 92	}
 93
 94	// Validate timezone config on startup
 95	if _, err := loadLocation(config.Timezone); err != nil {
 96		log.Fatalf("Timezone validation failed: %v", err)
 97	}
 98
 99	mcpServer := NewMCPServer(&config)
100
101	baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
102	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
103	listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
104	log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
105	if err := sseServer.Start(listenAddr); err != nil {
106		log.Fatalf("Server error: %v", err)
107	}
108}
109
110// loadLocation loads a timezone location string, returning a *time.Location or error
111func loadLocation(timezone string) (*time.Location, error) {
112	if timezone == "" {
113		return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
114	}
115	loc, err := time.LoadLocation(timezone)
116	if err != nil {
117		return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err)
118	}
119	return loc, nil
120}
121
122// closeFile properly closes a file, handling any errors
123func closeFile(f *os.File) {
124	err := f.Close()
125	if err != nil {
126		log.Printf("Error closing file: %v", err)
127	}
128}
129
130func NewMCPServer(config *Config) *server.MCPServer {
131	hooks := &server.Hooks{}
132
133	hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
134		fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
135	})
136	hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
137		fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
138	})
139	hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
140		fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
141	})
142	hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
143		fmt.Printf("beforeInitialize: %v, %v\n", id, message)
144	})
145	hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
146		fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
147	})
148	hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
149		fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
150	})
151	hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
152		fmt.Printf("beforeCallTool: %v, %v\n", id, message)
153	})
154
155	mcpServer := server.NewMCPServer(
156		"Lunatask MCP Server",
157		"0.1.0",
158		server.WithHooks(hooks),
159		server.WithToolCapabilities(true),
160	)
161
162	mcpServer.AddTool(mcp.NewTool("get_task_timestamp",
163		mcp.WithDescription("Retrieves the formatted timestamp for a task"),
164		mcp.WithString("natural_language_date",
165			mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."),
166			mcp.Required(),
167		),
168	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
169		natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
170		if !ok || natLangDate == "" {
171			return reportMCPError("Missing or invalid required argument: natural_language_date")
172		}
173		loc, err := loadLocation(config.Timezone)
174		if err != nil {
175			return reportMCPError(err.Error())
176		}
177		parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
178		if err != nil {
179			return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
180		}
181		return &mcp.CallToolResult{
182			Content: []mcp.Content{
183				mcp.TextContent{
184					Type: "text",
185					Text: parsedTime.Format(time.RFC3339),
186				},
187			},
188		}, nil
189	})
190
191	mcpServer.AddTool(
192		mcp.NewTool(
193			"list_areas_and_goals",
194			mcp.WithDescription("List areas and goals and their IDs."),
195		),
196		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
197			var b strings.Builder
198			for _, area := range config.Areas {
199				fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
200				for _, goal := range area.Goals {
201					fmt.Fprintf(&b, "  - %s: %s\n", goal.Name, goal.ID)
202				}
203			}
204			return &mcp.CallToolResult{
205				Content: []mcp.Content{
206					mcp.TextContent{
207						Type: "text",
208						Text: b.String(),
209					},
210				},
211			}, nil
212		},
213	)
214
215	mcpServer.AddTool(mcp.NewTool("create_task",
216		mcp.WithDescription("Creates a new task"),
217		mcp.WithString("area_id",
218			mcp.Description("Area ID in which to create the task"),
219			mcp.Required(),
220		),
221		mcp.WithString("goal_id",
222			mcp.Description("Goal ID, which must belong to the provided area, to associate the task with."),
223		),
224		mcp.WithString("name",
225			mcp.Description("Plain text task name using sentence case."),
226			mcp.Required(),
227		),
228		mcp.WithString("note",
229			mcp.Description("Note attached to the task, optionally Markdown-formatted"),
230		),
231		mcp.WithNumber("estimate",
232			mcp.Description("Estimated time completion time in minutes"),
233			mcp.Min(0),
234			mcp.Max(1440),
235		),
236		mcp.WithNumber("priority",
237			mcp.Description("Task priority, -2 being lowest, 0 being normal, and 2 being highest"),
238			mcp.Min(-2),
239			mcp.Max(2),
240		),
241		mcp.WithString("motivation",
242			mcp.Description("Motivation driving task creation"),
243			mcp.Enum("must", "should", "want"),
244		),
245		mcp.WithString("status",
246			mcp.Description("Task state, such as in progress, provided as 'started', already started, provided as 'started', soon, provided as 'next', blocked as waiting, omit unspecified, and so on. Intuit the task's status."),
247			mcp.Enum("later", "next", "started", "waiting", "completed"),
248		),
249		mcp.WithString("scheduled_on",
250			mcp.Description("Formatted timestamp from get_task_timestamp tool"),
251		),
252	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
253		return handleCreateTask(ctx, request, config)
254	})
255
256	mcpServer.AddTool(mcp.NewTool("update_task",
257		mcp.WithDescription("Updates an existing task. Only provided fields will be targeted for update."),
258		mcp.WithString("task_id",
259			mcp.Description("ID of the task to update."),
260			mcp.Required(),
261		),
262		mcp.WithString("area_id",
263			mcp.Description("New Area ID for the task. Must be a valid Area ID from 'list_areas_and_goals'."),
264		),
265		mcp.WithString("goal_id",
266			mcp.Description("New Goal ID for the task. Must belong to the provided 'area_id' (if 'area_id' is also being updated) or the task's current area. If 'goal_id' is specified, 'area_id' must also be specified if you intend to validate the goal against a new area, or the goal must exist in the task's current area."),
267		),
268		mcp.WithString("name",
269			mcp.Description("New plain text task name using sentence case."),
270		),
271		mcp.WithString("note",
272			mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string might clear the note."),
273		),
274		mcp.WithNumber("estimate",
275			mcp.Description("New estimated time completion time in minutes."),
276			mcp.Min(0),
277			mcp.Max(720), // Aligned with CreateTaskRequest validation tag
278		),
279		mcp.WithNumber("priority",
280			mcp.Description("New task priority, -2 being lowest, 0 being normal, and 2 being highest."),
281			mcp.Min(-2),
282			mcp.Max(2),
283		),
284		mcp.WithString("motivation",
285			mcp.Description("New motivation driving the task."),
286			mcp.Enum("must", "should", "want", ""), // Allow empty string to potentially clear/unset
287		),
288		mcp.WithString("status",
289			mcp.Description("New task state."),
290			mcp.Enum("later", "next", "started", "waiting", "completed", ""), // Allow empty string
291		),
292		mcp.WithString("scheduled_on",
293			mcp.Description("New scheduled date/time as a formatted timestamp from get_task_timestamp tool. Sending an empty string might clear the scheduled date."),
294		),
295	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
296		return handleUpdateTask(ctx, request, config)
297	})
298
299	return mcpServer
300}
301
302func reportMCPError(msg string) (*mcp.CallToolResult, error) {
303	return &mcp.CallToolResult{
304		IsError: true,
305		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
306	}, nil
307}
308
309// handleCreateTask handles the creation of a task in Lunatask
310func handleCreateTask(
311	ctx context.Context,
312	request mcp.CallToolRequest,
313	config *Config,
314) (*mcp.CallToolResult, error) {
315	arguments := request.Params.Arguments
316
317	// Validate timezone before proceeding any further
318	if _, err := loadLocation(config.Timezone); err != nil {
319		return reportMCPError(err.Error())
320	}
321
322	areaID, ok := arguments["area_id"].(string)
323	if !ok || areaID == "" {
324		return reportMCPError("Missing or invalid required argument: area_id")
325	}
326
327	var area *Area
328	for i := range config.Areas {
329		if config.Areas[i].ID == areaID {
330			area = &config.Areas[i]
331			break
332		}
333	}
334	if area == nil {
335		return reportMCPError("Area not found for given area_id")
336	}
337
338	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
339		found := false
340		for _, goal := range area.Goals {
341			if goal.ID == goalID {
342				found = true
343				break
344			}
345		}
346		if !found {
347			return reportMCPError("Goal not found in specified area for given goal_id")
348		}
349	}
350
351	if priorityVal, exists := arguments["priority"]; exists && priorityVal != nil {
352		if priority, ok := priorityVal.(float64); ok {
353			if priority < -2 || priority > 2 {
354				return reportMCPError("'priority' must be between -2 and 2 (inclusive)")
355			}
356		} else {
357			return reportMCPError("'priority' must be a number")
358		}
359	}
360
361	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
362		if motivation, ok := motivationVal.(string); ok && motivation != "" {
363			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
364			if !validMotivations[motivation] {
365				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
366			}
367		} else if ok {
368			// empty string is allowed
369		} else {
370			return reportMCPError("'motivation' must be a string")
371		}
372	}
373
374	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
375		if status, ok := statusVal.(string); ok && status != "" {
376			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
377			if !validStatus[status] {
378				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
379			}
380		} else if ok {
381			// empty string is allowed
382		} else {
383			return reportMCPError("'status' must be a string")
384		}
385	}
386
387	// Validate scheduled_on format if provided
388	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
389		if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
390			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
391				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_task_timestamp tool first.", scheduledOnStr))
392			}
393		} else if !ok {
394			// It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
395			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
396		}
397		// If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
398	}
399
400	// Create Lunatask client
401	client := lunatask.NewClient(config.AccessToken)
402
403	// Prepare the task request
404	var task lunatask.CreateTaskRequest
405	argBytes, err := json.Marshal(arguments)
406	if err != nil {
407		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
408	}
409	if err := json.Unmarshal(argBytes, &task); err != nil {
410		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
411	}
412
413	// Call the client to create the task
414	response, err := client.CreateTask(ctx, &task)
415	if err != nil {
416		return reportMCPError(fmt.Sprintf("%v", err))
417	}
418
419	// Handle the case where task already exists
420	if response == nil {
421		return &mcp.CallToolResult{
422			Content: []mcp.Content{
423				mcp.TextContent{
424					Type: "text",
425					Text: "Task already exists (not an error).",
426				},
427			},
428		}, nil
429	}
430
431	return &mcp.CallToolResult{
432		Content: []mcp.Content{
433			mcp.TextContent{
434				Type: "text",
435				Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
436			},
437		},
438	}, nil
439}
440
441// handleUpdateTask handles the update of a task in Lunatask
442func handleUpdateTask(
443	ctx context.Context,
444	request mcp.CallToolRequest,
445	config *Config,
446) (*mcp.CallToolResult, error) {
447	arguments := request.Params.Arguments
448
449	taskID, ok := arguments["task_id"].(string)
450	if !ok || taskID == "" {
451		return reportMCPError("Missing or invalid required argument: task_id")
452	}
453
454	// Validate timezone before proceeding, as it might be used by API or for scheduled_on
455	if _, err := loadLocation(config.Timezone); err != nil {
456		return reportMCPError(err.Error())
457	}
458
459	updatePayload := lunatask.CreateTaskRequest{} // Reusing CreateTaskRequest for the update body
460
461	var specifiedArea *Area // Used for goal validation if area_id is also specified
462	areaIDProvided := false
463
464	if areaIDArg, exists := arguments["area_id"]; exists {
465		if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
466			updatePayload.AreaID = areaIDStr
467			areaIDProvided = true
468			found := false
469			for i := range config.Areas {
470				if config.Areas[i].ID == areaIDStr {
471					specifiedArea = &config.Areas[i]
472					found = true
473					break
474				}
475			}
476			if !found {
477				return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
478			}
479		} else if !ok && areaIDArg != nil { // Exists but not a string
480			return reportMCPError("Invalid type for area_id argument: expected string.")
481		}
482		// If areaIDArg is an empty string or nil, it's fine, AreaID in payload will be "" (or not set if using pointers/map)
483		// With CreateTaskRequest, it will be "" if not explicitly set to a non-empty string.
484	}
485
486	if goalIDArg, exists := arguments["goal_id"]; exists {
487		if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
488			updatePayload.GoalID = goalIDStr
489			// If goal_id is specified, but area_id is not, we cannot validate the goal against a specific area from config.
490			// The API will have to handle this. For stricter local validation, one might require area_id here.
491			// For now, we proceed, assuming the API can handle it or the goal is in the task's current (unchanged) area.
492			// If area_id WAS provided, specifiedArea would be set.
493			// If area_id was NOT provided, we need to check all areas, or rely on API.
494			// Let's enforce that if goal_id is given, and area_id is also given, the goal must be in that area.
495			// If goal_id is given and area_id is NOT, we can't validate locally.
496			// The description for goal_id parameter hints at this.
497			if specifiedArea != nil { // Only validate goal if its intended area (new or existing) is known
498				foundGoal := false
499				for _, goal := range specifiedArea.Goals {
500					if goal.ID == goalIDStr {
501						foundGoal = true
502						break
503					}
504				}
505				if !foundGoal {
506					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedArea.Name, goalIDStr))
507				}
508			} else if areaIDProvided { // area_id was provided but somehow specifiedArea is nil (should be caught above)
509				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
510			}
511		} else if !ok && goalIDArg != nil {
512			return reportMCPError("Invalid type for goal_id argument: expected string.")
513		}
514	}
515
516	if nameArg, exists := arguments["name"]; exists {
517		if nameStr, ok := nameArg.(string); ok { // Allow empty string for name to potentially clear it
518			updatePayload.Name = nameStr
519		} else if !ok && nameArg != nil {
520			return reportMCPError("Invalid type for name argument: expected string.")
521		}
522	}
523
524	if noteArg, exists := arguments["note"]; exists {
525		if noteStr, ok := noteArg.(string); ok {
526			updatePayload.Note = noteStr
527		} else if !ok && noteArg != nil {
528			return reportMCPError("Invalid type for note argument: expected string.")
529		}
530	}
531
532	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
533		if estimateVal, ok := estimateArg.(float64); ok {
534			// Validation for min/max (0-720) is in CreateTaskRequest struct tags,
535			// checked by lunatask.ValidateTask.
536			// MCP tool also defines this range.
537			updatePayload.Estimate = int(estimateVal)
538		} else {
539			return reportMCPError("Invalid type for estimate argument: expected number.")
540		}
541	}
542
543	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
544		if priorityVal, ok := priorityArg.(float64); ok {
545			if priorityVal < -2 || priorityVal > 2 { // MCP tool range
546				return reportMCPError("'priority' must be between -2 and 2 (inclusive).")
547			}
548			updatePayload.Priority = int(priorityVal)
549		} else {
550			return reportMCPError("Invalid type for priority argument: expected number.")
551		}
552	}
553
554	if motivationArg, exists := arguments["motivation"]; exists {
555		if motivationStr, ok := motivationArg.(string); ok {
556			if motivationStr != "" { // Allow empty string to be passed if desired (e.g. to clear)
557				validMotivations := map[string]bool{"must": true, "should": true, "want": true}
558				if !validMotivations[motivationStr] {
559					return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
560				}
561			}
562			updatePayload.Motivation = motivationStr
563		} else if !ok && motivationArg != nil {
564			return reportMCPError("Invalid type for motivation argument: expected string.")
565		}
566	}
567
568	if statusArg, exists := arguments["status"]; exists {
569		if statusStr, ok := statusArg.(string); ok {
570			if statusStr != "" { // Allow empty string
571				validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
572				if !validStatus[statusStr] {
573					return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
574				}
575			}
576			updatePayload.Status = statusStr
577		} else if !ok && statusArg != nil {
578			return reportMCPError("Invalid type for status argument: expected string.")
579		}
580	}
581
582	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
583		if scheduledOnStr, ok := scheduledOnArg.(string); ok {
584			if scheduledOnStr != "" { // Allow empty string to potentially clear scheduled_on
585				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
586					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr))
587				}
588			}
589			updatePayload.ScheduledOn = scheduledOnStr
590		} else if !ok && scheduledOnArg != nil {
591			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
592		}
593	}
594
595	// Create Lunatask client
596	client := lunatask.NewClient(config.AccessToken)
597
598	// Call the client to update the task
599	// The updatePayload (CreateTaskRequest) will be validated by client.UpdateTask->ValidateTask
600	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
601	if err != nil {
602		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
603	}
604
605	// The API returns the updated task details.
606	// We can construct a more detailed message if needed, e.g., by marshaling response.Task to JSON.
607	return &mcp.CallToolResult{
608		Content: []mcp.Content{
609			mcp.TextContent{
610				Type: "text",
611				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
612			},
613		},
614	}, nil
615}
616
617func createDefaultConfigFile(configPath string) {
618	defaultConfig := Config{
619		Server: ServerConfig{
620			Host: "localhost",
621			Port: 8080,
622		},
623		AccessToken: "",
624		Timezone:    "UTC",
625		Areas: []Area{{
626			Name: "Example Area",
627			ID:   "area-id-placeholder",
628			Goals: []Goal{{
629				Name: "Example Goal",
630				ID:   "goal-id-placeholder",
631			}},
632		}},
633	}
634	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
635	if err != nil {
636		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
637	}
638	defer closeFile(file)
639	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
640		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
641	}
642	fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token, correct area/goal IDs, and your timezone (IANA/Olson format, e.g. 'America/New_York'), then restart the server.\n", configPath)
643	os.Exit(1)
644}