@taazkareem/clickup-mcp-server 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +6 -6
  2. package/build/logger.js +26 -1
  3. package/build/server.js +1 -1
  4. package/build/services/clickup/base.js +22 -1
  5. package/build/services/clickup/bulk.js +111 -69
  6. package/build/services/clickup/index.js +2 -2
  7. package/build/services/clickup/task/index.js +32 -0
  8. package/build/services/clickup/task/task-attachments.js +97 -0
  9. package/build/services/clickup/task/task-comments.js +104 -0
  10. package/build/services/clickup/task/task-core.js +439 -0
  11. package/build/services/clickup/task/task-custom-fields.js +97 -0
  12. package/build/services/clickup/task/task-search.js +462 -0
  13. package/build/services/clickup/task/task-service.js +25 -0
  14. package/build/services/clickup/task/task-tags.js +101 -0
  15. package/build/services/clickup/workspace.js +81 -36
  16. package/build/tools/folder.js +1 -1
  17. package/build/tools/list.js +2 -4
  18. package/build/tools/task/attachments.js +49 -20
  19. package/build/tools/task/attachments.types.js +9 -0
  20. package/build/tools/task/bulk-operations.js +102 -18
  21. package/build/tools/task/handlers.js +216 -53
  22. package/build/tools/task/index.js +1 -1
  23. package/build/tools/task/main.js +161 -32
  24. package/build/tools/task/single-operations.js +82 -17
  25. package/build/tools/task/utilities.js +47 -75
  26. package/build/tools/utils.js +2 -2
  27. package/build/utils/date-utils.js +149 -30
  28. package/build/utils/resolver-utils.js +33 -40
  29. package/build/utils/sponsor-service.js +1 -1
  30. package/package.json +1 -1
  31. package/build/mcp-tools.js +0 -64
  32. package/build/server-state.js +0 -93
  33. package/build/server.log +0 -0
  34. package/build/services/clickup/task.js +0 -701
  35. package/build/tools/bulk-tasks.js +0 -36
  36. package/build/tools/debug.js +0 -76
  37. package/build/tools/logs.js +0 -55
  38. package/build/tools/task.js +0 -1554
  39. package/build/utils/params-utils.js +0 -39
  40. package/build/utils/sponsor-analytics.js +0 -100
  41. package/build/utils/sponsor-utils.js +0 -57
