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,3 @@
1
+ """ClickUp CLI — the missing ClickUp CLI for developers and AI agents."""
2
+
3
+ __version__ = "1.2.0"
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m clickup_cli`."""
2
+
3
+ from .cli import main
4
+
5
+ main()
clickup_cli/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ """CLI parser, dispatch, and main entry point."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .client import ClickUpClient
9
+ from .helpers import output, error, resolve_id_args
10
+
11
+
12
+ GLOBAL_FLAGS = {"--pretty", "--dry-run", "--debug"}
13
+
14
+
15
+ def normalize_cli_argv(argv):
16
+ """Allow global flags before or after the command group."""
17
+ front = []
18
+ rest = []
19
+
20
+ for arg in argv:
21
+ if arg in GLOBAL_FLAGS:
22
+ if arg not in front:
23
+ front.append(arg)
24
+ else:
25
+ rest.append(arg)
26
+
27
+ return front + rest
28
+
29
+
30
+ def build_parser():
31
+ F = argparse.RawDescriptionHelpFormatter
32
+
33
+ parser = argparse.ArgumentParser(
34
+ prog="clickup",
35
+ formatter_class=F,
36
+ description="""\
37
+ ClickUp CLI — manage tasks, comments, docs, folders, lists, spaces, and teams from the command line.
38
+
39
+ Covers eight command groups: tasks, comments, docs, folders, lists, spaces, team, and tags.
40
+ All successful output is JSON printed to stdout.
41
+ Errors are printed to stderr with a non-zero exit code.
42
+
43
+ Global flags can appear before or after the command group:
44
+ clickup --pretty tasks list --space <name>
45
+ clickup tasks list --space <name> --pretty
46
+ clickup --dry-run tasks create --space <name> --name "My task"
47
+ clickup --debug tasks list --space <name>""",
48
+ epilog="""\
49
+ examples:
50
+ clickup init # set up config
51
+ clickup tasks list --space <name>
52
+ clickup tasks list --list 12345
53
+ clickup --dry-run tasks create --space <name> --name "Fix login"
54
+ clickup folders list --space <name>
55
+ clickup lists list --folder 12345
56
+ clickup comments list abc123
57
+ clickup docs pages doc_abc123
58
+
59
+ current coverage:
60
+ tasks — list, get, create, update, search, delete, move, merge
61
+ comments — list, add, update, delete, thread, reply
62
+ docs — list, get, create, pages, get-page, edit-page, create-page
63
+ folders — list, get, create, update, delete
64
+ lists — list, get, create, update, delete
65
+ spaces — list, get, statuses
66
+ team — whoami, members
67
+ tags — list, add, remove
68
+
69
+ Use <group> --help for details on each group.""",
70
+ )
71
+ parser.add_argument(
72
+ "--pretty",
73
+ action="store_true",
74
+ help="Pretty-print JSON output with indentation",
75
+ )
76
+ parser.add_argument(
77
+ "--dry-run",
78
+ action="store_true",
79
+ help="Preview the API request without executing it (safe for mutations)",
80
+ )
81
+ parser.add_argument(
82
+ "--debug",
83
+ action="store_true",
84
+ help="Log API requests and responses to stderr for troubleshooting",
85
+ )
86
+ parser.add_argument(
87
+ "--version",
88
+ action="version",
89
+ version=f"%(prog)s {__version__}",
90
+ )
91
+
92
+ subparsers = parser.add_subparsers(dest="group", required=True)
93
+
94
+ # init (standalone — no register_parser, handled specially in main())
95
+ init_parser = subparsers.add_parser(
96
+ "init",
97
+ formatter_class=F,
98
+ help="Set up ClickUp CLI configuration",
99
+ description="""\
100
+ Set up the ClickUp CLI by connecting to your workspace.
101
+
102
+ Fetches your workspaces and spaces, then writes a config file to
103
+ ~/.config/clickup-cli/config.json.
104
+
105
+ Run interactively (prompts for token) or pass --token for automation.""",
106
+ epilog="""\
107
+ examples:
108
+ clickup init
109
+ clickup init --token pk_YOUR_API_TOKEN""",
110
+ )
111
+ init_parser.add_argument(
112
+ "--token",
113
+ type=str,
114
+ help="ClickUp API token (skips interactive prompt)",
115
+ )
116
+
117
+ # Register all command group parsers from their modules
118
+ from .commands.tasks import register_parser as tasks_register
119
+ from .commands.comments import register_parser as comments_register
120
+ from .commands.docs import register_parser as docs_register
121
+ from .commands.spaces import register_parser as spaces_register
122
+ from .commands.folders import register_parser as folders_register
123
+ from .commands.lists import register_parser as lists_register
124
+ from .commands.team import register_parser as team_register
125
+ from .commands.tags import register_parser as tags_register
126
+
127
+ tasks_register(subparsers, F)
128
+ comments_register(subparsers, F)
129
+ docs_register(subparsers, F)
130
+ spaces_register(subparsers, F)
131
+ folders_register(subparsers, F)
132
+ lists_register(subparsers, F)
133
+ team_register(subparsers, F)
134
+ tags_register(subparsers, F)
135
+
136
+ return parser
137
+
138
+
139
+ def dispatch(client, args):
140
+ """Route parsed args to the correct command handler."""
141
+ from .commands import HANDLERS
142
+
143
+ key = f"{args.group}_{args.command}"
144
+ handler = HANDLERS.get(key)
145
+ if not handler:
146
+ error(f"Unknown command: {args.group} {args.command}")
147
+ assert handler is not None
148
+ return handler(client, args)
149
+
150
+
151
+ def main():
152
+ parser = build_parser()
153
+ args = parser.parse_args(normalize_cli_argv(sys.argv[1:]))
154
+ resolve_id_args(args)
155
+
156
+ # Handle init before loading config (config may not exist yet)
157
+ if args.group == "init":
158
+ from .commands.init import cmd_init
159
+ cmd_init(args)
160
+ return
161
+
162
+ # Load config and resolve token
163
+ from .config import load_config
164
+ config = load_config()
165
+
166
+ token = os.environ.get("CLICKUP_API_TOKEN") or config.get("api_token")
167
+ if not token:
168
+ error(
169
+ "No API token found. Set CLICKUP_API_TOKEN or run: clickup init"
170
+ )
171
+
172
+ client = ClickUpClient(token, dry_run=args.dry_run, debug=args.debug)
173
+ result = dispatch(client, args)
174
+
175
+ if result is not None:
176
+ output(result, pretty=args.pretty)
clickup_cli/client.py ADDED
@@ -0,0 +1,115 @@
1
+ """ClickUp API client with rate limiting, dry-run, and debug support."""
2
+
3
+ import json as _json
4
+ import sys
5
+ import time
6
+
7
+ import requests
8
+
9
+ from .helpers import error
10
+
11
+
12
+ class ClickUpClient:
13
+ BASE_V2 = "https://api.clickup.com/api/v2"
14
+ BASE_V3 = "https://api.clickup.com/api/v3"
15
+
16
+ def __init__(self, token, dry_run=False, debug=False):
17
+ self.token = token
18
+ self.dry_run = dry_run
19
+ self.debug = debug
20
+ self.session = requests.Session()
21
+ self.session.headers.update(
22
+ {
23
+ "Authorization": token,
24
+ "Content-Type": "application/json",
25
+ }
26
+ )
27
+
28
+ def _log(self, msg):
29
+ """Print debug message to stderr."""
30
+ if self.debug:
31
+ print(f"[debug] {msg}", file=sys.stderr)
32
+
33
+ def _check_rate_limit(self, response):
34
+ """Sleep proactively if rate limit is running low."""
35
+ remaining = response.headers.get("X-RateLimit-Remaining")
36
+ reset = response.headers.get("X-RateLimit-Reset")
37
+ if remaining is not None and int(remaining) < 10 and reset:
38
+ wait = max(0, int(reset) - int(time.time())) + 1
39
+ print(
40
+ f"Rate limit low ({remaining} remaining), waiting {wait}s...",
41
+ file=sys.stderr,
42
+ )
43
+ time.sleep(wait)
44
+
45
+ def _request(self, method, url, allow_dry_run=False, **kwargs):
46
+ """Make an HTTP request with rate limit handling and error reporting."""
47
+ if self.dry_run and not allow_dry_run:
48
+ return {"dry_run": True, "method": method, "url": url, "kwargs": kwargs}
49
+
50
+ self._log(f"{method} {url}")
51
+ if kwargs.get("params"):
52
+ self._log(f" params: {kwargs['params']}")
53
+ if kwargs.get("json"):
54
+ self._log(f" body: {_json.dumps(kwargs['json'], ensure_ascii=False)}")
55
+
56
+ def _do_request():
57
+ try:
58
+ return self.session.request(method, url, **kwargs)
59
+ except requests.ConnectionError:
60
+ error("Couldn't reach ClickUp API — check your network connection")
61
+
62
+ response = _do_request()
63
+ self._log(f" → {response.status_code} ({len(response.text)} bytes)")
64
+
65
+ if response.status_code == 429:
66
+ reset = response.headers.get("X-RateLimit-Reset")
67
+ if reset:
68
+ wait = max(0, int(reset) - int(time.time())) + 1
69
+ print(f"Rate limited (429), waiting {wait}s...", file=sys.stderr)
70
+ time.sleep(wait)
71
+ response = _do_request()
72
+
73
+ if response.status_code == 401:
74
+ error(
75
+ "Authentication failed — check your API token"
76
+ )
77
+
78
+ if not response.ok:
79
+ body = response.text
80
+ try:
81
+ body = response.json()
82
+ except ValueError:
83
+ pass
84
+ error(f"API error {response.status_code} on {method} {url}: {body}")
85
+
86
+ self._check_rate_limit(response)
87
+
88
+ if response.status_code == 204 or not response.text:
89
+ return {}
90
+ return response.json()
91
+
92
+ def get_v2(self, path, params=None, allow_dry_run=False):
93
+ return self._request(
94
+ "GET", f"{self.BASE_V2}{path}", params=params, allow_dry_run=allow_dry_run
95
+ )
96
+
97
+ def post_v2(self, path, data=None):
98
+ return self._request("POST", f"{self.BASE_V2}{path}", json=data)
99
+
100
+ def put_v2(self, path, data=None):
101
+ return self._request("PUT", f"{self.BASE_V2}{path}", json=data)
102
+
103
+ def delete_v2(self, path):
104
+ return self._request("DELETE", f"{self.BASE_V2}{path}")
105
+
106
+ def get_v3(self, path, params=None, allow_dry_run=False):
107
+ return self._request(
108
+ "GET", f"{self.BASE_V3}{path}", params=params, allow_dry_run=allow_dry_run
109
+ )
110
+
111
+ def post_v3(self, path, data=None):
112
+ return self._request("POST", f"{self.BASE_V3}{path}", json=data)
113
+
114
+ def put_v3(self, path, data=None):
115
+ return self._request("PUT", f"{self.BASE_V3}{path}", json=data)
@@ -0,0 +1,71 @@
1
+ """Command handler registry — maps dispatch keys to handler functions."""
2
+
3
+ from .tasks import (
4
+ cmd_tasks_list, cmd_tasks_get, cmd_tasks_create,
5
+ cmd_tasks_update, cmd_tasks_search,
6
+ cmd_tasks_delete, cmd_tasks_move, cmd_tasks_merge,
7
+ )
8
+ from .comments import (
9
+ cmd_comments_list, cmd_comments_add,
10
+ cmd_comments_update, cmd_comments_delete,
11
+ cmd_comments_thread, cmd_comments_reply,
12
+ )
13
+ from .docs import (
14
+ cmd_docs_list, cmd_docs_get, cmd_docs_create, cmd_docs_pages,
15
+ cmd_docs_get_page, cmd_docs_edit_page, cmd_docs_create_page,
16
+ )
17
+ from .spaces import cmd_spaces_list, cmd_spaces_get, cmd_spaces_statuses
18
+ from .team import cmd_team_whoami, cmd_team_members
19
+ from .tags import cmd_tags_list, cmd_tags_add, cmd_tags_remove
20
+ from .folders import (
21
+ cmd_folders_list, cmd_folders_get, cmd_folders_create,
22
+ cmd_folders_update, cmd_folders_delete,
23
+ )
24
+ from .lists import (
25
+ cmd_lists_list, cmd_lists_get, cmd_lists_create,
26
+ cmd_lists_update, cmd_lists_delete,
27
+ )
28
+
29
+ # Keys use f"{args.group}_{args.command}" format from dispatch().
30
+ # Some keys retain hyphens matching argparse subparser names.
31
+ HANDLERS = {
32
+ "tasks_list": cmd_tasks_list,
33
+ "tasks_get": cmd_tasks_get,
34
+ "tasks_create": cmd_tasks_create,
35
+ "tasks_update": cmd_tasks_update,
36
+ "tasks_search": cmd_tasks_search,
37
+ "tasks_delete": cmd_tasks_delete,
38
+ "tasks_move": cmd_tasks_move,
39
+ "tasks_merge": cmd_tasks_merge,
40
+ "comments_list": cmd_comments_list,
41
+ "comments_add": cmd_comments_add,
42
+ "comments_update": cmd_comments_update,
43
+ "comments_delete": cmd_comments_delete,
44
+ "comments_thread": cmd_comments_thread,
45
+ "comments_reply": cmd_comments_reply,
46
+ "docs_list": cmd_docs_list,
47
+ "docs_get": cmd_docs_get,
48
+ "docs_create": cmd_docs_create,
49
+ "docs_pages": cmd_docs_pages,
50
+ "docs_get-page": cmd_docs_get_page,
51
+ "docs_edit-page": cmd_docs_edit_page,
52
+ "docs_create-page": cmd_docs_create_page,
53
+ "spaces_list": cmd_spaces_list,
54
+ "spaces_get": cmd_spaces_get,
55
+ "spaces_statuses": cmd_spaces_statuses,
56
+ "team_whoami": cmd_team_whoami,
57
+ "team_members": cmd_team_members,
58
+ "tags_list": cmd_tags_list,
59
+ "tags_add": cmd_tags_add,
60
+ "tags_remove": cmd_tags_remove,
61
+ "folders_list": cmd_folders_list,
62
+ "folders_get": cmd_folders_get,
63
+ "folders_create": cmd_folders_create,
64
+ "folders_update": cmd_folders_update,
65
+ "folders_delete": cmd_folders_delete,
66
+ "lists_list": cmd_lists_list,
67
+ "lists_get": cmd_lists_get,
68
+ "lists_create": cmd_lists_create,
69
+ "lists_update": cmd_lists_update,
70
+ "lists_delete": cmd_lists_delete,
71
+ }
@@ -0,0 +1,278 @@
1
+ """Comment command handlers — list, add, update, delete, thread, reply."""
2
+
3
+ from ..helpers import read_content, error, fetch_all_comments, add_id_argument
4
+
5
+
6
+ def register_parser(subparsers, F):
7
+ """Register all comments subcommands on the given subparsers object."""
8
+ comments_parser = subparsers.add_parser(
9
+ "comments",
10
+ formatter_class=F,
11
+ help="Full comment CRUD: list, add, update, delete, thread, reply",
12
+ description="""\
13
+ Manage task comments — full CRUD plus threaded replies.
14
+
15
+ Subcommands:
16
+ list — list comments on a task
17
+ add — add a comment to a task (mutating)
18
+ update — edit an existing comment (mutating)
19
+ delete — delete a comment (destructive)
20
+ thread — get threaded replies to a comment
21
+ reply — reply to a comment in a thread (mutating)""",
22
+ epilog="""\
23
+ examples:
24
+ clickup comments list abc123
25
+ clickup comments add abc123 --text "Work complete"
26
+ clickup --dry-run comments add abc123 --file report.md""",
27
+ )
28
+ comments_sub = comments_parser.add_subparsers(dest="command", required=True)
29
+
30
+ # comments list
31
+ cl = comments_sub.add_parser(
32
+ "list",
33
+ formatter_class=F,
34
+ help="List comments on a task",
35
+ description="""\
36
+ List comments on a task. By default returns the first page (up to 25).
37
+ Use --all to paginate through every comment on the task.
38
+
39
+ Use this when you need to read discussion or work logs on a task.""",
40
+ epilog="""\
41
+ returns:
42
+ {"comments": [...], "count": N}
43
+
44
+ examples:
45
+ clickup comments list abc123
46
+ clickup comments list abc123 --all
47
+ clickup --pretty comments list abc123""",
48
+ )
49
+ add_id_argument(cl, "task_id", "ClickUp task ID")
50
+ cl.add_argument(
51
+ "--all",
52
+ action="store_true",
53
+ dest="fetch_all",
54
+ help="Fetch all comment pages (default: first page only)",
55
+ )
56
+
57
+ # comments add
58
+ ca = comments_sub.add_parser(
59
+ "add",
60
+ formatter_class=F,
61
+ help="Add a comment to a task",
62
+ description="""\
63
+ Add a comment to a task. This is a mutating command.
64
+
65
+ Exactly one of --text or --file is required.
66
+ Use --text for short inline comments. Use --file to post content from
67
+ a file (useful for longer markdown or structured logs).
68
+
69
+ Use --dry-run to preview the request body without posting the comment.
70
+ Global flags may appear before or after the command group:
71
+ clickup --dry-run comments add abc123 --text "Preview this" """,
72
+ epilog="""\
73
+ returns:
74
+ The created comment object from the API.
75
+
76
+ examples:
77
+ clickup comments add abc123 --text "Task complete"
78
+ clickup comments add abc123 --file session_log.md
79
+ clickup --dry-run comments add abc123 --text "Test comment"
80
+
81
+ notes:
82
+ --text and --file are mutually exclusive. Using both is an error.""",
83
+ )
84
+ add_id_argument(ca, "task_id", "ClickUp task ID")
85
+ ca.add_argument(
86
+ "--text", type=str, help="Inline comment text (mutually exclusive with --file)"
87
+ )
88
+ ca.add_argument(
89
+ "--file", type=str, help="Path to a file containing comment content"
90
+ )
91
+
92
+ # comments update
93
+ cu = comments_sub.add_parser(
94
+ "update",
95
+ formatter_class=F,
96
+ help="Edit an existing comment",
97
+ description="""\
98
+ Edit an existing comment's text or resolved status. This is a mutating command.
99
+
100
+ At least one change is required: --text/--file for new text, or
101
+ --resolve/--unresolve to change the resolved state.
102
+
103
+ Use --dry-run to preview without applying changes.
104
+ Global flags may appear before or after the command group:
105
+ clickup --dry-run comments update 456 --text "Fixed text" """,
106
+ epilog="""\
107
+ returns:
108
+ {"status": "ok", "action": "updated", "comment_id": "..."}
109
+
110
+ examples:
111
+ clickup comments update 456 --text "Corrected info"
112
+ clickup comments update 456 --file updated_notes.md
113
+ clickup comments update 456 --resolve
114
+ clickup --dry-run comments update 456 --text "Test"
115
+
116
+ notes:
117
+ --text and --file are mutually exclusive. Using both is an error.
118
+ --resolve and --unresolve are mutually exclusive.""",
119
+ )
120
+ add_id_argument(cu, "comment_id", "ClickUp comment ID")
121
+ cu.add_argument(
122
+ "--text", type=str, help="New comment text (mutually exclusive with --file)"
123
+ )
124
+ cu.add_argument(
125
+ "--file", type=str, help="Path to a file containing new comment text"
126
+ )
127
+ resolve_group = cu.add_mutually_exclusive_group()
128
+ resolve_group.add_argument(
129
+ "--resolve",
130
+ action="store_const",
131
+ const=True,
132
+ dest="resolved",
133
+ help="Mark the comment as resolved",
134
+ )
135
+ resolve_group.add_argument(
136
+ "--unresolve",
137
+ action="store_const",
138
+ const=False,
139
+ dest="resolved",
140
+ help="Mark the comment as unresolved",
141
+ )
142
+
143
+ # comments delete
144
+ cd = comments_sub.add_parser(
145
+ "delete",
146
+ formatter_class=F,
147
+ help="Delete a comment (destructive)",
148
+ description="""\
149
+ Delete a comment permanently. This is a destructive, irreversible command.
150
+
151
+ Use --dry-run to preview the operation without deleting anything.
152
+ Global flags may appear before or after the command group:
153
+ clickup --dry-run comments delete 456""",
154
+ epilog="""\
155
+ returns:
156
+ {"status": "ok", "action": "deleted", "comment_id": "..."}
157
+
158
+ examples:
159
+ clickup --dry-run comments delete 456
160
+ clickup comments delete 456""",
161
+ )
162
+ add_id_argument(cd, "comment_id", "ClickUp comment ID to delete")
163
+
164
+ # comments thread
165
+ ct = comments_sub.add_parser(
166
+ "thread",
167
+ formatter_class=F,
168
+ help="Get threaded replies to a comment",
169
+ description="""\
170
+ Get all threaded replies to a specific comment. The parent comment is NOT
171
+ included in the response — only the replies.
172
+
173
+ Use this to read conversation threads on tasks.""",
174
+ epilog="""\
175
+ returns:
176
+ {"replies": [...], "count": N}
177
+
178
+ examples:
179
+ clickup comments thread 456
180
+ clickup --pretty comments thread 456""",
181
+ )
182
+ add_id_argument(ct, "comment_id", "Parent comment ID")
183
+
184
+ # comments reply
185
+ cr = comments_sub.add_parser(
186
+ "reply",
187
+ formatter_class=F,
188
+ help="Reply to a comment in a thread",
189
+ description="""\
190
+ Post a threaded reply to a specific comment. This is a mutating command.
191
+
192
+ Exactly one of --text or --file is required.
193
+
194
+ Use --dry-run to preview without posting.
195
+ Global flags may appear before or after the command group:
196
+ clickup --dry-run comments reply 456 --text "Preview" """,
197
+ epilog="""\
198
+ returns:
199
+ The created reply object from the API.
200
+
201
+ examples:
202
+ clickup comments reply 456 --text "Agreed, let's proceed"
203
+ clickup comments reply 456 --file detailed_response.md
204
+ clickup --dry-run comments reply 456 --text "Test reply"
205
+
206
+ notes:
207
+ --text and --file are mutually exclusive. Using both is an error.""",
208
+ )
209
+ add_id_argument(cr, "comment_id", "Parent comment ID to reply to")
210
+ cr.add_argument(
211
+ "--text", type=str, help="Inline reply text (mutually exclusive with --file)"
212
+ )
213
+ cr.add_argument("--file", type=str, help="Path to a file containing reply content")
214
+
215
+
216
+ def cmd_comments_list(client, args):
217
+ if client.dry_run:
218
+ return {"dry_run": True, "action": "list_comments", "task_id": args.task_id}
219
+
220
+ resp = client.get_v2(f"/task/{args.task_id}/comment")
221
+ comments = resp.get("comments", [])
222
+
223
+ if not args.fetch_all or len(comments) < 25:
224
+ return {"comments": comments, "count": len(comments)}
225
+
226
+ # Paginate through all comments
227
+ all_comments = fetch_all_comments(client, args.task_id)
228
+ return {"comments": all_comments, "count": len(all_comments)}
229
+
230
+
231
+ def cmd_comments_add(client, args):
232
+ text = read_content(args.text, args.file, "--text")
233
+ if not text:
234
+ error("Provide comment text via --text or --file")
235
+ body = {"comment_text": text, "notify_all": False}
236
+ return client.post_v2(f"/task/{args.task_id}/comment", data=body)
237
+
238
+
239
+ def cmd_comments_update(client, args):
240
+ """Update an existing comment's text or resolved status."""
241
+ text = read_content(args.text, args.file, "--text")
242
+ body = {}
243
+ if text:
244
+ body["comment_text"] = text
245
+ if args.resolved is not None:
246
+ body["resolved"] = args.resolved
247
+ if not body:
248
+ error("Nothing to update — provide at least one of: --text, --file, --resolve, --unresolve")
249
+ if client.dry_run:
250
+ return {"dry_run": True, "action": "update", "comment_id": args.comment_id, "body": body}
251
+ client.put_v2(f"/comment/{args.comment_id}", data=body)
252
+ return {"status": "ok", "action": "updated", "comment_id": args.comment_id}
253
+
254
+
255
+ def cmd_comments_delete(client, args):
256
+ """Delete a comment by ID."""
257
+ if client.dry_run:
258
+ return {"dry_run": True, "action": "delete", "comment_id": args.comment_id}
259
+ client.delete_v2(f"/comment/{args.comment_id}")
260
+ return {"status": "ok", "action": "deleted", "comment_id": args.comment_id}
261
+
262
+
263
+ def cmd_comments_thread(client, args):
264
+ """Get threaded replies to a comment."""
265
+ resp = client.get_v2(f"/comment/{args.comment_id}/reply")
266
+ replies = resp.get("comments", resp if isinstance(resp, list) else [])
267
+ return {"replies": replies, "count": len(replies)}
268
+
269
+
270
+ def cmd_comments_reply(client, args):
271
+ """Reply to a comment (threaded)."""
272
+ text = read_content(args.text, args.file, "--text")
273
+ if not text:
274
+ error("Provide reply text via --text or --file")
275
+ body = {"comment_text": text, "notify_all": False}
276
+ if client.dry_run:
277
+ return {"dry_run": True, "action": "reply", "comment_id": args.comment_id, "body": body}
278
+ return client.post_v2(f"/comment/{args.comment_id}/reply", data=body)