Skip to main content

clickup_cli/commands/
task.rs

1use crate::client::ClickUpClient;
2use crate::commands::auth::resolve_token;
3use crate::commands::workspace::resolve_workspace;
4use crate::error::CliError;
5use crate::git;
6use crate::output::OutputConfig;
7use crate::Cli;
8use clap::Subcommand;
9
10#[derive(Subcommand)]
11pub enum TaskCommands {
12    /// List tasks in a list
13    List {
14        /// List ID
15        #[arg(long)]
16        list: String,
17        /// Filter by status
18        #[arg(long)]
19        status: Option<Vec<String>>,
20        /// Filter by assignee
21        #[arg(long)]
22        assignee: Option<Vec<String>>,
23        /// Filter by tag
24        #[arg(long)]
25        tag: Option<Vec<String>>,
26        /// Include closed tasks
27        #[arg(long)]
28        include_closed: bool,
29        /// Order by field
30        #[arg(long)]
31        order_by: Option<String>,
32        /// Reverse sort order
33        #[arg(long)]
34        reverse: bool,
35    },
36    /// Search tasks across workspace
37    Search {
38        /// Filter by space
39        #[arg(long)]
40        space: Option<String>,
41        /// Filter by folder
42        #[arg(long)]
43        folder: Option<String>,
44        /// Filter by list
45        #[arg(long)]
46        list: Option<String>,
47        /// Filter by status
48        #[arg(long)]
49        status: Option<Vec<String>>,
50        /// Filter by assignee
51        #[arg(long)]
52        assignee: Option<Vec<String>>,
53        /// Filter by tag
54        #[arg(long)]
55        tag: Option<Vec<String>>,
56    },
57    /// Get task details
58    Get {
59        /// Task ID (auto-detected from git branch if omitted)
60        id: Option<String>,
61        /// Include subtasks
62        #[arg(long)]
63        subtasks: bool,
64        /// Treat ID as custom task ID
65        #[arg(long)]
66        custom_task_id: bool,
67    },
68    /// Create a task
69    Create {
70        /// List ID
71        #[arg(long)]
72        list: String,
73        /// Task name
74        #[arg(long)]
75        name: String,
76        /// Description
77        #[arg(long)]
78        description: Option<String>,
79        /// Status
80        #[arg(long)]
81        status: Option<String>,
82        /// Priority (1=urgent, 2=high, 3=normal, 4=low)
83        #[arg(long)]
84        priority: Option<u8>,
85        /// Assignee user ID
86        #[arg(long)]
87        assignee: Option<Vec<String>>,
88        /// Tag name
89        #[arg(long)]
90        tag: Option<Vec<String>>,
91        /// Due date (YYYY-MM-DD)
92        #[arg(long)]
93        due_date: Option<String>,
94        /// Parent task ID (creates subtask)
95        #[arg(long)]
96        parent: Option<String>,
97    },
98    /// Update a task
99    Update {
100        /// Task ID (auto-detected from git branch if omitted)
101        id: Option<String>,
102        /// New name
103        #[arg(long)]
104        name: Option<String>,
105        /// New status
106        #[arg(long)]
107        status: Option<String>,
108        /// New priority (1-4)
109        #[arg(long)]
110        priority: Option<u8>,
111        /// Add assignee
112        #[arg(long)]
113        add_assignee: Option<Vec<String>>,
114        /// Remove assignee
115        #[arg(long)]
116        rem_assignee: Option<Vec<String>>,
117        /// New description
118        #[arg(long)]
119        description: Option<String>,
120    },
121    /// Delete a task (explicit ID required — never auto-detects from branch)
122    Delete {
123        /// Task ID
124        id: Option<String>,
125    },
126    /// Get time in status for task(s)
127    TimeInStatus {
128        /// Task ID(s) — multiple IDs triggers bulk mode
129        ids: Vec<String>,
130    },
131    /// Add a tag to a task. Usage: add-tag <task_id> <tag_name>  OR  add-tag <tag_name> (task auto-detected from branch)
132    AddTag {
133        /// Task ID (or tag name if only one arg is given)
134        task_or_tag: String,
135        /// Tag name (when task_or_tag is a task ID)
136        tag_name: Option<String>,
137    },
138    /// Remove a tag from a task. Usage: remove-tag <task_id> <tag_name>  OR  remove-tag <tag_name> (task auto-detected)
139    RemoveTag {
140        /// Task ID (or tag name if only one arg is given)
141        task_or_tag: String,
142        /// Tag name (when task_or_tag is a task ID)
143        tag_name: Option<String>,
144    },
145    /// Add a dependency to a task
146    #[command(name = "add-dep")]
147    AddDep {
148        /// Task ID (auto-detected from git branch if omitted)
149        id: Option<String>,
150        /// This task depends on another task (task is a blocker)
151        #[arg(long, conflicts_with = "dependency_of")]
152        depends_on: Option<String>,
153        /// This task is a dependency of another task (task is blocked by)
154        #[arg(long)]
155        dependency_of: Option<String>,
156    },
157    /// Remove a dependency from a task
158    #[command(name = "remove-dep")]
159    RemoveDep {
160        /// Task ID (auto-detected from git branch if omitted)
161        id: Option<String>,
162        /// Remove depends-on relationship with this task ID
163        #[arg(long, conflicts_with = "dependency_of")]
164        depends_on: Option<String>,
165        /// Remove dependency-of relationship with this task ID
166        #[arg(long)]
167        dependency_of: Option<String>,
168    },
169    /// Link two tasks together
170    Link {
171        /// Task ID
172        id: String,
173        /// Target task ID to link to
174        target_id: String,
175    },
176    /// Unlink two tasks
177    Unlink {
178        /// Task ID
179        id: String,
180        /// Target task ID to unlink from
181        target_id: String,
182    },
183    /// Move a task to a different list (v3)
184    Move {
185        /// Destination list ID
186        #[arg(long)]
187        list: String,
188        /// Task ID (auto-detected from git branch if omitted)
189        id: Option<String>,
190    },
191    /// Set per-user time estimate on a task (v3)
192    #[command(name = "set-estimate")]
193    SetEstimate {
194        /// Assignee user ID
195        #[arg(long)]
196        assignee: String,
197        /// Time estimate in milliseconds
198        #[arg(long)]
199        time: u64,
200        /// Task ID (auto-detected from git branch if omitted)
201        id: Option<String>,
202    },
203    /// Replace all per-user time estimates on a task (v3)
204    #[command(name = "replace-estimates")]
205    ReplaceEstimates {
206        /// Per-user estimate in the form ASSIGNEE:MS (repeat for each user).
207        /// ASSIGNEE is a numeric user ID or the literal string `unassigned`.
208        /// At least one --estimate is required unless --body is provided.
209        /// This REPLACES the full set of per-user estimates on the task; any
210        /// user not listed here is removed.
211        #[arg(long = "estimate")]
212        estimates: Vec<String>,
213        /// Raw JSON body (overrides --estimate). Must be an array of
214        /// {assignee, time} objects per ClickUp's spec.
215        #[arg(long)]
216        body: Option<String>,
217        /// Task ID (auto-detected from git branch if omitted)
218        id: Option<String>,
219    },
220}
221
222const TASK_FIELDS: &[&str] = &["id", "name", "status", "priority", "assignees", "due_date"];
223
224pub async fn execute(command: TaskCommands, cli: &Cli) -> Result<(), CliError> {
225    let token = resolve_token(cli)?;
226    let client = ClickUpClient::new(&token, cli.timeout)?;
227    let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
228
229    match command {
230        TaskCommands::List {
231            list,
232            status,
233            assignee,
234            tag,
235            include_closed,
236            order_by,
237            reverse,
238        } => {
239            let mut params = Vec::new();
240            if include_closed {
241                params.push("include_closed=true".to_string());
242            }
243            if let Some(statuses) = &status {
244                for s in statuses {
245                    params.push(format!("statuses[]={}", s));
246                }
247            }
248            if let Some(assignees) = &assignee {
249                for a in assignees {
250                    params.push(format!("assignees[]={}", a));
251                }
252            }
253            if let Some(tags) = &tag {
254                for t in tags {
255                    params.push(format!("tags[]={}", t));
256                }
257            }
258            if let Some(ob) = &order_by {
259                params.push(format!("order_by={}", ob));
260            }
261            if reverse {
262                params.push("reverse=true".to_string());
263            }
264            if let Some(page) = cli.page {
265                params.push(format!("page={}", page));
266            }
267
268            let query = if params.is_empty() {
269                String::new()
270            } else {
271                format!("?{}", params.join("&"))
272            };
273
274            if cli.all {
275                // Auto-paginate
276                let mut all_tasks = Vec::new();
277                let mut page = 0u32;
278                loop {
279                    let mut page_params = params.clone();
280                    page_params.push(format!("page={}", page));
281                    let page_query = format!("?{}", page_params.join("&"));
282                    let resp = client
283                        .get(&format!("/v2/list/{}/task{}", list, page_query))
284                        .await?;
285                    let tasks = resp
286                        .get("tasks")
287                        .and_then(|t| t.as_array())
288                        .cloned()
289                        .unwrap_or_default();
290                    let is_last = resp
291                        .get("last_page")
292                        .and_then(|v| v.as_bool())
293                        .unwrap_or(true);
294                    all_tasks.extend(tasks);
295                    if is_last {
296                        break;
297                    }
298                    if let Some(limit) = cli.limit {
299                        if all_tasks.len() >= limit {
300                            all_tasks.truncate(limit);
301                            break;
302                        }
303                    }
304                    page += 1;
305                }
306                output.print_items(&all_tasks, TASK_FIELDS, "id");
307            } else {
308                let resp = client
309                    .get(&format!("/v2/list/{}/task{}", list, query))
310                    .await?;
311                let mut tasks = resp
312                    .get("tasks")
313                    .and_then(|t| t.as_array())
314                    .cloned()
315                    .unwrap_or_default();
316                if let Some(limit) = cli.limit {
317                    tasks.truncate(limit);
318                }
319                output.print_items(&tasks, TASK_FIELDS, "id");
320            }
321            Ok(())
322        }
323        TaskCommands::Search {
324            space,
325            folder,
326            list,
327            status,
328            assignee,
329            tag,
330        } => {
331            let ws_id = resolve_workspace(cli)?;
332            let mut params = Vec::new();
333            if let Some(s) = &space {
334                params.push(format!("space_ids[]={}", s));
335            }
336            if let Some(f) = &folder {
337                params.push(format!("project_ids[]={}", f));
338            }
339            if let Some(l) = &list {
340                params.push(format!("list_ids[]={}", l));
341            }
342            if let Some(statuses) = &status {
343                for s in statuses {
344                    params.push(format!("statuses[]={}", s));
345                }
346            }
347            if let Some(assignees) = &assignee {
348                for a in assignees {
349                    params.push(format!("assignees[]={}", a));
350                }
351            }
352            if let Some(tags) = &tag {
353                for t in tags {
354                    params.push(format!("tags[]={}", t));
355                }
356            }
357            if let Some(page) = cli.page {
358                params.push(format!("page={}", page));
359            }
360            let query = if params.is_empty() {
361                String::new()
362            } else {
363                format!("?{}", params.join("&"))
364            };
365            let resp = client
366                .get(&format!("/v2/team/{}/task{}", ws_id, query))
367                .await?;
368            let mut tasks = resp
369                .get("tasks")
370                .and_then(|t| t.as_array())
371                .cloned()
372                .unwrap_or_default();
373            if let Some(limit) = cli.limit {
374                tasks.truncate(limit);
375            }
376            output.print_items(&tasks, TASK_FIELDS, "id");
377            Ok(())
378        }
379        TaskCommands::Get {
380            id,
381            subtasks,
382            custom_task_id,
383        } => {
384            let task = git::require_task(cli, id.as_deref(), true)?;
385            let mut params = Vec::new();
386            if subtasks {
387                params.push("include_subtasks=true".to_string());
388            }
389            if custom_task_id || task.is_custom {
390                params.push("custom_task_ids=true".to_string());
391                let ws_id = resolve_workspace(cli)?;
392                params.push(format!("team_id={}", ws_id));
393            }
394            let query = if params.is_empty() {
395                String::new()
396            } else {
397                format!("?{}", params.join("&"))
398            };
399            let resp = client
400                .get(&format!("/v2/task/{}{}", task.id, query))
401                .await?;
402            output.print_single(&resp, TASK_FIELDS, "id");
403            Ok(())
404        }
405        TaskCommands::Create {
406            list,
407            name,
408            description,
409            status,
410            priority,
411            assignee,
412            tag,
413            due_date,
414            parent,
415        } => {
416            let mut body = serde_json::json!({ "name": name });
417            if let Some(d) = description {
418                body["markdown_content"] = serde_json::Value::String(d);
419            }
420            if let Some(s) = status {
421                body["status"] = serde_json::Value::String(s);
422            }
423            if let Some(p) = priority {
424                body["priority"] = serde_json::json!(p);
425            }
426            if let Some(assignees) = assignee {
427                let ids: Vec<serde_json::Value> = assignees
428                    .iter()
429                    .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
430                    .collect();
431                body["assignees"] = serde_json::Value::Array(ids);
432            }
433            if let Some(tags) = tag {
434                body["tags"] = serde_json::json!(tags);
435            }
436            if let Some(d) = due_date {
437                body["due_date"] = serde_json::Value::String(date_to_ms(&d)?);
438            }
439            if let Some(p) = parent {
440                body["parent"] = serde_json::Value::String(p);
441            }
442            let resp = client
443                .post(&format!("/v2/list/{}/task", list), &body)
444                .await?;
445            output.print_single(&resp, TASK_FIELDS, "id");
446            Ok(())
447        }
448        TaskCommands::Update {
449            id,
450            name,
451            status,
452            priority,
453            add_assignee,
454            rem_assignee,
455            description,
456        } => {
457            let task = git::require_task(cli, id.as_deref(), true)?;
458            let mut body = serde_json::Map::new();
459            if let Some(n) = name {
460                body.insert("name".into(), serde_json::Value::String(n));
461            }
462            if let Some(s) = status {
463                body.insert("status".into(), serde_json::Value::String(s));
464            }
465            if let Some(p) = priority {
466                body.insert("priority".into(), serde_json::json!(p));
467            }
468            if let Some(d) = description {
469                body.insert("markdown_content".into(), serde_json::Value::String(d));
470            }
471            // Assignee add/remove uses nested object
472            if add_assignee.is_some() || rem_assignee.is_some() {
473                let mut assignees = serde_json::Map::new();
474                if let Some(add) = add_assignee {
475                    let ids: Vec<serde_json::Value> = add
476                        .iter()
477                        .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
478                        .collect();
479                    assignees.insert("add".into(), serde_json::Value::Array(ids));
480                }
481                if let Some(rem) = rem_assignee {
482                    let ids: Vec<serde_json::Value> = rem
483                        .iter()
484                        .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
485                        .collect();
486                    assignees.insert("rem".into(), serde_json::Value::Array(ids));
487                }
488                body.insert("assignees".into(), serde_json::Value::Object(assignees));
489            }
490            let path = if task.is_custom {
491                let ws_id = resolve_workspace(cli)?;
492                format!(
493                    "/v2/task/{}?custom_task_ids=true&team_id={}",
494                    task.id, ws_id
495                )
496            } else {
497                format!("/v2/task/{}", task.id)
498            };
499            let resp = client.put(&path, &serde_json::Value::Object(body)).await?;
500            output.print_single(&resp, TASK_FIELDS, "id");
501            Ok(())
502        }
503        TaskCommands::Delete { id } => {
504            let task = git::require_task(cli, id.as_deref(), false)?;
505            let path = if task.is_custom {
506                let ws_id = resolve_workspace(cli)?;
507                format!(
508                    "/v2/task/{}?custom_task_ids=true&team_id={}",
509                    task.id, ws_id
510                )
511            } else {
512                format!("/v2/task/{}", task.id)
513            };
514            client.delete(&path).await?;
515            output.print_message(&format!("Task {} deleted", task.raw));
516            Ok(())
517        }
518        TaskCommands::AddTag {
519            task_or_tag,
520            tag_name,
521        } => {
522            let (task, tag_name) = resolve_task_tag(cli, task_or_tag, tag_name)?;
523            client
524                .post(
525                    &format!("/v2/task/{}/tag/{}", task.id, tag_name),
526                    &serde_json::json!({}),
527                )
528                .await?;
529            output.print_message(&format!("Tag '{}' added to task {}", tag_name, task.raw));
530            Ok(())
531        }
532        TaskCommands::RemoveTag {
533            task_or_tag,
534            tag_name,
535        } => {
536            let (task, tag_name) = resolve_task_tag(cli, task_or_tag, tag_name)?;
537            client
538                .delete(&format!("/v2/task/{}/tag/{}", task.id, tag_name))
539                .await?;
540            output.print_message(&format!(
541                "Tag '{}' removed from task {}",
542                tag_name, task.raw
543            ));
544            Ok(())
545        }
546        TaskCommands::AddDep {
547            id,
548            depends_on,
549            dependency_of,
550        } => {
551            let task = git::require_task(cli, id.as_deref(), true)?;
552            let body = if let Some(other) = depends_on {
553                serde_json::json!({ "depends_on": other })
554            } else if let Some(other) = dependency_of {
555                serde_json::json!({ "dependency_of": other })
556            } else {
557                return Err(CliError::ClientError {
558                    message: "Specify --depends-on or --dependency-of".into(),
559                    status: 0,
560                });
561            };
562            client
563                .post(&format!("/v2/task/{}/dependency", task.id), &body)
564                .await?;
565            output.print_message(&format!("Dependency added to task {}", task.raw));
566            Ok(())
567        }
568        TaskCommands::RemoveDep {
569            id,
570            depends_on,
571            dependency_of,
572        } => {
573            let task = git::require_task(cli, id.as_deref(), true)?;
574            let body = if let Some(other) = depends_on {
575                serde_json::json!({ "depends_on": other })
576            } else if let Some(other) = dependency_of {
577                serde_json::json!({ "dependency_of": other })
578            } else {
579                return Err(CliError::ClientError {
580                    message: "Specify --depends-on or --dependency-of".into(),
581                    status: 0,
582                });
583            };
584            client
585                .delete_with_body(&format!("/v2/task/{}/dependency", task.id), &body)
586                .await?;
587            output.print_message(&format!("Dependency removed from task {}", task.raw));
588            Ok(())
589        }
590        TaskCommands::Link { id, target_id } => {
591            client
592                .post(
593                    &format!("/v2/task/{}/link/{}", id, target_id),
594                    &serde_json::json!({}),
595                )
596                .await?;
597            output.print_message(&format!("Task {} linked to {}", id, target_id));
598            Ok(())
599        }
600        TaskCommands::Unlink { id, target_id } => {
601            client
602                .delete(&format!("/v2/task/{}/link/{}", id, target_id))
603                .await?;
604            output.print_message(&format!("Task {} unlinked from {}", id, target_id));
605            Ok(())
606        }
607        TaskCommands::Move { id, list } => {
608            let task = git::require_task(cli, id.as_deref(), true)?;
609            let ws_id = resolve_workspace(cli)?;
610            client
611                .put(
612                    &format!(
613                        "/v3/workspaces/{}/tasks/{}/home_list/{}",
614                        ws_id, task.id, list
615                    ),
616                    &serde_json::json!({}),
617                )
618                .await?;
619            output.print_message(&format!("Task {} moved to list {}", task.raw, list));
620            Ok(())
621        }
622        TaskCommands::SetEstimate { id, assignee, time } => {
623            let task = git::require_task(cli, id.as_deref(), true)?;
624            let ws_id = resolve_workspace(cli)?;
625            let body = serde_json::json!({
626                "time_estimates": [{"user_id": assignee, "time_estimate": time}]
627            });
628            let resp = client
629                .patch(
630                    &format!(
631                        "/v3/workspaces/{}/tasks/{}/time_estimates_by_user",
632                        ws_id, task.id
633                    ),
634                    &body,
635                )
636                .await?;
637            output.print_single(&resp, TASK_FIELDS, "id");
638            Ok(())
639        }
640        TaskCommands::ReplaceEstimates {
641            id,
642            estimates,
643            body: raw_body,
644            ..
645        } => {
646            let task = git::require_task(cli, id.as_deref(), true)?;
647            let ws_id = resolve_workspace(cli)?;
648
649            // ClickUp's spec for this endpoint:
650            //   PUT /v3/workspaces/{ws}/tasks/{id}/time_estimates_by_user
651            //   body: [ { assignee: int|"unassigned", time: ms_int }, ... ]
652            // The previous implementation wrapped a single entry in
653            // {time_estimates: [{user_id, time_estimate}]} which is neither
654            // the right shape nor the right field names. Worse, it accepted
655            // only one assignee at a time, so calling "replace" silently
656            // erased every other user's estimate. This rebuild accepts an
657            // array.
658            let body: serde_json::Value = if let Some(raw) = raw_body {
659                serde_json::from_str(&raw).map_err(|e| CliError::ClientError {
660                    message: format!("Invalid JSON body: {}", e),
661                    status: 0,
662                })?
663            } else {
664                if estimates.is_empty() {
665                    return Err(CliError::ClientError {
666                        message: "Provide at least one --estimate ASSIGNEE:MS or use --body for the raw JSON array.".into(),
667                        status: 0,
668                    });
669                }
670                let entries: Result<Vec<serde_json::Value>, CliError> = estimates
671                    .into_iter()
672                    .map(|raw| {
673                        let (assignee_raw, ms_raw) = raw.split_once(':').ok_or_else(|| {
674                            CliError::ClientError {
675                                message: format!(
676                                    "--estimate must be ASSIGNEE:MS (got '{}')",
677                                    raw
678                                ),
679                                status: 0,
680                            }
681                        })?;
682                        let assignee_raw = assignee_raw.trim();
683                        let ms = ms_raw.trim().parse::<i64>().map_err(|_| {
684                            CliError::ClientError {
685                                message: format!(
686                                    "--estimate MS must be a non-negative integer (got '{}')",
687                                    ms_raw
688                                ),
689                                status: 0,
690                            }
691                        })?;
692                        let assignee_val = if assignee_raw.eq_ignore_ascii_case("unassigned") {
693                            serde_json::Value::String("unassigned".to_string())
694                        } else {
695                            let n = assignee_raw.parse::<i64>().map_err(|_| {
696                                CliError::ClientError {
697                                    message: format!(
698                                        "--estimate ASSIGNEE must be a numeric user id or 'unassigned' (got '{}')",
699                                        assignee_raw
700                                    ),
701                                    status: 0,
702                                }
703                            })?;
704                            serde_json::json!(n)
705                        };
706                        Ok(serde_json::json!({"assignee": assignee_val, "time": ms}))
707                    })
708                    .collect();
709                serde_json::Value::Array(entries?)
710            };
711
712            let resp = client
713                .put(
714                    &format!(
715                        "/v3/workspaces/{}/tasks/{}/time_estimates_by_user",
716                        ws_id, task.id
717                    ),
718                    &body,
719                )
720                .await?;
721            output.print_single(&resp, TASK_FIELDS, "id");
722            Ok(())
723        }
724        TaskCommands::TimeInStatus { ids } => {
725            let ids = if ids.is_empty() {
726                let task = git::require_task(cli, None, true)?;
727                vec![task.id]
728            } else {
729                ids
730            };
731            if ids.len() == 1 {
732                let resp = client
733                    .get(&format!("/v2/task/{}/time_in_status", ids[0]))
734                    .await?;
735                if cli.output == "json" {
736                    println!("{}", serde_json::to_string_pretty(&resp).unwrap());
737                } else {
738                    // Print status durations
739                    if let Some(statuses) = resp.get("current_status").and_then(|v| v.as_object()) {
740                        println!(
741                            "Current: {} ({}ms)",
742                            statuses
743                                .get("status")
744                                .and_then(|v| v.as_str())
745                                .unwrap_or("-"),
746                            statuses
747                                .get("total_time")
748                                .and_then(|v| v.as_object())
749                                .and_then(|o| o.get("by_minute"))
750                                .and_then(|v| v.as_u64())
751                                .unwrap_or(0)
752                        );
753                    }
754                    // Print all statuses
755                    if let Some(statuses_arr) =
756                        resp.get("status_history").and_then(|v| v.as_array())
757                    {
758                        for s in statuses_arr {
759                            let name = s.get("status").and_then(|v| v.as_str()).unwrap_or("-");
760                            let time = s
761                                .get("total_time")
762                                .and_then(|v| v.as_object())
763                                .and_then(|o| o.get("by_minute"))
764                                .and_then(|v| v.as_u64())
765                                .unwrap_or(0);
766                            println!("  {} — {}ms", name, time);
767                        }
768                    }
769                }
770            } else {
771                // Bulk mode. ClickUp's endpoint requires repeated `task_ids=`
772                // query params, not a comma-joined list; a comma-joined value
773                // is treated as a single unknown ID.
774                let query = ids
775                    .iter()
776                    .map(|id| format!("task_ids={}", id))
777                    .collect::<Vec<_>>()
778                    .join("&");
779                let resp = client
780                    .get(&format!("/v2/task/bulk_time_in_status/task_ids?{}", query))
781                    .await?;
782                if cli.output == "json" {
783                    println!("{}", serde_json::to_string_pretty(&resp).unwrap());
784                } else {
785                    // Print per-task summary
786                    if let Some(obj) = resp.as_object() {
787                        for (task_id, data) in obj {
788                            let current = data
789                                .get("current_status")
790                                .and_then(|v| v.get("status"))
791                                .and_then(|v| v.as_str())
792                                .unwrap_or("-");
793                            println!("{}: {}", task_id, current);
794                        }
795                    }
796                }
797            }
798            Ok(())
799        }
800    }
801}
802
803/// Disambiguate `add-tag` / `remove-tag` positionals:
804/// - Two args: `<task_id> <tag_name>` — explicit ID, parsed through `parse_task_id`.
805/// - One arg: `<tag_name>` — task auto-detected from branch.
806fn resolve_task_tag(
807    cli: &Cli,
808    task_or_tag: String,
809    tag_name: Option<String>,
810) -> Result<(git::ResolvedTask, String), CliError> {
811    match tag_name {
812        Some(tag) => Ok((git::parse_task_id(&task_or_tag), tag)),
813        None => {
814            let task = git::require_task(cli, None, true)?;
815            Ok((task, task_or_tag))
816        }
817    }
818}
819
820fn date_to_ms(date_str: &str) -> Result<String, CliError> {
821    let naive = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
822        CliError::ClientError {
823            message: format!("Invalid date '{}'. Use YYYY-MM-DD format.", date_str),
824            status: 0,
825        }
826    })?;
827    let dt = naive.and_hms_opt(0, 0, 0).unwrap().and_utc();
828    Ok((dt.timestamp_millis()).to_string())
829}