From c9b63236e163c88be1f5792e4bc3d6b3efce02ae Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 21 Jul 2026 00:59:36 +0000
Subject: [PATCH 01/24] feat!: overhaul all API routes and tools against
current ClickUp API (v5.1.0)
Audit of every route/tool against the current ClickUp REST API (v2 + v3
OpenAPI specs, May 2026), with independent adversarial verification of
each finding (186 confirmed). Fixes all confirmed request/response
mismatches, removes ~40 tools that called nonexistent endpoints, and
adds missing documented endpoints. Highlights:
- fix markdown_description on task create/update (descriptions were
silently dropped), custom_fields JSON query filter, subtask pagination
- rewrite Chat for API v3 (workspaces-scoped paths, cursor pagination)
- fix list_template paths, goals key_result endpoints/fields/envelope,
webhook events/scoping/payload schema, view filter grammar and
full-object updates, dependency direction semantics
- rebuild attachments on the two real endpoints (multipart upload + v3
listing); remove fabricated attachment/webhook/dependency/docs tools
- docs v3: parent-body create, next_cursor pagination, content_edit_mode
- custom_task_ids/team_id support across task-scoped endpoints
- OAuth Bearer vs personal token handling, Retry-After floor, ECODE
- new tools: filtered team tasks, task merge, task tags, space CRUD +
space tags, team views, workspace fields, time-entry tags, doc
pageListing, whoami, user groups, plan, custom roles
150/150 jest tests pass; server registers 157 tools via live MCP
handshake.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01M18XtDQugWqcmBQLjhyjRo
---
README.md | 10 +-
RELEASE_NOTES.md | 60 ++
package-lock.json | 6 +-
package.json | 4 +-
packages/core/package.json | 4 +-
.../clickup-client/attachments-enhanced.ts | 413 ++--------
packages/core/src/clickup-client/auth.ts | 110 ++-
.../core/src/clickup-client/chat-enhanced.ts | 275 ++++---
.../core/src/clickup-client/checklists.ts | 87 ++-
.../src/clickup-client/comments-enhanced.ts | 128 +++-
packages/core/src/clickup-client/comments.ts | 85 +--
.../clickup-client/custom-fields-enhanced.ts | 503 ++++++-------
.../clickup-client/dependencies-enhanced.ts | 494 ++++++------
.../core/src/clickup-client/docs-enhanced.ts | 462 +++++-------
packages/core/src/clickup-client/docs.ts | 161 ++--
packages/core/src/clickup-client/folders.ts | 53 +-
.../core/src/clickup-client/goals-enhanced.ts | 87 ++-
packages/core/src/clickup-client/index.ts | 46 +-
packages/core/src/clickup-client/lists.ts | 48 +-
.../core/src/clickup-client/secure-client.ts | 69 +-
packages/core/src/clickup-client/spaces.ts | 141 +++-
packages/core/src/clickup-client/tasks.ts | 261 ++++++-
.../clickup-client/time-tracking-enhanced.ts | 230 +++++-
.../core/src/clickup-client/views-enhanced.ts | 282 +++----
.../src/clickup-client/webhooks-enhanced.ts | 277 +++----
packages/core/src/index-efficiency-simple.ts | 4 +-
.../core/src/schemas/attachments-schemas.ts | 406 ++--------
packages/core/src/schemas/chat-schemas.ts | 318 ++++----
.../core/src/schemas/custom-field-schemas.ts | 337 +++------
.../core/src/schemas/dependencies-schemas.ts | 378 ++++------
packages/core/src/schemas/document-schemas.ts | 219 +++---
packages/core/src/schemas/goals-schemas.ts | 103 +--
packages/core/src/schemas/response-schemas.ts | 11 +-
packages/core/src/schemas/task-schemas.ts | 1 -
.../core/src/schemas/time-tracking-schemas.ts | 92 ++-
packages/core/src/schemas/views-schemas.ts | 258 +++----
packages/core/src/schemas/webhook-schemas.ts | 165 ++--
.../src/tests/delete-merge-operations.test.ts | 20 +-
.../core/src/tools/attachments-tools-setup.ts | 422 +----------
packages/core/src/tools/bulk-task-tools.ts | 91 +--
packages/core/src/tools/chat-tools.ts | 364 ++++-----
packages/core/src/tools/checklist-tools.ts | 112 ++-
packages/core/src/tools/comment-tools.ts | 265 ++++---
packages/core/src/tools/custom-field-tools.ts | 705 ++++--------------
.../src/tools/dependencies-tools-setup.ts | 341 ++++-----
packages/core/src/tools/doc-tools-enhanced.ts | 495 ++++--------
packages/core/src/tools/doc-tools.ts | 2 +-
packages/core/src/tools/goals-tools.ts | 148 ++--
packages/core/src/tools/list-folder-tools.ts | 242 +++++-
packages/core/src/tools/space-tools.ts | 259 ++++++-
packages/core/src/tools/task-tools.ts | 194 ++++-
.../core/src/tools/time-tracking-tools.ts | 281 ++++++-
packages/core/src/tools/views-tools-setup.ts | 141 ++--
.../core/src/tools/webhook-tools-setup.ts | 258 ++-----
packages/core/src/tools/workspace-tools.ts | 76 ++
packages/core/src/utils/error-handling.ts | 20 +-
packages/core/src/utils/markdown.ts | 8 +-
57 files changed, 5195 insertions(+), 5837 deletions(-)
diff --git a/README.md b/README.md
index 879bc85..f8dcaa4 100644
--- a/README.md
+++ b/README.md
@@ -12,12 +12,12 @@
-A comprehensive Model Context Protocol (MCP) server suite providing AI assistants with complete ClickUp integration. Features **177+ core tools**, **AI-powered project intelligence**, **production-grade security**, and **full GitHub Flavored Markdown support**.
+A comprehensive Model Context Protocol (MCP) server suite providing AI assistants with complete ClickUp integration. Features **157+ core tools**, **AI-powered project intelligence**, **production-grade security**, and **full GitHub Flavored Markdown support**.
## 📦 Package Suite
### Core Server: `@chykalophia/clickup-mcp-server`
-Complete ClickUp API integration with **177+ tools** covering all major functionality:
+Complete ClickUp API integration with **157+ tools** covering all major functionality:
- Tasks, Lists, Spaces, Folders, Workspaces
- Comments, Attachments, Custom Fields, Views
- Time Tracking, Goals, Dependencies, Webhooks
@@ -44,7 +44,7 @@ This project uses a monorepo structure with multiple packages:
clickup-mcp-server/
├── packages/
│ ├── core/ # @chykalophia/clickup-mcp-server
-│ │ ├── 177+ core tools # Complete ClickUp API coverage
+│ │ ├── 157+ core tools # Complete ClickUp API coverage
│ │ ├── Production security # Zero vulnerabilities
│ │ └── Markdown support # GitHub Flavored Markdown
│ ├── intelligence/ # @chykalophia/clickup-intelligence-mcp-server
@@ -103,7 +103,7 @@ This Enhanced version is based on the original ClickUp MCP Server codebase by [D
- **Backward Compatible**: Existing plain text content continues to work
### 🛠️ **Comprehensive API Coverage**
-- **177+ Total Tools** covering 100% of major ClickUp API endpoints
+- **157+ Total Tools** covering 100% of major ClickUp API endpoints
- **9 Feature Domains**: Tasks, comments, docs, webhooks, views, dependencies, attachments, time tracking, goals
- **Real-time Integration**: Webhook processing with HMAC validation
- **Advanced Workflows**: Dependencies, custom fields, bulk operations
@@ -121,7 +121,7 @@ This Enhanced version is based on the original ClickUp MCP Server codebase by [D
- **Backward Compatibility**: Previous tool names are deprecated but documented for migration
- **Examples**: `clickup_create_task`, `clickup_get_workspaces`, `clickup_update_comment`
-## 📊 Complete Tool Inventory (177+ Tools)
+## 📊 Complete Tool Inventory (157+ Tools)
### 🧠 Efficiency & Intelligence Tools (20+ tools) ⭐
- **Smart Discovery**: `clickup_find_chat_channels`, `clickup_search_views_by_name`, `clickup_get_workspace_overview`
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 8d0c696..47beafb 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,65 @@
# Release Notes - ClickUp MCP Server Suite
+## Version 5.1.0 - Full API Routes Overhaul (audit against current ClickUp API)
+
+**Release Date**: July 21, 2026
+**Status**: Production Ready
+**Affects**: `@chykalophia/clickup-mcp-server` (core)
+
+### Overview
+
+Every route and tool in the core server was audited against the current ClickUp
+REST API (v2 + v3 OpenAPI specs, May 2026) with independent adversarial
+verification of each finding (186 confirmed of ~192 reported). All confirmed
+request/response mismatches were fixed, ~40 tools that called endpoints that do
+not exist in the ClickUp API were removed, and documented endpoints that were
+missing from the server were added. The server now registers 157 tools, all
+backed by real, documented ClickUp endpoints.
+
+### Critical fixes
+
+- **Task descriptions**: markdown was sent as `markdown_content`; the API field
+ is `markdown_description`. Markdown descriptions were silently dropped by
+ ClickUp on every create/update (including bulk).
+- **Chat rewritten for API v3**: previous paths (`/team/{id}/chat/...`) never
+ existed. All chat tools now use `/api/v3/workspaces/{workspace_id}/chat/...`
+ with correct bodies, `{data, next_cursor}` envelopes, and cursor pagination.
+- **List-from-template**: `/list/template/` -> `/list_template/` (was a
+ guaranteed 404 for both folder and space variants).
+- **Goals key results**: correct `key_result` endpoints, `steps_*` fields,
+ response envelope, and type enum (`percentage`/`automatic`).
+- **Webhooks**: real event enum (27 events + `*`), location scoping, full-body
+ updates, real delivery payload schema, raw-body HMAC verification.
+- **Views**: real filter grammar (`op`/`values`), full-object PUT semantics,
+ real settings/divide/columns fields, 0-indexed page, team-level views.
+- **Attachments**: rebuilt on the two real endpoints (v2 multipart upload with
+ the `attachment` form field; v3 parent-entity attachment listing).
+- **Custom fields**: removed create/update/delete field-definition tools (no
+ such API), real type vocabulary, `value_options`, workspace-level listing.
+- **Dependencies**: correct `depends_on`/`dependency_of` semantics (direction
+ was inverted), query-param delete, reads via task `dependencies` arrays.
+- **Docs v3**: correct create-doc body (`parent`, `visibility`, `create_page`),
+ `next_cursor` pagination, real `content_format` values, `content_edit_mode`
+ append/prepend, `pageListing`; removed unsupported update/delete/sharing/
+ template tools.
+- **Time tracking**: single-object running-timer response, `end` field on
+ update, `tag_action`, required `duration`, start-timer body params.
+- **Client/auth**: OAuth `Bearer` vs raw `pk_` token handling, `Retry-After`
+ honored as a minimum wait, `ECODE` surfaced in errors, 429 retry.
+
+### New tools (documented endpoints previously missing)
+
+Workspace-wide task search, native task merge, task tags add/remove,
+task-from-template, get folder, folder-from-template, list members, space
+create/update/delete + space tag CRUD, Everything-level (team) views,
+workspace custom fields, single time entry + time-entry tags, doc pageListing,
+whoami, user groups, workspace plan, and custom roles.
+
+### Verification
+
+150/150 jest tests pass; strict typecheck of the client/schema/util layers is
+clean; live MCP handshake registers all 157 tools.
+
## Version 5.0.3 - Fix `clickup_update_task` Assignees (silent watcher bug)
**Release Date**: May 12, 2026
diff --git a/package-lock.json b/package-lock.json
index e2facb0..c801593 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "clickup-mcp-monorepo",
- "version": "5.0.0",
+ "version": "5.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clickup-mcp-monorepo",
- "version": "5.0.0",
+ "version": "5.0.3",
"license": "MIT",
"workspaces": [
"packages/*"
@@ -7783,7 +7783,7 @@
},
"packages/core": {
"name": "@chykalophia/clickup-mcp-server",
- "version": "5.0.0",
+ "version": "5.0.3",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
diff --git a/package.json b/package.json
index 4f3eb3c..c8af514 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "clickup-mcp-monorepo",
- "version": "5.0.3",
- "description": "ClickUp MCP Server monorepo - Core server with 177+ tools and Intelligence server with AI-powered project management features",
+ "version": "5.1.0",
+ "description": "ClickUp MCP Server monorepo - Core server with 157+ tools and Intelligence server with AI-powered project management features",
"private": true,
"workspaces": [
"packages/*"
diff --git a/packages/core/package.json b/packages/core/package.json
index 0f97fd0..5e95320 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,7 +1,7 @@
{
"name": "@chykalophia/clickup-mcp-server",
- "version": "5.0.3",
- "description": "An intelligent Model Context Protocol server for ClickUp API integration with 177+ tools, AI-powered efficiency optimization, smart tool suggestions, and context-aware workflow recommendations",
+ "version": "5.1.0",
+ "description": "An intelligent Model Context Protocol server for ClickUp API integration with 157+ tools, AI-powered efficiency optimization, smart tool suggestions, and context-aware workflow recommendations",
"main": "build/index-enhanced.js",
"bin": {
"clickup-mcp-server": "build/index-enhanced.js",
diff --git a/packages/core/src/clickup-client/attachments-enhanced.ts b/packages/core/src/clickup-client/attachments-enhanced.ts
index b23cc97..11354e3 100644
--- a/packages/core/src/clickup-client/attachments-enhanced.ts
+++ b/packages/core/src/clickup-client/attachments-enhanced.ts
@@ -1,374 +1,95 @@
-/* eslint-disable max-len */
+import { readFile } from 'fs/promises';
import { ClickUpClient } from './index.js';
import type {
UploadAttachmentRequest,
- UpdateAttachmentMetadataRequest,
- GetAttachmentsFilter,
- AttachmentSharingRequest,
- BulkAttachmentOperation,
+ GetAttachmentsRequest,
+ AttachmentEntityType,
AttachmentResponse,
AttachmentListResponse,
- AttachmentUploadResponse,
- AttachmentStatsResponse,
} from '../schemas/attachments-schemas.js';
+// The list attachments endpoint only exists in the v3 API; the shared client
+// is bound to the v2 base URL, so v3 calls use absolute URLs (axios ignores
+// baseURL when the request URL is absolute).
+const V3_API_BASE_URL = 'https://api.clickup.com/api/v3';
+
+// Maps our entity types to the v3 endpoint's path segment values
+const ENTITY_TYPE_PATH_SEGMENTS: Record = {
+ task: 'attachments',
+ custom_field: 'custom_fields',
+};
+
export class AttachmentsEnhancedClient extends ClickUpClient {
constructor(apiToken: string) {
super({ apiToken });
}
/**
- * Upload a new attachment
- */
- async uploadAttachment(request: UploadAttachmentRequest): Promise {
- const endpoint = this.getParentEndpoint(request.parent_type, request.parent_id);
-
- const payload: any = {
- filename: request.filename,
- source: request.source,
- description: request.description,
- tags: request.tags,
- };
-
- if (request.file_data) {
- payload.file_data = request.file_data;
- } else if (request.file_url) {
- payload.file_url = request.file_url;
- }
-
- const response = await this.post(`${endpoint}/attachment`, payload);
- return response;
- }
-
- /**
- * Get attachments for a parent object
- */
- async getAttachments(filter: GetAttachmentsFilter): Promise {
- const endpoint = this.getParentEndpoint(filter.parent_type, filter.parent_id);
-
- const params = new URLSearchParams();
- if (filter.type) params.append('type', filter.type);
- if (filter.filename_contains) params.append('filename_contains', filter.filename_contains);
- if (filter.tags) params.append('tags', filter.tags.join(','));
- if (filter.date_from) params.append('date_from', filter.date_from.toString());
- if (filter.date_to) params.append('date_to', filter.date_to.toString());
- if (filter.limit) params.append('limit', filter.limit.toString());
- if (filter.offset) params.append('offset', filter.offset.toString());
-
- const queryString = params.toString();
- const fullEndpoint = `${endpoint}/attachment${queryString ? `?${queryString}` : ''}`;
-
- const response = await this.get(fullEndpoint);
- return response;
- }
-
- /**
- * Get a specific attachment by ID
- */
- async getAttachment(attachmentId: string): Promise {
- const response = await this.get<{ attachment: AttachmentResponse }>(
- `/attachment/${attachmentId}`
- );
- return response.attachment;
- }
-
- /**
- * Update attachment metadata
+ * Upload a file to a task (POST /api/v2/task/{task_id}/attachment).
+ * The file is sent as multipart/form-data with the binary in the
+ * 'attachment' form field. Returns the created attachment object.
*/
- async updateAttachmentMetadata(
- request: UpdateAttachmentMetadataRequest
- ): Promise {
- const updateData: Record = {};
-
- if (request.filename !== undefined) updateData.filename = request.filename;
- if (request.description !== undefined) updateData.description = request.description;
- if (request.tags !== undefined) updateData.tags = request.tags;
-
- const response = await this.put<{ attachment: AttachmentResponse }>(
- `/attachment/${request.attachment_id}`,
- updateData
+ async uploadAttachment(request: UploadAttachmentRequest): Promise {
+ const fileBytes = await this.resolveFileBytes(request);
+
+ const form = new FormData();
+ form.append('attachment', new Blob([fileBytes]), request.filename);
+ form.append('filename', request.filename);
+
+ const params: Record = {};
+ if (request.custom_task_ids) params.custom_task_ids = 'true';
+ if (request.team_id) params.team_id = request.team_id;
+
+ const response = await this.getAxiosInstance().post(
+ `/task/${request.task_id}/attachment`,
+ form,
+ {
+ params,
+ // Clear the instance-level 'application/json' default so axios
+ // serializes the FormData and sets the multipart/form-data
+ // content type with the correct boundary.
+ headers: { 'Content-Type': undefined },
+ }
);
- return response.attachment;
+ return response.data;
}
/**
- * Delete an attachment
+ * List attachments for a task or File custom field
+ * (GET /api/v3/workspaces/{workspace_id}/{entity_type}/{entity_id}/attachments).
+ * Results are cursor-paginated via limit + next_cursor.
*/
- async deleteAttachment(attachmentId: string): Promise<{ success: boolean }> {
- await this.delete(`/attachment/${attachmentId}`);
- return { success: true };
- }
-
- /**
- * Download an attachment
- */
- async downloadAttachment(attachmentId: string): Promise<{
- filename: string;
- mimetype: string;
- size: number;
- download_url: string;
- expires_at: string;
- }> {
- const response = await this.get<{
- filename: string;
- mimetype: string;
- size: number;
- download_url: string;
- expires_at: string;
- }>(`/attachment/${attachmentId}/download`);
-
- return response;
- }
-
- /**
- * Get attachment info without downloading
- */
- async getAttachmentInfo(attachmentId: string): Promise<{
- attachment: AttachmentResponse;
- download_info: {
- can_download: boolean;
- download_url?: string;
- expires_at?: string;
- requires_auth: boolean;
- };
- preview_info: {
- can_preview: boolean;
- preview_url?: string;
- thumbnail_url?: string;
- preview_type?: string;
- };
- }> {
- const response = await this.get<{
- attachment: AttachmentResponse;
- download_info: {
- can_download: boolean;
- download_url?: string;
- expires_at?: string;
- requires_auth: boolean;
- };
- preview_info: {
- can_preview: boolean;
- preview_url?: string;
- thumbnail_url?: string;
- preview_type?: string;
- };
- }>(`/attachment/${attachmentId}/info`);
-
- return response;
- }
-
- /**
- * Update attachment sharing settings
- */
- async updateAttachmentSharing(request: AttachmentSharingRequest): Promise {
- const payload = {
- access_level: request.access_level,
- expires_at: request.expires_at
- ? new Date(request.expires_at * 1000).toISOString()
- : undefined,
- password: request.password,
- };
-
- const response = await this.put<{ attachment: AttachmentResponse }>(
- `/attachment/${request.attachment_id}/sharing`,
- payload
+ async getAttachments(request: GetAttachmentsRequest): Promise {
+ const params: Record = {};
+ if (request.limit !== undefined) params.limit = request.limit;
+ if (request.next_cursor) params.cursor = request.next_cursor;
+
+ const entitySegment = ENTITY_TYPE_PATH_SEGMENTS[request.entity_type];
+ const response = await this.getAxiosInstance().get(
+ `${V3_API_BASE_URL}/workspaces/${request.workspace_id}/${entitySegment}/${request.entity_id}/attachments`,
+ { params }
);
- return response.attachment;
+ return response.data;
}
- /**
- * Perform bulk attachment operations
- */
- async bulkAttachmentOperations(operation: BulkAttachmentOperation): Promise<{
- success: boolean;
- results: Array<{
- attachment_id: string;
- success: boolean;
- error?: string;
- }>;
- }> {
- const response = await this.post<{
- success: boolean;
- results: Array<{
- attachment_id: string;
- success: boolean;
- error?: string;
- }>;
- }>('/attachment/bulk', operation);
-
- return response;
- }
-
- /**
- * Get attachment statistics for a workspace
- */
- async getAttachmentStats(workspaceId: string): Promise {
- const response = await this.get(
- `/team/${workspaceId}/attachment/stats`
- );
- return response;
- }
+ // Helper methods
- /**
- * Search attachments across workspace
- */
- async searchAttachments(
- workspaceId: string,
- query: {
- search_term?: string;
- type?: string;
- parent_type?: string;
- tags?: string[];
- date_from?: number;
- date_to?: number;
- min_size?: number;
- max_size?: number;
- uploaded_by?: number;
- limit?: number;
- offset?: number;
+ private async resolveFileBytes(request: UploadAttachmentRequest): Promise {
+ if (request.file_data) {
+ return Buffer.from(request.file_data, 'base64');
}
- ): Promise {
- const params = new URLSearchParams();
- if (query.search_term) params.append('search_term', query.search_term);
- if (query.type) params.append('type', query.type);
- if (query.parent_type) params.append('parent_type', query.parent_type);
- if (query.tags) params.append('tags', query.tags.join(','));
- if (query.date_from) params.append('date_from', query.date_from.toString());
- if (query.date_to) params.append('date_to', query.date_to.toString());
- if (query.min_size) params.append('min_size', query.min_size.toString());
- if (query.max_size) params.append('max_size', query.max_size.toString());
- if (query.uploaded_by) params.append('uploaded_by', query.uploaded_by.toString());
- if (query.limit) params.append('limit', query.limit.toString());
- if (query.offset) params.append('offset', query.offset.toString());
-
- const queryString = params.toString();
- const endpoint = `/team/${workspaceId}/attachment/search${queryString ? `?${queryString}` : ''}`;
-
- const response = await this.get(endpoint);
- return response;
- }
-
- /**
- * Generate attachment thumbnail
- */
- async generateAttachmentThumbnail(
- attachmentId: string,
- options?: {
- width?: number;
- height?: number;
- quality?: number;
+ if (request.file_path) {
+ return readFile(request.file_path);
}
- ): Promise<{
- thumbnail_url: string;
- expires_at: string;
- }> {
- const params = new URLSearchParams();
- if (options?.width) params.append('width', options.width.toString());
- if (options?.height) params.append('height', options.height.toString());
- if (options?.quality) params.append('quality', options.quality.toString());
-
- const queryString = params.toString();
- const endpoint = `/attachment/${attachmentId}/thumbnail${queryString ? `?${queryString}` : ''}`;
-
- const response = await this.post<{
- thumbnail_url: string;
- expires_at: string;
- }>(endpoint);
-
- return response;
- }
-
- /**
- * Copy attachment to another parent
- */
- async copyAttachment(
- attachmentId: string,
- targetParentId: string,
- targetParentType: string
- ): Promise {
- const payload = {
- target_parent_id: targetParentId,
- target_parent_type: targetParentType,
- };
-
- const response = await this.post<{ attachment: AttachmentResponse }>(
- `/attachment/${attachmentId}/copy`,
- payload
- );
- return response.attachment;
- }
-
- /**
- * Move attachment to another parent
- */
- async moveAttachment(
- attachmentId: string,
- targetParentId: string,
- targetParentType: string
- ): Promise {
- const payload = {
- target_parent_id: targetParentId,
- target_parent_type: targetParentType,
- };
-
- const response = await this.put<{ attachment: AttachmentResponse }>(
- `/attachment/${attachmentId}/move`,
- payload
- );
- return response.attachment;
- }
-
- /**
- * Get attachment version history
- */
- async getAttachmentVersions(attachmentId: string): Promise<{
- versions: Array<{
- version_id: string;
- version_number: number;
- filename: string;
- size: number;
- date_created: string;
- uploaded_by: {
- id: number;
- username: string;
- };
- is_current: boolean;
- download_url?: string;
- }>;
- }> {
- const response = await this.get<{
- versions: Array<{
- version_id: string;
- version_number: number;
- filename: string;
- size: number;
- date_created: string;
- uploaded_by: {
- id: number;
- username: string;
- };
- is_current: boolean;
- download_url?: string;
- }>;
- }>(`/attachment/${attachmentId}/versions`);
-
- return response;
- }
-
- // Helper methods
-
- private getParentEndpoint(parentType: string, parentId: string): string {
- switch (parentType) {
- case 'task':
- return `/task/${parentId}`;
- case 'comment':
- return `/comment/${parentId}`;
- case 'doc':
- return `/doc/${parentId}`;
- case 'chat':
- return `/chat/${parentId}`;
- default:
- throw new Error(`Invalid parent type: ${parentType}`);
+ if (request.file_url) {
+ const response = await fetch(request.file_url);
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch file from URL (${response.status} ${response.statusText})`
+ );
+ }
+ return Buffer.from(await response.arrayBuffer());
}
+ throw new Error('One of file_data, file_path, or file_url must be provided');
}
}
diff --git a/packages/core/src/clickup-client/auth.ts b/packages/core/src/clickup-client/auth.ts
index 4af9a6b..b409c01 100644
--- a/packages/core/src/clickup-client/auth.ts
+++ b/packages/core/src/clickup-client/auth.ts
@@ -40,7 +40,9 @@ export class AuthClient {
*/
async getAuthorizedUser(): Promise {
try {
- return await this.client.get('/user');
+ // The API wraps the payload in a { user: {...} } envelope
+ const response = await this.client.get<{ user: AuthorizedUser }>('/user');
+ return response.user;
} catch (error) {
console.error('Error getting authorized user:', error instanceof Error ? error.message : error);
throw error;
@@ -295,14 +297,16 @@ export class AuthClient {
* @returns Seats information including used, total, and available seats
*/
async getWorkspaceSeats(workspaceId: string): Promise<{
- members: object;
- filled_members_seats: number;
- total_member_seats: number;
- empty_member_seats: number;
- guests: object;
- filled_guest_seats: number;
- total_guest_seats: number;
- empty_guest_seats: number;
+ members: {
+ filled_member_seats: number;
+ total_member_seats: number;
+ empty_member_seats: number;
+ };
+ guests: {
+ filled_guest_seats: number;
+ total_guest_seats: number;
+ empty_guest_seats: number;
+ };
}> {
try {
return await this.client.get(`/team/${workspaceId}/seats`);
@@ -311,6 +315,94 @@ export class AuthClient {
throw error;
}
}
+
+ /**
+ * Get the User Groups (ClickUp "Teams" feature) in a workspace
+ * @param workspaceId The ID of the workspace to get user groups for
+ * @param groupIds Optional comma-separated group IDs to filter by
+ * @returns A list of user groups
+ */
+ async getUserGroups(workspaceId: string, groupIds?: string): Promise<{
+ groups: Array<{
+ id: string;
+ team_id: string;
+ userid: number;
+ name: string;
+ handle: string;
+ date_created: string;
+ initials: string;
+ members: Array<{
+ id: number;
+ username: string;
+ email: string;
+ color: string;
+ initials: string;
+ profilePicture: string | null;
+ }>;
+ avatar: {
+ attachment_id: string | null;
+ color: string | null;
+ source: string | null;
+ icon: string | null;
+ };
+ }>;
+ }> {
+ try {
+ const params: Record = { team_id: workspaceId };
+ if (groupIds) {
+ params.group_ids = groupIds;
+ }
+ return await this.client.get('/group', params);
+ } catch (error) {
+ console.error('Error getting user groups:', error instanceof Error ? error.message : error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get the pricing plan of a workspace
+ * @param workspaceId The ID of the workspace to get the plan for
+ * @returns The workspace's current pricing plan id and name
+ */
+ async getWorkspacePlan(workspaceId: string): Promise<{
+ plan_id: number;
+ plan_name: string;
+ }> {
+ try {
+ return await this.client.get(`/team/${workspaceId}/plan`);
+ } catch (error) {
+ console.error('Error getting workspace plan:', error instanceof Error ? error.message : error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get the Custom Roles defined in a workspace
+ * @param workspaceId The ID of the workspace to get custom roles for
+ * @param includeMembers Whether to include the members assigned to each role
+ * @returns A list of custom roles
+ */
+ async getCustomRoles(workspaceId: string, includeMembers?: boolean): Promise<{
+ custom_roles: Array<{
+ id: number;
+ team_id: string;
+ name: string;
+ inherited_role: number;
+ date_created: string;
+ members?: number[];
+ }>;
+ }> {
+ try {
+ const params: Record = {};
+ if (includeMembers !== undefined) {
+ params.include_members = includeMembers;
+ }
+ return await this.client.get(`/team/${workspaceId}/customroles`, params);
+ } catch (error) {
+ console.error('Error getting custom roles:', error instanceof Error ? error.message : error);
+ throw error;
+ }
+ }
}
export const createAuthClient = (client: ClickUpClient): AuthClient => {
diff --git a/packages/core/src/clickup-client/chat-enhanced.ts b/packages/core/src/clickup-client/chat-enhanced.ts
index 4ab4487..df508c9 100644
--- a/packages/core/src/clickup-client/chat-enhanced.ts
+++ b/packages/core/src/clickup-client/chat-enhanced.ts
@@ -5,64 +5,49 @@ import type {
CreateDirectMessageRequest,
UpdateChannelRequest,
GetChannelsFilter,
+ GetChannelUsersFilter,
SendMessageRequest,
UpdateMessageRequest,
CreateReplyRequest,
GetMessagesFilter,
GetRepliesFilter,
+ GetReactionsFilter,
CreateReactionRequest,
DeleteReactionRequest,
- AddChannelMemberRequest,
- RemoveChannelMemberRequest,
+ GetTaggedUsersFilter,
ChatChannel,
ChatMessage,
ChatReaction,
- ChatMember,
+ ChatSimpleUser,
} from '../schemas/chat-schemas.js';
-export interface ChatChannelsResponse {
- channels: ChatChannel[];
-}
-
-export interface ChatMessagesResponse {
- messages: ChatMessage[];
- has_more: boolean;
- next_cursor?: string;
-}
-
-export interface ChatRepliesResponse {
- replies: ChatMessage[];
- has_more: boolean;
- next_cursor?: string;
-}
-
-export interface ChatReactionsResponse {
- reactions: ChatReaction[];
-}
+// The ClickUp Chat API only exists under API v3
+const CHAT_API_BASE_URL = 'https://api.clickup.com/api/v3';
-export interface ChatMembersResponse {
- members: ChatMember[];
+// All v3 chat list endpoints share the {data, next_cursor} envelope
+export interface ChatPaginatedResponse {
+ data: T[];
+ next_cursor?: string | null;
}
-export interface ChatFollowersResponse {
- followers: ChatMember[];
+// Single-object responses are wrapped in {data: {...}}
+interface ChatDataResponse {
+ data: T;
}
-export interface TaggedUsersResponse {
- tagged_users: {
- id: number;
- username: string;
- email: string;
- color: string;
- profilePicture?: string;
- }[];
-}
+export type ChatChannelsResponse = ChatPaginatedResponse;
+export type ChatMessagesResponse = ChatPaginatedResponse;
+export type ChatRepliesResponse = ChatPaginatedResponse;
+export type ChatReactionsResponse = ChatPaginatedResponse;
+export type ChatMembersResponse = ChatPaginatedResponse;
+export type ChatFollowersResponse = ChatPaginatedResponse;
+export type TaggedUsersResponse = ChatPaginatedResponse;
export class ChatEnhancedClient {
private client: ClickUpClient;
constructor(apiToken: string) {
- this.client = new ClickUpClient({ apiToken });
+ this.client = new ClickUpClient({ apiToken, baseUrl: CHAT_API_BASE_URL });
}
// ========================================
@@ -70,15 +55,12 @@ export class ChatEnhancedClient {
// ========================================
/**
- * Retrieve all channels in a workspace
+ * Retrieve channels in a workspace (cursor-paginated)
*/
async getChannels(filter: GetChannelsFilter): Promise {
- const params: any = {};
- if (filter.archived !== undefined) params.archived = filter.archived;
- if (filter.type) params.type = filter.type;
-
+ const { workspace_id, ...params } = filter;
return this.client.get(
- `/team/${filter.workspace_id}/chat/channels`,
+ `/workspaces/${workspace_id}/chat/channels`,
params
);
}
@@ -88,79 +70,87 @@ export class ChatEnhancedClient {
*/
async createChannel(request: CreateChannelRequest): Promise {
const { workspace_id, ...channelData } = request;
- return this.client.post(`/team/${workspace_id}/chat/channel`, channelData);
+ const response = await this.client.post>(
+ `/workspaces/${workspace_id}/chat/channels`,
+ channelData
+ );
+ return response.data;
}
/**
* Create a channel on a specific space, folder, or list
+ * (the channel name derives from the location; no name field)
*/
async createChannelOnParent(request: CreateChannelOnParentRequest): Promise {
- const { parent_id, parent_type, ...channelData } = request;
- return this.client.post(`/${parent_type}/${parent_id}/chat/channel`, channelData);
+ const { workspace_id, parent_id, parent_type, ...channelData } = request;
+ const response = await this.client.post>(
+ `/workspaces/${workspace_id}/chat/channels/location`,
+ {
+ ...channelData,
+ location: { id: parent_id, type: parent_type },
+ }
+ );
+ return response.data;
}
/**
- * Create a direct message channel
+ * Create a direct message channel (up to 15 users; empty = self DM)
*/
async createDirectMessage(request: CreateDirectMessageRequest): Promise {
const { workspace_id, ...dmData } = request;
- return this.client.post(`/team/${workspace_id}/chat/dm`, dmData);
+ const response = await this.client.post>(
+ `/workspaces/${workspace_id}/chat/channels/direct_message`,
+ dmData
+ );
+ return response.data;
}
/**
* Get a single channel by ID
*/
- async getChannel(channelId: string): Promise {
- return this.client.get(`/chat/channel/${channelId}`);
+ async getChannel(workspaceId: string, channelId: string): Promise {
+ const response = await this.client.get>(
+ `/workspaces/${workspaceId}/chat/channels/${channelId}`
+ );
+ return response.data;
}
/**
* Update a channel
*/
async updateChannel(request: UpdateChannelRequest): Promise {
- const { channel_id, ...updateData } = request;
- return this.client.patch(`/chat/channel/${channel_id}`, updateData);
+ const { workspace_id, channel_id, ...updateData } = request;
+ const response = await this.client.patch>(
+ `/workspaces/${workspace_id}/chat/channels/${channel_id}`,
+ updateData
+ );
+ return response.data;
}
- /**
- * Delete a channel (NOT IMPLEMENTED - too dangerous as per instructions)
- */
- // async deleteChannel(channelId: string): Promise {
- // return this.client.delete(`/chat/channel/${channelId}`);
- // }
-
// ========================================
// CHANNEL MEMBERS & FOLLOWERS
// ========================================
/**
- * Get channel followers
- */
- async getChannelFollowers(channelId: string): Promise {
- return this.client.get(`/chat/channel/${channelId}/followers`);
- }
-
- /**
- * Get channel members
- */
- async getChannelMembers(channelId: string): Promise {
- return this.client.get(`/chat/channel/${channelId}/members`);
- }
-
- /**
- * Add member to channel
+ * Get channel followers (cursor-paginated)
*/
- async addChannelMember(request: AddChannelMemberRequest): Promise {
- const { channel_id, user_id } = request;
- return this.client.post(`/chat/channel/${channel_id}/member/${user_id}`, {});
+ async getChannelFollowers(filter: GetChannelUsersFilter): Promise {
+ const { workspace_id, channel_id, ...params } = filter;
+ return this.client.get(
+ `/workspaces/${workspace_id}/chat/channels/${channel_id}/followers`,
+ params
+ );
}
/**
- * Remove member from channel
+ * Get channel members (cursor-paginated)
*/
- async removeChannelMember(request: RemoveChannelMemberRequest): Promise {
- const { channel_id, user_id } = request;
- return this.client.delete(`/chat/channel/${channel_id}/member/${user_id}`);
+ async getChannelMembers(filter: GetChannelUsersFilter): Promise {
+ const { workspace_id, channel_id, ...params } = filter;
+ return this.client.get(
+ `/workspaces/${workspace_id}/chat/channels/${channel_id}/members`,
+ params
+ );
}
// ========================================
@@ -168,37 +158,43 @@ export class ChatEnhancedClient {
// ========================================
/**
- * Get messages from a channel
+ * Get messages from a channel (cursor-paginated)
*/
async getChannelMessages(filter: GetMessagesFilter): Promise {
- const { channel_id, ...params } = filter;
- return this.client.get(`/chat/channel/${channel_id}/messages`, params);
+ const { workspace_id, channel_id, ...params } = filter;
+ return this.client.get(
+ `/workspaces/${workspace_id}/chat/channels/${channel_id}/messages`,
+ params
+ );
}
/**
* Send a message to a channel
*/
async sendMessage(request: SendMessageRequest): Promise {
- const { channel_id, ...messageData } = request;
- return this.client.post(`/chat/channel/${channel_id}/message`, messageData);
+ const { workspace_id, channel_id, ...messageData } = request;
+ return this.client.post(
+ `/workspaces/${workspace_id}/chat/channels/${channel_id}/messages`,
+ messageData
+ );
}
/**
- * Update a message
+ * Update a message (message IDs are workspace-scoped in v3)
*/
async updateMessage(request: UpdateMessageRequest): Promise {
- const { channel_id, message_id, ...updateData } = request;
+ const { workspace_id, message_id, ...updateData } = request;
return this.client.patch(
- `/chat/channel/${channel_id}/message/${message_id}`,
+ `/workspaces/${workspace_id}/chat/messages/${message_id}`,
updateData
);
}
/**
- * Delete a message
+ * Delete a message (204 No Content)
*/
- async deleteMessage(channelId: string, messageId: string): Promise {
- return this.client.delete(`/chat/channel/${channelId}/message/${messageId}`);
+ async deleteMessage(workspaceId: string, messageId: string): Promise {
+ await this.client.delete(`/workspaces/${workspaceId}/chat/messages/${messageId}`);
}
// ========================================
@@ -206,12 +202,12 @@ export class ChatEnhancedClient {
// ========================================
/**
- * Get replies to a message
+ * Get replies to a message (cursor-paginated)
*/
async getMessageReplies(filter: GetRepliesFilter): Promise {
- const { channel_id, message_id, ...params } = filter;
+ const { workspace_id, message_id, ...params } = filter;
return this.client.get(
- `/chat/channel/${channel_id}/message/${message_id}/replies`,
+ `/workspaces/${workspace_id}/chat/messages/${message_id}/replies`,
params
);
}
@@ -220,9 +216,9 @@ export class ChatEnhancedClient {
* Create a reply to a message
*/
async createReply(request: CreateReplyRequest): Promise {
- const { channel_id, message_id, ...replyData } = request;
+ const { workspace_id, message_id, ...replyData } = request;
return this.client.post(
- `/chat/channel/${channel_id}/message/${message_id}/reply`,
+ `/workspaces/${workspace_id}/chat/messages/${message_id}/replies`,
replyData
);
}
@@ -232,32 +228,34 @@ export class ChatEnhancedClient {
// ========================================
/**
- * Get reactions for a message
+ * Get reactions for a message (cursor-paginated)
*/
- async getMessageReactions(channelId: string, messageId: string): Promise {
+ async getMessageReactions(filter: GetReactionsFilter): Promise {
+ const { workspace_id, message_id, ...params } = filter;
return this.client.get(
- `/chat/channel/${channelId}/message/${messageId}/reactions`
+ `/workspaces/${workspace_id}/chat/messages/${message_id}/reactions`,
+ params
);
}
/**
- * Create a reaction on a message
+ * Create a reaction on a message (reaction = emoji name, sent in the body)
*/
- async createReaction(request: CreateReactionRequest): Promise {
- const { channel_id, message_id, reaction } = request;
- return this.client.post(
- `/chat/channel/${channel_id}/message/${message_id}/reaction/${reaction}`,
- {}
+ async createReaction(request: CreateReactionRequest): Promise {
+ const { workspace_id, message_id, reaction } = request;
+ return this.client.post(
+ `/workspaces/${workspace_id}/chat/messages/${message_id}/reactions`,
+ { reaction }
);
}
/**
- * Delete a reaction from a message
+ * Delete a reaction from a message (204 No Content)
*/
async deleteReaction(request: DeleteReactionRequest): Promise {
- const { channel_id, message_id, reaction } = request;
- return this.client.delete(
- `/chat/channel/${channel_id}/message/${message_id}/reaction/${reaction}`
+ const { workspace_id, message_id, reaction } = request;
+ await this.client.delete(
+ `/workspaces/${workspace_id}/chat/messages/${message_id}/reactions/${encodeURIComponent(reaction)}`
);
}
@@ -266,11 +264,13 @@ export class ChatEnhancedClient {
// ========================================
/**
- * Get tagged users for a message
+ * Get tagged users for a message (cursor-paginated)
*/
- async getTaggedUsers(channelId: string, messageId: string): Promise {
+ async getTaggedUsers(filter: GetTaggedUsersFilter): Promise {
+ const { workspace_id, message_id, ...params } = filter;
return this.client.get(
- `/chat/channel/${channelId}/message/${messageId}/tagged`
+ `/workspaces/${workspace_id}/chat/messages/${message_id}/tagged_users`,
+ params
);
}
@@ -279,36 +279,29 @@ export class ChatEnhancedClient {
// ========================================
/**
- * Search channels by name
+ * Search channels by name (client-side filter over the paginated
+ * channel list — the v3 API has no search parameter)
*/
async searchChannels(workspaceId: string, query: string): Promise {
- return this.client.get(`/team/${workspaceId}/chat/channels`, {
- search: query,
- });
- }
-
- /**
- * Get channel statistics
- */
- async getChannelStats(channelId: string): Promise<{
- message_count: number;
- member_count: number;
- last_activity: string;
- }> {
- return this.client.get(`/chat/channel/${channelId}/stats`);
- }
-
- /**
- * Mark channel as read
- */
- async markChannelAsRead(channelId: string): Promise {
- return this.client.post(`/chat/channel/${channelId}/read`, {});
- }
-
- /**
- * Get unread message count for channel
- */
- async getUnreadCount(channelId: string): Promise<{ unread_count: number }> {
- return this.client.get(`/chat/channel/${channelId}/unread`);
+ const matches: ChatChannel[] = [];
+ const lowerQuery = query.toLowerCase();
+ let cursor: string | undefined;
+ const maxPages = 10;
+
+ for (let page = 0; page < maxPages; page++) {
+ const response = await this.client.get(
+ `/workspaces/${workspaceId}/chat/channels`,
+ cursor ? { cursor, limit: 100 } : { limit: 100 }
+ );
+ const channels = response.data ?? [];
+ matches.push(...channels.filter(channel => channel.name?.toLowerCase().includes(lowerQuery)));
+
+ if (!response.next_cursor) {
+ break;
+ }
+ cursor = response.next_cursor;
+ }
+
+ return { data: matches };
}
}
diff --git a/packages/core/src/clickup-client/checklists.ts b/packages/core/src/clickup-client/checklists.ts
index 666f11b..6a6949c 100644
--- a/packages/core/src/clickup-client/checklists.ts
+++ b/packages/core/src/clickup-client/checklists.ts
@@ -18,7 +18,12 @@ export interface Checklist {
id: string;
task_id: string;
name: string;
+ date_created?: string;
orderindex: number;
+ /** Number of resolved items in the checklist */
+ resolved: number;
+ /** Number of unresolved items in the checklist */
+ unresolved: number;
items: ChecklistItem[];
}
@@ -28,20 +33,30 @@ export interface CreateChecklistParams {
// Items must be created separately using the createChecklistItem method
}
+export interface CreateChecklistOptions {
+ custom_task_ids?: boolean;
+ team_id?: string;
+}
+
export interface UpdateChecklistParams {
- name: string;
+ name?: string;
+ /** Order of appearance of the checklist on the task; 0 = top */
+ position?: number;
}
export interface CreateChecklistItemParams {
name: string;
assignee?: number;
- resolved?: boolean;
+ // Note: The Create Checklist Item endpoint does not accept 'resolved';
+ // resolve an item via updateChecklistItem after creating it
}
export interface UpdateChecklistItemParams {
name?: string;
- assignee?: number;
+ assignee?: number | string | null;
resolved?: boolean;
+ /** Checklist item ID to nest this item under, or null to un-nest */
+ parent?: string | null;
}
export class ChecklistsClient {
@@ -55,28 +70,50 @@ export class ChecklistsClient {
* Create a new checklist in a task
* @param taskId The ID of the task to create the checklist in
* @param params The checklist parameters
- * @returns The created checklist
+ * @param options Optional custom_task_ids/team_id query params (needed when
+ * taskId is a custom task ID)
+ * @returns The created checklist (unwrapped from ClickUp's { checklist } envelope)
*/
- async createChecklist(taskId: string, params: CreateChecklistParams): Promise {
- return this.client.post(`/task/${taskId}/checklist`, params);
+ async createChecklist(
+ taskId: string,
+ params: CreateChecklistParams,
+ options?: CreateChecklistOptions
+ ): Promise {
+ // ClickUpClient.post has no query-param passthrough, so build the query
+ // string manually (same approach as tasks.ts getBulkTasksTimeInStatus).
+ const search = new URLSearchParams();
+ if (options?.custom_task_ids !== undefined) {
+ search.set('custom_task_ids', String(options.custom_task_ids));
+ }
+ if (options?.team_id !== undefined) {
+ search.set('team_id', options.team_id);
+ }
+ const query = search.toString();
+ const endpoint = query ? `/task/${taskId}/checklist?${query}` : `/task/${taskId}/checklist`;
+ const response = await this.client.post<{ checklist: Checklist }>(endpoint, params);
+ return response.checklist;
}
/**
- * Update an existing checklist
+ * Update an existing checklist (rename and/or reorder)
* @param checklistId The ID of the checklist to update
- * @param params The checklist parameters to update
- * @returns The updated checklist
+ * @param params The checklist parameters to update (name and/or position)
+ * @returns An empty object — ClickUp returns {} for this endpoint; use Get Task
+ * to see the updated checklist state
*/
- async updateChecklist(checklistId: string, params: UpdateChecklistParams): Promise {
+ async updateChecklist(
+ checklistId: string,
+ params: UpdateChecklistParams
+ ): Promise> {
return this.client.put(`/checklist/${checklistId}`, params);
}
/**
* Delete a checklist
* @param checklistId The ID of the checklist to delete
- * @returns Success message
+ * @returns An empty object — ClickUp returns {} for this endpoint
*/
- async deleteChecklist(checklistId: string): Promise<{ success: boolean }> {
+ async deleteChecklist(checklistId: string): Promise> {
return this.client.delete(`/checklist/${checklistId}`);
}
@@ -84,13 +121,18 @@ export class ChecklistsClient {
* Create a new checklist item in a checklist
* @param checklistId The ID of the checklist to create the item in
* @param params The checklist item parameters
- * @returns The created checklist item
+ * @returns The full parent checklist (including all items), unwrapped from
+ * ClickUp's { checklist } envelope — the API does not return the bare item
*/
async createChecklistItem(
checklistId: string,
params: CreateChecklistItemParams
- ): Promise {
- return this.client.post(`/checklist/${checklistId}/checklist_item`, params);
+ ): Promise {
+ const response = await this.client.post<{ checklist: Checklist }>(
+ `/checklist/${checklistId}/checklist_item`,
+ params
+ );
+ return response.checklist;
}
/**
@@ -98,26 +140,31 @@ export class ChecklistsClient {
* @param checklistId The ID of the checklist containing the item
* @param checklistItemId The ID of the checklist item to update
* @param params The checklist item parameters to update
- * @returns The updated checklist item
+ * @returns The full parent checklist (including all items), unwrapped from
+ * ClickUp's { checklist } envelope — the API does not return the bare item
*/
async updateChecklistItem(
checklistId: string,
checklistItemId: string,
params: UpdateChecklistItemParams
- ): Promise {
- return this.client.put(`/checklist/${checklistId}/checklist_item/${checklistItemId}`, params);
+ ): Promise {
+ const response = await this.client.put<{ checklist: Checklist }>(
+ `/checklist/${checklistId}/checklist_item/${checklistItemId}`,
+ params
+ );
+ return response.checklist;
}
/**
* Delete a checklist item
* @param checklistId The ID of the checklist containing the item
* @param checklistItemId The ID of the checklist item to delete
- * @returns Success message
+ * @returns An empty object — ClickUp returns {} for this endpoint
*/
async deleteChecklistItem(
checklistId: string,
checklistItemId: string
- ): Promise<{ success: boolean }> {
+ ): Promise> {
return this.client.delete(`/checklist/${checklistId}/checklist_item/${checklistItemId}`);
}
}
diff --git a/packages/core/src/clickup-client/comments-enhanced.ts b/packages/core/src/clickup-client/comments-enhanced.ts
index 2c98faf..0b364fb 100644
--- a/packages/core/src/clickup-client/comments-enhanced.ts
+++ b/packages/core/src/clickup-client/comments-enhanced.ts
@@ -54,12 +54,16 @@ export interface Comment {
export interface GetTaskCommentsParams {
start?: number;
start_id?: string;
+ custom_task_ids?: boolean; // Set true to reference the task by its custom task ID
+ team_id?: number; // Workspace ID; required when custom_task_ids is true
}
export interface CreateTaskCommentParams {
comment_text: string;
assignee?: number;
notify_all?: boolean;
+ custom_task_ids?: boolean; // Set true to reference the task by its custom task ID
+ team_id?: number; // Workspace ID; required when custom_task_ids is true
}
export interface GetChatViewCommentsParams {
@@ -68,7 +72,8 @@ export interface GetChatViewCommentsParams {
}
export interface CreateChatViewCommentParams {
- comment_text: string;
+ comment_text?: string;
+ comment?: ClickUpCommentBlock[]; // Structured comment blocks (supports @mentions via tag blocks)
notify_all?: boolean;
}
@@ -78,13 +83,15 @@ export interface GetListCommentsParams {
}
export interface CreateListCommentParams {
- comment_text: string;
+ comment_text?: string;
+ comment?: ClickUpCommentBlock[]; // Structured comment blocks (supports @mentions via tag blocks)
assignee?: number;
notify_all?: boolean;
}
export interface UpdateCommentParams {
- comment_text: string;
+ comment_text?: string; // Optional so resolve/assign-only updates leave the comment body untouched
+ comment?: ClickUpCommentBlock[]; // Structured comment blocks (supports @mentions via tag blocks)
assignee?: number;
resolved?: boolean;
}
@@ -95,10 +102,21 @@ export interface GetThreadedCommentsParams {
}
export interface CreateThreadedCommentParams {
- comment_text: string;
+ comment_text?: string;
+ comment?: ClickUpCommentBlock[]; // Structured comment blocks (supports @mentions via tag blocks)
notify_all?: boolean;
}
+/**
+ * Response returned by ClickUp's comment-creation endpoints.
+ * Create responses do NOT include the full Comment object.
+ */
+export interface CreateCommentResponse {
+ id: string;
+ hist_id: string;
+ date: number;
+}
+
/**
* Process comment response to add markdown representation
* SIMPLIFIED VERSION to avoid duplication issues
@@ -123,6 +141,41 @@ function processCommentResponse(comment: any): Comment {
return processed;
}
+/**
+ * Build the structured comment body for a create/update request.
+ * Prefers a caller-supplied comment block array (needed for @mentions);
+ * otherwise converts comment_text (markdown) into ClickUp's comment array.
+ * Sends ONLY the 'comment' array - no comment_text - to avoid duplication.
+ */
+function buildCommentBody(params: {
+ comment_text?: string;
+ comment?: ClickUpCommentBlock[];
+}): { comment: ClickUpCommentBlock[] } {
+ if (params.comment && params.comment.length > 0) {
+ return { comment: params.comment };
+ }
+ if (params.comment_text) {
+ return prepareCommentForClickUp(params.comment_text);
+ }
+ throw new Error('Either comment_text or a structured comment array is required');
+}
+
+/**
+ * Build the query string for task-comment endpoints that support
+ * custom task IDs (custom_task_ids + team_id).
+ */
+function buildTaskQueryString(params?: { custom_task_ids?: boolean; team_id?: number }): string {
+ const query = new URLSearchParams();
+ if (params?.custom_task_ids) {
+ query.set('custom_task_ids', 'true');
+ }
+ if (params?.team_id !== undefined) {
+ query.set('team_id', String(params.team_id));
+ }
+ const queryString = query.toString();
+ return queryString ? `?${queryString}` : '';
+}
+
/**
* Prepare comment parameters for ClickUp API using structured comment format
* This uses ClickUp's structured comment array format for proper markdown rendering
@@ -195,7 +248,10 @@ export class CommentsEnhancedClient {
return result;
}
- async createTaskComment(taskId: string, params: CreateTaskCommentParams): Promise {
+ async createTaskComment(
+ taskId: string,
+ params: CreateTaskCommentParams
+ ): Promise {
// Convert comment_text to structured array format
const structuredComment = prepareCommentForClickUp(params.comment_text);
@@ -205,9 +261,11 @@ export class CommentsEnhancedClient {
...structuredComment // This adds the 'comment' array, NOT comment_text
};
- const result = await this.client.post(`/task/${taskId}/comment`, payload);
-
- return processCommentResponse(result);
+ // Create responses only contain { id, hist_id, date } - no comment array to post-process
+ return this.client.post(
+ `/task/${taskId}/comment${buildTaskQueryString(params)}`,
+ payload
+ );
}
/**
@@ -239,17 +297,14 @@ export class CommentsEnhancedClient {
async createChatViewComment(
viewId: string,
params: CreateChatViewCommentParams
- ): Promise {
- // Convert comment_text to structured array format
- const structuredComment = prepareCommentForClickUp(params.comment_text);
-
+ ): Promise {
const payload = {
notify_all: params.notify_all || false,
- ...structuredComment // This adds the 'comment' array, NOT comment_text
+ ...buildCommentBody(params) // This adds the 'comment' array, NOT comment_text
};
- const result = await this.client.post(`/view/${viewId}/comment`, payload);
- return processCommentResponse(result);
+ // Create responses only contain { id, hist_id, date } - no comment array to post-process
+ return this.client.post(`/view/${viewId}/comment`, payload);
}
/**
@@ -278,18 +333,18 @@ export class CommentsEnhancedClient {
* @param params The comment parameters (supports markdown in comment_text)
* @returns The created comment with processed content
*/
- async createListComment(listId: string, params: CreateListCommentParams): Promise {
- // Convert comment_text to structured array format
- const structuredComment = prepareCommentForClickUp(params.comment_text);
-
+ async createListComment(
+ listId: string,
+ params: CreateListCommentParams
+ ): Promise {
const payload = {
notify_all: params.notify_all || false,
assignee: params.assignee,
- ...structuredComment // This adds the 'comment' array, NOT comment_text
+ ...buildCommentBody(params) // This adds the 'comment' array, NOT comment_text
};
- const result = await this.client.post(`/list/${listId}/comment`, payload);
- return processCommentResponse(result);
+ // Create responses only contain { id, hist_id, date } - no comment array to post-process
+ return this.client.post(`/list/${listId}/comment`, payload);
}
/**
@@ -299,15 +354,17 @@ export class CommentsEnhancedClient {
* @returns The updated comment with processed content
*/
async updateComment(commentId: string, params: UpdateCommentParams): Promise {
- // Convert comment_text to structured array format
- const structuredComment = prepareCommentForClickUp(params.comment_text);
-
- const payload = {
+ const payload: Record = {
assignee: params.assignee,
- resolved: params.resolved,
- ...structuredComment // This adds the 'comment' array, NOT comment_text
+ resolved: params.resolved
};
+ // Only send a new comment body when one was provided, so resolve/assign-only
+ // updates leave the stored comment untouched
+ if ((params.comment && params.comment.length > 0) || params.comment_text) {
+ Object.assign(payload, buildCommentBody(params)); // Adds the 'comment' array, NOT comment_text
+ }
+
const result = await this.client.put(`/comment/${commentId}`, payload);
return processCommentResponse(result);
}
@@ -318,7 +375,9 @@ export class CommentsEnhancedClient {
* @returns Success message
*/
async deleteComment(commentId: string): Promise<{ success: boolean }> {
- return this.client.delete(`/comment/${commentId}`);
+ // ClickUp returns an empty object on success; synthesize success from the 2xx response
+ await this.client.delete(`/comment/${commentId}`);
+ return { success: true };
}
/**
@@ -350,17 +409,14 @@ export class CommentsEnhancedClient {
async createThreadedComment(
commentId: string,
params: CreateThreadedCommentParams
- ): Promise {
- // Convert comment_text to structured array format
- const structuredComment = prepareCommentForClickUp(params.comment_text);
-
+ ): Promise {
const payload = {
notify_all: params.notify_all || false,
- ...structuredComment // This adds the 'comment' array, NOT comment_text
+ ...buildCommentBody(params) // This adds the 'comment' array, NOT comment_text
};
- const result = await this.client.post(`/comment/${commentId}/reply`, payload);
- return processCommentResponse(result);
+ // Create responses only contain { id, hist_id, date } - no comment array to post-process
+ return this.client.post(`/comment/${commentId}/reply`, payload);
}
}
diff --git a/packages/core/src/clickup-client/comments.ts b/packages/core/src/clickup-client/comments.ts
index ea8d204..50f8f71 100644
--- a/packages/core/src/clickup-client/comments.ts
+++ b/packages/core/src/clickup-client/comments.ts
@@ -55,12 +55,16 @@ export interface Comment {
export interface GetTaskCommentsParams {
start?: number;
start_id?: string;
+ custom_task_ids?: boolean; // Set true to reference the task by its custom task ID
+ team_id?: number; // Workspace ID; required when custom_task_ids is true
}
export interface CreateTaskCommentParams {
- comment_text?: string; // Make optional since we'll be using comment array
+ comment_text: string; // Required by the API; converted to the structured comment array before sending
assignee?: number;
notify_all?: boolean;
+ custom_task_ids?: boolean; // Set true to reference the task by its custom task ID
+ team_id?: number; // Workspace ID; required when custom_task_ids is true
}
export interface GetChatViewCommentsParams {
@@ -69,7 +73,7 @@ export interface GetChatViewCommentsParams {
}
export interface CreateChatViewCommentParams {
- comment_text?: string; // Make optional since we'll be using comment array
+ comment_text: string; // Required by the API; converted to the structured comment array before sending
notify_all?: boolean;
}
@@ -79,7 +83,7 @@ export interface GetListCommentsParams {
}
export interface CreateListCommentParams {
- comment_text?: string; // Make optional since we'll be using comment array
+ comment_text: string; // Required by the API; converted to the structured comment array before sending
assignee?: number;
notify_all?: boolean;
}
@@ -96,7 +100,7 @@ export interface GetThreadedCommentsParams {
}
export interface CreateThreadedCommentParams {
- comment_text?: string; // Make optional since we'll be using comment array
+ comment_text: string; // Required by the API; converted to the structured comment array before sending
notify_all?: boolean;
}
@@ -141,51 +145,6 @@ export class CommentsClient {
return result;
}
- /**
- * Clean up ClickUp comment response by removing duplicate text blocks
- * ClickUp automatically appends original text as final block - we remove it
- */
- private cleanupCommentResponse(processed: any): any {
- if (processed.comment && Array.isArray(processed.comment) && processed.comment.length > 1) {
- const lastBlock = processed.comment[processed.comment.length - 1];
-
- // Check if the last block is a duplicate of the original markdown
- if (
- lastBlock &&
- typeof lastBlock.text === 'string' &&
- (!lastBlock.attributes || Object.keys(lastBlock.attributes).length === 0)
- ) {
- // If the last block contains markdown syntax, it's likely the duplicate
- const hasMarkdownSyntax =
- /[*_`#[\]()>-]/.test(lastBlock.text) ||
- lastBlock.text.includes('```') ||
- lastBlock.text.includes('**') ||
- lastBlock.text.includes('##');
-
- if (hasMarkdownSyntax && lastBlock.text.length > 50) {
- // Remove the duplicate block
- processed.comment = processed.comment.slice(0, -1);
-
- // Also clean up the comment_text field
- if (processed.comment_text && processed.comment_text.includes(lastBlock.text)) {
- processed.comment_text = processed.comment_text.replace(lastBlock.text, '').trim();
- }
-
- // Regenerate clean comment_markdown
- if (processed.comment && Array.isArray(processed.comment)) {
- try {
- processed.comment_markdown = clickUpCommentToMarkdown({ comment: processed.comment });
- } catch (error) {
- console.warn('Failed to regenerate clean comment markdown:', error);
- }
- }
- }
- }
- }
-
- return processed;
- }
-
/**
* Create a new comment on a task
* @param taskId The ID of the task to comment on
@@ -196,6 +155,18 @@ export class CommentsClient {
// Use ONLY structured format - no comment_text to avoid duplication
const processedParams: any = { ...params };
+ // custom_task_ids/team_id are query params, not body fields
+ const query = new URLSearchParams();
+ if (params.custom_task_ids) {
+ query.set('custom_task_ids', 'true');
+ }
+ if (params.team_id !== undefined) {
+ query.set('team_id', String(params.team_id));
+ }
+ delete processedParams.custom_task_ids;
+ delete processedParams.team_id;
+ const queryString = query.toString();
+
if (params.comment_text) {
const commentData = prepareCommentForClickUp(params.comment_text);
@@ -208,15 +179,13 @@ export class CommentsClient {
delete processedParams.description;
}
- const result = await this.client.post(`/task/${taskId}/comment`, processedParams);
+ const result = await this.client.post(
+ `/task/${taskId}/comment${queryString ? `?${queryString}` : ''}`,
+ processedParams
+ );
// Process the response
- let processed = processClickUpResponse(result);
-
- // Clean up ClickUp's duplicate text blocks
- processed = this.cleanupCommentResponse(processed);
-
- return processed;
+ return processClickUpResponse(result);
}
/**
@@ -413,7 +382,9 @@ export class CommentsClient {
* @returns Success message
*/
async deleteComment(commentId: string): Promise<{ success: boolean }> {
- return this.client.delete(`/comment/${commentId}`);
+ // ClickUp returns an empty object on success; synthesize success from the 2xx response
+ await this.client.delete(`/comment/${commentId}`);
+ return { success: true };
}
/**
diff --git a/packages/core/src/clickup-client/custom-fields-enhanced.ts b/packages/core/src/clickup-client/custom-fields-enhanced.ts
index 10a4ee8..dc80dfb 100644
--- a/packages/core/src/clickup-client/custom-fields-enhanced.ts
+++ b/packages/core/src/clickup-client/custom-fields-enhanced.ts
@@ -6,21 +6,28 @@ import axios, { AxiosInstance } from 'axios';
// CUSTOM FIELD TYPE DEFINITIONS
// ========================================
+// Real ClickUp custom field type strings as returned by the API.
+// NOTE: The ClickUp public API has NO endpoints to create, update, or delete
+// custom field DEFINITIONS — fields must be created in the ClickUp UI.
+// The API can only list field definitions and get/set/remove field VALUES.
export type CustomFieldType =
+ | 'url'
+ | 'drop_down'
+ | 'email'
+ | 'phone'
+ | 'date'
| 'text'
- | 'textarea'
+ | 'checkbox'
| 'number'
| 'currency'
- | 'date'
- | 'drop_down'
+ | 'tasks'
+ | 'users'
+ | 'emoji'
| 'labels'
- | 'checkbox'
- | 'url'
- | 'email'
- | 'phone'
- | 'rating'
- | 'progress'
- | 'task_relationship';
+ | 'automatic_progress'
+ | 'manual_progress'
+ | 'short_text'
+ | 'location';
// Base custom field interface
export interface BaseCustomField {
@@ -33,9 +40,9 @@ export interface BaseCustomField {
type_config: Record;
}
-// Text fields
+// Text fields ('short_text' = single line, 'text' = long text)
export interface ShortTextField extends BaseCustomField {
- type: 'text';
+ type: 'short_text';
type_config: {
default?: string;
placeholder?: string;
@@ -43,7 +50,7 @@ export interface ShortTextField extends BaseCustomField {
}
export interface LongTextField extends BaseCustomField {
- type: 'textarea';
+ type: 'text';
type_config: {
default?: string;
placeholder?: string;
@@ -72,7 +79,7 @@ export interface CurrencyField extends BaseCustomField {
export interface DateField extends BaseCustomField {
type: 'date';
type_config: {
- default?: number; // Unix timestamp
+ default?: number; // Unix timestamp (milliseconds)
include_time?: boolean;
};
}
@@ -96,7 +103,11 @@ export interface DropdownField extends BaseCustomField {
export interface LabelsField extends BaseCustomField {
type: 'labels';
type_config: {
- options: DropdownOption[];
+ options: Array<{
+ id: string;
+ label: string;
+ color?: string;
+ }>;
};
}
@@ -133,34 +144,56 @@ export interface PhoneField extends BaseCustomField {
};
}
-// Rating fields
-export interface RatingField extends BaseCustomField {
- type: 'rating';
+// Rating fields (ClickUp calls these 'emoji')
+export interface EmojiField extends BaseCustomField {
+ type: 'emoji';
type_config: {
- default?: number;
- count: number; // 1-10 stars
+ code_point?: string; // emoji code point, e.g. '2b50' for star
+ count: number; // 1-10 rating scale
};
}
// Progress fields
-export interface ProgressField extends BaseCustomField {
- type: 'progress';
+export interface AutomaticProgressField extends BaseCustomField {
+ type: 'automatic_progress';
+ type_config: {
+ tracking?: Record;
+ complete_on?: number;
+ };
+}
+
+export interface ManualProgressField extends BaseCustomField {
+ type: 'manual_progress';
type_config: {
- default?: number;
start?: number; // default: 0
end?: number; // default: 100
- unit?: string; // %, points, etc.
+ current?: number;
};
}
-// Relationship fields
-export interface TaskRelationshipField extends BaseCustomField {
- type: 'task_relationship';
+// Relationship fields (task relationships are type 'tasks')
+export interface TasksField extends BaseCustomField {
+ type: 'tasks';
+ type_config: Record;
+}
+
+// People fields
+export interface UsersField extends BaseCustomField {
+ type: 'users';
type_config: {
- multiple?: boolean;
+ single_user?: boolean;
+ include_groups?: boolean;
+ include_guests?: boolean;
+ include_team_members?: boolean;
};
}
+// Location fields
+export interface LocationField extends BaseCustomField {
+ type: 'location';
+ type_config: Record;
+}
+
// Union type for all custom fields
export type CustomField =
| ShortTextField
@@ -174,9 +207,12 @@ export type CustomField =
| URLField
| EmailField
| PhoneField
- | RatingField
- | ProgressField
- | TaskRelationshipField;
+ | EmojiField
+ | AutomaticProgressField
+ | ManualProgressField
+ | TasksField
+ | UsersField
+ | LocationField;
// ========================================
// CUSTOM FIELD VALUE DEFINITIONS
@@ -190,7 +226,7 @@ export interface BaseCustomFieldValue {
}
export interface TextFieldValue extends BaseCustomFieldValue {
- type: 'text' | 'textarea';
+ type: 'text' | 'short_text';
value: {
value: string;
};
@@ -206,7 +242,7 @@ export interface NumberFieldValue extends BaseCustomFieldValue {
export interface DateFieldValue extends BaseCustomFieldValue {
type: 'date';
value: {
- value: number; // Unix timestamp
+ value: number; // Unix timestamp in milliseconds
};
}
@@ -226,7 +262,7 @@ export interface LabelsFieldValue extends BaseCustomFieldValue {
value: {
value: Array<{
id: string;
- name: string;
+ label: string;
color?: string;
}>;
};
@@ -246,24 +282,41 @@ export interface URLFieldValue extends BaseCustomFieldValue {
};
}
-export interface RatingFieldValue extends BaseCustomFieldValue {
- type: 'rating';
+export interface EmojiFieldValue extends BaseCustomFieldValue {
+ type: 'emoji';
value: {
- value: number;
+ value: number; // integer rating
};
}
export interface ProgressFieldValue extends BaseCustomFieldValue {
- type: 'progress';
+ type: 'automatic_progress' | 'manual_progress';
value: {
value: number;
};
}
-export interface TaskRelationshipFieldValue extends BaseCustomFieldValue {
- type: 'task_relationship';
+export interface TasksFieldValue extends BaseCustomFieldValue {
+ type: 'tasks';
value: {
- value: string | string[];
+ value: Array>; // linked tasks
+ };
+}
+
+export interface UsersFieldValue extends BaseCustomFieldValue {
+ type: 'users';
+ value: {
+ value: Array>; // user objects
+ };
+}
+
+export interface LocationFieldValue extends BaseCustomFieldValue {
+ type: 'location';
+ value: {
+ value: {
+ location: { lat: number; lng: number };
+ formatted_address?: string;
+ };
};
}
@@ -275,35 +328,42 @@ export type CustomFieldValue =
| LabelsFieldValue
| CheckboxFieldValue
| URLFieldValue
- | RatingFieldValue
+ | EmojiFieldValue
| ProgressFieldValue
- | TaskRelationshipFieldValue;
+ | TasksFieldValue
+ | UsersFieldValue
+ | LocationFieldValue;
// ========================================
// PARAMETER INTERFACES
// ========================================
-export interface CreateCustomFieldParams {
- name: string;
- type: CustomFieldType;
- type_config?: Record;
- required?: boolean;
- hide_from_guests?: boolean;
-}
-
-export interface UpdateCustomFieldParams {
- name?: string;
- type_config?: Record;
- required?: boolean;
- hide_from_guests?: boolean;
+/**
+ * Options accepted by Set/Remove Custom Field Value (and task reads).
+ * custom_task_ids/team_id let tasks be addressed by custom task ID
+ * (e.g. 'DEV-1234'); team_id is required when custom_task_ids is true.
+ */
+export interface TaskAddressingOptions {
+ customTaskIds?: boolean;
+ teamId?: string | number;
+}
+
+/**
+ * Extra options for Set Custom Field Value. value_options is sent as a
+ * top-level sibling of value in the request body; for date fields,
+ * { time: true } stores/displays the time component.
+ */
+export interface SetFieldValueOptions extends TaskAddressingOptions {
+ valueOptions?: {
+ time?: boolean;
+ };
}
export interface SetFieldValueParams {
value: any; // Type depends on field type
-}
-
-export interface GetCustomFieldsParams {
- include_deleted?: boolean;
+ value_options?: {
+ time?: boolean;
+ };
}
export interface CustomFieldsResponse {
@@ -324,19 +384,16 @@ export class EnhancedCustomFieldsClient {
}
// ========================================
- // CUSTOM FIELD MANAGEMENT
+ // CUSTOM FIELD LISTING
// ========================================
/**
- * Get custom fields for a list
+ * Get custom fields for a list (includes fields inherited from parent levels)
*/
- async getListCustomFields(
- listId: string,
- params?: GetCustomFieldsParams
- ): Promise {
+ async getListCustomFields(listId: string): Promise {
try {
const url = `https://api.clickup.com/api/v2/list/${listId}/field`;
- const response = await this.http.get(url, { params });
+ const response = await this.http.get(url);
return response.data.fields || [];
} catch (error) {
console.error('Error getting list custom fields:', error instanceof Error ? error.message : error);
@@ -345,15 +402,13 @@ export class EnhancedCustomFieldsClient {
}
/**
- * Get custom fields for a folder
+ * Get custom fields created at the folder level (does not include fields
+ * created at the list level)
*/
- async getFolderCustomFields(
- folderId: string,
- params?: GetCustomFieldsParams
- ): Promise {
+ async getFolderCustomFields(folderId: string): Promise {
try {
const url = `https://api.clickup.com/api/v2/folder/${folderId}/field`;
- const response = await this.http.get(url, { params });
+ const response = await this.http.get(url);
return response.data.fields || [];
} catch (error) {
console.error('Error getting folder custom fields:', error instanceof Error ? error.message : error);
@@ -362,15 +417,13 @@ export class EnhancedCustomFieldsClient {
}
/**
- * Get custom fields for a space
+ * Get custom fields created at the space level (does not include fields
+ * created at the folder or list level)
*/
- async getSpaceCustomFields(
- spaceId: string,
- params?: GetCustomFieldsParams
- ): Promise {
+ async getSpaceCustomFields(spaceId: string): Promise {
try {
const url = `https://api.clickup.com/api/v2/space/${spaceId}/field`;
- const response = await this.http.get(url, { params });
+ const response = await this.http.get(url);
return response.data.fields || [];
} catch (error) {
console.error('Error getting space custom fields:', error instanceof Error ? error.message : error);
@@ -379,97 +432,61 @@ export class EnhancedCustomFieldsClient {
}
/**
- * Create a custom field in a list
+ * Get custom fields created at the workspace (team) level
*/
- async createListCustomField(
- listId: string,
- params: CreateCustomFieldParams
- ): Promise {
+ async getTeamCustomFields(teamId: string): Promise {
try {
- const url = `https://api.clickup.com/api/v2/list/${listId}/field`;
- const response = await this.http.post(url, params);
- return response.data;
- } catch (error) {
- console.error('Error creating list custom field:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to create custom field in list ${listId}`);
- }
- }
-
- /**
- * Create a custom field in a folder
- */
- async createFolderCustomField(
- folderId: string,
- params: CreateCustomFieldParams
- ): Promise {
- try {
- const url = `https://api.clickup.com/api/v2/folder/${folderId}/field`;
- const response = await this.http.post(url, params);
- return response.data;
- } catch (error) {
- console.error('Error creating folder custom field:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to create custom field in folder ${folderId}`);
- }
- }
-
- /**
- * Create a custom field in a space
- */
- async createSpaceCustomField(
- spaceId: string,
- params: CreateCustomFieldParams
- ): Promise {
- try {
- const url = `https://api.clickup.com/api/v2/space/${spaceId}/field`;
- const response = await this.http.post(url, params);
- return response.data;
+ const url = `https://api.clickup.com/api/v2/team/${teamId}/field`;
+ const response = await this.http.get(url);
+ return response.data.fields || [];
} catch (error) {
- console.error('Error creating space custom field:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to create custom field in space ${spaceId}`);
+ console.error('Error getting workspace custom fields:', error instanceof Error ? error.message : error);
+ throw this.handleError(error, `Failed to get custom fields for workspace ${teamId}`);
}
}
- /**
- * Update a custom field
- */
- async updateCustomField(fieldId: string, params: UpdateCustomFieldParams): Promise {
- try {
- const url = `https://api.clickup.com/api/v2/field/${fieldId}`;
- const response = await this.http.put(url, params);
- return response.data;
- } catch (error) {
- console.error('Error updating custom field:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to update custom field ${fieldId}`);
- }
- }
+ // ========================================
+ // CUSTOM FIELD VALUE MANAGEMENT
+ // ========================================
/**
- * Delete a custom field
+ * Build the custom_task_ids/team_id query params for task-addressed requests
*/
- async deleteCustomField(fieldId: string): Promise {
- try {
- const url = `https://api.clickup.com/api/v2/field/${fieldId}`;
- await this.http.delete(url);
- } catch (error) {
- console.error('Error deleting custom field:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to delete custom field ${fieldId}`);
+ private buildTaskAddressingParams(options?: TaskAddressingOptions): Record {
+ const params: Record = {};
+ if (options?.customTaskIds) {
+ params.custom_task_ids = true;
+ if (options.teamId !== undefined) {
+ params.team_id = options.teamId;
+ }
}
+ return params;
}
- // ========================================
- // CUSTOM FIELD VALUE MANAGEMENT
- // ========================================
-
/**
- * Set a custom field value on a task
+ * Set a custom field value on a task.
+ *
+ * The value shape depends on the field type (e.g. dropdown = option UUID,
+ * labels = array of label option UUIDs, users/tasks = {add:[],rem:[]},
+ * date = Unix ms, location = {location:{lat,lng},formatted_address}).
+ * For date fields, pass options.valueOptions = { time: true } to store the
+ * time component — it is sent as a top-level sibling of value in the body.
*/
- async setCustomFieldValue(taskId: string, fieldId: string, value: any): Promise {
+ async setCustomFieldValue(
+ taskId: string,
+ fieldId: string,
+ value: any,
+ options?: SetFieldValueOptions
+ ): Promise {
try {
const url = `https://api.clickup.com/api/v2/task/${taskId}/field/${fieldId}`;
- await this.http.post(
- url,
- { value }
- );
+ const body: Record = { value };
+ if (options?.valueOptions) {
+ body.value_options = options.valueOptions;
+ }
+ await this.http.post(url, body, {
+ params: this.buildTaskAddressingParams(options),
+ });
} catch (error) {
console.error('Error setting custom field value:', error instanceof Error ? error.message : error);
throw this.handleError(
@@ -482,10 +499,16 @@ export class EnhancedCustomFieldsClient {
/**
* Remove a custom field value from a task
*/
- async removeCustomFieldValue(taskId: string, fieldId: string): Promise {
+ async removeCustomFieldValue(
+ taskId: string,
+ fieldId: string,
+ options?: TaskAddressingOptions
+ ): Promise {
try {
const url = `https://api.clickup.com/api/v2/task/${taskId}/field/${fieldId}`;
- await this.http.delete(url);
+ await this.http.delete(url, {
+ params: this.buildTaskAddressingParams(options),
+ });
} catch (error) {
console.error('Error removing custom field value:', error instanceof Error ? error.message : error);
throw this.handleError(
@@ -498,11 +521,17 @@ export class EnhancedCustomFieldsClient {
/**
* Get a custom field value from a task
*/
- async getCustomFieldValue(taskId: string, fieldId: string): Promise {
+ async getCustomFieldValue(
+ taskId: string,
+ fieldId: string,
+ options?: TaskAddressingOptions
+ ): Promise {
try {
// Get task details which includes custom field values
const taskUrl = `https://api.clickup.com/api/v2/task/${taskId}`;
- const response = await this.http.get(taskUrl);
+ const response = await this.http.get(taskUrl, {
+ params: this.buildTaskAddressingParams(options),
+ });
const task = response.data;
const customField = task.custom_fields?.find((field: any) => field.id === fieldId);
@@ -530,10 +559,15 @@ export class EnhancedCustomFieldsClient {
/**
* Get all custom field values for a task
*/
- async getTaskCustomFieldValues(taskId: string): Promise {
+ async getTaskCustomFieldValues(
+ taskId: string,
+ options?: TaskAddressingOptions
+ ): Promise {
try {
const taskUrl = `https://api.clickup.com/api/v2/task/${taskId}`;
- const response = await this.http.get(taskUrl);
+ const response = await this.http.get(taskUrl, {
+ params: this.buildTaskAddressingParams(options),
+ });
const task = response.data;
return (
@@ -558,28 +592,34 @@ export class EnhancedCustomFieldsClient {
*/
async bulkSetCustomFieldValues(
taskId: string,
- fieldValues: Array<{ field_id: string; value: any }>
+ fieldValues: Array<{ field_id: string; value: any; value_options?: { time?: boolean } }>,
+ options?: TaskAddressingOptions
): Promise {
try {
- const results = [];
+ const results: Array<{ field_id: string; value: any; status: string; error?: string }> = [];
// ClickUp doesn't have a native bulk API — parallelize independent calls
const CONCURRENCY = 5;
for (let i = 0; i < fieldValues.length; i += CONCURRENCY) {
const chunk = fieldValues.slice(i, i + CONCURRENCY);
const chunkResults = await Promise.allSettled(
- chunk.map(({ field_id, value }) =>
- this.setCustomFieldValue(taskId, field_id, value).then(() => ({ field_id, value }))
+ chunk.map(({ field_id, value, value_options }) =>
+ this.setCustomFieldValue(taskId, field_id, value, {
+ ...options,
+ valueOptions: value_options,
+ })
)
);
- for (const result of chunkResults) {
+ // Promise.allSettled preserves input order, so index back into the chunk
+ chunkResults.forEach((result, j) => {
+ const { field_id, value } = chunk[j];
if (result.status === 'fulfilled') {
- results.push({ field_id: result.value.field_id, value: result.value.value, status: 'success' });
+ results.push({ field_id, value, status: 'success' });
} else {
const errorMessage = result.reason instanceof Error ? result.reason.message : 'Unknown error';
- results.push({ field_id: '', value: null, status: 'error', error: errorMessage });
+ results.push({ field_id, value, status: 'error', error: errorMessage });
}
- }
+ });
}
return results;
@@ -599,7 +639,7 @@ export class EnhancedCustomFieldsClient {
validateFieldValue(field: CustomField, value: any): boolean {
switch (field.type) {
case 'text':
- case 'textarea':
+ case 'short_text':
return typeof value === 'string';
case 'number':
@@ -607,7 +647,8 @@ export class EnhancedCustomFieldsClient {
return typeof value === 'number' && !isNaN(value);
case 'date':
- return typeof value === 'number' && value > 0;
+ // Unix timestamp in MILLISECONDS (e.g. 1667367645000)
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
case 'checkbox':
return typeof value === 'boolean';
@@ -622,101 +663,49 @@ export class EnhancedCustomFieldsClient {
return typeof value === 'string' && value.length > 0;
case 'drop_down':
+ // Canonical value is the option UUID (type_config.options[].id)
return field.type_config.options?.some((opt: DropdownOption) => opt.id === value);
case 'labels':
return (
Array.isArray(value) &&
- value.every(v => field.type_config.options?.some((opt: DropdownOption) => opt.id === v))
+ value.every(v => field.type_config.options?.some((opt: { id: string }) => opt.id === v))
);
- case 'rating':
- return typeof value === 'number' && value >= 0 && value <= (field.type_config.count || 5);
-
- case 'progress': {
- const { start = 0, end = 100 } = field.type_config;
- return typeof value === 'number' && value >= start && value <= end;
+ case 'emoji': {
+ // Rating fields: an integer within the configured count range
+ const count = (field.type_config as { count?: number }).count ?? 5;
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= count;
}
- case 'task_relationship':
- if (field.type_config.multiple) {
- return Array.isArray(value) && value.every(v => typeof v === 'string');
- }
- return typeof value === 'string';
+ case 'automatic_progress':
+ // Computed by ClickUp — cannot be set via the API
+ return false;
- default:
- return true;
+ case 'manual_progress': {
+ const { start = 0, end = 100 } = field.type_config as { start?: number; end?: number };
+ return typeof value === 'number' && value >= start && value <= end;
}
- }
-
- /**
- * Get field type configuration template
- */
- getFieldTypeTemplate(type: CustomFieldType): Record {
- switch (type) {
- case 'text':
- case 'textarea':
- return {
- default: '',
- placeholder: ''
- };
-
- case 'number':
- return {
- default: 0,
- precision: 0
- };
-
- case 'currency':
- return {
- default: 0,
- precision: 2,
- currency_type: 'USD'
- };
-
- case 'date':
- return {
- include_time: false
- };
- case 'drop_down':
- case 'labels':
- return {
- options: []
- };
+ case 'users':
+ case 'tasks':
+ // People and task-relationship fields take { add: [ids], rem: [ids] }
+ return this.isValidAddRemValue(value);
- case 'checkbox':
- return {
- default: false
- };
-
- case 'url':
- case 'email':
- case 'phone':
- return {
- placeholder: ''
- };
-
- case 'rating':
- return {
- count: 5,
- default: 0
- };
-
- case 'progress':
- return {
- start: 0,
- end: 100,
- unit: '%'
- };
-
- case 'task_relationship':
- return {
- multiple: false
- };
+ case 'location':
+ // { location: { lat, lng }, formatted_address? }
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof value.location === 'object' &&
+ value.location !== null &&
+ typeof value.location.lat === 'number' &&
+ typeof value.location.lng === 'number' &&
+ (value.formatted_address === undefined || typeof value.formatted_address === 'string')
+ );
default:
- return {};
+ return true;
}
}
@@ -724,6 +713,20 @@ export class EnhancedCustomFieldsClient {
// UTILITY METHODS
// ========================================
+ private isValidAddRemValue(value: any): boolean {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return false;
+ }
+ const isIdArray = (arr: any): boolean =>
+ Array.isArray(arr) && arr.every(v => typeof v === 'string' || typeof v === 'number');
+ const hasAdd = value.add !== undefined;
+ const hasRem = value.rem !== undefined;
+ if (!hasAdd && !hasRem) {
+ return false;
+ }
+ return (!hasAdd || isIdArray(value.add)) && (!hasRem || isIdArray(value.rem));
+ }
+
private isValidURL(string: string): boolean {
try {
const url = new URL(string);
diff --git a/packages/core/src/clickup-client/dependencies-enhanced.ts b/packages/core/src/clickup-client/dependencies-enhanced.ts
index 31319b4..f0c218b 100644
--- a/packages/core/src/clickup-client/dependencies-enhanced.ts
+++ b/packages/core/src/clickup-client/dependencies-enhanced.ts
@@ -1,350 +1,272 @@
import { ClickUpClient } from './index.js';
-import type {
- CreateDependencyRequest,
- UpdateDependencyRequest,
- GetDependenciesFilter,
- DependencyGraphOptions,
- DependencyConflictCheck,
- BulkDependencyOperation,
- DependencyResponse,
- DependencyListResponse,
- DependencyGraphResponse,
- DependencyConflictResponse,
- // DependencyGraphNode
+import {
+ validateDependencyChain,
+ type CreateDependencyRequest,
+ type DeleteDependencyRequest,
+ type GetTaskDependenciesRequest,
+ type AddTaskLinkRequest,
+ type DeleteTaskLinkRequest,
+ type DependencyGraphOptions,
+ type DependencyConflictCheck,
+ type BulkDependencyOperation,
+ type TaskDependency,
+ type LinkedTask,
+ type TaskRelationships,
+ type DependencyGraphNode,
+ type DependencyGraphEdge,
+ type DependencyGraphResponse,
+ type DependencyConflictResponse,
} from '../schemas/dependencies-schemas.js';
+// Minimal slice of a Get Task response used for relationship reads
+interface TaskWithRelationships {
+ id: string;
+ name?: string;
+ status?: { status: string };
+ url?: string;
+ dependencies?: TaskDependency[];
+ linked_tasks?: LinkedTask[];
+}
+
+// Query params accepted by all four Task Relationships endpoints
+interface CustomTaskIdParams {
+ custom_task_ids?: boolean;
+ team_id?: string;
+}
+
export class DependenciesEnhancedClient extends ClickUpClient {
constructor(apiToken: string) {
super({ apiToken });
}
/**
- * Create a new dependency between tasks
- */
- async createDependency(request: CreateDependencyRequest): Promise {
- const payload = {
- depends_on: request.depends_on,
- type: request.type,
- link_id: request.link_id,
- };
-
- const response = await this.post<{ dependency: DependencyResponse }>(
- `/task/${request.task_id}/dependency`,
- payload
- );
- return response.dependency;
- }
-
- /**
- * Get dependencies for a task
+ * Build the custom_task_ids/team_id query string shared by all four endpoints
*/
- async getTaskDependencies(filter: GetDependenciesFilter): Promise {
- const params = new URLSearchParams();
- if (filter.type) params.append('type', filter.type);
- if (filter.status) params.append('status', filter.status);
- if (filter.include_resolved) params.append('include_resolved', 'true');
-
- const queryString = params.toString();
- const endpoint = `/task/${filter.task_id}/dependency${queryString ? `?${queryString}` : ''}`;
-
- const response = await this.get(endpoint);
- return response;
+ private buildCustomIdQuery(params?: CustomTaskIdParams): string {
+ const search = new URLSearchParams();
+ if (params?.custom_task_ids !== undefined) {
+ search.set('custom_task_ids', String(params.custom_task_ids));
+ }
+ if (params?.team_id !== undefined) {
+ search.set('team_id', params.team_id);
+ }
+ const queryString = search.toString();
+ return queryString ? `?${queryString}` : '';
}
/**
- * Update an existing dependency
+ * Create a dependency between two tasks (POST /task/{task_id}/dependency).
+ * Exactly one of depends_on (task is waiting on) or dependency_of (task is
+ * blocking) must be provided; the API returns an empty object on success.
*/
- async updateDependency(request: UpdateDependencyRequest): Promise {
- const updateData: Record = {};
-
- if (request.type) updateData.type = request.type;
- if (request.status) updateData.status = request.status;
+ async createDependency(request: CreateDependencyRequest): Promise<{ success: boolean }> {
+ const { task_id, depends_on, dependency_of, custom_task_ids, team_id } = request;
+ const payload = depends_on ? { depends_on } : { dependency_of };
- const response = await this.put<{ dependency: DependencyResponse }>(
- `/dependency/${request.dependency_id}`,
- updateData
+ await this.post(
+ `/task/${task_id}/dependency${this.buildCustomIdQuery({ custom_task_ids, team_id })}`,
+ payload
);
- return response.dependency;
+ return { success: true };
}
/**
- * Delete a dependency
+ * Delete a dependency between two tasks (DELETE /task/{task_id}/dependency).
+ * Exactly one of depends_on or dependency_of is passed as a query parameter.
*/
- async deleteDependency(dependencyId: string): Promise<{ success: boolean }> {
- await this.delete(`/dependency/${dependencyId}`);
+ async deleteDependency(request: DeleteDependencyRequest): Promise<{ success: boolean }> {
+ const { task_id, depends_on, dependency_of, custom_task_ids, team_id } = request;
+
+ await this.delete(`/task/${task_id}/dependency`, {
+ params: { depends_on, dependency_of, custom_task_ids, team_id },
+ });
return { success: true };
}
/**
- * Get dependency graph for a task
+ * Get a task's dependencies and linked tasks. The API has no dependency read
+ * endpoint; relationships come from the dependencies/linked_tasks arrays
+ * embedded in the Get Task response.
*/
- async getDependencyGraph(options: DependencyGraphOptions): Promise {
- const params = new URLSearchParams();
- params.append('depth', options.depth.toString());
- params.append('direction', options.direction);
- if (options.include_resolved) params.append('include_resolved', 'true');
- if (options.include_broken) params.append('include_broken', 'true');
-
- const queryString = params.toString();
- const endpoint = `/task/${options.task_id}/dependency/graph?${queryString}`;
+ async getTaskDependencies(request: GetTaskDependenciesRequest): Promise {
+ const { task_id, custom_task_ids, team_id } = request;
+ const task = await this.get(`/task/${task_id}`, {
+ custom_task_ids,
+ team_id,
+ });
- const response = await this.get(endpoint);
- return response;
+ return {
+ task_id: task.id ?? task_id,
+ dependencies: task.dependencies ?? [],
+ linked_tasks: task.linked_tasks ?? [],
+ };
}
/**
- * Check for dependency conflicts
+ * Link two tasks together (POST /task/{task_id}/link/{links_to}).
+ * Returns the updated task.
*/
- async checkDependencyConflicts(
- check: DependencyConflictCheck
- ): Promise {
- const payload = {
- proposed_dependencies: check.proposed_dependencies || [],
- };
-
- const response = await this.post(
- `/task/${check.task_id}/dependency/conflicts`,
- payload
+ async addTaskLink(request: AddTaskLinkRequest): Promise> {
+ const { task_id, links_to, custom_task_ids, team_id } = request;
+ const response = await this.post<{ task?: Record }>(
+ `/task/${task_id}/link/${links_to}${this.buildCustomIdQuery({ custom_task_ids, team_id })}`
);
- return response;
+ return response.task ?? response;
}
/**
- * Perform bulk dependency operations
+ * Remove a link between two tasks (DELETE /task/{task_id}/link/{links_to}).
+ * Returns the updated task.
*/
- async bulkDependencyOperations(operation: BulkDependencyOperation): Promise<{
- success: boolean;
- results: Array<{
- success: boolean;
- dependency?: DependencyResponse;
- error?: string;
- }>;
- }> {
- const response = await this.post<{
- success: boolean;
- results: Array<{
- success: boolean;
- dependency?: DependencyResponse;
- error?: string;
- }>;
- }>('/dependency/bulk', operation);
-
- return response;
+ async deleteTaskLink(request: DeleteTaskLinkRequest): Promise> {
+ const { task_id, links_to, custom_task_ids, team_id } = request;
+ const response = await this.delete<{ task?: Record }>(
+ `/task/${task_id}/link/${links_to}`,
+ { params: { custom_task_ids, team_id } }
+ );
+ return response.task ?? response;
}
/**
- * Get all dependencies in a workspace
+ * Build a dependency graph client-side by breadth-first traversal of Get Task
+ * responses (there is no server-side graph endpoint). Nodes beyond the
+ * requested depth may appear in edges without being fetched as nodes.
*/
- async getWorkspaceDependencies(
- workspaceId: string,
- options?: {
- status?: string;
- type?: string;
- limit?: number;
- offset?: number;
- }
- ): Promise {
- const params = new URLSearchParams();
- if (options?.status) params.append('status', options.status);
- if (options?.type) params.append('type', options.type);
- if (options?.limit) params.append('limit', options.limit.toString());
- if (options?.offset) params.append('offset', options.offset.toString());
+ async getDependencyGraph(options: DependencyGraphOptions): Promise {
+ const depth = options.depth ?? 3;
+ const nodes = new Map();
+ const edges = new Map();
+ const visited = new Set();
+ let frontier = [options.task_id];
- const queryString = params.toString();
- const endpoint = `/team/${workspaceId}/dependency${queryString ? `?${queryString}` : ''}`;
+ for (let level = 0; level <= depth && frontier.length > 0; level++) {
+ const next: string[] = [];
- const response = await this.get(endpoint);
- return response;
- }
+ for (const taskId of frontier) {
+ if (visited.has(taskId)) continue;
+ visited.add(taskId);
- /**
- * Get dependency statistics for a workspace
- */
- async getDependencyStats(workspaceId: string): Promise<{
- total_dependencies: number;
- active_dependencies: number;
- resolved_dependencies: number;
- broken_dependencies: number;
- circular_dependencies: number;
- most_dependent_tasks: Array<{
- task_id: string;
- task_name: string;
- dependency_count: number;
- }>;
- most_blocking_tasks: Array<{
- task_id: string;
- task_name: string;
- blocking_count: number;
- }>;
- }> {
- const response = await this.get<{
- total_dependencies: number;
- active_dependencies: number;
- resolved_dependencies: number;
- broken_dependencies: number;
- circular_dependencies: number;
- most_dependent_tasks: Array<{
- task_id: string;
- task_name: string;
- dependency_count: number;
- }>;
- most_blocking_tasks: Array<{
- task_id: string;
- task_name: string;
- blocking_count: number;
- }>;
- }>(`/team/${workspaceId}/dependency/stats`);
+ let task: TaskWithRelationships;
+ try {
+ task = await this.get(`/task/${taskId}`);
+ } catch {
+ // Skip tasks that are deleted or inaccessible
+ continue;
+ }
- return response;
- }
+ nodes.set(task.id, {
+ task_id: task.id,
+ name: task.name,
+ status: task.status?.status,
+ url: task.url,
+ });
- /**
- * Resolve dependency conflicts automatically
- */
- async resolveDependencyConflicts(
- taskId: string,
- resolution: {
- break_cycles?: boolean;
- remove_duplicates?: boolean;
- update_invalid_statuses?: boolean;
+ for (const dep of task.dependencies ?? []) {
+ edges.set(`${dep.task_id}->${dep.depends_on}`, {
+ task_id: dep.task_id,
+ depends_on: dep.depends_on,
+ type: dep.type,
+ });
+ const neighbor = dep.task_id === task.id ? dep.depends_on : dep.task_id;
+ if (!visited.has(neighbor)) {
+ next.push(neighbor);
+ }
+ }
+ }
+
+ frontier = next;
}
- ): Promise<{
- success: boolean;
- resolved_conflicts: number;
- remaining_conflicts: number;
- actions_taken: Array<{
- action: string;
- description: string;
- affected_dependencies: string[];
- }>;
- }> {
- const response = await this.post<{
- success: boolean;
- resolved_conflicts: number;
- remaining_conflicts: number;
- actions_taken: Array<{
- action: string;
- description: string;
- affected_dependencies: string[];
- }>;
- }>(`/task/${taskId}/dependency/resolve`, resolution);
- return response;
- }
+ const edgeList = Array.from(edges.values());
+ const { cycles } = validateDependencyChain(edgeList);
- /**
- * Get dependency timeline impact
- */
- async getDependencyTimelineImpact(taskId: string): Promise<{
- task_id: string;
- current_timeline: {
- start_date?: string;
- due_date?: string;
- estimated_duration_days: number;
- };
- dependency_impact: {
- earliest_start_date?: string;
- latest_due_date?: string;
- critical_path_duration_days: number;
- buffer_days: number;
+ return {
+ root_task_id: options.task_id,
+ depth,
+ nodes: Array.from(nodes.values()),
+ edges: edgeList,
+ cycles,
};
- blocking_tasks: Array<{
- task_id: string;
- task_name: string;
- delay_days: number;
- }>;
- dependent_tasks: Array<{
- task_id: string;
- task_name: string;
- affected_start_date?: string;
- }>;
- }> {
- const response = await this.get<{
- task_id: string;
- current_timeline: {
- start_date?: string;
- due_date?: string;
- estimated_duration_days: number;
- };
- dependency_impact: {
- earliest_start_date?: string;
- latest_due_date?: string;
- critical_path_duration_days: number;
- buffer_days: number;
- };
- blocking_tasks: Array<{
- task_id: string;
- task_name: string;
- delay_days: number;
- }>;
- dependent_tasks: Array<{
- task_id: string;
- task_name: string;
- affected_start_date?: string;
- }>;
- }>(`/task/${taskId}/dependency/timeline`);
-
- return response;
}
/**
- * Export dependency graph
+ * Check for dependency conflicts (cycles and duplicates) client-side by
+ * traversing existing dependencies and overlaying any proposed ones.
*/
- async exportDependencyGraph(
- taskId: string,
- format: 'json' | 'csv' | 'graphml' = 'json'
- ): Promise<{
- format: string;
- data: string;
- download_url?: string;
- }> {
- const params = new URLSearchParams();
- params.append('format', format);
+ async checkDependencyConflicts(
+ check: DependencyConflictCheck
+ ): Promise {
+ const graph = await this.getDependencyGraph({ task_id: check.task_id, depth: 10 });
+ const existingKeys = new Set(graph.edges.map(edge => `${edge.task_id}->${edge.depends_on}`));
- const response = await this.get<{
- format: string;
- data: string;
- download_url?: string;
- }>(`/task/${taskId}/dependency/export?${params.toString()}`);
+ const conflicts: DependencyConflictResponse['conflicts'] = [];
+ const proposedEdges = (check.proposed_dependencies ?? []).map(proposed =>
+ proposed.depends_on
+ ? { task_id: check.task_id, depends_on: proposed.depends_on }
+ : { task_id: proposed.dependency_of as string, depends_on: check.task_id }
+ );
+
+ for (const edge of proposedEdges) {
+ if (existingKeys.has(`${edge.task_id}->${edge.depends_on}`)) {
+ conflicts.push({
+ type: 'duplicate',
+ description: `Task ${edge.task_id} already depends on ${edge.depends_on}`,
+ affected_tasks: [edge.task_id, edge.depends_on],
+ });
+ }
+ }
+
+ const { cycles } = validateDependencyChain([...graph.edges, ...proposedEdges]);
+ for (const cycle of cycles) {
+ conflicts.push({
+ type: 'circular',
+ description: `Circular dependency: ${cycle.join(' -> ')}`,
+ affected_tasks: cycle,
+ });
+ }
- return response;
+ return {
+ has_conflicts: conflicts.length > 0,
+ conflicts,
+ };
}
/**
- * Import dependency graph
+ * Perform bulk dependency operations client-side by issuing individual
+ * create/delete calls (there is no bulk dependency endpoint).
*/
- async importDependencyGraph(
- workspaceId: string,
- data: {
- format: 'json' | 'csv';
- data: string;
- options?: {
- merge_existing?: boolean;
- validate_tasks?: boolean;
- create_missing_tasks?: boolean;
- };
- }
- ): Promise<{
+ async bulkDependencyOperations(operation: BulkDependencyOperation): Promise<{
success: boolean;
- imported_dependencies: number;
- skipped_dependencies: number;
- errors: Array<{
- line_number?: number;
- error: string;
- data?: any;
+ results: Array<{
+ task_id: string;
+ depends_on?: string;
+ dependency_of?: string;
+ success: boolean;
+ error?: string;
}>;
}> {
- const response = await this.post<{
- success: boolean;
- imported_dependencies: number;
- skipped_dependencies: number;
- errors: Array<{
- line_number?: number;
- error: string;
- data?: any;
- }>;
- }>(`/team/${workspaceId}/dependency/import`, data);
+ const results = [];
- return response;
+ for (const dependency of operation.dependencies) {
+ try {
+ if (operation.operation === 'create') {
+ await this.createDependency(dependency);
+ } else {
+ await this.deleteDependency(dependency);
+ }
+ results.push({ ...dependency, success: true });
+ } catch (error: unknown) {
+ results.push({
+ ...dependency,
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ return {
+ success: results.every(result => result.success),
+ results,
+ };
}
}
diff --git a/packages/core/src/clickup-client/docs-enhanced.ts b/packages/core/src/clickup-client/docs-enhanced.ts
index fac7818..3b268ff 100644
--- a/packages/core/src/clickup-client/docs-enhanced.ts
+++ b/packages/core/src/clickup-client/docs-enhanced.ts
@@ -2,7 +2,7 @@
import { ClickUpClient } from './index.js';
import axios, { AxiosInstance } from 'axios';
-// Enhanced interfaces based on research
+// Enhanced interfaces based on the public v3 Docs API
export interface Doc {
id: string;
name: string;
@@ -19,93 +19,98 @@ export interface Doc {
type: number;
content?: string;
url?: string;
- sharing?: SharingConfig;
page_count?: number;
}
export interface Page {
id: string;
name: string;
+ sub_title?: string;
content: string;
- content_format: ContentFormat;
+ content_format?: ApiContentFormat;
doc_id: string;
parent_page_id?: string;
- position: number;
date_created: number;
date_updated: number;
creator: number;
+ pages?: Page[];
}
+export interface PageListingEntry {
+ id: string;
+ name: string;
+ doc_id?: string;
+ parent_page_id?: string;
+ pages?: PageListingEntry[];
+}
+
+// Values accepted by tool inputs ('markdown'/'html' are normalized before sending)
export type ContentFormat = 'markdown' | 'html' | 'text/md' | 'text/plain' | 'text/html';
+// Values the ClickUp v3 API actually accepts
+export type ApiContentFormat = 'text/md' | 'text/plain' | 'text/html';
-export interface SharingConfig {
- public: boolean;
- public_share_expires_on?: number;
- public_fields?: string[];
- team_sharing?: boolean;
- guest_sharing?: boolean;
- token?: string;
- seo_optimized?: boolean;
-}
+export type ContentEditMode = 'replace' | 'append' | 'prepend';
+
+/**
+ * Parent types documented for Create Doc:
+ * 4 = Space, 5 = Folder, 6 = List, 7 = Everything, 12 = Workspace
+ */
+export type DocParentType = 4 | 5 | 6 | 7 | 12;
// Parameter interfaces
export interface CreateDocParams {
- workspace_id?: string;
+ workspace_id: string;
+ name: string;
+ /** Explicit parent placement inside the workspace hierarchy */
+ parent?: {
+ id: string;
+ type: DocParentType;
+ };
+ /** Convenience: place the doc in a space (parent type 4) */
space_id?: string;
+ /** Convenience: place the doc in a folder (parent type 5) */
folder_id?: string;
- name: string;
- content?: string;
- public?: boolean;
- template_id?: string;
-}
-
-export interface UpdateDocParams {
- name?: string;
+ /** Initial content; added as the first page in a follow-up call */
content?: string;
+ content_format?: ContentFormat;
public?: boolean;
+ visibility?: 'PUBLIC' | 'PRIVATE';
+ create_page?: boolean;
}
export interface CreatePageParams {
name: string;
content: string;
+ sub_title?: string;
content_format?: ContentFormat;
parent_page_id?: string;
- position?: number;
}
export interface UpdatePageParams {
name?: string;
+ sub_title?: string;
content?: string;
+ content_edit_mode?: ContentEditMode;
content_format?: ContentFormat;
- position?: number;
-}
-
-export interface SharingParams {
- public?: boolean;
- public_share_expires_on?: number;
- public_fields?: string[];
- team_sharing?: boolean;
- guest_sharing?: boolean;
-}
-
-export interface CreateFromTemplateParams {
- workspace_id?: string;
- space_id?: string;
- folder_id?: string;
- name: string;
- template_variables?: Record;
}
export interface GetDocsParams {
- cursor?: string;
+ id?: string;
+ creator?: number;
deleted?: boolean;
archived?: boolean;
+ parent_id?: string;
+ parent_type?: string;
limit?: number;
+ cursor?: string;
}
-export interface SearchDocsParams {
- query: string;
- cursor?: string;
+export interface SearchDocsParams extends GetDocsParams {
+ /**
+ * Free-text name filter. The v3 API has no query parameter, so this is
+ * applied client-side against the returned doc names.
+ */
+ query?: string;
}
export interface DocsResponse {
@@ -114,8 +119,20 @@ export interface DocsResponse {
}
/**
- * Enhanced Documents Client with full CRUD operations
- * Extends the existing read-only functionality with write operations
+ * Enhanced Documents Client aligned with the public ClickUp v3 Docs API.
+ *
+ * Supported operations (all under /api/v3/workspaces/{workspaceId}/):
+ * - GET docs (search/list docs)
+ * - POST docs (create doc)
+ * - GET docs/{docId} (fetch doc)
+ * - GET docs/{docId}/page_listing (page hierarchy without content)
+ * - GET docs/{docId}/pages (fetch pages with content)
+ * - POST docs/{docId}/pages (create page)
+ * - GET docs/{docId}/pages/{pageId}
+ * - PUT docs/{docId}/pages/{pageId} (edit page, supports append/prepend)
+ *
+ * The public API provides NO doc update/delete, page delete, sharing, or
+ * template endpoints.
*/
export class EnhancedDocsClient {
private client: ClickUpClient;
@@ -128,7 +145,7 @@ export class EnhancedDocsClient {
}
// ========================================
- // EXISTING READ OPERATIONS (Enhanced)
+ // READ OPERATIONS
// ========================================
/**
@@ -146,7 +163,7 @@ export class EnhancedDocsClient {
}
/**
- * Get the pages of a doc
+ * Get the pages of a doc (with content)
*/
async getDocPages(
workspaceId: string,
@@ -157,7 +174,7 @@ export class EnhancedDocsClient {
const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/pages`;
const params = {
max_page_depth: -1,
- content_format: contentFormat
+ content_format: normalizeContentFormat(contentFormat)
};
const response = await this.http.get(url, { params });
@@ -170,132 +187,129 @@ export class EnhancedDocsClient {
}
/**
- * Search for docs in a workspace
+ * Get the page hierarchy of a doc (IDs and names, no content).
+ * Cheap table-of-contents call compared to getDocPages.
*/
- async searchDocs(workspaceId: string, params: SearchDocsParams): Promise {
+ async getDocPageListing(
+ workspaceId: string,
+ docId: string,
+ maxPageDepth: number = -1
+ ): Promise {
try {
- const url = `https://api.clickup.com/api/v2/team/${workspaceId}/docs/search`;
- const queryParams: any = {
- doc_name: params.query,
- cursor: params.cursor
- };
-
- if (params.query.startsWith('space:')) {
- const spaceId = params.query.substring(6);
- queryParams.space_id = spaceId;
- delete queryParams.doc_name;
- }
-
- const response = await this.http.get(url, { params: queryParams });
-
+ const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/page_listing`;
+ const response = await this.http.get(url, { params: { max_page_depth: maxPageDepth } });
return response.data;
} catch (error) {
- console.error('Error searching docs:', error instanceof Error ? error.message : error);
- throw this.handleError(error, 'Failed to search docs');
+ console.error('Error getting doc page listing:', error instanceof Error ? error.message : error);
+ throw this.handleError(error, 'Failed to get doc page listing');
}
}
- // ========================================
- // NEW: DOCUMENT CRUD OPERATIONS
- // ========================================
-
/**
- * Create a new document
+ * Search for docs in a workspace.
+ *
+ * Uses GET /api/v3/workspaces/{workspaceId}/docs with the documented
+ * filters (id, creator, deleted, archived, parent_id, parent_type, limit,
+ * cursor). The API has no free-text search parameter, so `query` is
+ * matched client-side against doc names.
*/
- async createDoc(params: CreateDocParams): Promise {
+ async searchDocs(workspaceId: string, params: SearchDocsParams): Promise {
try {
- let url: string;
-
- // Determine the correct endpoint based on parent
- if (params.workspace_id) {
- url = `https://api.clickup.com/api/v3/workspaces/${params.workspace_id}/docs`;
- } else if (params.space_id) {
- url = `https://api.clickup.com/api/v3/spaces/${params.space_id}/docs`;
- } else if (params.folder_id) {
- url = `https://api.clickup.com/api/v3/folders/${params.folder_id}/docs`;
- } else {
- throw new Error('Must specify workspace_id, space_id, or folder_id');
- }
-
- const requestBody = {
- name: params.name,
- content: params.content || '',
- public: params.public || false
- };
-
- // Add template_id if provided
- if (params.template_id) {
- (requestBody as any).template_id = params.template_id;
+ const { query, ...filters } = params;
+ const result = await this.getDocsFromWorkspace(workspaceId, filters);
+
+ if (query && Array.isArray(result.docs)) {
+ const lowerQuery = query.toLowerCase();
+ return {
+ ...result,
+ docs: result.docs.filter(doc => doc.name?.toLowerCase().includes(lowerQuery))
+ };
}
- const response = await this.http.post(url, requestBody);
-
- return response.data;
+ return result;
} catch (error) {
- console.error('Error creating document:', error instanceof Error ? error.message : error);
- throw this.handleError(error, 'Failed to create document');
+ console.error('Error searching docs:', error instanceof Error ? error.message : error);
+ throw this.handleError(error, 'Failed to search docs');
}
}
/**
- * Update an existing document
+ * Get document details
* @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
* @param docId The document ID
*/
- async updateDoc(workspaceId: string, docId: string, params: UpdateDocParams): Promise {
+ async getDoc(workspaceId: string, docId: string): Promise {
try {
const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}`;
- const requestBody: any = {};
- if (params.name !== undefined) requestBody.name = params.name;
- if (params.content !== undefined) requestBody.content = params.content;
- if (params.public !== undefined) requestBody.public = params.public;
-
- const response = await this.http.put(url, requestBody);
+ const response = await this.http.get(url);
return response.data;
} catch (error) {
- console.error('Error updating document:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to update document ${docId}`);
+ console.error('Error getting document:', error instanceof Error ? error.message : error);
+ throw this.handleError(error, `Failed to get document ${docId}`);
}
}
+ // ========================================
+ // DOCUMENT CREATION
+ // ========================================
+
/**
- * Delete a document
- * @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
- * @param docId The document ID
+ * Create a new document.
+ *
+ * Always POSTs to /api/v3/workspaces/{workspaceId}/docs; placement in the
+ * hierarchy is expressed via the `parent` body field ({id, type} with
+ * 4=space, 5=folder, 6=list, 7=everything, 12=workspace).
+ *
+ * If `content` is supplied, an initial page is created in a follow-up call
+ * (the create-doc endpoint does not accept content).
*/
- async deleteDoc(workspaceId: string, docId: string): Promise {
+ async createDoc(params: CreateDocParams): Promise {
try {
- const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}`;
+ const url = `https://api.clickup.com/api/v3/workspaces/${params.workspace_id}/docs`;
- await this.http.delete(url);
- } catch (error) {
- console.error('Error deleting document:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to delete document ${docId}`);
- }
- }
+ let parent = params.parent;
+ if (!parent && params.space_id) {
+ parent = { id: params.space_id, type: 4 };
+ }
+ if (!parent && params.folder_id) {
+ parent = { id: params.folder_id, type: 5 };
+ }
- /**
- * Get document details
- * @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
- * @param docId The document ID
- */
- async getDoc(workspaceId: string, docId: string): Promise {
- try {
- const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}`;
+ const visibility = params.visibility || (params.public ? 'PUBLIC' : 'PRIVATE');
- const response = await this.http.get(url);
+ const requestBody: Record = {
+ name: params.name,
+ visibility,
+ // When content is supplied we create the first page ourselves
+ create_page: params.content ? false : params.create_page !== false
+ };
+ if (parent) {
+ requestBody.parent = parent;
+ }
- return response.data;
+ const response = await this.http.post(url, requestBody);
+ const doc: Doc = response.data;
+
+ // The create endpoint does not accept content; add it as the first page
+ if (params.content && doc?.id) {
+ await this.createPage(params.workspace_id, doc.id, {
+ name: params.name,
+ content: params.content,
+ content_format: params.content_format || 'text/md'
+ });
+ }
+
+ return doc;
} catch (error) {
- console.error('Error getting document:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to get document ${docId}`);
+ console.error('Error creating document:', error instanceof Error ? error.message : error);
+ throw this.handleError(error, 'Failed to create document');
}
}
// ========================================
- // NEW: PAGE MANAGEMENT OPERATIONS
+ // PAGE MANAGEMENT OPERATIONS
// ========================================
/**
@@ -307,17 +321,17 @@ export class EnhancedDocsClient {
try {
const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/pages`;
- const requestBody = {
+ const requestBody: Record = {
name: params.name,
content: params.content,
- content_format: params.content_format || 'markdown'
+ content_format: normalizeContentFormat(params.content_format)
};
- if (params.parent_page_id) {
- (requestBody as any).parent_page_id = params.parent_page_id;
+ if (params.sub_title) {
+ requestBody.sub_title = params.sub_title;
}
- if (params.position !== undefined) {
- (requestBody as any).position = params.position;
+ if (params.parent_page_id) {
+ requestBody.parent_page_id = params.parent_page_id;
}
const response = await this.http.post(url, requestBody);
@@ -330,7 +344,8 @@ export class EnhancedDocsClient {
}
/**
- * Update an existing page
+ * Update an existing page (Edit Page).
+ * Supports content_edit_mode 'replace' (default), 'append', and 'prepend'.
* @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
* @param docId The document ID
* @param pageId The page ID
@@ -344,11 +359,14 @@ export class EnhancedDocsClient {
try {
const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/pages/${pageId}`;
- const requestBody: any = {};
+ const requestBody: Record = {};
if (params.name !== undefined) requestBody.name = params.name;
- if (params.content !== undefined) requestBody.content = params.content;
- if (params.content_format !== undefined) requestBody.content_format = params.content_format;
- if (params.position !== undefined) requestBody.position = params.position;
+ if (params.sub_title !== undefined) requestBody.sub_title = params.sub_title;
+ if (params.content !== undefined) {
+ requestBody.content = params.content;
+ requestBody.content_edit_mode = params.content_edit_mode || 'replace';
+ requestBody.content_format = normalizeContentFormat(params.content_format);
+ }
const response = await this.http.put(url, requestBody);
@@ -359,23 +377,6 @@ export class EnhancedDocsClient {
}
}
- /**
- * Delete a page from a document
- * @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
- * @param docId The document ID
- * @param pageId The page ID
- */
- async deletePage(workspaceId: string, docId: string, pageId: string): Promise {
- try {
- const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/pages/${pageId}`;
-
- await this.http.delete(url);
- } catch (error) {
- console.error('Error deleting page:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to delete page ${pageId} from document ${docId}`);
- }
- }
-
/**
* Get page details
* @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
@@ -390,7 +391,9 @@ export class EnhancedDocsClient {
): Promise {
try {
const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/pages/${pageId}`;
- const params = contentFormat ? { content_format: contentFormat } : {};
+ const params = contentFormat
+ ? { content_format: normalizeContentFormat(contentFormat) }
+ : {};
const response = await this.http.get(url, { params });
@@ -401,71 +404,6 @@ export class EnhancedDocsClient {
}
}
- // ========================================
- // NEW: SHARING MANAGEMENT
- // ========================================
-
- /**
- * Get document sharing settings
- * @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
- * @param docId The document ID
- */
- async getDocSharing(workspaceId: string, docId: string): Promise {
- try {
- const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/sharing`;
-
- const response = await this.http.get(url);
-
- return response.data;
- } catch (error) {
- console.error('Error getting document sharing:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to get sharing settings for document ${docId}`);
- }
- }
-
- /**
- * Update document sharing settings
- * @param workspaceId The workspace ID containing the document (required by ClickUp v3 API)
- * @param docId The document ID
- */
- async updateDocSharing(
- workspaceId: string,
- docId: string,
- params: SharingParams
- ): Promise {
- try {
- const url = `https://api.clickup.com/api/v3/workspaces/${workspaceId}/docs/${docId}/sharing`;
-
- const response = await this.http.put(url, params);
-
- return response.data;
- } catch (error) {
- console.error('Error updating document sharing:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to update sharing settings for document ${docId}`);
- }
- }
-
- // ========================================
- // NEW: TEMPLATE OPERATIONS
- // ========================================
-
- /**
- * Create document from template
- */
- async createDocFromTemplate(templateId: string, params: CreateFromTemplateParams): Promise {
- try {
- const createParams: CreateDocParams = {
- ...params,
- template_id: templateId
- };
-
- return await this.createDoc(createParams);
- } catch (error) {
- console.error('Error creating document from template:', error instanceof Error ? error.message : error);
- throw this.handleError(error, `Failed to create document from template ${templateId}`);
- }
- }
-
// ========================================
// UTILITY METHODS
// ========================================
@@ -500,57 +438,25 @@ export class EnhancedDocsClient {
return new Error(`${context}: ${error.message || 'Unknown error'}`);
}
+}
- /**
- * Validate content format
- */
- private validateContentFormat(format: ContentFormat): boolean {
- const validFormats: ContentFormat[] = [
- 'markdown',
- 'html',
- 'text/md',
- 'text/plain',
- 'text/html'
- ];
- return validFormats.includes(format);
- }
-
- /**
- * Sanitize HTML content with comprehensive security measures
- */
- private sanitizeHtml(html: string): string {
- if (!html || typeof html !== 'string') {
- return '';
- }
-
- // Comprehensive HTML sanitization
- return (
- html
- // Remove script tags and their content
- .replace(/