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			mcp.Required(),
271		),
272		mcp.WithString("note",
273			mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string might clear the note."),
274		),
275		mcp.WithNumber("estimate",
276			mcp.Description("New estimated time completion time in minutes."),
277			mcp.Min(0),
278			mcp.Max(720), // Aligned with CreateTaskRequest validation tag
279		),
280		mcp.WithNumber("priority",
281			mcp.Description("New task priority, -2 being lowest, 0 being normal, and 2 being highest."),
282			mcp.Min(-2),
283			mcp.Max(2),
284		),
285		mcp.WithString("motivation",
286			mcp.Description("New motivation driving the task."),
287			mcp.Enum("must", "should", "want", ""), // Allow empty string to potentially clear/unset
288		),
289		mcp.WithString("status",
290			mcp.Description("New task state."),
291			mcp.Enum("later", "next", "started", "waiting", "completed", ""), // Allow empty string
292		),
293		mcp.WithString("scheduled_on",
294			mcp.Description("New scheduled date/time as a formatted timestamp from get_task_timestamp tool. Sending an empty string might clear the scheduled date."),
295		),
296	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
297		return handleUpdateTask(ctx, request, config)
298	})
299
300	return mcpServer
301}
302
303func reportMCPError(msg string) (*mcp.CallToolResult, error) {
304	return &mcp.CallToolResult{
305		IsError: true,
306		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
307	}, nil
308}
309
310// handleCreateTask handles the creation of a task in Lunatask
311func handleCreateTask(
312	ctx context.Context,
313	request mcp.CallToolRequest,
314	config *Config,
315) (*mcp.CallToolResult, error) {
316	arguments := request.Params.Arguments
317
318	// Validate timezone before proceeding any further
319	if _, err := loadLocation(config.Timezone); err != nil {
320		return reportMCPError(err.Error())
321	}
322
323	areaID, ok := arguments["area_id"].(string)
324	if !ok || areaID == "" {
325		return reportMCPError("Missing or invalid required argument: area_id")
326	}
327
328	var area *Area
329	for i := range config.Areas {
330		if config.Areas[i].ID == areaID {
331			area = &config.Areas[i]
332			break
333		}
334	}
335	if area == nil {
336		return reportMCPError("Area not found for given area_id")
337	}
338
339	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
340		found := false
341		for _, goal := range area.Goals {
342			if goal.ID == goalID {
343				found = true
344				break
345			}
346		}
347		if !found {
348			return reportMCPError("Goal not found in specified area for given goal_id")
349		}
350	}
351
352	if priorityVal, exists := arguments["priority"]; exists && priorityVal != nil {
353		if priority, ok := priorityVal.(float64); ok {
354			if priority < -2 || priority > 2 {
355				return reportMCPError("'priority' must be between -2 and 2 (inclusive)")
356			}
357		} else {
358			return reportMCPError("'priority' must be a number")
359		}
360	}
361
362	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
363		if motivation, ok := motivationVal.(string); ok && motivation != "" {
364			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
365			if !validMotivations[motivation] {
366				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
367			}
368		} else if ok {
369			// empty string is allowed
370		} else {
371			return reportMCPError("'motivation' must be a string")
372		}
373	}
374
375	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
376		if status, ok := statusVal.(string); ok && status != "" {
377			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
378			if !validStatus[status] {
379				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
380			}
381		} else if ok {
382			// empty string is allowed
383		} else {
384			return reportMCPError("'status' must be a string")
385		}
386	}
387
388	// Validate scheduled_on format if provided
389	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
390		if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
391			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
392				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))
393			}
394		} else if !ok {
395			// It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
396			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
397		}
398		// If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
399	}
400
401	// Create Lunatask client
402	client := lunatask.NewClient(config.AccessToken)
403
404	// Prepare the task request
405	var task lunatask.CreateTaskRequest
406	argBytes, err := json.Marshal(arguments)
407	if err != nil {
408		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
409	}
410	if err := json.Unmarshal(argBytes, &task); err != nil {
411		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
412	}
413
414	// Call the client to create the task
415	response, err := client.CreateTask(ctx, &task)
416	if err != nil {
417		return reportMCPError(fmt.Sprintf("%v", err))
418	}
419
420	// Handle the case where task already exists
421	if response == nil {
422		return &mcp.CallToolResult{
423			Content: []mcp.Content{
424				mcp.TextContent{
425					Type: "text",
426					Text: "Task already exists (not an error).",
427				},
428			},
429		}, nil
430	}
431
432	return &mcp.CallToolResult{
433		Content: []mcp.Content{
434			mcp.TextContent{
435				Type: "text",
436				Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
437			},
438		},
439	}, nil
440}
441
442// handleUpdateTask handles the update of a task in Lunatask
443func handleUpdateTask(
444	ctx context.Context,
445	request mcp.CallToolRequest,
446	config *Config,
447) (*mcp.CallToolResult, error) {
448	arguments := request.Params.Arguments
449
450	taskID, ok := arguments["task_id"].(string)
451	if !ok || taskID == "" {
452		return reportMCPError("Missing or invalid required argument: task_id")
453	}
454
455	// Validate timezone before proceeding, as it might be used by API or for scheduled_on
456	if _, err := loadLocation(config.Timezone); err != nil {
457		return reportMCPError(err.Error())
458	}
459
460	updatePayload := lunatask.CreateTaskRequest{} // Reusing CreateTaskRequest for the update body
461
462	var specifiedArea *Area // Used for goal validation if area_id is also specified
463	areaIDProvided := false
464
465	if areaIDArg, exists := arguments["area_id"]; exists {
466		if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
467			updatePayload.AreaID = areaIDStr
468			areaIDProvided = true
469			found := false
470			for i := range config.Areas {
471				if config.Areas[i].ID == areaIDStr {
472					specifiedArea = &config.Areas[i]
473					found = true
474					break
475				}
476			}
477			if !found {
478				return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
479			}
480		} else if !ok && areaIDArg != nil { // Exists but not a string
481			return reportMCPError("Invalid type for area_id argument: expected string.")
482		}
483		// If areaIDArg is an empty string or nil, it's fine, AreaID in payload will be "" (or not set if using pointers/map)
484		// With CreateTaskRequest, it will be "" if not explicitly set to a non-empty string.
485	}
486
487	if goalIDArg, exists := arguments["goal_id"]; exists {
488		if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
489			updatePayload.GoalID = goalIDStr
490			// If goal_id is specified, but area_id is not, we cannot validate the goal against a specific area from config.
491			// The API will have to handle this. For stricter local validation, one might require area_id here.
492			// For now, we proceed, assuming the API can handle it or the goal is in the task's current (unchanged) area.
493			// If area_id WAS provided, specifiedArea would be set.
494			// If area_id was NOT provided, we need to check all areas, or rely on API.
495			// Let's enforce that if goal_id is given, and area_id is also given, the goal must be in that area.
496			// If goal_id is given and area_id is NOT, we can't validate locally.
497			// The description for goal_id parameter hints at this.
498			if specifiedArea != nil { // Only validate goal if its intended area (new or existing) is known
499				foundGoal := false
500				for _, goal := range specifiedArea.Goals {
501					if goal.ID == goalIDStr {
502						foundGoal = true
503						break
504					}
505				}
506				if !foundGoal {
507					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedArea.Name, goalIDStr))
508				}
509			} else if areaIDProvided { // area_id was provided but somehow specifiedArea is nil (should be caught above)
510				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
511			}
512		} else if !ok && goalIDArg != nil {
513			return reportMCPError("Invalid type for goal_id argument: expected string.")
514		}
515	}
516
517	// Name is now required by the MCP tool definition.
518	// The lunatask.ValidateTask (called by client.UpdateTask) will ensure it's not an empty string
519	// due to the "required" tag on CreateTaskRequest.Name.
520	nameArg := arguments["name"] // MCP framework ensures "name" exists.
521	if nameStr, ok := nameArg.(string); ok {
522		updatePayload.Name = nameStr
523	} else {
524		// This case should ideally be caught by MCP's type checking.
525		// A defensive check is good.
526		return reportMCPError("Invalid type for name argument: expected string.")
527	}
528
529	if noteArg, exists := arguments["note"]; exists {
530		if noteStr, ok := noteArg.(string); ok {
531			updatePayload.Note = noteStr
532		} else if !ok && noteArg != nil {
533			return reportMCPError("Invalid type for note argument: expected string.")
534		}
535	}
536
537	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
538		if estimateVal, ok := estimateArg.(float64); ok {
539			// Validation for min/max (0-720) is in CreateTaskRequest struct tags,
540			// checked by lunatask.ValidateTask.
541			// MCP tool also defines this range.
542			updatePayload.Estimate = int(estimateVal)
543		} else {
544			return reportMCPError("Invalid type for estimate argument: expected number.")
545		}
546	}
547
548	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
549		if priorityVal, ok := priorityArg.(float64); ok {
550			if priorityVal < -2 || priorityVal > 2 { // MCP tool range
551				return reportMCPError("'priority' must be between -2 and 2 (inclusive).")
552			}
553			updatePayload.Priority = int(priorityVal)
554		} else {
555			return reportMCPError("Invalid type for priority argument: expected number.")
556		}
557	}
558
559	if motivationArg, exists := arguments["motivation"]; exists {
560		if motivationStr, ok := motivationArg.(string); ok {
561			if motivationStr != "" { // Allow empty string to be passed if desired (e.g. to clear)
562				validMotivations := map[string]bool{"must": true, "should": true, "want": true}
563				if !validMotivations[motivationStr] {
564					return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
565				}
566			}
567			updatePayload.Motivation = motivationStr
568		} else if !ok && motivationArg != nil {
569			return reportMCPError("Invalid type for motivation argument: expected string.")
570		}
571	}
572
573	if statusArg, exists := arguments["status"]; exists {
574		if statusStr, ok := statusArg.(string); ok {
575			if statusStr != "" { // Allow empty string
576				validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
577				if !validStatus[statusStr] {
578					return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
579				}
580			}
581			updatePayload.Status = statusStr
582		} else if !ok && statusArg != nil {
583			return reportMCPError("Invalid type for status argument: expected string.")
584		}
585	}
586
587	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
588		if scheduledOnStr, ok := scheduledOnArg.(string); ok {
589			if scheduledOnStr != "" { // Allow empty string to potentially clear scheduled_on
590				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
591					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr))
592				}
593			}
594			updatePayload.ScheduledOn = scheduledOnStr
595		} else if !ok && scheduledOnArg != nil {
596			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
597		}
598	}
599
600	// Create Lunatask client
601	client := lunatask.NewClient(config.AccessToken)
602
603	// Call the client to update the task
604	// The updatePayload (CreateTaskRequest) will be validated by client.UpdateTask->ValidateTask
605	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
606	if err != nil {
607		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
608	}
609
610	// The API returns the updated task details.
611	// We can construct a more detailed message if needed, e.g., by marshaling response.Task to JSON.
612	return &mcp.CallToolResult{
613		Content: []mcp.Content{
614			mcp.TextContent{
615				Type: "text",
616				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
617			},
618		},
619	}, nil
620}
621
622func createDefaultConfigFile(configPath string) {
623	defaultConfig := Config{
624		Server: ServerConfig{
625			Host: "localhost",
626			Port: 8080,
627		},
628		AccessToken: "",
629		Timezone:    "UTC",
630		Areas: []Area{{
631			Name: "Example Area",
632			ID:   "area-id-placeholder",
633			Goals: []Goal{{
634				Name: "Example Goal",
635				ID:   "goal-id-placeholder",
636			}},
637		}},
638	}
639	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
640	if err != nil {
641		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
642	}
643	defer closeFile(file)
644	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
645		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
646	}
647	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)
648	os.Exit(1)
649}