diff --git a/AGENTS.md b/AGENTS.md index 3bd272c00bd653afbe720003624b74c0d03889d1..0200d9c2d5a18f61c0f0b9b3b5a8b4857f940239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,6 @@ just run # Build and run ``` cmd/lunatask-mcp-server.go → Config, tool registration, SSE server tools/ → MCP tool handlers -lunatask/ → HTTP client for Lunatask API ``` **Data flow**: SSE request → MCP server → tool handler → lunatask client → Lunatask API diff --git a/go.mod b/go.mod index 0f1f210c8ea4af3718090c5c4a3219d57728005c..9170c8342ddeb361e37ffa042c2732f299247960 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ module git.sr.ht/~amolith/lunatask-mcp-server -go 1.24.2 +go 1.25.5 require ( + git.secluded.site/go-lunatask v0.1.0-rc1 github.com/BurntSushi/toml v1.5.0 github.com/ijt/go-anytime v1.9.2 github.com/mark3labs/mcp-go v0.23.1 diff --git a/go.sum b/go.sum index 363ee2919d454a5188a4cd5e60d6455fb0111145..a96b9cd5eaa4f4db84fce2b5c37768730cba1859 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.secluded.site/go-lunatask v0.1.0-rc1 h1:02np5gzm7f0D9uOcvq0/6qsLjREbyowQ++ZQTG9JNVA= +git.secluded.site/go-lunatask v0.1.0-rc1/go.mod h1:sWUQxme1z7qfsfS59nU5hqPvsRCt+HBmT/yBeIn6Fmc= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/lunatask/builders.go b/lunatask/builders.go deleted file mode 100644 index 52949036c9ff0123f1dfb4b6a06000abc68dad33..0000000000000000000000000000000000000000 --- a/lunatask/builders.go +++ /dev/null @@ -1,304 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import "time" - -// TaskBuilder provides a fluent interface for constructing CreateTaskRequest. -type TaskBuilder struct { - req CreateTaskRequest -} - -// NewTask creates a new TaskBuilder with the required name field. -func NewTask(name string) *TaskBuilder { - return &TaskBuilder{req: CreateTaskRequest{Name: name}} -} - -// InArea sets the area ID for the task. -func (b *TaskBuilder) InArea(areaID string) *TaskBuilder { - b.req.AreaID = &areaID - return b -} - -// InGoal sets the goal ID for the task. -func (b *TaskBuilder) InGoal(goalID string) *TaskBuilder { - b.req.GoalID = &goalID - return b -} - -// WithNote sets the note/description for the task. -func (b *TaskBuilder) WithNote(note string) *TaskBuilder { - b.req.Note = ¬e - return b -} - -// WithStatus sets the task status (later, next, started, waiting, completed). -func (b *TaskBuilder) WithStatus(status string) *TaskBuilder { - b.req.Status = &status - return b -} - -// WithMotivation sets the motivation (must, should, want). -func (b *TaskBuilder) WithMotivation(motivation string) *TaskBuilder { - b.req.Motivation = &motivation - return b -} - -// WithEstimate sets the time estimate in minutes (0-720). -func (b *TaskBuilder) WithEstimate(minutes int) *TaskBuilder { - b.req.Estimate = &minutes - return b -} - -// WithPriority sets the priority (-2 to 2: lowest, low, neutral, high, highest). -func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder { - b.req.Priority = &priority - return b -} - -// WithEisenhower sets the Eisenhower matrix category (0-4). -func (b *TaskBuilder) WithEisenhower(eisenhower int) *TaskBuilder { - b.req.Eisenhower = &eisenhower - return b -} - -// ScheduledOn sets the scheduled date for the task. -func (b *TaskBuilder) ScheduledOn(date Date) *TaskBuilder { - b.req.ScheduledOn = &date - return b -} - -// CompletedAt sets the completion timestamp. -func (b *TaskBuilder) CompletedAt(t time.Time) *TaskBuilder { - b.req.CompletedAt = &t - return b -} - -// FromSource sets the external source integration. -func (b *TaskBuilder) FromSource(source, sourceID string) *TaskBuilder { - b.req.Source = &source - b.req.SourceID = &sourceID - return b -} - -// Build returns the constructed CreateTaskRequest. -func (b *TaskBuilder) Build() *CreateTaskRequest { - return &b.req -} - -// TaskUpdateBuilder provides a fluent interface for constructing UpdateTaskRequest. -type TaskUpdateBuilder struct { - req UpdateTaskRequest -} - -// NewTaskUpdate creates a new TaskUpdateBuilder. -func NewTaskUpdate() *TaskUpdateBuilder { - return &TaskUpdateBuilder{} -} - -// Name sets the task name. -func (b *TaskUpdateBuilder) Name(name string) *TaskUpdateBuilder { - b.req.Name = &name - return b -} - -// InArea sets the area ID for the task. -func (b *TaskUpdateBuilder) InArea(areaID string) *TaskUpdateBuilder { - b.req.AreaID = &areaID - return b -} - -// InGoal sets the goal ID for the task. -func (b *TaskUpdateBuilder) InGoal(goalID string) *TaskUpdateBuilder { - b.req.GoalID = &goalID - return b -} - -// WithNote sets the note/description for the task. -func (b *TaskUpdateBuilder) WithNote(note string) *TaskUpdateBuilder { - b.req.Note = ¬e - return b -} - -// WithStatus sets the task status (later, next, started, waiting, completed). -func (b *TaskUpdateBuilder) WithStatus(status string) *TaskUpdateBuilder { - b.req.Status = &status - return b -} - -// WithMotivation sets the motivation (must, should, want). -func (b *TaskUpdateBuilder) WithMotivation(motivation string) *TaskUpdateBuilder { - b.req.Motivation = &motivation - return b -} - -// WithEstimate sets the time estimate in minutes (0-720). -func (b *TaskUpdateBuilder) WithEstimate(minutes int) *TaskUpdateBuilder { - b.req.Estimate = &minutes - return b -} - -// WithPriority sets the priority (-2 to 2: lowest, low, neutral, high, highest). -func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder { - b.req.Priority = &priority - return b -} - -// WithEisenhower sets the Eisenhower matrix category (0-4). -func (b *TaskUpdateBuilder) WithEisenhower(eisenhower int) *TaskUpdateBuilder { - b.req.Eisenhower = &eisenhower - return b -} - -// ScheduledOn sets the scheduled date for the task. -func (b *TaskUpdateBuilder) ScheduledOn(date Date) *TaskUpdateBuilder { - b.req.ScheduledOn = &date - return b -} - -// CompletedAt sets the completion timestamp. -func (b *TaskUpdateBuilder) CompletedAt(t time.Time) *TaskUpdateBuilder { - b.req.CompletedAt = &t - return b -} - -// Build returns the constructed UpdateTaskRequest. -func (b *TaskUpdateBuilder) Build() *UpdateTaskRequest { - return &b.req -} - -// NoteBuilder provides a fluent interface for constructing CreateNoteRequest. -type NoteBuilder struct { - req CreateNoteRequest -} - -// NewNote creates a new NoteBuilder. -func NewNote() *NoteBuilder { - return &NoteBuilder{} -} - -// WithName sets the note name/title. -func (b *NoteBuilder) WithName(name string) *NoteBuilder { - b.req.Name = &name - return b -} - -// WithContent sets the note content. -func (b *NoteBuilder) WithContent(content string) *NoteBuilder { - b.req.Content = &content - return b -} - -// InNotebook sets the notebook ID. -func (b *NoteBuilder) InNotebook(notebookID string) *NoteBuilder { - b.req.NotebookID = ¬ebookID - return b -} - -// FromSource sets the external source integration. -func (b *NoteBuilder) FromSource(source, sourceID string) *NoteBuilder { - b.req.Source = &source - b.req.SourceID = &sourceID - return b -} - -// Build returns the constructed CreateNoteRequest. -func (b *NoteBuilder) Build() *CreateNoteRequest { - return &b.req -} - -// JournalEntryBuilder provides a fluent interface for constructing CreateJournalEntryRequest. -type JournalEntryBuilder struct { - req CreateJournalEntryRequest -} - -// NewJournalEntry creates a new JournalEntryBuilder with the required date. -func NewJournalEntry(date Date) *JournalEntryBuilder { - return &JournalEntryBuilder{req: CreateJournalEntryRequest{DateOn: date}} -} - -// WithName sets the entry title (defaults to weekday name if omitted). -func (b *JournalEntryBuilder) WithName(name string) *JournalEntryBuilder { - b.req.Name = &name - return b -} - -// WithContent sets the Markdown content. -func (b *JournalEntryBuilder) WithContent(content string) *JournalEntryBuilder { - b.req.Content = &content - return b -} - -// Build returns the constructed CreateJournalEntryRequest. -func (b *JournalEntryBuilder) Build() *CreateJournalEntryRequest { - return &b.req -} - -// PersonBuilder provides a fluent interface for constructing CreatePersonRequest. -type PersonBuilder struct { - req CreatePersonRequest -} - -// NewPerson creates a new PersonBuilder. -func NewPerson() *PersonBuilder { - return &PersonBuilder{} -} - -// WithFirstName sets the person's first name. -func (b *PersonBuilder) WithFirstName(name string) *PersonBuilder { - b.req.FirstName = &name - return b -} - -// WithLastName sets the person's last name. -func (b *PersonBuilder) WithLastName(name string) *PersonBuilder { - b.req.LastName = &name - return b -} - -// WithRelationshipStrength sets the relationship strength category. -func (b *PersonBuilder) WithRelationshipStrength(strength string) *PersonBuilder { - b.req.RelationshipStrength = &strength - return b -} - -// FromSource sets the external source integration. -func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder { - b.req.Source = &source - b.req.SourceID = &sourceID - return b -} - -// Build returns the constructed CreatePersonRequest. -func (b *PersonBuilder) Build() *CreatePersonRequest { - return &b.req -} - -// TimelineNoteBuilder provides a fluent interface for constructing CreatePersonTimelineNoteRequest. -type TimelineNoteBuilder struct { - req CreatePersonTimelineNoteRequest -} - -// NewTimelineNote creates a new TimelineNoteBuilder with the required person ID. -func NewTimelineNote(personID string) *TimelineNoteBuilder { - return &TimelineNoteBuilder{req: CreatePersonTimelineNoteRequest{PersonID: personID}} -} - -// OnDate sets the date for the timeline note. -func (b *TimelineNoteBuilder) OnDate(date Date) *TimelineNoteBuilder { - b.req.DateOn = &date - return b -} - -// WithContent sets the Markdown content. -func (b *TimelineNoteBuilder) WithContent(content string) *TimelineNoteBuilder { - b.req.Content = &content - return b -} - -// Build returns the constructed CreatePersonTimelineNoteRequest. -func (b *TimelineNoteBuilder) Build() *CreatePersonTimelineNoteRequest { - return &b.req -} diff --git a/lunatask/client.go b/lunatask/client.go deleted file mode 100644 index e053d2b2eeef2b5d68f8769f02cbec18337f455c..0000000000000000000000000000000000000000 --- a/lunatask/client.go +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" -) - -// API error types for typed error handling -var ( - // ErrBadRequest indicates invalid, malformed, or missing parameters (400) - ErrBadRequest = errors.New("bad request") - // ErrUnauthorized indicates missing, wrong, or revoked access token (401) - ErrUnauthorized = errors.New("unauthorized") - // ErrPaymentRequired indicates a subscription is required (402) - ErrPaymentRequired = errors.New("subscription required") - // ErrNotFound indicates the specified entity could not be found (404) - ErrNotFound = errors.New("not found") - // ErrUnprocessableEntity indicates the provided entity is not valid (422) - ErrUnprocessableEntity = errors.New("unprocessable entity") - // ErrServerError indicates an internal server error (500) - ErrServerError = errors.New("server error") - // ErrServiceUnavailable indicates temporary maintenance (503) - ErrServiceUnavailable = errors.New("service unavailable") - // ErrTimeout indicates request timed out (524) - ErrTimeout = errors.New("request timed out") -) - -// APIError wraps an API error with status code and response body -type APIError struct { - StatusCode int - Body string - Err error -} - -func (e *APIError) Error() string { - if e.Body != "" { - return fmt.Sprintf("%s (status %d): %s", e.Err.Error(), e.StatusCode, e.Body) - } - return fmt.Sprintf("%s (status %d)", e.Err.Error(), e.StatusCode) -} - -func (e *APIError) Unwrap() error { - return e.Err -} - -// newAPIError creates an APIError from an HTTP status code -func newAPIError(statusCode int, body string) *APIError { - var err error - switch statusCode { - case http.StatusBadRequest: - err = ErrBadRequest - case http.StatusUnauthorized: - err = ErrUnauthorized - case http.StatusPaymentRequired: - err = ErrPaymentRequired - case http.StatusNotFound: - err = ErrNotFound - case http.StatusUnprocessableEntity: - err = ErrUnprocessableEntity - case http.StatusInternalServerError: - err = ErrServerError - case http.StatusServiceUnavailable: - err = ErrServiceUnavailable - case 524: - err = ErrTimeout - default: - err = fmt.Errorf("unexpected status %d", statusCode) - } - return &APIError{StatusCode: statusCode, Body: body, Err: err} -} - -// DefaultBaseURL is the default Lunatask API base URL. -const DefaultBaseURL = "https://api.lunatask.app/v1" - -// Client handles communication with the Lunatask API. -type Client struct { - accessToken string - baseURL string - httpClient *http.Client -} - -// Option configures a Client. -type Option func(*Client) - -// WithHTTPClient sets a custom HTTP client. -func WithHTTPClient(client *http.Client) Option { - return func(c *Client) { - c.httpClient = client - } -} - -// WithBaseURL sets a custom base URL (useful for testing). -func WithBaseURL(url string) Option { - return func(c *Client) { - c.baseURL = url - } -} - -// NewClient creates a new Lunatask API client. -func NewClient(accessToken string, opts ...Option) *Client { - c := &Client{ - accessToken: accessToken, - baseURL: DefaultBaseURL, - httpClient: &http.Client{}, - } - for _, opt := range opts { - opt(c) - } - return c -} - -// doRequest performs an HTTP request and handles common response processing. -// Returns the response body and status code. 204 No Content returns nil body. -func (c *Client) doRequest(req *http.Request) ([]byte, int, error) { - req.Header.Set("Authorization", "bearer "+c.accessToken) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, 0, fmt.Errorf("failed to send HTTP request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, resp.StatusCode, newAPIError(resp.StatusCode, string(body)) - } - - // 204 No Content is a valid success with no body - if resp.StatusCode == http.StatusNoContent { - return nil, resp.StatusCode, nil - } - - return body, resp.StatusCode, nil -} - -// doJSON performs an HTTP request with optional JSON body and unmarshals the response. -// Returns (nil, true, nil) for 204 No Content responses. -func doJSON[T any](c *Client, ctx context.Context, method, path string, body any) (*T, bool, error) { - var reqBody io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, false, fmt.Errorf("failed to marshal request body: %w", err) - } - reqBody = bytes.NewReader(data) - } - - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) - if err != nil { - return nil, false, fmt.Errorf("failed to create HTTP request: %w", err) - } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - respBody, statusCode, err := c.doRequest(req) - if err != nil { - return nil, false, err - } - - if statusCode == http.StatusNoContent { - return nil, true, nil - } - - var result T - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, false, fmt.Errorf("failed to parse response: %w", err) - } - - return &result, false, nil -} - -// PingResponse represents the response from the /ping endpoint. -type PingResponse struct { - Message string `json:"message"` -} - -// Ping verifies the access token is valid by calling the /ping endpoint. -// Returns the ping response on success, or an error (typically ErrUnauthorized) on failure. -func (c *Client) Ping(ctx context.Context) (*PingResponse, error) { - resp, _, err := doJSON[PingResponse](c, ctx, http.MethodGet, "/ping", nil) - return resp, err -} diff --git a/lunatask/habits.go b/lunatask/habits.go deleted file mode 100644 index 1ad56897d57b0e375ab95a616716132e8b8add7a..0000000000000000000000000000000000000000 --- a/lunatask/habits.go +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "context" - "fmt" - "net/http" -) - -// TrackHabitActivityRequest represents the request to track a habit activity. -type TrackHabitActivityRequest struct { - // PerformedOn is the date when the activity was performed. - PerformedOn Date `json:"performed_on"` -} - -// TrackHabitActivityResponse represents the response from Lunatask API when tracking a habit activity. -type TrackHabitActivityResponse struct { - Status string `json:"status"` - Message string `json:"message,omitempty"` -} - -// TrackHabitActivity tracks an activity for a habit in Lunatask. -// The PerformedOn field must be an ISO-8601 date (e.g., "2024-08-26"). -func (c *Client) TrackHabitActivity(ctx context.Context, habitID string, request *TrackHabitActivityRequest) (*TrackHabitActivityResponse, error) { - if habitID == "" { - return nil, fmt.Errorf("%w: habit ID cannot be empty", ErrBadRequest) - } - if request.PerformedOn.IsZero() { - return nil, fmt.Errorf("%w: performed_on is required", ErrBadRequest) - } - - resp, _, err := doJSON[TrackHabitActivityResponse](c, ctx, http.MethodPost, "/habits/"+habitID+"/track", request) - if err != nil { - return nil, err - } - - return resp, nil -} diff --git a/lunatask/journal.go b/lunatask/journal.go deleted file mode 100644 index a9dbda9083c27e7eb3efd59edb2696fd31a92266..0000000000000000000000000000000000000000 --- a/lunatask/journal.go +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "context" - "net/http" - "time" -) - -// JournalEntry represents a journal entry returned from the Lunatask API. -// Note: name and content are E2EE and not returned by the API. -type JournalEntry struct { - ID string `json:"id"` - DateOn Date `json:"date_on"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// CreateJournalEntryRequest represents the request to create a journal entry. -type CreateJournalEntryRequest struct { - // DateOn is the date for the journal entry (required). - DateOn Date `json:"date_on"` - // Name is the title for the entry (optional, defaults to weekday name like "Tuesday, July 1st"). - Name *string `json:"name,omitempty"` - // Content is the Markdown content of the entry (optional). - Content *string `json:"content,omitempty"` -} - -// journalEntryResponse represents a single journal entry response from the API. -type journalEntryResponse struct { - JournalEntry JournalEntry `json:"journal_entry"` -} - -// CreateJournalEntry creates a new journal entry for a given date. -func (c *Client) CreateJournalEntry(ctx context.Context, entry *CreateJournalEntryRequest) (*JournalEntry, error) { - resp, _, err := doJSON[journalEntryResponse](c, ctx, http.MethodPost, "/journal_entries", entry) - if err != nil { - return nil, err - } - - return &resp.JournalEntry, nil -} diff --git a/lunatask/notes.go b/lunatask/notes.go deleted file mode 100644 index be5f2e4ff1c55a589bbac6acde9b5d1daf0ec77d..0000000000000000000000000000000000000000 --- a/lunatask/notes.go +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" -) - -// Note represents a note returned from the Lunatask API. -// Note: name and content are E2EE and not returned by the API. -type Note struct { - ID string `json:"id"` - NotebookID *string `json:"notebook_id"` - DateOn *Date `json:"date_on"` - Sources []Source `json:"sources"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// CreateNoteRequest represents the request to create a note in Lunatask. -type CreateNoteRequest struct { - Name *string `json:"name,omitempty"` - Content *string `json:"content,omitempty"` - NotebookID *string `json:"notebook_id,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` -} - -// UpdateNoteRequest represents the request to update a note in Lunatask. -// All fields are optional; only provided fields will be updated. -// Note: updating content replaces the entire content (E2EE prevents appending). -type UpdateNoteRequest struct { - Name *string `json:"name,omitempty"` - Content *string `json:"content,omitempty"` - NotebookID *string `json:"notebook_id,omitempty"` - DateOn *Date `json:"date_on,omitempty"` -} - -// noteResponse represents a single note response from the API. -type noteResponse struct { - Note Note `json:"note"` -} - -// notesResponse represents a list of notes response from the API. -type notesResponse struct { - Notes []Note `json:"notes"` -} - -// ListNotesOptions contains optional filters for listing notes. -type ListNotesOptions struct { - Source *string - SourceID *string -} - -// ListNotes retrieves all notes, optionally filtered by source and/or source_id. -func (c *Client) ListNotes(ctx context.Context, opts *ListNotesOptions) ([]Note, error) { - path := "/notes" - - if opts != nil { - params := url.Values{} - if opts.Source != nil && *opts.Source != "" { - params.Set("source", *opts.Source) - } - if opts.SourceID != nil && *opts.SourceID != "" { - params.Set("source_id", *opts.SourceID) - } - if len(params) > 0 { - path = fmt.Sprintf("%s?%s", path, params.Encode()) - } - } - - resp, _, err := doJSON[notesResponse](c, ctx, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - - return resp.Notes, nil -} - -// GetNote retrieves a specific note by ID. -// Note: name and content fields are E2EE and will be null in the response. -func (c *Client) GetNote(ctx context.Context, noteID string) (*Note, error) { - if noteID == "" { - return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[noteResponse](c, ctx, http.MethodGet, "/notes/"+noteID, nil) - if err != nil { - return nil, err - } - - return &resp.Note, nil -} - -// CreateNote creates a new note in Lunatask. -// Returns nil, nil if a matching note already exists in the same notebook -// with the same source/source_id (HTTP 204). -func (c *Client) CreateNote(ctx context.Context, note *CreateNoteRequest) (*Note, error) { - resp, noContent, err := doJSON[noteResponse](c, ctx, http.MethodPost, "/notes", note) - if err != nil { - return nil, err - } - if noContent { - return nil, nil - } - - return &resp.Note, nil -} - -// UpdateNote updates an existing note in Lunatask. -func (c *Client) UpdateNote(ctx context.Context, noteID string, note *UpdateNoteRequest) (*Note, error) { - if noteID == "" { - return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[noteResponse](c, ctx, http.MethodPut, "/notes/"+noteID, note) - if err != nil { - return nil, err - } - - return &resp.Note, nil -} - -// DeleteNote deletes a note in Lunatask. -func (c *Client) DeleteNote(ctx context.Context, noteID string) (*Note, error) { - if noteID == "" { - return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[noteResponse](c, ctx, http.MethodDelete, "/notes/"+noteID, nil) - if err != nil { - return nil, err - } - - return &resp.Note, nil -} diff --git a/lunatask/people.go b/lunatask/people.go deleted file mode 100644 index 8e86004f2f96455ed8ac49e9f3be9561b38b6ebd..0000000000000000000000000000000000000000 --- a/lunatask/people.go +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" -) - -// Person represents a person/relationship returned from the Lunatask API. -// Note: first_name and last_name are E2EE and not returned by the API. -type Person struct { - ID string `json:"id"` - RelationshipStrength *string `json:"relationship_strength"` - Sources []Source `json:"sources"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// CreatePersonRequest represents the request to create a person in Lunatask. -type CreatePersonRequest struct { - FirstName *string `json:"first_name,omitempty"` - LastName *string `json:"last_name,omitempty"` - RelationshipStrength *string `json:"relationship_strength,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` -} - -// personResponse represents a single person response from the API. -type personResponse struct { - Person Person `json:"person"` -} - -// peopleResponse represents a list of people response from the API. -type peopleResponse struct { - People []Person `json:"people"` -} - -// ListPeopleOptions contains optional filters for listing people. -type ListPeopleOptions struct { - Source *string - SourceID *string -} - -// ListPeople retrieves all people, optionally filtered by source and/or source_id. -func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) { - path := "/people" - - if opts != nil { - params := url.Values{} - if opts.Source != nil && *opts.Source != "" { - params.Set("source", *opts.Source) - } - if opts.SourceID != nil && *opts.SourceID != "" { - params.Set("source_id", *opts.SourceID) - } - if len(params) > 0 { - path = fmt.Sprintf("%s?%s", path, params.Encode()) - } - } - - resp, _, err := doJSON[peopleResponse](c, ctx, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - - return resp.People, nil -} - -// GetPerson retrieves a specific person by ID. -func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) { - if personID == "" { - return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[personResponse](c, ctx, http.MethodGet, "/people/"+personID, nil) - if err != nil { - return nil, err - } - - return &resp.Person, nil -} - -// CreatePerson creates a new person/relationship in Lunatask. -// Returns nil, nil if a matching person already exists with the same -// source/source_id (HTTP 204). -func (c *Client) CreatePerson(ctx context.Context, person *CreatePersonRequest) (*Person, error) { - resp, noContent, err := doJSON[personResponse](c, ctx, http.MethodPost, "/people", person) - if err != nil { - return nil, err - } - if noContent { - return nil, nil - } - - return &resp.Person, nil -} - -// DeletePerson deletes a person in Lunatask. -func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) { - if personID == "" { - return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[personResponse](c, ctx, http.MethodDelete, "/people/"+personID, nil) - if err != nil { - return nil, err - } - - return &resp.Person, nil -} diff --git a/lunatask/tasks.go b/lunatask/tasks.go deleted file mode 100644 index 911aac63346bb1001fc407c17afb29c36d33f1b8..0000000000000000000000000000000000000000 --- a/lunatask/tasks.go +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" -) - -// Task represents a task returned from the Lunatask API -type Task struct { - ID string `json:"id"` - AreaID *string `json:"area_id"` - GoalID *string `json:"goal_id"` - Name *string `json:"name"` - Note *string `json:"note"` - Status *string `json:"status"` - PreviousStatus *string `json:"previous_status"` - Estimate *int `json:"estimate"` - Priority *int `json:"priority"` - Progress *int `json:"progress"` - Motivation *string `json:"motivation"` - Eisenhower *int `json:"eisenhower"` - Sources []Source `json:"sources"` - ScheduledOn *Date `json:"scheduled_on"` - CompletedAt *time.Time `json:"completed_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// CreateTaskRequest represents the request to create a task in Lunatask -type CreateTaskRequest struct { - Name string `json:"name"` - AreaID *string `json:"area_id,omitempty"` - GoalID *string `json:"goal_id,omitempty"` - Note *string `json:"note,omitempty"` - Status *string `json:"status,omitempty"` - Motivation *string `json:"motivation,omitempty"` - Estimate *int `json:"estimate,omitempty"` - Priority *int `json:"priority,omitempty"` - Eisenhower *int `json:"eisenhower,omitempty"` - ScheduledOn *Date `json:"scheduled_on,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` -} - -// UpdateTaskRequest represents the request to update a task in Lunatask. -// All fields are optional; only provided fields will be updated. -type UpdateTaskRequest struct { - Name *string `json:"name,omitempty"` - AreaID *string `json:"area_id,omitempty"` - GoalID *string `json:"goal_id,omitempty"` - Note *string `json:"note,omitempty"` - Status *string `json:"status,omitempty"` - Motivation *string `json:"motivation,omitempty"` - Estimate *int `json:"estimate,omitempty"` - Priority *int `json:"priority,omitempty"` - Eisenhower *int `json:"eisenhower,omitempty"` - ScheduledOn *Date `json:"scheduled_on,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` -} - -// taskResponse represents a single task response from the API -type taskResponse struct { - Task Task `json:"task"` -} - -// tasksResponse represents a list of tasks response from the API -type tasksResponse struct { - Tasks []Task `json:"tasks"` -} - -// ListTasksOptions contains optional filters for listing tasks -type ListTasksOptions struct { - Source *string - SourceID *string -} - -// ListTasks retrieves all tasks, optionally filtered by source and/or source_id -func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task, error) { - path := "/tasks" - - if opts != nil { - params := url.Values{} - if opts.Source != nil && *opts.Source != "" { - params.Set("source", *opts.Source) - } - if opts.SourceID != nil && *opts.SourceID != "" { - params.Set("source_id", *opts.SourceID) - } - if len(params) > 0 { - path = fmt.Sprintf("%s?%s", path, params.Encode()) - } - } - - resp, _, err := doJSON[tasksResponse](c, ctx, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - - return resp.Tasks, nil -} - -// GetTask retrieves a specific task by ID -func (c *Client) GetTask(ctx context.Context, taskID string) (*Task, error) { - if taskID == "" { - return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[taskResponse](c, ctx, http.MethodGet, "/tasks/"+taskID, nil) - if err != nil { - return nil, err - } - - return &resp.Task, nil -} - -// CreateTask creates a new task in Lunatask. -// Returns nil, nil if a matching task already exists (HTTP 204). -func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*Task, error) { - if task.Name == "" { - return nil, fmt.Errorf("%w: name is required", ErrBadRequest) - } - - resp, noContent, err := doJSON[taskResponse](c, ctx, http.MethodPost, "/tasks", task) - if err != nil { - return nil, err - } - if noContent { - return nil, nil - } - - return &resp.Task, nil -} - -// UpdateTask updates an existing task in Lunatask -func (c *Client) UpdateTask(ctx context.Context, taskID string, task *UpdateTaskRequest) (*Task, error) { - if taskID == "" { - return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[taskResponse](c, ctx, http.MethodPut, "/tasks/"+taskID, task) - if err != nil { - return nil, err - } - - return &resp.Task, nil -} - -// DeleteTask deletes a task in Lunatask -func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) { - if taskID == "" { - return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) - } - - resp, _, err := doJSON[taskResponse](c, ctx, http.MethodDelete, "/tasks/"+taskID, nil) - if err != nil { - return nil, err - } - - return &resp.Task, nil -} diff --git a/lunatask/timeline.go b/lunatask/timeline.go deleted file mode 100644 index d2bc893950c622170b2c35ae8106fc2d31156faf..0000000000000000000000000000000000000000 --- a/lunatask/timeline.go +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "context" - "fmt" - "net/http" - "time" -) - -// PersonTimelineNote represents a note on a person's memory timeline. -// Note: content is E2EE and not returned by the API. -type PersonTimelineNote struct { - ID string `json:"id"` - DateOn *Date `json:"date_on"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// CreatePersonTimelineNoteRequest represents the request to create a timeline note. -type CreatePersonTimelineNoteRequest struct { - // PersonID is the ID of the person to add the note to (required). - PersonID string `json:"person_id"` - // DateOn is the ISO-8601 date for the note (optional, defaults to today). - DateOn *Date `json:"date_on,omitempty"` - // Content is the Markdown content of the note (optional but impractical if empty). - Content *string `json:"content,omitempty"` -} - -// personTimelineNoteResponse represents a single timeline note response from the API. -type personTimelineNoteResponse struct { - PersonTimelineNote PersonTimelineNote `json:"person_timeline_note"` -} - -// CreatePersonTimelineNote creates a new note on a person's memory timeline. -func (c *Client) CreatePersonTimelineNote(ctx context.Context, note *CreatePersonTimelineNoteRequest) (*PersonTimelineNote, error) { - if note.PersonID == "" { - return nil, fmt.Errorf("%w: person_id is required", ErrBadRequest) - } - - resp, _, err := doJSON[personTimelineNoteResponse](c, ctx, http.MethodPost, "/person_timeline_notes", note) - if err != nil { - return nil, err - } - - return &resp.PersonTimelineNote, nil -} diff --git a/lunatask/types.go b/lunatask/types.go deleted file mode 100644 index c4e3b4cd0e83ab5de9908d0d5f2752ec2ec9a4ca..0000000000000000000000000000000000000000 --- a/lunatask/types.go +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import ( - "encoding/json" - "time" -) - -// Source represents an external source integration (e.g., GitHub, Todoist). -// Used across multiple entity types for tracking where items originated. -type Source struct { - Source string `json:"source"` - SourceID string `json:"source_id"` -} - -// Date represents a date-only value (YYYY-MM-DD) with proper JSON marshaling. -// Used for fields like scheduled_on that don't include time components. -type Date struct { - time.Time -} - -const dateFormat = "2006-01-02" - -// MarshalJSON implements json.Marshaler for Date. -func (d Date) MarshalJSON() ([]byte, error) { - if d.IsZero() { - return []byte("null"), nil - } - return json.Marshal(d.Format(dateFormat)) -} - -// UnmarshalJSON implements json.Unmarshaler for Date. -func (d *Date) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - return nil - } - - var s string - if err := json.Unmarshal(data, &s); err != nil { - return err - } - - t, err := time.Parse(dateFormat, s) - if err != nil { - return err - } - - d.Time = t - return nil -} - -// String returns the date in YYYY-MM-DD format. -func (d Date) String() string { - if d.IsZero() { - return "" - } - return d.Format(dateFormat) -} - -// NewDate creates a Date from a time.Time, discarding time components. -func NewDate(t time.Time) Date { - return Date{time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)} -} - -// ParseDate parses a date string in YYYY-MM-DD format. -func ParseDate(s string) (Date, error) { - t, err := time.Parse(dateFormat, s) - if err != nil { - return Date{}, err - } - return Date{t}, nil -} - -// Today returns the current date. -func Today() Date { - return NewDate(time.Now()) -} diff --git a/tools/habits.go b/tools/habits.go index cf8719bc327919ce645d7acafd4e5b3f8254f163..9505119d36c0956e630d23057392567b42fd4999 100644 --- a/tools/habits.go +++ b/tools/habits.go @@ -9,7 +9,7 @@ import ( "fmt" "strings" - "git.sr.ht/~amolith/lunatask-mcp-server/lunatask" + "git.secluded.site/go-lunatask" "github.com/mark3labs/mcp-go/mcp" ) diff --git a/tools/tasks.go b/tools/tasks.go index 3c12356a803391b124cc45fd54e1f93c7e453465..1d88261d97a1ab9092f21970d8dc3127f2a5ab6c 100644 --- a/tools/tasks.go +++ b/tools/tasks.go @@ -9,7 +9,7 @@ import ( "fmt" "strings" - "git.sr.ht/~amolith/lunatask-mcp-server/lunatask" + "git.secluded.site/go-lunatask" "github.com/mark3labs/mcp-go/mcp" )