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,153 @@
1
+ """Init command — interactive workspace setup."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ import requests
8
+
9
+
10
+ def cmd_init(args):
11
+ """Set up ClickUp CLI configuration interactively or via --token."""
12
+ token = getattr(args, "token", None)
13
+
14
+ if not token:
15
+ print("Enter your ClickUp API token (starts with pk_):")
16
+ print(" Find it at: https://app.clickup.com/settings/apps")
17
+ try:
18
+ token = input("> ").strip()
19
+ except (EOFError, KeyboardInterrupt):
20
+ print("\nAborted.", file=sys.stderr)
21
+ sys.exit(1)
22
+
23
+ if not token:
24
+ print("Error: API token is required.", file=sys.stderr)
25
+ sys.exit(1)
26
+
27
+ # Fetch workspaces
28
+ print("Fetching workspaces...", file=sys.stderr)
29
+ headers = {"Authorization": token, "Content-Type": "application/json"}
30
+
31
+ try:
32
+ resp = requests.get(
33
+ "https://api.clickup.com/api/v2/team", headers=headers, timeout=15
34
+ )
35
+ except requests.ConnectionError:
36
+ print("Error: Could not reach ClickUp API. Check your network.", file=sys.stderr)
37
+ sys.exit(1)
38
+
39
+ if resp.status_code == 401:
40
+ print("Error: Invalid API token.", file=sys.stderr)
41
+ sys.exit(1)
42
+
43
+ if not resp.ok:
44
+ print(f"Error: API returned {resp.status_code}: {resp.text}", file=sys.stderr)
45
+ sys.exit(1)
46
+
47
+ teams = resp.json().get("teams", [])
48
+ if not teams:
49
+ print("Error: No workspaces found for this token.", file=sys.stderr)
50
+ sys.exit(1)
51
+
52
+ # Select workspace
53
+ if len(teams) == 1:
54
+ team = teams[0]
55
+ print(f"Found workspace: {team['name']} (ID: {team['id']})", file=sys.stderr)
56
+ else:
57
+ print(f"\nFound {len(teams)} workspaces:", file=sys.stderr)
58
+ for i, t in enumerate(teams, 1):
59
+ print(f" {i}. {t['name']} (ID: {t['id']})", file=sys.stderr)
60
+ try:
61
+ choice = input(f"Select workspace [1-{len(teams)}]: ").strip()
62
+ idx = int(choice) - 1
63
+ if idx < 0 or idx >= len(teams):
64
+ raise ValueError
65
+ team = teams[idx]
66
+ except (ValueError, EOFError, KeyboardInterrupt):
67
+ print("\nAborted.", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+ workspace_id = str(team["id"])
71
+
72
+ # Find authenticated user
73
+ user_id = ""
74
+ members = team.get("members", [])
75
+ if len(members) == 1:
76
+ u = members[0].get("user", {})
77
+ user_id = str(u.get("id", ""))
78
+ print(f"Found user: {u.get('username', 'unknown')} (ID: {user_id})", file=sys.stderr)
79
+ elif members:
80
+ print(f"\nFound {len(members)} members:", file=sys.stderr)
81
+ for i, m in enumerate(members, 1):
82
+ u = m.get("user", {})
83
+ print(
84
+ f" {i}. {u.get('username', 'unknown')} — {u.get('email', '')} (ID: {u.get('id')})",
85
+ file=sys.stderr,
86
+ )
87
+ try:
88
+ choice = input(f"Which one is you? [1-{len(members)}] (Enter to skip): ").strip()
89
+ if choice:
90
+ idx = int(choice) - 1
91
+ if 0 <= idx < len(members):
92
+ u = members[idx].get("user", {})
93
+ user_id = str(u.get("id", ""))
94
+ else:
95
+ raise ValueError
96
+ except (ValueError, EOFError, KeyboardInterrupt):
97
+ print("Skipped — set user_id in config later if needed.", file=sys.stderr)
98
+
99
+ # Fetch spaces
100
+ print("Fetching spaces...", file=sys.stderr)
101
+ try:
102
+ resp = requests.get(
103
+ f"https://api.clickup.com/api/v2/team/{workspace_id}/space",
104
+ headers=headers,
105
+ timeout=15,
106
+ )
107
+ except requests.ConnectionError:
108
+ print("Error: Could not fetch spaces.", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ spaces_raw = resp.json().get("spaces", [])
112
+
113
+ # Build spaces config
114
+ spaces = {}
115
+ for s in spaces_raw:
116
+ # Use lowercase name as key, sanitized for CLI use
117
+ key = s["name"].lower().replace(" ", "-")
118
+ lists_resp = requests.get(
119
+ f"https://api.clickup.com/api/v2/space/{s['id']}/list",
120
+ headers=headers,
121
+ timeout=15,
122
+ )
123
+ lists = lists_resp.json().get("lists", [])
124
+ default_list_id = lists[0]["id"] if lists else ""
125
+ spaces[key] = {"space_id": str(s["id"]), "list_id": default_list_id}
126
+ status = f" (default list: {default_list_id})" if default_list_id else " (no lists)"
127
+ print(f" {key}: {s['name']}{status}", file=sys.stderr)
128
+
129
+ config = {
130
+ "api_token": token,
131
+ "workspace_id": workspace_id,
132
+ "user_id": user_id,
133
+ "spaces": spaces,
134
+ "default_tags": [],
135
+ "draft_tag": "draft",
136
+ "good_as_is_tag": "good as is",
137
+ "default_priority": 4,
138
+ }
139
+
140
+ # Write config
141
+ config_dir = os.path.expanduser("~/.config/clickup-cli")
142
+ config_path = os.path.join(config_dir, "config.json")
143
+ os.makedirs(config_dir, exist_ok=True)
144
+
145
+ with open(config_path, "w", encoding="utf-8") as f:
146
+ json.dump(config, f, indent=2, ensure_ascii=False)
147
+ f.write("\n")
148
+
149
+ print(f"\nConfig saved to {config_path}", file=sys.stderr)
150
+ print(f"Workspace: {team['name']} ({len(spaces)} spaces)", file=sys.stderr)
151
+ print("\nTry it out:", file=sys.stderr)
152
+ print(" clickup spaces list", file=sys.stderr)
153
+ print(" clickup team whoami", file=sys.stderr)
@@ -0,0 +1,258 @@
1
+ """List command handlers — list, get, create, update, delete."""
2
+
3
+ from ..helpers import read_content, error, resolve_space_id, add_id_argument
4
+
5
+
6
+ def register_parser(subparsers, F):
7
+ """Register all lists subcommands on the given subparsers object."""
8
+ lists_parser = subparsers.add_parser(
9
+ "lists",
10
+ formatter_class=F,
11
+ help="Full list CRUD: list, get, create, update, delete",
12
+ description="""\
13
+ Manage ClickUp lists — the containers that hold tasks.
14
+
15
+ Lists can live directly in a space (folderless) or inside a folder.
16
+ Use --folder or --space to specify the parent depending on the context.
17
+
18
+ Subcommands:
19
+ list — list lists in a folder or folderless lists in a space
20
+ get — fetch full details of a list by ID
21
+ create — create a new list in a folder or space (mutating)
22
+ update — update a list's name, content, or status (mutating)
23
+ delete — delete a list (destructive)""",
24
+ epilog="""\
25
+ examples:
26
+ clickup lists list --folder 12345
27
+ clickup lists list --space <name>
28
+ clickup lists get 12345
29
+ clickup --dry-run lists create --folder 12345 --name "Tasks"
30
+ clickup lists create --space <name> --name "Backlog"
31
+ clickup lists update 12345 --name "Renamed list"
32
+ clickup --dry-run lists delete 12345""",
33
+ )
34
+ lists_sub = lists_parser.add_subparsers(dest="command", required=True)
35
+
36
+ # lists list
37
+ ll = lists_sub.add_parser(
38
+ "list",
39
+ formatter_class=F,
40
+ help="List lists in a folder or folderless lists in a space",
41
+ description="""\
42
+ List lists in a specific context. Exactly one of --folder or --space
43
+ is required:
44
+
45
+ --folder <id> — lists all lists inside a folder
46
+ --space <name> — lists only folderless lists in a space""",
47
+ epilog="""\
48
+ returns:
49
+ {"lists": [...], "count": N}
50
+
51
+ examples:
52
+ clickup lists list --folder 12345
53
+ clickup lists list --space <name>
54
+ clickup --pretty lists list --space <name>
55
+
56
+ notes:
57
+ --folder and --space are mutually exclusive.
58
+ --space only returns folderless lists. To see lists inside folders,
59
+ use 'folders list --space <name>' first to find folder IDs, then
60
+ 'lists list --folder <id>' for each folder.""",
61
+ )
62
+ ll_target = ll.add_mutually_exclusive_group(required=True)
63
+ ll_target.add_argument(
64
+ "--folder", type=str, help="Folder ID — list all lists inside this folder"
65
+ )
66
+ ll_target.add_argument(
67
+ "--space",
68
+ type=str,
69
+ help="Space name or ID — list folderless lists in this space",
70
+ )
71
+
72
+ # lists get
73
+ lg = lists_sub.add_parser(
74
+ "get",
75
+ formatter_class=F,
76
+ help="Fetch full details of a list by ID",
77
+ description="""\
78
+ Fetch full details of a list by its ClickUp list ID.
79
+
80
+ Returns list metadata including name, content, task count, statuses,
81
+ folder, and space information.""",
82
+ epilog="""\
83
+ returns:
84
+ One list JSON object with all fields.
85
+
86
+ examples:
87
+ clickup lists get 901816700000
88
+ clickup --pretty lists get 12345""",
89
+ )
90
+ add_id_argument(lg, "list_id", "ClickUp list ID")
91
+
92
+ # lists create
93
+ lc = lists_sub.add_parser(
94
+ "create",
95
+ formatter_class=F,
96
+ help="Create a new list in a folder or space",
97
+ description="""\
98
+ Create a new list. This is a mutating command.
99
+
100
+ Exactly one of --folder or --space is required:
101
+
102
+ --folder <id> — creates the list inside a folder
103
+ --space <name> — creates a folderless list directly in a space
104
+
105
+ Use --dry-run to preview the request body without creating the list.
106
+ Global flags may appear before or after the command group:
107
+ clickup --dry-run lists create --folder 12345 --name "Tasks" """,
108
+ epilog="""\
109
+ returns:
110
+ The created list object from the API.
111
+
112
+ examples:
113
+ clickup lists create --folder 12345 --name "Tasks"
114
+ clickup lists create --space <name> --name "Backlog"
115
+ clickup --dry-run lists create --space <name> --name "Test list"
116
+
117
+ notes:
118
+ --folder and --space are mutually exclusive. Using both is an error.""",
119
+ )
120
+ lc_target = lc.add_mutually_exclusive_group(required=True)
121
+ lc_target.add_argument(
122
+ "--folder", type=str, help="Folder ID — create list inside this folder"
123
+ )
124
+ lc_target.add_argument(
125
+ "--space",
126
+ type=str,
127
+ help="Space name or ID — create a folderless list in this space",
128
+ )
129
+ lc.add_argument("--name", required=True, help="List name (required)")
130
+ lc.add_argument("--content", type=str, help="List description/content")
131
+ lc.add_argument("--status", type=str, help="Initial list status")
132
+
133
+ # lists update
134
+ lu = lists_sub.add_parser(
135
+ "update",
136
+ formatter_class=F,
137
+ help="Update a list (name, content, status)",
138
+ description="""\
139
+ Update a list's name, content, or status. This is a mutating command.
140
+
141
+ At least one mutable field is required: --name, --content, --content-file,
142
+ or --status. If none are provided, the command exits with an error.
143
+
144
+ Use --dry-run to preview the request body without applying changes.
145
+ Global flags may appear before or after the command group:
146
+ clickup --dry-run lists update 12345 --name "New name" """,
147
+ epilog="""\
148
+ returns:
149
+ The updated list object from the API.
150
+
151
+ examples:
152
+ clickup lists update 12345 --name "Renamed list"
153
+ clickup lists update 12345 --content "Updated description"
154
+ clickup --dry-run lists update 12345 --name "Test rename" """,
155
+ )
156
+ add_id_argument(lu, "list_id", "ClickUp list ID to update")
157
+ lu.add_argument("--name", type=str, help="New list name")
158
+ lu.add_argument(
159
+ "--content",
160
+ type=str,
161
+ help="Inline description (mutually exclusive with --content-file)",
162
+ )
163
+ lu.add_argument(
164
+ "--content-file", type=str, help="Path to a file containing list description"
165
+ )
166
+ lu.add_argument("--status", type=str, help="New list status")
167
+
168
+ # lists delete
169
+ ld = lists_sub.add_parser(
170
+ "delete",
171
+ formatter_class=F,
172
+ help="Delete a list (destructive)",
173
+ description="""\
174
+ Delete a list permanently. This is a destructive, irreversible command.
175
+
176
+ Deleting a list also deletes all tasks inside it. Use with extreme caution.
177
+
178
+ Use --dry-run to preview the operation without deleting anything.
179
+ Global flags may appear before or after the command group:
180
+ clickup --dry-run lists delete 12345""",
181
+ epilog="""\
182
+ returns:
183
+ {"status": "ok", "action": "deleted", "list_id": "..."}
184
+
185
+ examples:
186
+ clickup --dry-run lists delete 12345
187
+ clickup lists delete 12345""",
188
+ )
189
+ add_id_argument(ld, "list_id", "ClickUp list ID to delete")
190
+
191
+
192
+ def _resolve_list_parent(args):
193
+ """Resolve folder or space target for list operations."""
194
+ if args.folder:
195
+ return f"/folder/{args.folder}/list", {"folder_id": args.folder}
196
+ elif args.space:
197
+ space_id = resolve_space_id(args.space)
198
+ return f"/space/{space_id}/list", {"space_id": space_id}
199
+ else:
200
+ error("Provide either --folder <folder_id> or --space <name|id>")
201
+
202
+
203
+ def cmd_lists_list(client, args):
204
+ """List lists in a folder or folderless lists in a space."""
205
+ path, _ = _resolve_list_parent(args)
206
+ resp = client.get_v2(path)
207
+ lists = resp.get("lists", [])
208
+ return {"lists": lists, "count": len(lists)}
209
+
210
+
211
+ def cmd_lists_get(client, args):
212
+ """Get full details of a list by ID."""
213
+ return client.get_v2(f"/list/{args.list_id}")
214
+
215
+
216
+ def cmd_lists_create(client, args):
217
+ """Create a list in a folder or directly in a space (folderless)."""
218
+ body = {"name": args.name}
219
+ if args.content:
220
+ body["content"] = args.content
221
+ if args.status:
222
+ body["status"] = args.status
223
+
224
+ endpoint, target = _resolve_list_parent(args)
225
+
226
+ if client.dry_run:
227
+ return {"dry_run": True, "action": "create_list", **target, "body": body}
228
+
229
+ return client.post_v2(endpoint, data=body)
230
+
231
+
232
+ def cmd_lists_update(client, args):
233
+ """Update a list (name, content, status)."""
234
+ desc = read_content(args.content, args.content_file, "--content")
235
+ body = {}
236
+ if args.name:
237
+ body["name"] = args.name
238
+ if desc:
239
+ body["content"] = desc
240
+ if args.status:
241
+ body["status"] = args.status
242
+
243
+ if not body:
244
+ error("Nothing to update — provide at least one of: --name, --content, --content-file, --status")
245
+
246
+ if client.dry_run:
247
+ return {"dry_run": True, "action": "update_list", "list_id": args.list_id, "body": body}
248
+
249
+ return client.put_v2(f"/list/{args.list_id}", data=body)
250
+
251
+
252
+ def cmd_lists_delete(client, args):
253
+ """Delete a list by ID."""
254
+ if client.dry_run:
255
+ return {"dry_run": True, "action": "delete_list", "list_id": args.list_id}
256
+
257
+ client.delete_v2(f"/list/{args.list_id}")
258
+ return {"status": "ok", "action": "deleted", "list_id": args.list_id}
@@ -0,0 +1,137 @@
1
+ """Space command handlers — list, get, statuses."""
2
+
3
+ from ..config import WORKSPACE_ID
4
+ from ..helpers import resolve_space_id, add_id_argument
5
+
6
+
7
+ def register_parser(subparsers, F):
8
+ """Register all spaces subcommands on the given subparsers object."""
9
+ spaces_parser = subparsers.add_parser(
10
+ "spaces",
11
+ formatter_class=F,
12
+ help="List spaces, view details, and discover statuses",
13
+ description="""\
14
+ Inspect workspace spaces — list all spaces, view space details, and
15
+ discover valid statuses per space.
16
+
17
+ Subcommands:
18
+ list — list all spaces in the workspace
19
+ get — fetch full details of a specific space
20
+ statuses — list valid statuses for a space
21
+
22
+ All commands are read-only. Configured space names and raw ClickUp
23
+ space IDs are both accepted.""",
24
+ epilog="""\
25
+ examples:
26
+ clickup spaces list
27
+ clickup spaces get <space>
28
+ clickup spaces statuses <space>
29
+
30
+ notes:
31
+ These commands hit the API directly — no caching.
32
+ Use 'spaces statuses' to find valid status names before setting
33
+ task statuses, avoiding "Status does not exist" errors.""",
34
+ )
35
+ spaces_sub = spaces_parser.add_subparsers(dest="command", required=True)
36
+
37
+ # spaces list
38
+ spaces_sub.add_parser(
39
+ "list",
40
+ formatter_class=F,
41
+ help="List all spaces in the workspace",
42
+ description="""\
43
+ List all spaces in the workspace. Returns space names, IDs, and basic
44
+ metadata for every space the authenticated user can access.
45
+
46
+ Use this for discovery — find available spaces and their IDs without
47
+ relying on hardcoded configuration.""",
48
+ epilog="""\
49
+ returns:
50
+ {"spaces": [...], "count": N}
51
+
52
+ examples:
53
+ clickup spaces list
54
+ clickup --pretty spaces list""",
55
+ )
56
+
57
+ # spaces get
58
+ sg = spaces_sub.add_parser(
59
+ "get",
60
+ formatter_class=F,
61
+ help="Fetch full details of a specific space",
62
+ description="""\
63
+ Fetch full details of a space including statuses, features, and members.
64
+
65
+ Accepts a configured space name or a raw ClickUp space ID.
66
+ Use 'spaces list' to discover available spaces.""",
67
+ epilog="""\
68
+ returns:
69
+ One space JSON object with all fields (statuses, features, members, etc.)
70
+
71
+ examples:
72
+ clickup spaces get <space>
73
+ clickup spaces get 901810200000
74
+ clickup --pretty spaces get <space>""",
75
+ )
76
+ add_id_argument(sg, "space", "Space name (from config) or raw space ID")
77
+
78
+ # spaces statuses
79
+ ss = spaces_sub.add_parser(
80
+ "statuses",
81
+ formatter_class=F,
82
+ help="List valid statuses for a space",
83
+ description="""\
84
+ List the valid workflow statuses for a space. Returns the status name,
85
+ type (open, closed, custom), color, and order.
86
+
87
+ Use this before setting a task status to avoid "Status does not exist"
88
+ errors. Status names are space-specific — each space has its own set.""",
89
+ epilog="""\
90
+ returns:
91
+ {"space": "<name>", "statuses": [...], "count": N}
92
+
93
+ Each status has: status (name), type, color, orderindex.
94
+
95
+ examples:
96
+ clickup spaces statuses <space>
97
+ clickup spaces statuses 901810200000
98
+ clickup --pretty spaces statuses <space>
99
+
100
+ notes:
101
+ Accepts a configured space name or raw space ID.
102
+ Statuses can only be modified via the ClickUp UI, not the API.""",
103
+ )
104
+ add_id_argument(ss, "space", "Space name (from config) or raw space ID")
105
+
106
+
107
+ def cmd_spaces_list(client, args):
108
+ """List all spaces in the workspace."""
109
+ resp = client.get_v2(f"/team/{WORKSPACE_ID}/space")
110
+ spaces = resp.get("spaces", [])
111
+ return {"spaces": spaces, "count": len(spaces)}
112
+
113
+
114
+ def cmd_spaces_get(client, args):
115
+ """Get full details of a specific space."""
116
+ space_id = resolve_space_id(args.space)
117
+ return client.get_v2(f"/space/{space_id}")
118
+
119
+
120
+ def cmd_spaces_statuses(client, args):
121
+ """List valid statuses for a space."""
122
+ space_id = resolve_space_id(args.space)
123
+ resp = client.get_v2(f"/space/{space_id}")
124
+ statuses = resp.get("statuses", [])
125
+ return {
126
+ "space": args.space,
127
+ "statuses": [
128
+ {
129
+ "status": s.get("status"),
130
+ "type": s.get("type"),
131
+ "color": s.get("color"),
132
+ "orderindex": s.get("orderindex"),
133
+ }
134
+ for s in statuses
135
+ ],
136
+ "count": len(statuses),
137
+ }
@@ -0,0 +1,132 @@
1
+ """Tag command handlers — list, add, remove."""
2
+
3
+ from ..helpers import resolve_space_id, add_id_argument
4
+
5
+
6
+ def register_parser(subparsers, F):
7
+ """Register all tags subcommands on the given subparsers object."""
8
+ tags_parser = subparsers.add_parser(
9
+ "tags",
10
+ formatter_class=F,
11
+ help="List space tags, add/remove tags on tasks",
12
+ description="""\
13
+ Manage tags — list available tags in a space, add or remove tags on tasks.
14
+
15
+ Subcommands:
16
+ list — list all tags in a space
17
+ add — add a tag to a task (mutating)
18
+ remove — remove a tag from a task (mutating)
19
+
20
+ Tag names are lowercase in the API, even if they display with title case
21
+ in the ClickUp UI. The CLI lowercases tag names automatically.""",
22
+ epilog="""\
23
+ examples:
24
+ clickup tags list --space <name>
25
+ clickup tags add abc123 --tag "in review"
26
+ clickup tags remove abc123 --tag "draft" """,
27
+ )
28
+ tags_sub = tags_parser.add_subparsers(dest="command", required=True)
29
+
30
+ # tags list
31
+ tgl = tags_sub.add_parser(
32
+ "list",
33
+ formatter_class=F,
34
+ help="List all tags in a space",
35
+ description="""\
36
+ List all tags available in a space. Returns tag names, colors, and metadata.
37
+
38
+ Accepts a configured space name or a raw space ID.""",
39
+ epilog="""\
40
+ returns:
41
+ {"tags": [...], "count": N}
42
+
43
+ examples:
44
+ clickup tags list --space <name>
45
+ clickup --pretty tags list --space <name>""",
46
+ )
47
+ tgl.add_argument(
48
+ "--space",
49
+ required=True,
50
+ type=str,
51
+ help="Space name (from config) or raw space ID",
52
+ )
53
+
54
+ # tags add
55
+ tga = tags_sub.add_parser(
56
+ "add",
57
+ formatter_class=F,
58
+ help="Add a tag to a task",
59
+ description="""\
60
+ Add a tag to a task. This is a mutating command.
61
+
62
+ The tag name is automatically lowercased (ClickUp API requirement).
63
+ The tag must already exist in the space — this command does not create tags.
64
+
65
+ Use --dry-run to preview without applying.
66
+ Global flags may appear before or after the command group:
67
+ clickup --dry-run tags add abc123 --tag "in review" """,
68
+ epilog="""\
69
+ returns:
70
+ {"status": "ok", "action": "tag_added", "task_id": "...", "tag": "..."}
71
+
72
+ examples:
73
+ clickup tags add abc123 --tag "in review"
74
+ clickup --dry-run tags add abc123 --tag "draft" """,
75
+ )
76
+ add_id_argument(tga, "task_id", "ClickUp task ID")
77
+ tga.add_argument(
78
+ "--tag", required=True, type=str, help="Tag name to add (auto-lowercased)"
79
+ )
80
+
81
+ # tags remove
82
+ tgr = tags_sub.add_parser(
83
+ "remove",
84
+ formatter_class=F,
85
+ help="Remove a tag from a task",
86
+ description="""\
87
+ Remove a tag from a task. This is a mutating command.
88
+
89
+ The tag name is automatically lowercased (ClickUp API requirement).
90
+
91
+ Use --dry-run to preview without applying.
92
+ Global flags may appear before or after the command group:
93
+ clickup --dry-run tags remove abc123 --tag "draft" """,
94
+ epilog="""\
95
+ returns:
96
+ {"status": "ok", "action": "tag_removed", "task_id": "...", "tag": "..."}
97
+
98
+ examples:
99
+ clickup tags remove abc123 --tag "draft"
100
+ clickup --dry-run tags remove abc123 --tag "in review" """,
101
+ )
102
+ add_id_argument(tgr, "task_id", "ClickUp task ID")
103
+ tgr.add_argument(
104
+ "--tag", required=True, type=str, help="Tag name to remove (auto-lowercased)"
105
+ )
106
+
107
+
108
+ def cmd_tags_list(client, args):
109
+ """List all tags in a space."""
110
+ space_id = resolve_space_id(args.space)
111
+ resp = client.get_v2(f"/space/{space_id}/tag")
112
+ tags = resp.get("tags", [])
113
+ return {"tags": tags, "count": len(tags)}
114
+
115
+
116
+ def _tag_action(client, args, method, dry_action, done_action):
117
+ """Shared logic for tag add/remove."""
118
+ tag_name = args.tag.lower()
119
+ if client.dry_run:
120
+ return {"dry_run": True, "action": dry_action, "task_id": args.task_id, "tag": tag_name}
121
+ method(f"/task/{args.task_id}/tag/{tag_name}", **({"data": {}} if method == client.post_v2 else {}))
122
+ return {"status": "ok", "action": done_action, "task_id": args.task_id, "tag": tag_name}
123
+
124
+
125
+ def cmd_tags_add(client, args):
126
+ """Add a tag to a task."""
127
+ return _tag_action(client, args, client.post_v2, "add_tag", "tag_added")
128
+
129
+
130
+ def cmd_tags_remove(client, args):
131
+ """Remove a tag from a task."""
132
+ return _tag_action(client, args, client.delete_v2, "remove_tag", "tag_removed")