Skip to main content

clickup_cli/commands/
task.rs

1use clap::Subcommand;
2use crate::client::ClickUpClient;
3use crate::commands::auth::resolve_token;
4use crate::commands::workspace::resolve_workspace;
5use crate::error::CliError;
6use crate::output::OutputConfig;
7use crate::Cli;
8
9#[derive(Subcommand)]
10pub enum TaskCommands {
11    /// List tasks in a list
12    List {
13        /// List ID
14        #[arg(long)]
15        list: String,
16        /// Filter by status
17        #[arg(long)]
18        status: Option<Vec<String>>,
19        /// Filter by assignee
20        #[arg(long)]
21        assignee: Option<Vec<String>>,
22        /// Filter by tag
23        #[arg(long)]
24        tag: Option<Vec<String>>,
25        /// Include closed tasks
26        #[arg(long)]
27        include_closed: bool,
28        /// Order by field
29        #[arg(long)]
30        order_by: Option<String>,
31        /// Reverse sort order
32        #[arg(long)]
33        reverse: bool,
34    },
35    /// Search tasks across workspace
36    Search {
37        /// Filter by space
38        #[arg(long)]
39        space: Option<String>,
40        /// Filter by folder
41        #[arg(long)]
42        folder: Option<String>,
43        /// Filter by list
44        #[arg(long)]
45        list: Option<String>,
46        /// Filter by status
47        #[arg(long)]
48        status: Option<Vec<String>>,
49        /// Filter by assignee
50        #[arg(long)]
51        assignee: Option<Vec<String>>,
52        /// Filter by tag
53        #[arg(long)]
54        tag: Option<Vec<String>>,
55    },
56    /// Get task details
57    Get {
58        /// Task ID
59        id: String,
60        /// Include subtasks
61        #[arg(long)]
62        subtasks: bool,
63        /// Treat ID as custom task ID
64        #[arg(long)]
65        custom_task_id: bool,
66    },
67    /// Create a task
68    Create {
69        /// List ID
70        #[arg(long)]
71        list: String,
72        /// Task name
73        #[arg(long)]
74        name: String,
75        /// Description
76        #[arg(long)]
77        description: Option<String>,
78        /// Status
79        #[arg(long)]
80        status: Option<String>,
81        /// Priority (1=urgent, 2=high, 3=normal, 4=low)
82        #[arg(long)]
83        priority: Option<u8>,
84        /// Assignee user ID
85        #[arg(long)]
86        assignee: Option<Vec<String>>,
87        /// Tag name
88        #[arg(long)]
89        tag: Option<Vec<String>>,
90        /// Due date (YYYY-MM-DD)
91        #[arg(long)]
92        due_date: Option<String>,
93        /// Parent task ID (creates subtask)
94        #[arg(long)]
95        parent: Option<String>,
96    },
97    /// Update a task
98    Update {
99        /// Task ID
100        id: String,
101        /// New name
102        #[arg(long)]
103        name: Option<String>,
104        /// New status
105        #[arg(long)]
106        status: Option<String>,
107        /// New priority (1-4)
108        #[arg(long)]
109        priority: Option<u8>,
110        /// Add assignee
111        #[arg(long)]
112        add_assignee: Option<Vec<String>>,
113        /// Remove assignee
114        #[arg(long)]
115        rem_assignee: Option<Vec<String>>,
116        /// New description
117        #[arg(long)]
118        description: Option<String>,
119    },
120    /// Delete a task
121    Delete {
122        /// Task ID
123        id: String,
124    },
125    /// Get time in status for task(s)
126    TimeInStatus {
127        /// Task ID(s) — multiple IDs triggers bulk mode
128        ids: Vec<String>,
129    },
130    /// Add a tag to a task
131    AddTag {
132        /// Task ID
133        task_id: String,
134        /// Tag name
135        tag_name: String,
136    },
137    /// Remove a tag from a task
138    RemoveTag {
139        /// Task ID
140        task_id: String,
141        /// Tag name
142        tag_name: String,
143    },
144    /// Add a dependency to a task
145    #[command(name = "add-dep")]
146    AddDep {
147        /// Task ID
148        id: String,
149        /// This task depends on another task (task is a blocker)
150        #[arg(long, conflicts_with = "dependency_of")]
151        depends_on: Option<String>,
152        /// This task is a dependency of another task (task is blocked by)
153        #[arg(long)]
154        dependency_of: Option<String>,
155    },
156    /// Remove a dependency from a task
157    #[command(name = "remove-dep")]
158    RemoveDep {
159        /// Task ID
160        id: String,
161        /// Remove depends-on relationship with this task ID
162        #[arg(long, conflicts_with = "dependency_of")]
163        depends_on: Option<String>,
164        /// Remove dependency-of relationship with this task ID
165        #[arg(long)]
166        dependency_of: Option<String>,
167    },
168    /// Link two tasks together
169    Link {
170        /// Task ID
171        id: String,
172        /// Target task ID to link to
173        target_id: String,
174    },
175    /// Unlink two tasks
176    Unlink {
177        /// Task ID
178        id: String,
179        /// Target task ID to unlink from
180        target_id: String,
181    },
182    /// Move a task to a different list (v3)
183    Move {
184        /// Task ID
185        id: String,
186        /// Destination list ID
187        #[arg(long)]
188        list: String,
189    },
190    /// Set per-user time estimate on a task (v3)
191    #[command(name = "set-estimate")]
192    SetEstimate {
193        /// Task ID
194        id: String,
195        /// Assignee user ID
196        #[arg(long)]
197        assignee: String,
198        /// Time estimate in milliseconds
199        #[arg(long)]
200        time: u64,
201    },
202    /// Replace all per-user time estimates on a task (v3)
203    #[command(name = "replace-estimates")]
204    ReplaceEstimates {
205        /// Task ID
206        id: String,
207        /// Assignee user ID
208        #[arg(long)]
209        assignee: String,
210        /// Time estimate in milliseconds
211        #[arg(long)]
212        time: u64,
213    },
214}
215
216const TASK_FIELDS: &[&str] = &["id", "name", "status", "priority", "assignees", "due_date"];
217
218pub async fn execute(command: TaskCommands, cli: &Cli) -> Result<(), CliError> {
219    let token = resolve_token(cli)?;
220    let client = ClickUpClient::new(&token, cli.timeout)?;
221    let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
222
223    match command {
224        TaskCommands::List {
225            list,
226            status,
227            assignee,
228            tag,
229            include_closed,
230            order_by,
231            reverse,
232        } => {
233            let mut params = Vec::new();
234            if include_closed {
235                params.push("include_closed=true".to_string());
236            }
237            if let Some(statuses) = &status {
238                for s in statuses {
239                    params.push(format!("statuses[]={}", s));
240                }
241            }
242            if let Some(assignees) = &assignee {
243                for a in assignees {
244                    params.push(format!("assignees[]={}", a));
245                }
246            }
247            if let Some(tags) = &tag {
248                for t in tags {
249                    params.push(format!("tags[]={}", t));
250                }
251            }
252            if let Some(ob) = &order_by {
253                params.push(format!("order_by={}", ob));
254            }
255            if reverse {
256                params.push("reverse=true".to_string());
257            }
258            if let Some(page) = cli.page {
259                params.push(format!("page={}", page));
260            }
261
262            let query = if params.is_empty() {
263                String::new()
264            } else {
265                format!("?{}", params.join("&"))
266            };
267
268            if cli.all {
269                // Auto-paginate
270                let mut all_tasks = Vec::new();
271                let mut page = 0u32;
272                loop {
273                    let mut page_params = params.clone();
274                    page_params.push(format!("page={}", page));
275                    let page_query = format!("?{}", page_params.join("&"));
276                    let resp = client
277                        .get(&format!("/v2/list/{}/task{}", list, page_query))
278                        .await?;
279                    let tasks = resp
280                        .get("tasks")
281                        .and_then(|t| t.as_array())
282                        .cloned()
283                        .unwrap_or_default();
284                    let is_last = resp
285                        .get("last_page")
286                        .and_then(|v| v.as_bool())
287                        .unwrap_or(true);
288                    all_tasks.extend(tasks);
289                    if is_last {
290                        break;
291                    }
292                    if let Some(limit) = cli.limit {
293                        if all_tasks.len() >= limit {
294                            all_tasks.truncate(limit);
295                            break;
296                        }
297                    }
298                    page += 1;
299                }
300                output.print_items(&all_tasks, TASK_FIELDS, "id");
301            } else {
302                let resp = client
303                    .get(&format!("/v2/list/{}/task{}", list, query))
304                    .await?;
305                let mut tasks = resp
306                    .get("tasks")
307                    .and_then(|t| t.as_array())
308                    .cloned()
309                    .unwrap_or_default();
310                if let Some(limit) = cli.limit {
311                    tasks.truncate(limit);
312                }
313                output.print_items(&tasks, TASK_FIELDS, "id");
314            }
315            Ok(())
316        }
317        TaskCommands::Search {
318            space,
319            folder,
320            list,
321            status,
322            assignee,
323            tag,
324        } => {
325            let ws_id = resolve_workspace(cli)?;
326            let mut params = Vec::new();
327            if let Some(s) = &space {
328                params.push(format!("space_ids[]={}", s));
329            }
330            if let Some(f) = &folder {
331                params.push(format!("project_ids[]={}", f));
332            }
333            if let Some(l) = &list {
334                params.push(format!("list_ids[]={}", l));
335            }
336            if let Some(statuses) = &status {
337                for s in statuses {
338                    params.push(format!("statuses[]={}", s));
339                }
340            }
341            if let Some(assignees) = &assignee {
342                for a in assignees {
343                    params.push(format!("assignees[]={}", a));
344                }
345            }
346            if let Some(tags) = &tag {
347                for t in tags {
348                    params.push(format!("tags[]={}", t));
349                }
350            }
351            if let Some(page) = cli.page {
352                params.push(format!("page={}", page));
353            }
354            let query = if params.is_empty() {
355                String::new()
356            } else {
357                format!("?{}", params.join("&"))
358            };
359            let resp = client
360                .get(&format!("/v2/team/{}/task{}", ws_id, query))
361                .await?;
362            let mut tasks = resp
363                .get("tasks")
364                .and_then(|t| t.as_array())
365                .cloned()
366                .unwrap_or_default();
367            if let Some(limit) = cli.limit {
368                tasks.truncate(limit);
369            }
370            output.print_items(&tasks, TASK_FIELDS, "id");
371            Ok(())
372        }
373        TaskCommands::Get {
374            id,
375            subtasks,
376            custom_task_id,
377        } => {
378            let mut params = Vec::new();
379            if subtasks {
380                params.push("include_subtasks=true".to_string());
381            }
382            if custom_task_id {
383                params.push("custom_task_ids=true".to_string());
384                let ws_id = resolve_workspace(cli)?;
385                params.push(format!("team_id={}", ws_id));
386            }
387            let query = if params.is_empty() {
388                String::new()
389            } else {
390                format!("?{}", params.join("&"))
391            };
392            let resp = client
393                .get(&format!("/v2/task/{}{}", id, query))
394                .await?;
395            output.print_single(&resp, TASK_FIELDS, "id");
396            Ok(())
397        }
398        TaskCommands::Create {
399            list,
400            name,
401            description,
402            status,
403            priority,
404            assignee,
405            tag,
406            due_date,
407            parent,
408        } => {
409            let mut body = serde_json::json!({ "name": name });
410            if let Some(d) = description {
411                body["description"] = serde_json::Value::String(d);
412            }
413            if let Some(s) = status {
414                body["status"] = serde_json::Value::String(s);
415            }
416            if let Some(p) = priority {
417                body["priority"] = serde_json::json!(p);
418            }
419            if let Some(assignees) = assignee {
420                let ids: Vec<serde_json::Value> = assignees
421                    .iter()
422                    .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
423                    .collect();
424                body["assignees"] = serde_json::Value::Array(ids);
425            }
426            if let Some(tags) = tag {
427                body["tags"] = serde_json::json!(tags);
428            }
429            if let Some(d) = due_date {
430                body["due_date"] = serde_json::Value::String(date_to_ms(&d)?);
431            }
432            if let Some(p) = parent {
433                body["parent"] = serde_json::Value::String(p);
434            }
435            let resp = client
436                .post(&format!("/v2/list/{}/task", list), &body)
437                .await?;
438            output.print_single(&resp, TASK_FIELDS, "id");
439            Ok(())
440        }
441        TaskCommands::Update {
442            id,
443            name,
444            status,
445            priority,
446            add_assignee,
447            rem_assignee,
448            description,
449        } => {
450            let mut body = serde_json::Map::new();
451            if let Some(n) = name {
452                body.insert("name".into(), serde_json::Value::String(n));
453            }
454            if let Some(s) = status {
455                body.insert("status".into(), serde_json::Value::String(s));
456            }
457            if let Some(p) = priority {
458                body.insert("priority".into(), serde_json::json!(p));
459            }
460            if let Some(d) = description {
461                body.insert("description".into(), serde_json::Value::String(d));
462            }
463            // Assignee add/remove uses nested object
464            if add_assignee.is_some() || rem_assignee.is_some() {
465                let mut assignees = serde_json::Map::new();
466                if let Some(add) = add_assignee {
467                    let ids: Vec<serde_json::Value> = add
468                        .iter()
469                        .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
470                        .collect();
471                    assignees.insert("add".into(), serde_json::Value::Array(ids));
472                }
473                if let Some(rem) = rem_assignee {
474                    let ids: Vec<serde_json::Value> = rem
475                        .iter()
476                        .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
477                        .collect();
478                    assignees.insert("rem".into(), serde_json::Value::Array(ids));
479                }
480                body.insert("assignees".into(), serde_json::Value::Object(assignees));
481            }
482            let resp = client
483                .put(&format!("/v2/task/{}", id), &serde_json::Value::Object(body))
484                .await?;
485            output.print_single(&resp, TASK_FIELDS, "id");
486            Ok(())
487        }
488        TaskCommands::Delete { id } => {
489            client.delete(&format!("/v2/task/{}", id)).await?;
490            output.print_message(&format!("Task {} deleted", id));
491            Ok(())
492        }
493        TaskCommands::AddTag { task_id, tag_name } => {
494            client
495                .post(
496                    &format!("/v2/task/{}/tag/{}", task_id, tag_name),
497                    &serde_json::json!({}),
498                )
499                .await?;
500            output.print_message(&format!("Tag '{}' added to task {}", tag_name, task_id));
501            Ok(())
502        }
503        TaskCommands::RemoveTag { task_id, tag_name } => {
504            client
505                .delete(&format!("/v2/task/{}/tag/{}", task_id, tag_name))
506                .await?;
507            output.print_message(&format!("Tag '{}' removed from task {}", tag_name, task_id));
508            Ok(())
509        }
510        TaskCommands::AddDep {
511            id,
512            depends_on,
513            dependency_of,
514        } => {
515            let body = if let Some(other) = depends_on {
516                serde_json::json!({ "depends_on": other })
517            } else if let Some(other) = dependency_of {
518                serde_json::json!({ "dependency_of": other })
519            } else {
520                return Err(CliError::ClientError {
521                    message: "Specify --depends-on or --dependency-of".into(),
522                    status: 0,
523                });
524            };
525            client
526                .post(&format!("/v2/task/{}/dependency", id), &body)
527                .await?;
528            output.print_message(&format!("Dependency added to task {}", id));
529            Ok(())
530        }
531        TaskCommands::RemoveDep {
532            id,
533            depends_on,
534            dependency_of,
535        } => {
536            let body = if let Some(other) = depends_on {
537                serde_json::json!({ "depends_on": other })
538            } else if let Some(other) = dependency_of {
539                serde_json::json!({ "dependency_of": other })
540            } else {
541                return Err(CliError::ClientError {
542                    message: "Specify --depends-on or --dependency-of".into(),
543                    status: 0,
544                });
545            };
546            client
547                .delete_with_body(&format!("/v2/task/{}/dependency", id), &body)
548                .await?;
549            output.print_message(&format!("Dependency removed from task {}", id));
550            Ok(())
551        }
552        TaskCommands::Link { id, target_id } => {
553            client
554                .post(
555                    &format!("/v2/task/{}/link/{}", id, target_id),
556                    &serde_json::json!({}),
557                )
558                .await?;
559            output.print_message(&format!("Task {} linked to {}", id, target_id));
560            Ok(())
561        }
562        TaskCommands::Unlink { id, target_id } => {
563            client
564                .delete(&format!("/v2/task/{}/link/{}", id, target_id))
565                .await?;
566            output.print_message(&format!("Task {} unlinked from {}", id, target_id));
567            Ok(())
568        }
569        TaskCommands::Move { id, list } => {
570            let ws_id = crate::commands::workspace::resolve_workspace(cli)?;
571            client
572                .put(
573                    &format!("/v3/workspaces/{}/tasks/{}/home_list/{}", ws_id, id, list),
574                    &serde_json::json!({}),
575                )
576                .await?;
577            output.print_message(&format!("Task {} moved to list {}", id, list));
578            Ok(())
579        }
580        TaskCommands::SetEstimate { id, assignee, time } => {
581            let ws_id = crate::commands::workspace::resolve_workspace(cli)?;
582            let body = serde_json::json!({
583                "time_estimates": [{"user_id": assignee, "time_estimate": time}]
584            });
585            let resp = client
586                .patch(
587                    &format!("/v3/workspaces/{}/tasks/{}/time_estimates_by_user", ws_id, id),
588                    &body,
589                )
590                .await?;
591            output.print_single(&resp, TASK_FIELDS, "id");
592            Ok(())
593        }
594        TaskCommands::ReplaceEstimates { id, assignee, time } => {
595            let ws_id = crate::commands::workspace::resolve_workspace(cli)?;
596            let body = serde_json::json!({
597                "time_estimates": [{"user_id": assignee, "time_estimate": time}]
598            });
599            let resp = client
600                .put(
601                    &format!("/v3/workspaces/{}/tasks/{}/time_estimates_by_user", ws_id, id),
602                    &body,
603                )
604                .await?;
605            output.print_single(&resp, TASK_FIELDS, "id");
606            Ok(())
607        }
608        TaskCommands::TimeInStatus { ids } => {
609            if ids.len() == 1 {
610                let resp = client
611                    .get(&format!("/v2/task/{}/time_in_status", ids[0]))
612                    .await?;
613                if cli.output == "json" {
614                    println!("{}", serde_json::to_string_pretty(&resp).unwrap());
615                } else {
616                    // Print status durations
617                    if let Some(statuses) = resp.get("current_status").and_then(|v| v.as_object()) {
618                        println!("Current: {} ({}ms)",
619                            statuses.get("status").and_then(|v| v.as_str()).unwrap_or("-"),
620                            statuses.get("total_time").and_then(|v| v.as_object())
621                                .and_then(|o| o.get("by_minute"))
622                                .and_then(|v| v.as_u64())
623                                .unwrap_or(0)
624                        );
625                    }
626                    // Print all statuses
627                    if let Some(statuses_arr) = resp.get("status_history").and_then(|v| v.as_array()) {
628                        for s in statuses_arr {
629                            let name = s.get("status").and_then(|v| v.as_str()).unwrap_or("-");
630                            let time = s.get("total_time").and_then(|v| v.as_object())
631                                .and_then(|o| o.get("by_minute"))
632                                .and_then(|v| v.as_u64())
633                                .unwrap_or(0);
634                            println!("  {} — {}ms", name, time);
635                        }
636                    }
637                }
638            } else {
639                // Bulk mode
640                let task_ids = ids.join(",");
641                let resp = client
642                    .get(&format!("/v2/task/bulk_time_in_status/task_ids?task_ids={}", task_ids))
643                    .await?;
644                if cli.output == "json" {
645                    println!("{}", serde_json::to_string_pretty(&resp).unwrap());
646                } else {
647                    // Print per-task summary
648                    if let Some(obj) = resp.as_object() {
649                        for (task_id, data) in obj {
650                            let current = data
651                                .get("current_status")
652                                .and_then(|v| v.get("status"))
653                                .and_then(|v| v.as_str())
654                                .unwrap_or("-");
655                            println!("{}: {}", task_id, current);
656                        }
657                    }
658                }
659            }
660            Ok(())
661        }
662    }
663}
664
665fn date_to_ms(date_str: &str) -> Result<String, CliError> {
666    let naive = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
667        .map_err(|_| CliError::ClientError {
668            message: format!("Invalid date '{}'. Use YYYY-MM-DD format.", date_str),
669            status: 0,
670        })?;
671    let dt = naive.and_hms_opt(0, 0, 0).unwrap().and_utc();
672    Ok((dt.timestamp_millis()).to_string())
673}