feat(habits)!: add habit tracking and listing capabilities

Amolith created

This commit introduces functionality to track and list habits
via the Lunatask API and new MCP tools.

Key changes include:
- `lunatask.TrackHabitActivity`: New client method and associated types
  for tracking habit completions. Includes request validation.
- `list_habits` MCP tool: Lists configured habits and their IDs.
- `track_habit_activity` MCP tool: Allows tracking of habit activity
  using a habit ID and a timestamp (obtained via `get_timestamp`).
- Configuration: Added `Habits` section to `config.toml` for defining
  habits.

Tooling improvements:
- Renamed `get_task_timestamp` MCP tool to `get_timestamp`.
- Clarified descriptions for parameters in `create_task` and
  `update_task` MCP tools.

BREAKING CHANGE: The MCP tool `get_task_timestamp` has been renamed to
`get_timestamp`. Clients using the old tool name need to reload their
integration.

Change summary

lunatask/habits.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++++
main.go            | 105 ++++++++++++++++++++++++++++++++++++++++----
2 files changed, 209 insertions(+), 10 deletions(-)

Detailed changes

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
+}

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 {