Detailed changes
@@ -148,6 +148,9 @@ linters:
- dupl # Builder types differ but share method signatures
- path: cmd/
text: unused-parameter # Cobra callback signatures can't be changed
+ - path: internal/mcp/
+ linters:
+ - nilerr # MCP handlers return errors in result, not as Go error
- path: internal/ui/
linters:
- gochecknoglobals # Style constants are package-level vars
@@ -166,7 +166,7 @@ func validateReference(input string, _ bool) (string, error) {
return "", errRefRequired
}
- _, id, err := lunatask.ParseDeepLink(input)
+ _, id, err := lunatask.ParseReference(input)
if err != nil {
return "", errRefFormat
}
@@ -158,43 +158,21 @@ func resolveStatusFilter(cmd *cobra.Command) (string, error) {
}
func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task {
- filtered := make([]lunatask.Task, 0, len(tasks))
- today := time.Now().Truncate(24 * time.Hour)
-
- for _, task := range tasks {
- if !matchesFilters(task, areaID, statusFilter, showAll, today) {
- continue
- }
-
- filtered = append(filtered, task)
- }
-
- return filtered
-}
-
-func matchesFilters(task lunatask.Task, areaID, statusFilter string, showAll bool, today time.Time) bool {
- if areaID != "" && (task.AreaID == nil || *task.AreaID != areaID) {
- return false
- }
-
- if statusFilter != "" && (task.Status == nil || string(*task.Status) != statusFilter) {
- return false
+ opts := &lunatask.TaskFilterOptions{
+ IncludeCompleted: showAll,
+ Today: time.Now(),
}
- // Default filter: exclude completed tasks unless completed today
- if !showAll && statusFilter == "" && isOldCompleted(task, today) {
- return false
+ if areaID != "" {
+ opts.AreaID = &areaID
}
- return true
-}
-
-func isOldCompleted(task lunatask.Task, today time.Time) bool {
- if task.Status == nil || *task.Status != lunatask.StatusCompleted {
- return false
+ if statusFilter != "" {
+ s := lunatask.TaskStatus(statusFilter)
+ opts.Status = &s
}
- return task.CompletedAt == nil || task.CompletedAt.Before(today)
+ return lunatask.FilterTasks(tasks, opts)
}
func outputJSON(cmd *cobra.Command, tasks []lunatask.Task) error {
@@ -7,7 +7,7 @@ module git.secluded.site/lune
go 1.25.5
require (
- git.secluded.site/go-lunatask v0.1.0-rc10
+ git.secluded.site/go-lunatask v0.1.0-rc9.1
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/huh v0.8.0
@@ -2,8 +2,8 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
-git.secluded.site/go-lunatask v0.1.0-rc10 h1:KKkYNs/cipNjIlRPXAvpPm5QcWSuA3REcG8XZ8sALk4=
-git.secluded.site/go-lunatask v0.1.0-rc10/go.mod h1:rxps7BBqF+BkY8VN5E7J9zSOzSbtZ1hDmLEOHxjTHZQ=
+git.secluded.site/go-lunatask v0.1.0-rc9.1 h1:6dJcP3P+2QraPQ/wfPjCWaXv2mr1B4lMvBuQCNZd1t8=
+git.secluded.site/go-lunatask v0.1.0-rc9.1/go.mod h1:rxps7BBqF+BkY8VN5E7J9zSOzSbtZ1hDmLEOHxjTHZQ=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@@ -56,8 +56,6 @@ func NewHandler(accessToken string, habits []shared.HabitProvider) *Handler {
}
// HandleTrack records a habit activity.
-//
-//nolint:nilerr // MCP returns errors in result, not Go error.
func (h *Handler) HandleTrack(
ctx context.Context,
_ *mcp.CallToolRequest,
@@ -58,6 +58,21 @@ type CreateOutput struct {
DeepLink string `json:"deep_link"`
}
+// parsedCreateInput holds validated and parsed create input fields.
+type parsedCreateInput struct {
+ Name string
+ AreaID *string
+ GoalID *string
+ Status *lunatask.TaskStatus
+ Note *string
+ Priority *lunatask.Priority
+ Estimate *int
+ Motivation *lunatask.Motivation
+ Important *bool
+ Urgent *bool
+ ScheduledOn *lunatask.Date
+}
+
// Handler handles task-related MCP tool requests.
type Handler struct {
client *lunatask.Client
@@ -73,110 +88,148 @@ func NewHandler(accessToken string, areas []shared.AreaProvider) *Handler {
}
// HandleCreate creates a new task.
-//
-//nolint:cyclop,funlen,gocognit,nilerr // MCP error pattern; repetitive field handling.
func (h *Handler) HandleCreate(
ctx context.Context,
_ *mcp.CallToolRequest,
input CreateInput,
) (*mcp.CallToolResult, CreateOutput, error) {
+ parsed, errResult := parseCreateInput(input)
+ if errResult != nil {
+ return errResult, CreateOutput{}, nil
+ }
+
+ builder := h.client.NewTask(parsed.Name)
+ applyToTaskBuilder(builder, parsed)
+
+ task, err := builder.Create(ctx)
+ if err != nil {
+ return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+ }
+
+ deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+ return nil, CreateOutput{
+ ID: task.ID,
+ DeepLink: deepLink,
+ }, nil
+}
+
+//nolint:cyclop,funlen
+func parseCreateInput(input CreateInput) (*parsedCreateInput, *mcp.CallToolResult) {
+ parsed := &parsedCreateInput{
+ Name: input.Name,
+ AreaID: input.AreaID,
+ GoalID: input.GoalID,
+ Note: input.Note,
+ Estimate: input.Estimate,
+ Important: input.Important,
+ Urgent: input.Urgent,
+ }
+
if input.AreaID != nil {
if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
- return shared.ErrorResult("invalid area_id: expected UUID"), CreateOutput{}, nil
+ return nil, shared.ErrorResult("invalid area_id: expected UUID")
}
}
if input.GoalID != nil {
if err := lunatask.ValidateUUID(*input.GoalID); err != nil {
- return shared.ErrorResult("invalid goal_id: expected UUID"), CreateOutput{}, nil
+ return nil, shared.ErrorResult("invalid goal_id: expected UUID")
}
}
if input.Estimate != nil {
if err := shared.ValidateEstimate(*input.Estimate); err != nil {
- return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
}
- builder := h.client.NewTask(input.Name)
-
- if input.AreaID != nil {
- builder.InArea(*input.AreaID)
- }
-
- if input.GoalID != nil {
- builder.InGoal(*input.GoalID)
- }
-
if input.Status != nil {
status, err := lunatask.ParseTaskStatus(*input.Status)
if err != nil {
- return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
- builder.WithStatus(status)
- }
-
- if input.Note != nil {
- builder.WithNote(*input.Note)
+ parsed.Status = &status
}
if input.Priority != nil {
priority, err := lunatask.ParsePriority(*input.Priority)
if err != nil {
- return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
- builder.Priority(priority)
- }
-
- if input.Estimate != nil {
- builder.WithEstimate(*input.Estimate)
+ parsed.Priority = &priority
}
if input.Motivation != nil {
motivation, err := lunatask.ParseMotivation(*input.Motivation)
if err != nil {
- return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
- builder.WithMotivation(motivation)
+ parsed.Motivation = &motivation
}
- if input.Important != nil {
- if *input.Important {
+ if input.ScheduledOn != nil {
+ date, err := dateutil.Parse(*input.ScheduledOn)
+ if err != nil {
+ return nil, shared.ErrorResult(err.Error())
+ }
+
+ parsed.ScheduledOn = &date
+ }
+
+ return parsed, nil
+}
+
+//nolint:cyclop
+func applyToTaskBuilder(builder *lunatask.TaskBuilder, parsed *parsedCreateInput) {
+ if parsed.AreaID != nil {
+ builder.InArea(*parsed.AreaID)
+ }
+
+ if parsed.GoalID != nil {
+ builder.InGoal(*parsed.GoalID)
+ }
+
+ if parsed.Status != nil {
+ builder.WithStatus(*parsed.Status)
+ }
+
+ if parsed.Note != nil {
+ builder.WithNote(*parsed.Note)
+ }
+
+ if parsed.Priority != nil {
+ builder.Priority(*parsed.Priority)
+ }
+
+ if parsed.Estimate != nil {
+ builder.WithEstimate(*parsed.Estimate)
+ }
+
+ if parsed.Motivation != nil {
+ builder.WithMotivation(*parsed.Motivation)
+ }
+
+ if parsed.Important != nil {
+ if *parsed.Important {
builder.Important()
} else {
builder.NotImportant()
}
}
- if input.Urgent != nil {
- if *input.Urgent {
+ if parsed.Urgent != nil {
+ if *parsed.Urgent {
builder.Urgent()
} else {
builder.NotUrgent()
}
}
- if input.ScheduledOn != nil {
- date, err := dateutil.Parse(*input.ScheduledOn)
- if err != nil {
- return shared.ErrorResult(err.Error()), CreateOutput{}, nil
- }
-
- builder.ScheduledOn(date)
- }
-
- task, err := builder.Create(ctx)
- if err != nil {
- return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+ if parsed.ScheduledOn != nil {
+ builder.ScheduledOn(*parsed.ScheduledOn)
}
-
- deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
-
- return nil, CreateOutput{
- ID: task.ID,
- DeepLink: deepLink,
- }, nil
}
@@ -7,6 +7,7 @@ package task
import (
"context"
+ "git.secluded.site/go-lunatask"
"git.secluded.site/lune/internal/mcp/shared"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -39,9 +40,9 @@ func (h *Handler) HandleDelete(
_ *mcp.CallToolRequest,
input DeleteInput,
) (*mcp.CallToolResult, DeleteOutput, error) {
- id, err := resolveID(input.ID)
+ _, id, err := lunatask.ParseReference(input.ID)
if err != nil {
- return shared.ErrorResult(err.Error()), DeleteOutput{}, nil
+ return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), DeleteOutput{}, nil
}
if _, err := h.client.DeleteTask(ctx, id); err != nil {
@@ -54,11 +54,7 @@ type Summary struct {
GoalID *string `json:"goal_id,omitempty"`
}
-const hoursPerDay = 24
-
// HandleList lists tasks.
-//
-//nolint:cyclop,funlen,nilerr // Complexity from field handling; MCP returns errors in result, not Go error.
func (h *Handler) HandleList(
ctx context.Context,
_ *mcp.CallToolRequest,
@@ -81,22 +77,30 @@ func (h *Handler) HandleList(
return shared.ErrorResult(err.Error()), ListOutput{}, nil
}
- includeCompleted := input.IncludeCompleted != nil && *input.IncludeCompleted
- today := time.Now().Truncate(hoursPerDay * time.Hour)
+ opts := &lunatask.TaskFilterOptions{
+ AreaID: input.AreaID,
+ IncludeCompleted: input.IncludeCompleted != nil && *input.IncludeCompleted,
+ Today: time.Now(),
+ }
- filtered := make([]lunatask.Task, 0, len(tasks))
+ if input.Status != nil {
+ s := lunatask.TaskStatus(*input.Status)
+ opts.Status = &s
+ }
- for _, task := range tasks {
- if !matchesFilters(task, input.AreaID, input.Status, includeCompleted, today) {
- continue
- }
+ filtered := lunatask.FilterTasks(tasks, opts)
+ summaries := buildSummaries(filtered)
- filtered = append(filtered, task)
- }
+ return nil, ListOutput{
+ Tasks: summaries,
+ Count: len(summaries),
+ }, nil
+}
- summaries := make([]Summary, 0, len(filtered))
+func buildSummaries(tasks []lunatask.Task) []Summary {
+ summaries := make([]Summary, 0, len(tasks))
- for _, task := range filtered {
+ for _, task := range tasks {
summary := Summary{
ID: task.ID,
CreatedAt: task.CreatedAt.Format(time.RFC3339),
@@ -124,39 +128,5 @@ func (h *Handler) HandleList(
summaries = append(summaries, summary)
}
- return nil, ListOutput{
- Tasks: summaries,
- Count: len(summaries),
- }, nil
-}
-
-func matchesFilters(
- task lunatask.Task,
- areaID, status *string,
- includeCompleted bool,
- today time.Time,
-) bool {
- if areaID != nil && (task.AreaID == nil || *task.AreaID != *areaID) {
- return false
- }
-
- if status != nil {
- if task.Status == nil || string(*task.Status) != *status {
- return false
- }
- }
-
- if !includeCompleted && isOldCompleted(task, today) {
- return false
- }
-
- return true
-}
-
-func isOldCompleted(task lunatask.Task, today time.Time) bool {
- if task.Status == nil || *task.Status != lunatask.StatusCompleted {
- return false
- }
-
- return task.CompletedAt == nil || task.CompletedAt.Before(today)
+ return summaries
}
@@ -53,9 +53,9 @@ func (h *Handler) HandleShow(
_ *mcp.CallToolRequest,
input ShowInput,
) (*mcp.CallToolResult, ShowOutput, error) {
- id, err := resolveID(input.ID)
+ _, id, err := lunatask.ParseReference(input.ID)
if err != nil {
- return shared.ErrorResult(err.Error()), ShowOutput{}, nil
+ return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), ShowOutput{}, nil
}
task, err := h.client.GetTask(ctx, id)
@@ -6,7 +6,6 @@ package task
import (
"context"
- "fmt"
"git.secluded.site/go-lunatask"
"git.secluded.site/lune/internal/dateutil"
@@ -60,134 +59,175 @@ type UpdateOutput struct {
DeepLink string `json:"deep_link"`
}
+// parsedUpdateInput holds validated and parsed update input fields.
+type parsedUpdateInput struct {
+ ID string
+ Name *string
+ AreaID *string
+ GoalID *string
+ Status *lunatask.TaskStatus
+ Note *string
+ Priority *lunatask.Priority
+ Estimate *int
+ Motivation *lunatask.Motivation
+ Important *bool
+ Urgent *bool
+ ScheduledOn *lunatask.Date
+}
+
// HandleUpdate updates an existing task.
-//
-//nolint:cyclop,funlen,gocognit,nilerr // MCP error pattern; repetitive field handling.
func (h *Handler) HandleUpdate(
ctx context.Context,
_ *mcp.CallToolRequest,
input UpdateInput,
) (*mcp.CallToolResult, UpdateOutput, error) {
- id, err := resolveID(input.ID)
+ parsed, errResult := parseUpdateInput(input)
+ if errResult != nil {
+ return errResult, UpdateOutput{}, nil
+ }
+
+ builder := h.client.NewTaskUpdate(parsed.ID)
+ applyToTaskUpdateBuilder(builder, parsed)
+
+ task, err := builder.Update(ctx)
if err != nil {
return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
}
+ deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+ return nil, UpdateOutput{
+ ID: task.ID,
+ DeepLink: deepLink,
+ }, nil
+}
+
+//nolint:cyclop,funlen
+func parseUpdateInput(input UpdateInput) (*parsedUpdateInput, *mcp.CallToolResult) {
+ _, id, err := lunatask.ParseReference(input.ID)
+ if err != nil {
+ return nil, shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link")
+ }
+
+ parsed := &parsedUpdateInput{
+ ID: id,
+ Name: input.Name,
+ AreaID: input.AreaID,
+ GoalID: input.GoalID,
+ Note: input.Note,
+ Estimate: input.Estimate,
+ Important: input.Important,
+ Urgent: input.Urgent,
+ }
+
if input.AreaID != nil {
if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
- return shared.ErrorResult("invalid area_id: expected UUID"), UpdateOutput{}, nil
+ return nil, shared.ErrorResult("invalid area_id: expected UUID")
}
}
if input.GoalID != nil {
if err := lunatask.ValidateUUID(*input.GoalID); err != nil {
- return shared.ErrorResult("invalid goal_id: expected UUID"), UpdateOutput{}, nil
+ return nil, shared.ErrorResult("invalid goal_id: expected UUID")
}
}
if input.Estimate != nil {
if err := shared.ValidateEstimate(*input.Estimate); err != nil {
- return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
}
- builder := h.client.NewTaskUpdate(id)
-
- if input.Name != nil {
- builder.Name(*input.Name)
- }
-
- if input.AreaID != nil {
- builder.InArea(*input.AreaID)
- }
-
- if input.GoalID != nil {
- builder.InGoal(*input.GoalID)
- }
-
if input.Status != nil {
status, err := lunatask.ParseTaskStatus(*input.Status)
if err != nil {
- return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
- builder.WithStatus(status)
- }
-
- if input.Note != nil {
- builder.WithNote(*input.Note)
+ parsed.Status = &status
}
if input.Priority != nil {
priority, err := lunatask.ParsePriority(*input.Priority)
if err != nil {
- return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
- builder.Priority(priority)
- }
-
- if input.Estimate != nil {
- builder.WithEstimate(*input.Estimate)
+ parsed.Priority = &priority
}
if input.Motivation != nil {
motivation, err := lunatask.ParseMotivation(*input.Motivation)
if err != nil {
- return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+ return nil, shared.ErrorResult(err.Error())
}
- builder.WithMotivation(motivation)
+ parsed.Motivation = &motivation
}
- if input.Important != nil {
- if *input.Important {
- builder.Important()
- } else {
- builder.NotImportant()
+ if input.ScheduledOn != nil {
+ date, err := dateutil.Parse(*input.ScheduledOn)
+ if err != nil {
+ return nil, shared.ErrorResult(err.Error())
}
+
+ parsed.ScheduledOn = &date
}
- if input.Urgent != nil {
- if *input.Urgent {
- builder.Urgent()
- } else {
- builder.NotUrgent()
- }
+ return parsed, nil
+}
+
+//nolint:cyclop
+func applyToTaskUpdateBuilder(builder *lunatask.TaskUpdateBuilder, parsed *parsedUpdateInput) {
+ if parsed.Name != nil {
+ builder.Name(*parsed.Name)
}
- if input.ScheduledOn != nil {
- date, err := dateutil.Parse(*input.ScheduledOn)
- if err != nil {
- return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
- }
+ if parsed.AreaID != nil {
+ builder.InArea(*parsed.AreaID)
+ }
- builder.ScheduledOn(date)
+ if parsed.GoalID != nil {
+ builder.InGoal(*parsed.GoalID)
}
- task, err := builder.Update(ctx)
- if err != nil {
- return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+ if parsed.Status != nil {
+ builder.WithStatus(*parsed.Status)
}
- deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+ if parsed.Note != nil {
+ builder.WithNote(*parsed.Note)
+ }
- return nil, UpdateOutput{
- ID: task.ID,
- DeepLink: deepLink,
- }, nil
-}
+ if parsed.Priority != nil {
+ builder.Priority(*parsed.Priority)
+ }
-// resolveID extracts a UUID from either a raw UUID or a lunatask:// deep link.
-func resolveID(input string) (string, error) {
- _, id, err := lunatask.ParseDeepLink(input)
- if err == nil {
- return id, nil
+ if parsed.Estimate != nil {
+ builder.WithEstimate(*parsed.Estimate)
}
- if err := lunatask.ValidateUUID(input); err != nil {
- return "", fmt.Errorf("invalid ID: %w", err)
+ if parsed.Motivation != nil {
+ builder.WithMotivation(*parsed.Motivation)
}
- return input, nil
+ if parsed.Important != nil {
+ if *parsed.Important {
+ builder.Important()
+ } else {
+ builder.NotImportant()
+ }
+ }
+
+ if parsed.Urgent != nil {
+ if *parsed.Urgent {
+ builder.Urgent()
+ } else {
+ builder.NotUrgent()
+ }
+ }
+
+ if parsed.ScheduledOn != nil {
+ builder.ScheduledOn(*parsed.ScheduledOn)
+ }
}
@@ -63,7 +63,6 @@ func (h *Handler) Handle(
) (*mcp.CallToolResult, Output, error) {
parsed, err := dateutil.Parse(input.Date)
if err != nil {
- //nolint:nilerr // MCP pattern: user errors in CallToolResult, nil Go error
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
@@ -20,7 +20,7 @@ var ErrInvalidReference = errors.New("invalid reference: expected UUID or lunata
// - UUID: "3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
// - Deep link: "lunatask://areas/3bbf1923-64ae-4bcf-96a9-9bb86c799dab"
func Reference(input string) (string, error) {
- _, id, err := lunatask.ParseDeepLink(input)
+ _, id, err := lunatask.ParseReference(input)
if err != nil {
return "", fmt.Errorf("%w: %s", ErrInvalidReference, input)
}