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