@@ -0,0 +1,462 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service - Search Module
6
+ *
7
+ * Handles search and lookup operations for tasks in ClickUp, including:
8
+ * - Finding tasks by name
9
+ * - Global workspace task lookup
10
+ * - Task summaries and detailed task data
11
+ */
12
+ import { TaskServiceCore } from './task-core.js';
13
+ import { isNameMatch } from '../../../utils/resolver-utils.js';
14
+ import { findListIDByName } from '../../../tools/list.js';
15
+ import { estimateTokensFromObject, wouldExceedTokenLimit } from '../../../utils/token-utils.js';
16
+ /**
17
+ * Search functionality for the TaskService
18
+ */
19
+ export class TaskServiceSearch extends TaskServiceCore {
20
+ /**
21
+ * Find a task by name within a specific list
22
+ * @param listId The ID of the list to search in
23
+ * @param taskName The name of the task to find
24
+ * @returns The task if found, otherwise null
25
+ */
26
+ async findTaskByName(listId, taskName) {
27
+ this.logOperation('findTaskByName', { listId, taskName });
28
+ try {
29
+ const tasks = await this.getTasks(listId);
30
+ // Find task by exact match first, then case-insensitive, then substring
31
+ // Exact match
32
+ let match = tasks.find(task => task.name === taskName);
33
+ if (match)
34
+ return match;
35
+ // Case-insensitive match
36
+ match = tasks.find(task => task.name.toLowerCase() === taskName.toLowerCase());
37
+ if (match)
38
+ return match;
39
+ // Substring match
40
+ match = tasks.find(task => task.name.toLowerCase().includes(taskName.toLowerCase()));
41
+ return match || null;
42
+ }
43
+ catch (error) {
44
+ throw this.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ }
47
+ /**
48
+ * Formats a task into a lightweight summary format
49
+ * @param task The task to format
50
+ * @returns A TaskSummary object
51
+ */
52
+ formatTaskSummary(task) {
53
+ return {
54
+ id: task.id,
55
+ name: task.name,
56
+ status: task.status.status,
57
+ list: {
58
+ id: task.list.id,
59
+ name: task.list.name
60
+ },
61
+ due_date: task.due_date,
62
+ url: task.url,
63
+ priority: this.extractPriorityValue(task),
64
+ tags: task.tags.map(tag => ({
65
+ name: tag.name,
66
+ tag_bg: tag.tag_bg,
67
+ tag_fg: tag.tag_fg
68
+ }))
69
+ };
70
+ }
71
+ /**
72
+ * Estimates token count for a task in JSON format
73
+ * @param task ClickUp task
74
+ * @returns Estimated token count
75
+ */
76
+ estimateTaskTokens(task) {
77
+ return estimateTokensFromObject(task);
78
+ }
79
+ /**
80
+ * Get filtered tasks across the entire team/workspace using tags and other filters
81
+ * @param filters Task filters to apply including tags, list/folder/space filtering
82
+ * @returns Either a DetailedTaskResponse or WorkspaceTasksResponse depending on detail_level
83
+ */
84
+ async getWorkspaceTasks(filters = {}) {
85
+ try {
86
+ this.logOperation('getWorkspaceTasks', { filters });
87
+ const params = this.buildTaskFilterParams(filters);
88
+ const response = await this.makeRequest(async () => {
89
+ return await this.client.get(`/team/${this.teamId}/task`, {
90
+ params
91
+ });
92
+ });
93
+ const tasks = response.data.tasks;
94
+ const totalCount = tasks.length; // Note: This is just the current page count
95
+ const hasMore = totalCount === 100; // ClickUp returns max 100 tasks per page
96
+ const nextPage = (filters.page || 0) + 1;
97
+ // If the estimated token count exceeds 50,000 or detail_level is 'summary',
98
+ // return summary format for efficiency and to avoid hitting token limits
99
+ const TOKEN_LIMIT = 50000;
100
+ // Estimate tokens for the full response
101
+ let tokensExceedLimit = false;
102
+ if (filters.detail_level !== 'summary' && tasks.length > 0) {
103
+ // We only need to check token count if detailed was requested
104
+ // For summary requests, we always return summary format
105
+ // First check with a sample task - if one task exceeds the limit, we definitely need summary
106
+ const sampleTask = tasks[0];
107
+ // Check if all tasks would exceed the token limit
108
+ const estimatedTokensPerTask = this.estimateTaskTokens(sampleTask);
109
+ const estimatedTotalTokens = estimatedTokensPerTask * tasks.length;
110
+ // Add 10% overhead for the response wrapper
111
+ tokensExceedLimit = estimatedTotalTokens * 1.1 > TOKEN_LIMIT;
112
+ // Double-check with more precise estimation if we're close to the limit
113
+ if (!tokensExceedLimit && estimatedTotalTokens * 1.1 > TOKEN_LIMIT * 0.8) {
114
+ // More precise check - build a representative sample and extrapolate
115
+ tokensExceedLimit = wouldExceedTokenLimit({ tasks, total_count: totalCount, has_more: hasMore, next_page: nextPage }, TOKEN_LIMIT);
116
+ }
117
+ }
118
+ // Determine if we should return summary or detailed based on request and token limit
119
+ const shouldUseSummary = filters.detail_level === 'summary' || tokensExceedLimit;
120
+ this.logOperation('getWorkspaceTasks', {
121
+ totalTasks: tasks.length,
122
+ estimatedTokens: tasks.reduce((count, task) => count + this.estimateTaskTokens(task), 0),
123
+ usingDetailedFormat: !shouldUseSummary,
124
+ requestedFormat: filters.detail_level || 'auto'
125
+ });
126
+ if (shouldUseSummary) {
127
+ return {
128
+ summaries: tasks.map(task => this.formatTaskSummary(task)),
129
+ total_count: totalCount,
130
+ has_more: hasMore,
131
+ next_page: nextPage
132
+ };
133
+ }
134
+ return {
135
+ tasks,
136
+ total_count: totalCount,
137
+ has_more: hasMore,
138
+ next_page: nextPage
139
+ };
140
+ }
141
+ catch (error) {
142
+ this.logOperation('getWorkspaceTasks', { error: error.message, status: error.response?.status });
143
+ throw this.handleError(error, 'Failed to get workspace tasks');
144
+ }
145
+ }
146
+ /**
147
+ * Get task summaries for lightweight retrieval
148
+ * @param filters Task filters to apply
149
+ * @returns WorkspaceTasksResponse with task summaries
150
+ */
151
+ async getTaskSummaries(filters = {}) {
152
+ return this.getWorkspaceTasks({ ...filters, detail_level: 'summary' });
153
+ }
154
+ /**
155
+ * Get detailed task data
156
+ * @param filters Task filters to apply
157
+ * @returns DetailedTaskResponse with full task data
158
+ */
159
+ async getTaskDetails(filters = {}) {
160
+ return this.getWorkspaceTasks({ ...filters, detail_level: 'detailed' });
161
+ }
162
+ /**
163
+ * Unified method for finding tasks by ID or name with consistent handling of global lookup
164
+ *
165
+ * This method provides a single entry point for all task lookup operations:
166
+ * - Direct lookup by task ID (highest priority)
167
+ * - Lookup by task name within a specific list
168
+ * - Global lookup by task name across the entire workspace
169
+ *
170
+ * @param options Lookup options with the following parameters:
171
+ * - taskId: Optional task ID for direct lookup
172
+ * - customTaskId: Optional custom task ID for direct lookup
173
+ * - taskName: Optional task name to search for
174
+ * - listId: Optional list ID to scope the search
175
+ * - listName: Optional list name to scope the search
176
+ * - allowMultipleMatches: Whether to return all matches instead of throwing an error
177
+ * - useSmartDisambiguation: Whether to automatically select the most recently updated task
178
+ * - includeFullDetails: Whether to include full task details (true) or just task summaries (false)
179
+ * - includeListContext: Whether to include list/folder/space context with results
180
+ * @returns Either a single task or an array of tasks depending on options
181
+ * @throws Error if task cannot be found or if multiple matches are found when not allowed
182
+ */
183
+ async findTasks({ taskId, customTaskId, taskName, listId, listName, allowMultipleMatches = false, useSmartDisambiguation = false, includeFullDetails = true, includeListContext = true }) {
184
+ try {
185
+ this.logOperation('findTasks', {
186
+ taskId,
187
+ customTaskId,
188
+ taskName,
189
+ listId,
190
+ listName,
191
+ allowMultipleMatches,
192
+ useSmartDisambiguation
193
+ });
194
+ // Case 1: Direct task ID lookup (highest priority)
195
+ if (taskId) {
196
+ // Check if it looks like a custom ID
197
+ if (taskId.includes('-') && /^[A-Z]+\-\d+$/.test(taskId)) {
198
+ this.logOperation('findTasks', { detectedCustomId: taskId });
199
+ try {
200
+ // Try to get it as a custom ID first
201
+ let resolvedListId;
202
+ if (listId) {
203
+ resolvedListId = listId;
204
+ }
205
+ else if (listName) {
206
+ const listInfo = await findListIDByName(this.workspaceService, listName);
207
+ if (listInfo) {
208
+ resolvedListId = listInfo.id;
209
+ }
210
+ }
211
+ const foundTask = await this.getTaskByCustomId(taskId, resolvedListId);
212
+ return foundTask;
213
+ }
214
+ catch (error) {
215
+ // If it fails as a custom ID, try as a regular ID
216
+ this.logOperation('findTasks', {
217
+ message: `Failed to find task with custom ID "${taskId}", falling back to regular ID`,
218
+ error: error.message
219
+ });
220
+ return await this.getTask(taskId);
221
+ }
222
+ }
223
+ // Regular task ID
224
+ return await this.getTask(taskId);
225
+ }
226
+ // Case 2: Explicit custom task ID lookup
227
+ if (customTaskId) {
228
+ let resolvedListId;
229
+ if (listId) {
230
+ resolvedListId = listId;
231
+ }
232
+ else if (listName) {
233
+ const listInfo = await findListIDByName(this.workspaceService, listName);
234
+ if (listInfo) {
235
+ resolvedListId = listInfo.id;
236
+ }
237
+ }
238
+ return await this.getTaskByCustomId(customTaskId, resolvedListId);
239
+ }
240
+ // Case 3: Task name lookup (requires either list context or global lookup)
241
+ if (taskName) {
242
+ // Case 3a: Task name + list context - search in specific list
243
+ if (listId || listName) {
244
+ let resolvedListId;
245
+ if (listId) {
246
+ resolvedListId = listId;
247
+ }
248
+ else {
249
+ const listInfo = await findListIDByName(this.workspaceService, listName);
250
+ if (!listInfo) {
251
+ throw new Error(`List "${listName}" not found`);
252
+ }
253
+ resolvedListId = listInfo.id;
254
+ }
255
+ const foundTask = await this.findTaskByName(resolvedListId, taskName);
256
+ if (!foundTask) {
257
+ throw new Error(`Task "${taskName}" not found in list`);
258
+ }
259
+ // If includeFullDetails is true and we need context not already in the task,
260
+ // get full details, otherwise return what we already have
261
+ if (includeFullDetails && (!foundTask.list || !foundTask.list.name || !foundTask.status)) {
262
+ return await this.getTask(foundTask.id);
263
+ }
264
+ return foundTask;
265
+ }
266
+ // Case 3b: Task name without list context - global lookup across workspace
267
+ // Get lightweight task summaries for efficient first-pass filtering
268
+ const response = await this.getTaskSummaries({});
269
+ if (!this.workspaceService) {
270
+ throw new Error("Workspace service required for global task lookup");
271
+ }
272
+ // Create an index to efficiently look up list context information
273
+ const hierarchy = await this.workspaceService.getWorkspaceHierarchy();
274
+ const listContextMap = new Map();
275
+ // Function to recursively build list context map
276
+ function buildListContextMap(nodes, spaceId, spaceName, folderId, folderName) {
277
+ for (const node of nodes) {
278
+ if (node.type === 'space') {
279
+ // Process space children
280
+ if (node.children) {
281
+ buildListContextMap(node.children, node.id, node.name);
282
+ }
283
+ }
284
+ else if (node.type === 'folder') {
285
+ // Process folder children
286
+ if (node.children) {
287
+ buildListContextMap(node.children, spaceId, spaceName, node.id, node.name);
288
+ }
289
+ }
290
+ else if (node.type === 'list') {
291
+ // Add list context to map
292
+ listContextMap.set(node.id, {
293
+ listId: node.id,
294
+ listName: node.name,
295
+ spaceId: spaceId,
296
+ spaceName: spaceName,
297
+ folderId,
298
+ folderName
299
+ });
300
+ }
301
+ }
302
+ }
303
+ // Build the context map
304
+ buildListContextMap(hierarchy.root.children);
305
+ // Find tasks that match the provided name
306
+ const initialMatches = [];
307
+ // Process task summaries to find initial matches
308
+ for (const taskSummary of response.summaries) {
309
+ if (isNameMatch(taskSummary.name, taskName)) {
310
+ // Get list context information
311
+ const listContext = listContextMap.get(taskSummary.list.id);
312
+ if (listContext) {
313
+ // Store task summary and context
314
+ initialMatches.push({
315
+ id: taskSummary.id,
316
+ task: taskSummary,
317
+ listContext
318
+ });
319
+ }
320
+ }
321
+ }
322
+ // Handle the no matches case
323
+ if (initialMatches.length === 0) {
324
+ throw new Error(`Task "${taskName}" not found in any list across your workspace. Please check the task name and try again.`);
325
+ }
326
+ // Handle the single match case - we can return early if we don't need full details
327
+ if (initialMatches.length === 1 && !useSmartDisambiguation && !includeFullDetails) {
328
+ const match = initialMatches[0];
329
+ if (includeListContext) {
330
+ return {
331
+ ...match.task,
332
+ list: {
333
+ id: match.listContext.listId,
334
+ name: match.listContext.listName
335
+ },
336
+ folder: match.listContext.folderId ? {
337
+ id: match.listContext.folderId,
338
+ name: match.listContext.folderName
339
+ } : undefined,
340
+ space: {
341
+ id: match.listContext.spaceId,
342
+ name: match.listContext.spaceName
343
+ }
344
+ };
345
+ }
346
+ return match.task;
347
+ }
348
+ // For multiple matches or when we need details, fetch full task info
349
+ const fullMatches = [];
350
+ try {
351
+ // Process in sequence for better reliability
352
+ for (const match of initialMatches) {
353
+ const fullTask = await this.getTask(match.id);
354
+ if (includeListContext) {
355
+ // Enhance task with context information
356
+ fullTask.list = {
357
+ ...fullTask.list,
358
+ name: match.listContext.listName
359
+ };
360
+ if (match.listContext.folderId) {
361
+ fullTask.folder = {
362
+ id: match.listContext.folderId,
363
+ name: match.listContext.folderName
364
+ };
365
+ }
366
+ fullTask.space = {
367
+ id: match.listContext.spaceId,
368
+ name: match.listContext.spaceName
369
+ };
370
+ }
371
+ fullMatches.push(fullTask);
372
+ }
373
+ // Sort by update time for disambiguation
374
+ if (fullMatches.length > 1) {
375
+ fullMatches.sort((a, b) => {
376
+ const aDate = parseInt(a.date_updated || '0', 10);
377
+ const bDate = parseInt(b.date_updated || '0', 10);
378
+ return bDate - aDate; // Most recent first
379
+ });
380
+ }
381
+ }
382
+ catch (error) {
383
+ this.logOperation('findTasks', {
384
+ error: error.message,
385
+ message: "Failed to get detailed task information"
386
+ });
387
+ // If detailed fetch fails, use the summaries with context info
388
+ // This fallback ensures we still return something useful
389
+ if (allowMultipleMatches) {
390
+ return initialMatches.map(match => ({
391
+ ...match.task,
392
+ list: {
393
+ id: match.listContext.listId,
394
+ name: match.listContext.listName
395
+ },
396
+ folder: match.listContext.folderId ? {
397
+ id: match.listContext.folderId,
398
+ name: match.listContext.folderName
399
+ } : undefined,
400
+ space: {
401
+ id: match.listContext.spaceId,
402
+ name: match.listContext.spaceName
403
+ }
404
+ }));
405
+ }
406
+ else {
407
+ // For single result, return the first match
408
+ const match = initialMatches[0];
409
+ return {
410
+ ...match.task,
411
+ list: {
412
+ id: match.listContext.listId,
413
+ name: match.listContext.listName
414
+ },
415
+ folder: match.listContext.folderId ? {
416
+ id: match.listContext.folderId,
417
+ name: match.listContext.folderName
418
+ } : undefined,
419
+ space: {
420
+ id: match.listContext.spaceId,
421
+ name: match.listContext.spaceName
422
+ }
423
+ };
424
+ }
425
+ }
426
+ // Return results based on options
427
+ if (fullMatches.length === 1 || useSmartDisambiguation) {
428
+ return fullMatches[0]; // Return most recently updated if multiple and smart disambiguation enabled
429
+ }
430
+ else if (allowMultipleMatches) {
431
+ return fullMatches; // Return all matches
432
+ }
433
+ else {
434
+ // Format error message for multiple matches
435
+ const matchesInfo = fullMatches.map(task => {
436
+ const listName = task.list?.name || "Unknown list";
437
+ const folderName = task.folder?.name;
438
+ const spaceName = task.space?.name || "Unknown space";
439
+ const updateTime = task.date_updated
440
+ ? new Date(parseInt(task.date_updated, 10)).toLocaleString()
441
+ : "Unknown date";
442
+ const location = `list "${listName}"${folderName ? ` (folder: "${folderName}")` : ''} (space: "${spaceName}")`;
443
+ return `- "${task.name}" in ${location} - Updated ${updateTime}`;
444
+ }).join('\n');
445
+ throw new Error(`Multiple tasks found with name "${taskName}":\n${matchesInfo}\n\nPlease provide list context to disambiguate or set allowMultipleMatches to true.`);
446
+ }
447
+ }
448
+ // No valid lookup parameters provided
449
+ throw new Error("At least one of taskId, customTaskId, or taskName must be provided");
450
+ }
451
+ catch (error) {
452
+ if (error.message?.includes('Task "') && error.message?.includes('not found')) {
453
+ throw error;
454
+ }
455
+ if (error.message?.includes('Multiple tasks found')) {
456
+ throw error;
457
+ }
458
+ // Unexpected errors
459
+ throw this.handleError(error, `Error finding task: ${error.message}`);
460
+ }
461
+ }
462
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service
6
+ *
7
+ * Main entry point for the ClickUp Task Service.
8
+ * Combines all task-related functionality through inheritance:
9
+ * - Core operations (CRUD)
10
+ * - Search capabilities
11
+ * - File attachments
12
+ * - Comments
13
+ * - Tags
14
+ * - Custom fields
15
+ */
16
+ import { TaskServiceCustomFields } from './task-custom-fields.js';
17
+ /**
18
+ * Complete TaskService combining all task-related functionality
19
+ */
20
+ export class TaskService extends TaskServiceCustomFields {
21
+ constructor(apiKey, teamId, baseUrl, workspaceService) {
22
+ super(apiKey, teamId, baseUrl, workspaceService);
23
+ this.logOperation('constructor', { initialized: true });
24
+ }
25
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service - Tags Module
6
+ *
7
+ * Handles tag operations for ClickUp tasks, including:
8
+ * - Adding tags to a task
9
+ * - Removing tags from a task
10
+ */
11
+ import { TaskServiceComments } from './task-comments.js';
12
+ /**
13
+ * Tags functionality for the TaskService
14
+ */
15
+ export class TaskServiceTags extends TaskServiceComments {
16
+ /**
17
+ * Add a tag to a task
18
+ *
19
+ * @param taskId ID of the task
20
+ * @param tagName Name of the tag to add
21
+ * @returns Success response
22
+ */
23
+ async addTagToTask(taskId, tagName) {
24
+ this.logOperation('addTagToTask', { taskId, tagName });
25
+ try {
26
+ const payload = {
27
+ tag_name: tagName,
28
+ };
29
+ await this.client.post(`/task/${taskId}/tag/${encodeURIComponent(tagName)}`, payload);
30
+ return true;
31
+ }
32
+ catch (error) {
33
+ throw this.handleError(error, `Failed to add tag "${tagName}" to task`);
34
+ }
35
+ }
36
+ /**
37
+ * Remove a tag from a task
38
+ *
39
+ * @param taskId ID of the task
40
+ * @param tagName Name of the tag to remove
41
+ * @returns Success response
42
+ */
43
+ async removeTagFromTask(taskId, tagName) {
44
+ this.logOperation('removeTagFromTask', { taskId, tagName });
45
+ try {
46
+ await this.client.delete(`/task/${taskId}/tag/${encodeURIComponent(tagName)}`);
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ throw this.handleError(error, `Failed to remove tag "${tagName}" from task`);
51
+ }
52
+ }
53
+ /**
54
+ * Get all tags for a task
55
+ *
56
+ * @param taskId ID of the task
57
+ * @returns Array of task tags
58
+ */
59
+ async getTaskTags(taskId) {
60
+ this.logOperation('getTaskTags', { taskId });
61
+ try {
62
+ // We need to fetch the full task to get its tags
63
+ const task = await this.getTask(taskId);
64
+ return task.tags || [];
65
+ }
66
+ catch (error) {
67
+ throw this.handleError(error, 'Failed to get task tags');
68
+ }
69
+ }
70
+ /**
71
+ * Update all tags for a task (replaces existing tags)
72
+ *
73
+ * @param taskId ID of the task
74
+ * @param tagNames Array of tag names to set
75
+ * @returns Success response
76
+ */
77
+ async updateTaskTags(taskId, tagNames) {
78
+ this.logOperation('updateTaskTags', { taskId, tagNames });
79
+ try {
80
+ // First get existing tags
81
+ const existingTags = await this.getTaskTags(taskId);
82
+ const existingTagNames = existingTags.map(tag => tag.name);
83
+ // Remove tags that shouldn't be there
84
+ for (const tagName of existingTagNames) {
85
+ if (!tagNames.includes(tagName)) {
86
+ await this.removeTagFromTask(taskId, tagName);
87
+ }
88
+ }
89
+ // Add new tags
90
+ for (const tagName of tagNames) {
91
+ if (!existingTagNames.includes(tagName)) {
92
+ await this.addTagToTask(taskId, tagName);
93
+ }
94
+ }
95
+ return true;
96
+ }
97
+ catch (error) {
98
+ throw this.handleError(error, 'Failed to update task tags');
99
+ }
100
+ }
101
+ }