diff --git a/lunatask/habits.go b/lunatask/habits.go index 19a7f2af7683e79440e77af2861d19e270f54e59..72805c14fc9e0a4df405f9000420fda3c9abcfe5 100644 --- a/lunatask/habits.go +++ b/lunatask/habits.go @@ -3,3 +3,117 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package lunatask + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/go-playground/validator/v10" +) + +// TrackHabitActivityRequest represents the request to track a habit activity +type TrackHabitActivityRequest struct { + PerformedOn string `json:"performed_on" validate:"required,datetime"` +} + +// TrackHabitActivityResponse represents the response from Lunatask API when tracking a habit activity +type TrackHabitActivityResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// ValidateTrackHabitActivity validates the track habit activity request +func ValidateTrackHabitActivity(request *TrackHabitActivityRequest) error { + validate := validator.New() + if err := validate.Struct(request); err != nil { + var invalidValidationError *validator.InvalidValidationError + if errors.As(err, &invalidValidationError) { + return fmt.Errorf("invalid validation error: %w", err) + } + + var validationErrs validator.ValidationErrors + if errors.As(err, &validationErrs) { + var msgBuilder strings.Builder + msgBuilder.WriteString("habit activity validation failed:") + for _, e := range validationErrs { + fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value()) + } + return errors.New(msgBuilder.String()) + } + return fmt.Errorf("validation error: %w", err) + } + return nil +} + +// TrackHabitActivity tracks an activity for a habit in Lunatask +func (c *Client) TrackHabitActivity(ctx context.Context, habitID string, request *TrackHabitActivityRequest) (*TrackHabitActivityResponse, error) { + if habitID == "" { + return nil, errors.New("habit ID cannot be empty") + } + + // Validate the request + if err := ValidateTrackHabitActivity(request); err != nil { + return nil, err + } + + // Marshal the request to JSON + payloadBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + // Create the request + req, err := http.NewRequestWithContext( + ctx, + "POST", + fmt.Sprintf("%s/habits/%s/track", c.BaseURL, habitID), + bytes.NewBuffer(payloadBytes), + ) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "bearer "+c.AccessToken) + + // Send the request + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send HTTP request: %w", err) + } + defer func() { + if resp.Body != nil { + if err := resp.Body.Close(); err != nil { + // We're in a defer, so we can only log the error + fmt.Printf("Error closing response body: %v\n", err) + } + } + }() + + // Handle error status codes + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + // Read and parse the response + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var response TrackHabitActivityResponse + err = json.Unmarshal(respBody, &response) + if err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} diff --git a/main.go b/main.go index 71b8d312e6897eefdf791f043189673032b88be2..b519a671183d70a2ad9daefb4c21e21942f42923 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,11 @@ type Area struct { Goals []Goal `toml:"goals"` } +type Habit struct { + Name string `toml:"name"` + ID string `toml:"id"` +} + // Config holds the application's configuration loaded from TOML type ServerConfig struct { Host string `toml:"host"` @@ -46,6 +51,7 @@ type Config struct { Areas []Area `toml:"areas"` Server ServerConfig `toml:"server"` Timezone string `toml:"timezone"` + Habits []Habit `toml:"habits"` } var version = "" @@ -159,10 +165,10 @@ func NewMCPServer(config *Config) *server.MCPServer { server.WithToolCapabilities(true), ) - mcpServer.AddTool(mcp.NewTool("get_task_timestamp", + mcpServer.AddTool(mcp.NewTool("get_timestamp", mcp.WithDescription("Retrieves the formatted timestamp for a task"), mcp.WithString("natural_language_date", - mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."), + mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', 'now', etc."), mcp.Required(), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -212,6 +218,27 @@ func NewMCPServer(config *Config) *server.MCPServer { }, ) + mcpServer.AddTool( + mcp.NewTool( + "list_habits", + mcp.WithDescription("List habits and their IDs."), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var b strings.Builder + for _, habit := range config.Habits { + fmt.Fprintf(&b, "- %s: %s\n", habit.Name, habit.ID) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: b.String(), + }, + }, + }, nil + }, + ) + mcpServer.AddTool(mcp.NewTool("create_task", mcp.WithDescription("Creates a new task"), mcp.WithString("area_id", @@ -263,14 +290,14 @@ func NewMCPServer(config *Config) *server.MCPServer { mcp.Description("New Area ID for the task. Must be a valid Area ID from 'list_areas_and_goals'."), ), mcp.WithString("goal_id", - 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."), + mcp.Description("New Goal ID for the task. Must be a valid Goal ID from 'list_areas_and_goals'."), ), mcp.WithString("name", - mcp.Description("New plain text task name using sentence case."), + mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."), mcp.Required(), ), mcp.WithString("note", - mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string might clear the note."), + mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string WILL clear the note."), ), mcp.WithNumber("estimate", mcp.Description("New estimated time completion time in minutes."), @@ -307,6 +334,20 @@ func NewMCPServer(config *Config) *server.MCPServer { return handleDeleteTask(ctx, request, config) }) + mcpServer.AddTool(mcp.NewTool("track_habit_activity", + mcp.WithDescription("Tracks an activity for a habit in Lunatask"), + mcp.WithString("habit_id", + mcp.Description("ID of the habit to track activity for."), + mcp.Required(), + ), + mcp.WithString("performed_on", + mcp.Description("The timestamp the habit was performed, first obtained with get_timestamp."), + mcp.Required(), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleTrackHabitActivity(ctx, request, config) + }) + return mcpServer } @@ -634,22 +675,21 @@ func handleDeleteTask( request mcp.CallToolRequest, config *Config, ) (*mcp.CallToolResult, error) { - arguments := request.Params.Arguments - - taskID, ok := arguments["task_id"].(string) + taskID, ok := request.Params.Arguments["task_id"].(string) if !ok || taskID == "" { return reportMCPError("Missing or invalid required argument: task_id") } - // Create Lunatask client + // Create the Lunatask client client := lunatask.NewClient(config.AccessToken) - // Call the client to delete the task + // Delete the task _, err := client.DeleteTask(ctx, taskID) if err != nil { return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err)) } + // Return success response return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ @@ -660,6 +700,47 @@ func handleDeleteTask( }, nil } +// handleTrackHabitActivity handles tracking a habit activity in Lunatask +func handleTrackHabitActivity( + ctx context.Context, + request mcp.CallToolRequest, + config *Config, +) (*mcp.CallToolResult, error) { + habitID, ok := request.Params.Arguments["habit_id"].(string) + if !ok || habitID == "" { + return reportMCPError("Missing or invalid required argument: habit_id") + } + + performedOn, ok := request.Params.Arguments["performed_on"].(string) + if !ok || performedOn == "" { + return reportMCPError("Missing or invalid required argument: performed_on") + } + + // Create the Lunatask client + client := lunatask.NewClient(config.AccessToken) + + // Create the request + habitRequest := &lunatask.TrackHabitActivityRequest{ + PerformedOn: performedOn, + } + + // Track the habit activity + resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest) + if err != nil { + return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err)) + } + + // Return success response + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Habit activity tracked successfully. Status: %s, Message: %s", resp.Status, resp.Message), + }, + }, + }, nil +} + func createDefaultConfigFile(configPath string) { defaultConfig := Config{ Server: ServerConfig{ @@ -676,6 +757,10 @@ func createDefaultConfigFile(configPath string) { ID: "goal-id-placeholder", }}, }}, + Habits: []Habit{{ + Name: "Example Habit", + ID: "habit-id-placeholder", + }}, } file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil {