clickup-cli 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,733 @@
1
+ """Task command handlers — list, get, create, update, search, delete, move, merge."""
2
+
3
+ import re
4
+ import sys
5
+
6
+ import requests
7
+
8
+ from ..config import WORKSPACE_ID, SPACES, USER_ID, DEFAULT_TAGS, DRAFT_TAG, GOOD_AS_IS_TAG, DEFAULT_PRIORITY
9
+ from ..helpers import read_content, error, format_tasks, fetch_all_comments, add_id_argument
10
+
11
+
12
+ def register_parser(subparsers, F):
13
+ """Register all tasks subcommands on the given subparsers object."""
14
+ tasks_parser = subparsers.add_parser(
15
+ "tasks",
16
+ formatter_class=F,
17
+ help="Full task CRUD: list, get, create, update, search, delete, move, merge",
18
+ description="""\
19
+ Manage ClickUp tasks — full CRUD plus search, move, and merge.
20
+
21
+ Subcommands:
22
+ list — list tasks in a list or space (paginated internally)
23
+ get — fetch one task by ID
24
+ create — create a new task in a list or space (mutating)
25
+ update — update fields on an existing task (mutating)
26
+ search — search tasks by query across workspace or within a space
27
+ delete — delete a task (destructive)
28
+ move — move a task to a different list/space (mutating, v3)
29
+ merge — merge source tasks into a target task (mutating)
30
+
31
+ Tasks live in lists. Each space has a default list, but you can also
32
+ target a specific list (e.g. one inside a folder) using --list <id>.
33
+ Use 'folders list' and 'lists list' to discover list IDs.
34
+
35
+ Does not cover: checklists, custom fields, time tracking, or attachments.""",
36
+ epilog="""\
37
+ examples:
38
+ clickup tasks list --space <name>
39
+ clickup tasks list --list 12345
40
+ clickup tasks get abc123
41
+ clickup --dry-run tasks create --space <name> --name "New feature"
42
+ clickup tasks create --space <name> --list 12345 --name "In a folder list"
43
+ clickup tasks search "login bug" --space <name>
44
+ clickup tasks list --space <name> --subtasks
45
+ clickup tasks search "bug" --list 12345""",
46
+ )
47
+ tasks_sub = tasks_parser.add_subparsers(dest="command", required=True)
48
+
49
+ # tasks list
50
+ tl = tasks_sub.add_parser(
51
+ "list",
52
+ formatter_class=F,
53
+ help="List tasks in a list or space",
54
+ description="""\
55
+ List all tasks in a list. Results are paginated internally and returned
56
+ as a single JSON object with a tasks array and count.
57
+
58
+ By default, output is compact (id, name, status, priority, url).
59
+ Use --full for the raw API response, or --fields to pick specific fields.
60
+
61
+ Target the list using --space (uses the space's default list) or
62
+ --list (targets a specific list ID, e.g. one inside a folder).
63
+ If both are given, --list takes precedence.
64
+
65
+ Use --subtasks to include nested child tasks in the results. Without it,
66
+ only top-level tasks are returned (ClickUp API default).
67
+
68
+ Use this when you need to see all tasks, optionally filtered by status
69
+ or including closed tasks.""",
70
+ epilog="""\
71
+ returns:
72
+ {"tasks": [...], "count": N}
73
+
74
+ examples:
75
+ clickup tasks list --space <name>
76
+ clickup tasks list --space <name> --full
77
+ clickup tasks list --space <name> --fields id,name,url
78
+ clickup tasks list --space <name> --include-closed
79
+ clickup tasks list --space <name> --status "in progress"
80
+ clickup tasks list --space <name> --subtasks
81
+
82
+ notes:
83
+ Output is compact by default (id, name, status, priority, url).
84
+ Use --full for raw API response, or --fields for custom field selection.
85
+ At least one of --space or --list is required.
86
+ If both are given, --list takes precedence.
87
+ Use --subtasks to include nested child tasks (e.g. Epic/Story/Task hierarchies).
88
+ Use 'lists list --folder <id>' or 'lists list --space <name>' to
89
+ discover list IDs for lists inside folders.
90
+ Status values are space-specific. Check the space configuration
91
+ for valid status names before filtering.""",
92
+ )
93
+ tl.add_argument(
94
+ "--space",
95
+ type=str,
96
+ help="Space name (from config) or raw space ID — uses the space's default list",
97
+ )
98
+ tl.add_argument(
99
+ "--list",
100
+ type=str,
101
+ dest="list_id",
102
+ help="Raw list ID — targets a specific list (overrides --space)",
103
+ )
104
+ tl.add_argument(
105
+ "--include-closed",
106
+ action="store_true",
107
+ help="Include closed/completed tasks in results",
108
+ )
109
+ tl.add_argument(
110
+ "--status", type=str, help="Filter tasks by status name (space-specific)"
111
+ )
112
+ tl.add_argument(
113
+ "--subtasks",
114
+ action="store_true",
115
+ help="Include subtasks (nested child tasks) in results",
116
+ )
117
+ tl.add_argument(
118
+ "--fields",
119
+ type=str,
120
+ help="Comma-separated list of fields to return per task (e.g. id,name,status,url)",
121
+ )
122
+ tl.add_argument(
123
+ "--full",
124
+ action="store_true",
125
+ help="Return full raw API response (default is compact: id, name, status, priority, url)",
126
+ )
127
+
128
+ # tasks get
129
+ tg = tasks_sub.add_parser(
130
+ "get",
131
+ formatter_class=F,
132
+ help="Fetch one task by ID (includes comments by default)",
133
+ description="""\
134
+ Fetch a single task by its ClickUp task ID.
135
+
136
+ By default, comments are fetched and appended to the task output under
137
+ a "comments" key (array of {id, comment_text, user, date}) and a
138
+ "comment_count" field. This ensures task context is always complete
139
+ without needing a separate comments list call.
140
+
141
+ Use --no-comments to suppress comment fetching if output is too verbose
142
+ or you only need the task fields.""",
143
+ epilog="""\
144
+ returns:
145
+ One task JSON object with all fields, plus:
146
+ "comments": [{id, comment_text, user, date}, ...]
147
+ "comment_count": N
148
+
149
+ With --no-comments, returns the raw task object without comments.
150
+
151
+ examples:
152
+ clickup tasks get abc123
153
+ clickup --pretty tasks get abc123
154
+ clickup tasks get abc123 --no-comments""",
155
+ )
156
+ add_id_argument(tg, "task_id", "ClickUp task ID")
157
+ tg.add_argument(
158
+ "--no-comments",
159
+ action="store_true",
160
+ help="Skip auto-fetching comments (default: comments included)",
161
+ )
162
+
163
+ # tasks create
164
+ tc = tasks_sub.add_parser(
165
+ "create",
166
+ formatter_class=F,
167
+ help="Create a new task in a list or space",
168
+ description="""\
169
+ Create a new task in a list. This is a mutating command.
170
+
171
+ --space is always required (used to resolve the target list).
172
+ By default, the task is created in the space's default list. Use --list
173
+ to target a specific list instead (e.g. one inside a folder).
174
+
175
+ Tags are applied automatically based on config (default_tags, draft_tag).
176
+
177
+ Use --desc for inline text or --desc-file for file-based content.
178
+ Do not use both at the same time.
179
+
180
+ Use --dry-run to preview the request body without creating the task.
181
+ Global flags may appear before or after the command group:
182
+ clickup --dry-run tasks create --space <name> --name "My task"
183
+ clickup tasks create --space <name> --name "My task" --dry-run""",
184
+ epilog="""\
185
+ returns:
186
+ The created task object from the API.
187
+
188
+ examples:
189
+ clickup tasks create --space <name> --name "Add login page"
190
+ clickup tasks create --space <name> --name "Fix bug" --desc "Details here"
191
+ clickup tasks create --space <name> --list 12345 --name "In folder list"
192
+ clickup tasks create --space <name> --name "Read article" --desc-file notes.md
193
+ clickup --dry-run tasks create --space <name> --name "Test" --good-as-is
194
+
195
+ notes:
196
+ --space is always required (to resolve target list). --list is optional.
197
+ If --list is given, the task is created in that list instead of the
198
+ space's default list.
199
+ --desc and --desc-file are mutually exclusive. Using both is an error.
200
+ --good-as-is marks the task as intentionally simple (no draft tag).
201
+ --no-assign skips the default assignee.
202
+ --priority defaults to the value in your config (default: low).
203
+ Does not support: checklists, custom fields, attachments, or due dates.""",
204
+ )
205
+ tc.add_argument(
206
+ "--space",
207
+ type=str,
208
+ help="Target space (auto-inferred from --list if omitted)",
209
+ )
210
+ tc.add_argument(
211
+ "--list",
212
+ type=str,
213
+ dest="list_id",
214
+ help="Raw list ID — creates task in this list (overrides space default)",
215
+ )
216
+ tc.add_argument(
217
+ "--name",
218
+ required=True,
219
+ help="Task title (required)",
220
+ )
221
+ tc.add_argument(
222
+ "--desc",
223
+ type=str,
224
+ help="Inline description text (mutually exclusive with --desc-file)",
225
+ )
226
+ tc.add_argument(
227
+ "--desc-file", type=str, help="Path to a file containing description content"
228
+ )
229
+ tc.add_argument("--status", type=str, help="Initial task status (space-specific)")
230
+ tc.add_argument(
231
+ "--priority",
232
+ type=str,
233
+ help="Priority: urgent, high, normal, low (default: from config)",
234
+ )
235
+ tc.add_argument("--no-assign", action="store_true", help="Skip default assignee")
236
+ tc.add_argument(
237
+ "--good-as-is",
238
+ action="store_true",
239
+ help="Mark task as intentionally simple (no draft tag)",
240
+ )
241
+ tc.add_argument(
242
+ "--skip-dedup",
243
+ action="store_true",
244
+ default=False,
245
+ help="Skip duplicate check and create the task even if one with the same name exists",
246
+ )
247
+
248
+ # tasks update
249
+ tu = tasks_sub.add_parser(
250
+ "update",
251
+ formatter_class=F,
252
+ help="Update fields on an existing task",
253
+ description="""\
254
+ Update one or more fields on an existing task. This is a mutating command.
255
+
256
+ At least one mutable field is required: --name, --status, --priority,
257
+ --desc, or --desc-file. If none are provided, the command exits with an error.
258
+
259
+ Use --desc for inline text or --desc-file for file-based content.
260
+ Do not use both at the same time.
261
+
262
+ Use --dry-run to preview the request body without applying changes.
263
+ Global flags may appear before or after the command group:
264
+ clickup --dry-run tasks update abc123 --status "complete" """,
265
+ epilog="""\
266
+ returns:
267
+ The updated task object from the API.
268
+
269
+ examples:
270
+ clickup tasks update abc123 --name "Renamed task"
271
+ clickup tasks update abc123 --status "complete"
272
+ clickup tasks update abc123 --desc-file updated_spec.md
273
+ clickup --dry-run tasks update abc123 --status "in progress"
274
+
275
+ notes:
276
+ --desc and --desc-file are mutually exclusive. Using both is an error.
277
+ Does not support: changing assignees, tags, or custom fields.""",
278
+ )
279
+ add_id_argument(tu, "task_id", "ClickUp task ID to update")
280
+ tu.add_argument("--name", type=str, help="New task name")
281
+ tu.add_argument("--status", type=str, help="New status (space-specific)")
282
+ tu.add_argument(
283
+ "--priority", type=str, help="Priority: urgent, high, normal, low (or 1-4)"
284
+ )
285
+ tu.add_argument(
286
+ "--desc",
287
+ type=str,
288
+ help="Inline description text (mutually exclusive with --desc-file)",
289
+ )
290
+ tu.add_argument(
291
+ "--desc-file", type=str, help="Path to a file containing description content"
292
+ )
293
+
294
+ # tasks search
295
+ ts = tasks_sub.add_parser(
296
+ "search",
297
+ formatter_class=F,
298
+ help="Search tasks by query string",
299
+ description="""\
300
+ Search tasks across the workspace by a text query.
301
+
302
+ Results are paginated internally and returned as a single JSON object.
303
+ By default, output is compact (id, name, status, priority, url).
304
+ Use --full for the raw API response, or --fields to pick specific fields.
305
+
306
+ Use --space, --list, or --folder to scope results. Without any scope
307
+ filter, results may include tasks from all spaces.
308
+
309
+ When the query looks like a task ID (e.g. PROJ-39, PROJ-12), --name-prefix
310
+ is auto-applied to filter exact matches. Use --name-prefix explicitly for
311
+ other prefix-based filtering.""",
312
+ epilog="""\
313
+ returns:
314
+ {"tasks": [...], "count": N}
315
+
316
+ examples:
317
+ clickup tasks search "login bug"
318
+ clickup tasks search "PROJ-39" --space <name>
319
+ clickup tasks search "PROJ-8" --space <name>
320
+ clickup tasks search "PROJ" --space <name> --name-prefix "PROJ-9"
321
+ clickup tasks search "deploy" --include-closed --full
322
+ clickup tasks search "bug" --fields id,name,url
323
+ clickup tasks search "bug" --list 12345
324
+
325
+ notes:
326
+ Output is compact by default (id, name, status, priority, url).
327
+ Use --full for raw API response, or --fields for custom field selection.
328
+ Queries matching the pattern ABC-123 auto-apply --name-prefix.
329
+ Use --space, --list, or --folder to scope results.
330
+ --name-prefix filters the returned tasks client-side by task name prefix.
331
+ The search API has a default page size — this CLI handles pagination
332
+ automatically and returns all matching results.""",
333
+ )
334
+ add_id_argument(ts, "query", "Search query string")
335
+ ts.add_argument(
336
+ "--include-closed",
337
+ action="store_true",
338
+ help="Include closed/completed tasks in results",
339
+ )
340
+ ts.add_argument(
341
+ "--space", type=str, help="Scope search to a specific space"
342
+ )
343
+ ts.add_argument(
344
+ "--list", type=str, dest="list_id", help="Scope search to a specific list ID"
345
+ )
346
+ ts.add_argument(
347
+ "--folder",
348
+ type=str,
349
+ dest="folder_id",
350
+ help="Scope search to a specific folder ID (ClickUp calls this project_ids)",
351
+ )
352
+ ts.add_argument(
353
+ "--name-prefix",
354
+ type=str,
355
+ help="Keep only tasks whose name starts with this prefix (client-side filter)",
356
+ )
357
+ ts.add_argument(
358
+ "--fields",
359
+ type=str,
360
+ help="Comma-separated list of fields to return per task (e.g. id,name,status,url)",
361
+ )
362
+ ts.add_argument(
363
+ "--full",
364
+ action="store_true",
365
+ help="Return full raw API response (default is compact: id, name, status, priority, url)",
366
+ )
367
+
368
+ # tasks delete
369
+ td = tasks_sub.add_parser(
370
+ "delete",
371
+ formatter_class=F,
372
+ help="Delete a task (destructive)",
373
+ description="""\
374
+ Delete a task permanently. This is a destructive, irreversible command.
375
+
376
+ Use --dry-run to preview the operation without deleting anything.
377
+ Global flags may appear before or after the command group:
378
+ clickup --dry-run tasks delete abc123""",
379
+ epilog="""\
380
+ returns:
381
+ {"status": "ok", "action": "deleted", "task_id": "..."}
382
+
383
+ examples:
384
+ clickup --dry-run tasks delete abc123
385
+ clickup tasks delete abc123""",
386
+ )
387
+ add_id_argument(td, "task_id", "ClickUp task ID to delete")
388
+
389
+ # tasks move
390
+ tm = tasks_sub.add_parser(
391
+ "move",
392
+ formatter_class=F,
393
+ help="Move a task to a different list/space",
394
+ description="""\
395
+ Move a task to a different list. This is a mutating command (v3 API).
396
+
397
+ The destination can be a configured space name — which resolves
398
+ to that space's default list — or a raw ClickUp list ID.
399
+
400
+ For tasks in multiple lists, this changes the home list only.
401
+
402
+ Use --dry-run to preview without moving.
403
+ Global flags may appear before or after the command group:
404
+ clickup --dry-run tasks move abc123 --to <space-or-list-id>""",
405
+ epilog="""\
406
+ returns:
407
+ The updated task object from the API.
408
+
409
+ examples:
410
+ clickup tasks move abc123 --to <space-or-list-id>
411
+ clickup tasks move abc123 --to 901816700000
412
+ clickup --dry-run tasks move abc123 --to <space-or-list-id>""",
413
+ )
414
+ add_id_argument(tm, "task_id", "ClickUp task ID to move")
415
+ tm.add_argument(
416
+ "--to",
417
+ required=True,
418
+ dest="to_list",
419
+ help="Destination space name or raw list ID",
420
+ )
421
+
422
+ # tasks merge
423
+ tmg = tasks_sub.add_parser(
424
+ "merge",
425
+ formatter_class=F,
426
+ help="Merge source tasks into a target task",
427
+ description="""\
428
+ Merge one or more source tasks into a target task. This is a mutating command.
429
+
430
+ The source tasks are absorbed into the target. Their comments, attachments,
431
+ and activity are consolidated. Source tasks are removed after merging.
432
+
433
+ Use --dry-run to preview without merging.
434
+ Global flags may appear before or after the command group:
435
+ clickup --dry-run tasks merge abc123 --sources def456,ghi789""",
436
+ epilog="""\
437
+ returns:
438
+ The merged task object from the API.
439
+
440
+ examples:
441
+ clickup tasks merge abc123 --sources def456
442
+ clickup tasks merge abc123 --sources def456,ghi789
443
+ clickup --dry-run tasks merge abc123 --sources def456""",
444
+ )
445
+ add_id_argument(tmg, "task_id", "Target task ID (tasks merge into this)")
446
+ tmg.add_argument(
447
+ "--sources",
448
+ required=True,
449
+ dest="source_ids",
450
+ help="Comma-separated source task IDs to merge into the target",
451
+ )
452
+
453
+ PRIORITY_MAP = {"urgent": 1, "high": 2, "normal": 3, "low": 4}
454
+
455
+ # Pattern for task ID queries like PER-39, JMP-12, MTM-8
456
+ _TASK_ID_PATTERN = re.compile(r"^[A-Z]+-\d+$")
457
+
458
+
459
+ def _parse_fields(args):
460
+ """Parse --fields arg into a list, or None."""
461
+ raw = getattr(args, "fields", None)
462
+ if not raw:
463
+ return None
464
+ return [f.strip() for f in raw.split(",") if f.strip()]
465
+
466
+
467
+ def _format_and_wrap(tasks, args):
468
+ """Format tasks and wrap in standard response dict."""
469
+ fields = _parse_fields(args)
470
+ full = getattr(args, "full", False)
471
+ formatted = format_tasks(tasks, full=full, fields=fields)
472
+ return {"tasks": formatted, "count": len(formatted)}
473
+
474
+
475
+ def _resolve_priority(priority_arg):
476
+ """Resolve a priority name or number to the API integer value."""
477
+ if priority_arg is None:
478
+ return None
479
+ if priority_arg in PRIORITY_MAP:
480
+ return PRIORITY_MAP[priority_arg]
481
+ if priority_arg.isdigit() and int(priority_arg) in (1, 2, 3, 4):
482
+ return int(priority_arg)
483
+ error(f"Invalid priority: {priority_arg}. Use: urgent, high, normal, low (or 1-4)")
484
+
485
+
486
+ def _resolve_list_id(args):
487
+ """Resolve the target list ID from --list or --space args."""
488
+ if hasattr(args, "list_id") and args.list_id:
489
+ return args.list_id
490
+ if hasattr(args, "space") and args.space:
491
+ space = SPACES.get(args.space)
492
+ if not space:
493
+ error(f"Unknown space: {args.space}. Check your config file.")
494
+ return space["list_id"]
495
+ error("Provide either --space <name> or --list <list_id>")
496
+
497
+
498
+ def _paginate_tasks(client, path, params):
499
+ """Fetch all task pages from a paginated v2 endpoint."""
500
+ all_tasks = []
501
+ page = 0
502
+ while True:
503
+ params["page"] = str(page)
504
+ resp = client.get_v2(path, params=params)
505
+ tasks = resp.get("tasks", [])
506
+ all_tasks.extend(tasks)
507
+ if resp.get("last_page", False):
508
+ break
509
+ page += 1
510
+ return all_tasks
511
+
512
+
513
+ def cmd_tasks_list(client, args):
514
+ list_id = _resolve_list_id(args)
515
+ if client.dry_run:
516
+ return {"dry_run": True, "action": "list_tasks", "list_id": list_id}
517
+
518
+ params = {"archived": "false"}
519
+ if args.include_closed:
520
+ params["include_closed"] = "true"
521
+ if args.status:
522
+ params["statuses[]"] = args.status
523
+ if args.subtasks:
524
+ params["subtasks"] = "true"
525
+
526
+ all_tasks = _paginate_tasks(client, f"/list/{list_id}/task", params)
527
+ return _format_and_wrap(all_tasks, args)
528
+
529
+
530
+ def cmd_tasks_get(client, args):
531
+ task = client.get_v2(f"/task/{args.task_id}")
532
+
533
+ if getattr(args, "no_comments", False):
534
+ return task
535
+
536
+ # Auto-fetch comments and append to task output
537
+ try:
538
+ all_comments = fetch_all_comments(client, args.task_id)
539
+
540
+ # Slim down to useful fields
541
+ task["comments"] = [
542
+ {
543
+ "id": c.get("id"),
544
+ "comment_text": c.get("comment_text", ""),
545
+ "user": c.get("user", {}).get("username", "unknown"),
546
+ "date": c.get("date"),
547
+ }
548
+ for c in all_comments
549
+ ]
550
+ task["comment_count"] = len(all_comments)
551
+ except (requests.RequestException, KeyError, ValueError) as e:
552
+ print(f"warning: could not fetch comments: {e}", file=sys.stderr)
553
+ task["comments"] = []
554
+ task["comment_count"] = 0
555
+
556
+ return task
557
+
558
+
559
+ def _infer_space_from_list(client, list_id):
560
+ """Look up a list via API to find its parent space. Returns space name or ID."""
561
+ resp = client.get_v2(f"/list/{list_id}", allow_dry_run=True)
562
+ space_info = resp.get("space", {})
563
+ space_id = space_info.get("id")
564
+ if not space_id:
565
+ return None
566
+ for name, cfg in SPACES.items():
567
+ if cfg.get("space_id") == str(space_id):
568
+ return name
569
+ return str(space_id)
570
+
571
+
572
+ def cmd_tasks_create(client, args):
573
+ if not args.space and getattr(args, "list_id", None):
574
+ inferred = _infer_space_from_list(client, args.list_id)
575
+ if inferred:
576
+ args.space = inferred
577
+ print(
578
+ f"hint: inferred --space {inferred} from --list {args.list_id}",
579
+ file=sys.stderr,
580
+ )
581
+ if not args.space:
582
+ error("--space is required (or provide --list to auto-infer the space)")
583
+
584
+ list_id = _resolve_list_id(args)
585
+ desc = read_content(args.desc, args.desc_file, "--desc")
586
+
587
+ tags = list(DEFAULT_TAGS) # copy to avoid mutating config
588
+ if args.good_as_is:
589
+ tags.append(GOOD_AS_IS_TAG)
590
+ elif not desc:
591
+ tags.append(DRAFT_TAG)
592
+
593
+ priority = _resolve_priority(args.priority) if args.priority else DEFAULT_PRIORITY
594
+
595
+ body = {"name": args.name, "tags": tags, "priority": priority}
596
+
597
+ if desc:
598
+ body["markdown_description"] = desc
599
+
600
+ if args.status:
601
+ body["status"] = args.status
602
+
603
+ if not args.no_assign:
604
+ user_id = USER_ID
605
+ if user_id:
606
+ body["assignees"] = [int(user_id)]
607
+
608
+ if client.dry_run:
609
+ return {
610
+ "dry_run": True,
611
+ "body": body,
612
+ "space": args.space,
613
+ "list_id": list_id,
614
+ }
615
+
616
+ # Pre-create duplicate search
617
+ if not getattr(args, "skip_dedup", False):
618
+ search_resp = client.get_v2(
619
+ f"/team/{WORKSPACE_ID}/task",
620
+ params={"search": args.name, "list_ids[]": list_id},
621
+ )
622
+ existing = [
623
+ t for t in search_resp.get("tasks", [])
624
+ if t.get("name", "").lower() == args.name.lower()
625
+ ]
626
+ if existing:
627
+ match = existing[0]
628
+ print(
629
+ f"warning: found existing task with same name: "
630
+ f"{match.get('id')} — {match.get('url', 'no url')}",
631
+ file=sys.stderr,
632
+ )
633
+ match["duplicate_of"] = match["id"]
634
+ return match
635
+
636
+ return client.post_v2(f"/list/{list_id}/task", data=body)
637
+
638
+
639
+ def cmd_tasks_update(client, args):
640
+ desc = read_content(args.desc, args.desc_file, "--desc")
641
+ body = {}
642
+ if args.name:
643
+ body["name"] = args.name
644
+ if args.status:
645
+ body["status"] = args.status
646
+ if desc:
647
+ body["markdown_description"] = desc
648
+ if args.priority:
649
+ body["priority"] = _resolve_priority(args.priority)
650
+
651
+ if not body:
652
+ error(
653
+ "Nothing to update — provide at least one of: --name, --status, --desc, --desc-file, --priority"
654
+ )
655
+
656
+ return client.put_v2(f"/task/{args.task_id}", data=body)
657
+
658
+
659
+ def cmd_tasks_search(client, args):
660
+ if client.dry_run:
661
+ return {"dry_run": True, "action": "search_tasks", "query": args.query}
662
+
663
+ # Auto-apply --name-prefix when query looks like a task ID (e.g. PER-39)
664
+ name_prefix = getattr(args, "name_prefix", None)
665
+ if not name_prefix and _TASK_ID_PATTERN.match(args.query):
666
+ name_prefix = args.query
667
+ print(
668
+ f"hint: query \"{args.query}\" looks like a task ID — "
669
+ f"auto-applying --name-prefix \"{args.query}\" to filter exact matches",
670
+ file=sys.stderr,
671
+ )
672
+
673
+ params = {"search": args.query}
674
+ if args.include_closed:
675
+ params["include_closed"] = "true"
676
+ if args.space:
677
+ space = SPACES.get(args.space)
678
+ if space:
679
+ params["list_ids[]"] = space["list_id"]
680
+ if hasattr(args, "list_id") and args.list_id:
681
+ params["list_ids[]"] = args.list_id
682
+ if hasattr(args, "folder_id") and args.folder_id:
683
+ params["project_ids[]"] = args.folder_id
684
+
685
+ all_tasks = _paginate_tasks(client, f"/team/{WORKSPACE_ID}/task", params)
686
+
687
+ if name_prefix:
688
+ all_tasks = [
689
+ task
690
+ for task in all_tasks
691
+ if task.get("name", "").startswith(name_prefix)
692
+ ]
693
+
694
+ return _format_and_wrap(all_tasks, args)
695
+
696
+
697
+ def cmd_tasks_delete(client, args):
698
+ """Delete a task by ID."""
699
+ if client.dry_run:
700
+ return {"dry_run": True, "action": "delete", "task_id": args.task_id}
701
+ client.delete_v2(f"/task/{args.task_id}")
702
+ return {"status": "ok", "action": "deleted", "task_id": args.task_id}
703
+
704
+
705
+ def cmd_tasks_move(client, args):
706
+ """Move a task to a different list (v3 endpoint)."""
707
+ space = SPACES.get(args.to_list)
708
+ list_id = space["list_id"] if space else args.to_list
709
+
710
+ if client.dry_run:
711
+ return {
712
+ "dry_run": True,
713
+ "action": "move",
714
+ "task_id": args.task_id,
715
+ "destination_list_id": list_id,
716
+ }
717
+ return client.put_v3(
718
+ f"/workspaces/{WORKSPACE_ID}/tasks/{args.task_id}/home_list/{list_id}"
719
+ )
720
+
721
+
722
+ def cmd_tasks_merge(client, args):
723
+ """Merge source tasks into a target task."""
724
+ source_ids = [tid.strip() for tid in args.source_ids.split(",")]
725
+
726
+ if client.dry_run:
727
+ return {
728
+ "dry_run": True,
729
+ "action": "merge",
730
+ "target_task_id": args.task_id,
731
+ "source_task_ids": source_ids,
732
+ }
733
+ return client.post_v2(f"/task/{args.task_id}/merge", data={"task_ids": source_ids})