lvl3dev-todoist-cli 0.1.0__tar.gz

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,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: lvl3dev-todoist-cli
3
+ Version: 0.1.0
4
+ Summary: Simple command-line interface for the Todoist API Python SDK.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: todoist-api-python>=3.1.0
8
+
9
+ # lvl3dev-todoist-cli
10
+
11
+ Simple command-line interface for Todoist using the official `todoist-api-python` SDK.
12
+
13
+ ## Development setup (uv)
14
+
15
+ ```bash
16
+ uv sync
17
+ ```
18
+
19
+ Run commands through `uv`:
20
+
21
+ ```bash
22
+ uv run todoist --help
23
+ ```
24
+
25
+ ## Build and install (uv + pipx)
26
+
27
+ Build a wheel with `uv`:
28
+
29
+ ```bash
30
+ uv build
31
+ ```
32
+
33
+ Install globally with `pipx` from the built wheel:
34
+
35
+ ```bash
36
+ pipx install dist/lvl3dev_todoist_cli-*.whl
37
+ ```
38
+
39
+ For local iteration, reinstall after changes with:
40
+
41
+ ```bash
42
+ pipx install --force dist/lvl3dev_todoist_cli-*.whl
43
+ ```
44
+
45
+ ## Authentication
46
+
47
+ Set your API token:
48
+
49
+ ```bash
50
+ export TODOIST_API_TOKEN="YOUR_API_TOKEN"
51
+ ```
52
+
53
+ You can also pass `--token` per command.
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ todoist --help
59
+ ```
60
+
61
+ ### Tasks
62
+
63
+ ```bash
64
+ todoist tasks list
65
+ todoist tasks add "Pay rent" --due-string "tomorrow 9am" --priority 1
66
+ todoist tasks get <task_id>
67
+ todoist tasks update <task_id> --content "Pay rent and utilities"
68
+ todoist tasks complete <task_id>
69
+ todoist tasks delete <task_id>
70
+ ```
71
+
72
+ Priority values are user-facing Todoist priorities: `p1` highest, `p4` lowest.
73
+
74
+ ### Projects
75
+
76
+ ```bash
77
+ todoist projects list
78
+ todoist projects add "Operations"
79
+ todoist projects update <project_id> "Ops"
80
+ ```
81
+
82
+ ### Comments
83
+
84
+ ```bash
85
+ todoist comments list --task-id <task_id>
86
+ todoist comments add --task-id <task_id> "Started work"
87
+ ```
88
+
89
+ ### Labels
90
+
91
+ ```bash
92
+ todoist labels list
93
+ ```
94
+
95
+ ### JSON Output
96
+
97
+ Use `--json` to get structured output:
98
+
99
+ ```bash
100
+ todoist --json tasks list
101
+ ```
@@ -0,0 +1,93 @@
1
+ # lvl3dev-todoist-cli
2
+
3
+ Simple command-line interface for Todoist using the official `todoist-api-python` SDK.
4
+
5
+ ## Development setup (uv)
6
+
7
+ ```bash
8
+ uv sync
9
+ ```
10
+
11
+ Run commands through `uv`:
12
+
13
+ ```bash
14
+ uv run todoist --help
15
+ ```
16
+
17
+ ## Build and install (uv + pipx)
18
+
19
+ Build a wheel with `uv`:
20
+
21
+ ```bash
22
+ uv build
23
+ ```
24
+
25
+ Install globally with `pipx` from the built wheel:
26
+
27
+ ```bash
28
+ pipx install dist/lvl3dev_todoist_cli-*.whl
29
+ ```
30
+
31
+ For local iteration, reinstall after changes with:
32
+
33
+ ```bash
34
+ pipx install --force dist/lvl3dev_todoist_cli-*.whl
35
+ ```
36
+
37
+ ## Authentication
38
+
39
+ Set your API token:
40
+
41
+ ```bash
42
+ export TODOIST_API_TOKEN="YOUR_API_TOKEN"
43
+ ```
44
+
45
+ You can also pass `--token` per command.
46
+
47
+ ## Usage
48
+
49
+ ```bash
50
+ todoist --help
51
+ ```
52
+
53
+ ### Tasks
54
+
55
+ ```bash
56
+ todoist tasks list
57
+ todoist tasks add "Pay rent" --due-string "tomorrow 9am" --priority 1
58
+ todoist tasks get <task_id>
59
+ todoist tasks update <task_id> --content "Pay rent and utilities"
60
+ todoist tasks complete <task_id>
61
+ todoist tasks delete <task_id>
62
+ ```
63
+
64
+ Priority values are user-facing Todoist priorities: `p1` highest, `p4` lowest.
65
+
66
+ ### Projects
67
+
68
+ ```bash
69
+ todoist projects list
70
+ todoist projects add "Operations"
71
+ todoist projects update <project_id> "Ops"
72
+ ```
73
+
74
+ ### Comments
75
+
76
+ ```bash
77
+ todoist comments list --task-id <task_id>
78
+ todoist comments add --task-id <task_id> "Started work"
79
+ ```
80
+
81
+ ### Labels
82
+
83
+ ```bash
84
+ todoist labels list
85
+ ```
86
+
87
+ ### JSON Output
88
+
89
+ Use `--json` to get structured output:
90
+
91
+ ```bash
92
+ todoist --json tasks list
93
+ ```
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: lvl3dev-todoist-cli
3
+ Version: 0.1.0
4
+ Summary: Simple command-line interface for the Todoist API Python SDK.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: todoist-api-python>=3.1.0
8
+
9
+ # lvl3dev-todoist-cli
10
+
11
+ Simple command-line interface for Todoist using the official `todoist-api-python` SDK.
12
+
13
+ ## Development setup (uv)
14
+
15
+ ```bash
16
+ uv sync
17
+ ```
18
+
19
+ Run commands through `uv`:
20
+
21
+ ```bash
22
+ uv run todoist --help
23
+ ```
24
+
25
+ ## Build and install (uv + pipx)
26
+
27
+ Build a wheel with `uv`:
28
+
29
+ ```bash
30
+ uv build
31
+ ```
32
+
33
+ Install globally with `pipx` from the built wheel:
34
+
35
+ ```bash
36
+ pipx install dist/lvl3dev_todoist_cli-*.whl
37
+ ```
38
+
39
+ For local iteration, reinstall after changes with:
40
+
41
+ ```bash
42
+ pipx install --force dist/lvl3dev_todoist_cli-*.whl
43
+ ```
44
+
45
+ ## Authentication
46
+
47
+ Set your API token:
48
+
49
+ ```bash
50
+ export TODOIST_API_TOKEN="YOUR_API_TOKEN"
51
+ ```
52
+
53
+ You can also pass `--token` per command.
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ todoist --help
59
+ ```
60
+
61
+ ### Tasks
62
+
63
+ ```bash
64
+ todoist tasks list
65
+ todoist tasks add "Pay rent" --due-string "tomorrow 9am" --priority 1
66
+ todoist tasks get <task_id>
67
+ todoist tasks update <task_id> --content "Pay rent and utilities"
68
+ todoist tasks complete <task_id>
69
+ todoist tasks delete <task_id>
70
+ ```
71
+
72
+ Priority values are user-facing Todoist priorities: `p1` highest, `p4` lowest.
73
+
74
+ ### Projects
75
+
76
+ ```bash
77
+ todoist projects list
78
+ todoist projects add "Operations"
79
+ todoist projects update <project_id> "Ops"
80
+ ```
81
+
82
+ ### Comments
83
+
84
+ ```bash
85
+ todoist comments list --task-id <task_id>
86
+ todoist comments add --task-id <task_id> "Started work"
87
+ ```
88
+
89
+ ### Labels
90
+
91
+ ```bash
92
+ todoist labels list
93
+ ```
94
+
95
+ ### JSON Output
96
+
97
+ Use `--json` to get structured output:
98
+
99
+ ```bash
100
+ todoist --json tasks list
101
+ ```
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ ./todoist_cli/__init__.py
4
+ ./todoist_cli/cli.py
5
+ lvl3dev_todoist_cli.egg-info/PKG-INFO
6
+ lvl3dev_todoist_cli.egg-info/SOURCES.txt
7
+ lvl3dev_todoist_cli.egg-info/dependency_links.txt
8
+ lvl3dev_todoist_cli.egg-info/entry_points.txt
9
+ lvl3dev_todoist_cli.egg-info/requires.txt
10
+ lvl3dev_todoist_cli.egg-info/top_level.txt
11
+ todoist_cli/__init__.py
12
+ todoist_cli/cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ todoist = todoist_cli.cli:main
@@ -0,0 +1 @@
1
+ todoist-api-python>=3.1.0
@@ -0,0 +1,2 @@
1
+ dist
2
+ todoist_cli
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lvl3dev-todoist-cli"
7
+ version = "0.1.0"
8
+ description = "Simple command-line interface for the Todoist API Python SDK."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "todoist-api-python>=3.1.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ todoist = "todoist_cli.cli:main"
17
+
18
+ [tool.setuptools]
19
+ package-dir = {"" = "."}
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,700 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+ from datetime import date, datetime, timedelta
6
+ from typing import Any, Iterable
7
+
8
+ from requests.exceptions import HTTPError
9
+ from todoist_api_python.api import TodoistAPI
10
+
11
+
12
+ def parse_labels(raw: str | None) -> list[str] | None:
13
+ if not raw:
14
+ return None
15
+ labels = [label.strip() for label in raw.split(",")]
16
+ labels = [label for label in labels if label]
17
+ return labels or None
18
+
19
+
20
+ def user_priority_to_api(priority: int | None) -> int | None:
21
+ if priority is None:
22
+ return None
23
+ return 5 - priority
24
+
25
+
26
+ def api_priority_to_user(priority: Any) -> Any:
27
+ if isinstance(priority, int) and 1 <= priority <= 4:
28
+ return 5 - priority
29
+ return priority
30
+
31
+
32
+ def flatten_paged(result: Any) -> list[Any]:
33
+ if result is None:
34
+ return []
35
+ if isinstance(result, list):
36
+ if result and isinstance(result[0], list):
37
+ flat: list[Any] = []
38
+ for page in result:
39
+ flat.extend(page)
40
+ return flat
41
+ return result
42
+
43
+ if isinstance(result, Iterable) and not isinstance(result, (str, bytes, dict)):
44
+ flat = []
45
+ for item in result:
46
+ if isinstance(item, list):
47
+ flat.extend(item)
48
+ else:
49
+ flat.append(item)
50
+ return flat
51
+
52
+ return [result]
53
+
54
+
55
+ def to_dict(obj: Any) -> dict[str, Any]:
56
+ if obj is None:
57
+ return {}
58
+ if isinstance(obj, dict):
59
+ return obj
60
+ for method_name in ("to_dict", "model_dump", "dict"):
61
+ method = getattr(obj, method_name, None)
62
+ if callable(method):
63
+ try:
64
+ value = method()
65
+ if isinstance(value, dict):
66
+ return value
67
+ except Exception:
68
+ pass
69
+ data = getattr(obj, "__dict__", None)
70
+ if isinstance(data, dict):
71
+ return {k: v for k, v in data.items() if not k.startswith("_")}
72
+ return {"value": str(obj)}
73
+
74
+
75
+ def print_json(data: Any) -> None:
76
+ print(json.dumps(data, indent=2, default=str))
77
+
78
+
79
+ def require_token(args: argparse.Namespace) -> str:
80
+ token = args.token or os.getenv("TODOIST_API_TOKEN")
81
+ if not token:
82
+ raise SystemExit(
83
+ "Missing API token. Pass --token or set TODOIST_API_TOKEN in your environment."
84
+ )
85
+ return token
86
+
87
+
88
+ def task_due_text(task: Any) -> str:
89
+ due = getattr(task, "due", None)
90
+ if due is None:
91
+ return ""
92
+ return (
93
+ getattr(due, "string", None)
94
+ or getattr(due, "date", None)
95
+ or getattr(due, "datetime", None)
96
+ or str(due)
97
+ )
98
+
99
+
100
+ def task_due_date(task: Any) -> date | None:
101
+ due = getattr(task, "due", None)
102
+ if due is None:
103
+ return None
104
+
105
+ due_date_raw = getattr(due, "date", None)
106
+ if isinstance(due_date_raw, date):
107
+ return due_date_raw
108
+ if isinstance(due_date_raw, str):
109
+ try:
110
+ return date.fromisoformat(due_date_raw[:10])
111
+ except ValueError:
112
+ pass
113
+
114
+ due_datetime_raw = getattr(due, "datetime", None)
115
+ if isinstance(due_datetime_raw, datetime):
116
+ return due_datetime_raw.date()
117
+ if isinstance(due_datetime_raw, str):
118
+ normalized = due_datetime_raw.replace("Z", "+00:00")
119
+ try:
120
+ return datetime.fromisoformat(normalized).date()
121
+ except ValueError:
122
+ try:
123
+ return date.fromisoformat(due_datetime_raw[:10])
124
+ except ValueError:
125
+ return None
126
+
127
+ return None
128
+
129
+
130
+ def render_task_line(task: Any) -> str:
131
+ tid = getattr(task, "id", "")
132
+ content = getattr(task, "content", "")
133
+ priority = api_priority_to_user(getattr(task, "priority", ""))
134
+ due_text = task_due_text(task)
135
+ suffix = f" | p{priority}" if priority else ""
136
+ if due_text:
137
+ suffix += f" | due: {due_text}"
138
+ return f"{tid}\t{content}{suffix}"
139
+
140
+
141
+ def parse_date_arg(raw: str) -> date:
142
+ try:
143
+ return date.fromisoformat(raw)
144
+ except ValueError:
145
+ raise argparse.ArgumentTypeError(f"Invalid date '{raw}'. Use YYYY-MM-DD.")
146
+
147
+
148
+ def format_calendar_text(tasks_by_day: list[tuple[date, list[Any]]]) -> str:
149
+ lines: list[str] = []
150
+ for day, day_tasks in tasks_by_day:
151
+ lines.append(f"{day.isoformat()}\t{day.strftime('%A')} ({len(day_tasks)})")
152
+ lines.extend(f" {render_task_line(task)}" for task in day_tasks)
153
+ return "\n".join(lines)
154
+
155
+
156
+ def calendar_tasks_in_range(api: TodoistAPI, args: argparse.Namespace, start: date, end: date) -> Any:
157
+ kwargs: dict[str, Any] = {}
158
+ if args.project_id:
159
+ kwargs["project_id"] = args.project_id
160
+ if args.label:
161
+ kwargs["label"] = args.label
162
+ if args.limit:
163
+ kwargs["limit"] = args.limit
164
+
165
+ tasks = flatten_paged(api.get_tasks(**kwargs))
166
+ buckets: dict[date, list[Any]] = {}
167
+ for task in tasks:
168
+ due_day = task_due_date(task)
169
+ if due_day is None or due_day < start or due_day > end:
170
+ continue
171
+ buckets.setdefault(due_day, []).append(task)
172
+
173
+ tasks_by_day = sorted(
174
+ ((day, day_tasks) for day, day_tasks in buckets.items()),
175
+ key=lambda item: item[0],
176
+ )
177
+ for _, day_tasks in tasks_by_day:
178
+ day_tasks.sort(
179
+ key=lambda task: (
180
+ task_due_text(task),
181
+ str(getattr(task, "id", "")),
182
+ )
183
+ )
184
+
185
+ if args.json:
186
+ return {
187
+ "from": start.isoformat(),
188
+ "to": end.isoformat(),
189
+ "days": [
190
+ {
191
+ "date": day.isoformat(),
192
+ "weekday": day.strftime("%A"),
193
+ "tasks": [to_dict(task) for task in day_tasks],
194
+ }
195
+ for day, day_tasks in tasks_by_day
196
+ ],
197
+ }
198
+
199
+ if not tasks_by_day:
200
+ if start == end:
201
+ return f"No tasks due on {start.isoformat()}."
202
+ return f"No tasks due between {start.isoformat()} and {end.isoformat()}."
203
+
204
+ return format_calendar_text(tasks_by_day)
205
+
206
+
207
+ def task_list(api: TodoistAPI, args: argparse.Namespace) -> Any:
208
+ kwargs: dict[str, Any] = {}
209
+ if args.project_id:
210
+ kwargs["project_id"] = args.project_id
211
+ if args.label:
212
+ kwargs["label"] = args.label
213
+ if args.limit:
214
+ kwargs["limit"] = args.limit
215
+
216
+ tasks = flatten_paged(api.get_tasks(**kwargs))
217
+ if args.json:
218
+ return [to_dict(task) for task in tasks]
219
+ if not tasks:
220
+ return "No tasks found."
221
+
222
+ return "\n".join(render_task_line(task) for task in tasks)
223
+
224
+
225
+ def task_get(api: TodoistAPI, args: argparse.Namespace) -> Any:
226
+ task = api.get_task(args.task_id)
227
+ if args.json:
228
+ return to_dict(task)
229
+ tid = getattr(task, "id", "")
230
+ content = getattr(task, "content", "")
231
+ description = getattr(task, "description", "")
232
+ return f"{tid}\t{content}\n{description}".strip()
233
+
234
+
235
+ def task_add(api: TodoistAPI, args: argparse.Namespace) -> Any:
236
+ kwargs: dict[str, Any] = {
237
+ "content": args.content,
238
+ }
239
+ if args.project_id:
240
+ kwargs["project_id"] = args.project_id
241
+ if args.description:
242
+ kwargs["description"] = args.description
243
+ if args.due_string:
244
+ kwargs["due_string"] = args.due_string
245
+ if args.priority is not None:
246
+ kwargs["priority"] = user_priority_to_api(args.priority)
247
+ labels = parse_labels(args.labels)
248
+ if labels:
249
+ kwargs["labels"] = labels
250
+ task = api.add_task(**kwargs)
251
+ return to_dict(task) if args.json else f"Created task {getattr(task, 'id', '')}"
252
+
253
+
254
+ def task_update(api: TodoistAPI, args: argparse.Namespace) -> Any:
255
+ kwargs: dict[str, Any] = {}
256
+ if args.content is not None:
257
+ kwargs["content"] = args.content
258
+ if args.description is not None:
259
+ kwargs["description"] = args.description
260
+ if args.due_string is not None:
261
+ kwargs["due_string"] = args.due_string
262
+ if args.priority is not None:
263
+ kwargs["priority"] = user_priority_to_api(args.priority)
264
+ if args.labels is not None:
265
+ kwargs["labels"] = parse_labels(args.labels) or []
266
+ if not kwargs:
267
+ raise SystemExit("No fields provided to update.")
268
+ task = api.update_task(args.task_id, **kwargs)
269
+ return to_dict(task) if args.json else f"Updated task {args.task_id}"
270
+
271
+
272
+ def task_complete(api: TodoistAPI, args: argparse.Namespace) -> Any:
273
+ if hasattr(api, "complete_task"):
274
+ api.complete_task(args.task_id)
275
+ else:
276
+ api.close_task(args.task_id)
277
+ return {"status": "ok", "task_id": args.task_id} if args.json else f"Completed task {args.task_id}"
278
+
279
+
280
+ def task_delete(api: TodoistAPI, args: argparse.Namespace) -> Any:
281
+ api.delete_task(args.task_id)
282
+ return {"status": "ok", "task_id": args.task_id} if args.json else f"Deleted task {args.task_id}"
283
+
284
+
285
+ def project_list(api: TodoistAPI, args: argparse.Namespace) -> Any:
286
+ projects = flatten_paged(api.get_projects())
287
+ if args.json:
288
+ return [to_dict(project) for project in projects]
289
+ if not projects:
290
+ return "No projects found."
291
+ return "\n".join(
292
+ f"{getattr(project, 'id', '')}\t{getattr(project, 'name', '')}" for project in projects
293
+ )
294
+
295
+
296
+ def project_add(api: TodoistAPI, args: argparse.Namespace) -> Any:
297
+ kwargs: dict[str, Any] = {"name": args.name}
298
+ if args.view_style is not None:
299
+ kwargs["view_style"] = args.view_style
300
+ project = api.add_project(**kwargs)
301
+ return to_dict(project) if args.json else f"Created project {getattr(project, 'id', '')}"
302
+
303
+
304
+ def project_update(api: TodoistAPI, args: argparse.Namespace) -> Any:
305
+ project = api.update_project(args.project_id, name=args.name)
306
+ return to_dict(project) if args.json else f"Updated project {args.project_id}"
307
+
308
+
309
+ def project_view_style(api: TodoistAPI, args: argparse.Namespace) -> Any:
310
+ project = api.update_project(args.project_id, view_style=args.style)
311
+ if args.json:
312
+ return to_dict(project)
313
+ return f"Set project {args.project_id} view style to {args.style}"
314
+
315
+
316
+ def comment_list(api: TodoistAPI, args: argparse.Namespace) -> Any:
317
+ comments = flatten_paged(api.get_comments(task_id=args.task_id))
318
+ if args.json:
319
+ return [to_dict(comment) for comment in comments]
320
+ if not comments:
321
+ return "No comments found."
322
+ return "\n".join(
323
+ f"{getattr(comment, 'id', '')}\t{getattr(comment, 'content', '')}" for comment in comments
324
+ )
325
+
326
+
327
+ def comment_add(api: TodoistAPI, args: argparse.Namespace) -> Any:
328
+ comment = api.add_comment(task_id=args.task_id, content=args.content)
329
+ return to_dict(comment) if args.json else f"Created comment {getattr(comment, 'id', '')}"
330
+
331
+
332
+ def label_list(api: TodoistAPI, args: argparse.Namespace) -> Any:
333
+ labels = flatten_paged(api.get_labels())
334
+ if args.json:
335
+ return [to_dict(label) for label in labels]
336
+ if not labels:
337
+ return "No labels found."
338
+ return "\n".join(f"{getattr(label, 'id', '')}\t{getattr(label, 'name', '')}" for label in labels)
339
+
340
+
341
+ def section_list(api: TodoistAPI, args: argparse.Namespace) -> Any:
342
+ kwargs: dict[str, Any] = {}
343
+ if args.project_id:
344
+ kwargs["project_id"] = args.project_id
345
+ if args.limit:
346
+ kwargs["limit"] = args.limit
347
+
348
+ sections = flatten_paged(api.get_sections(**kwargs))
349
+ if args.json:
350
+ return [to_dict(section) for section in sections]
351
+ if not sections:
352
+ return "No sections found."
353
+
354
+ ordered_sections = sorted(
355
+ sections,
356
+ key=lambda section: (
357
+ str(getattr(section, "project_id", "")),
358
+ getattr(section, "order", 0),
359
+ str(getattr(section, "name", "")),
360
+ ),
361
+ )
362
+
363
+ lines = []
364
+ for section in ordered_sections:
365
+ section_id = getattr(section, "id", "")
366
+ name = getattr(section, "name", "")
367
+ project_id = getattr(section, "project_id", "")
368
+ order = getattr(section, "order", "")
369
+ suffix = []
370
+ if project_id:
371
+ suffix.append(f"project: {project_id}")
372
+ if order != "":
373
+ suffix.append(f"order: {order}")
374
+ details = f" | {' | '.join(suffix)}" if suffix else ""
375
+ lines.append(f"{section_id}\t{name}{details}")
376
+ return "\n".join(lines)
377
+
378
+
379
+ def section_add(api: TodoistAPI, args: argparse.Namespace) -> Any:
380
+ kwargs: dict[str, Any] = {"name": args.name, "project_id": args.project_id}
381
+ if args.order is not None:
382
+ kwargs["order"] = args.order
383
+ section = api.add_section(**kwargs)
384
+ return to_dict(section) if args.json else f"Created section {getattr(section, 'id', '')}"
385
+
386
+
387
+ def section_update(api: TodoistAPI, args: argparse.Namespace) -> Any:
388
+ section = api.update_section(args.section_id, args.name)
389
+ return to_dict(section) if args.json else f"Updated section {args.section_id}"
390
+
391
+
392
+ def section_delete(api: TodoistAPI, args: argparse.Namespace) -> Any:
393
+ api.delete_section(args.section_id)
394
+ return {"status": "ok", "section_id": args.section_id} if args.json else f"Deleted section {args.section_id}"
395
+
396
+
397
+ def board_show(api: TodoistAPI, args: argparse.Namespace) -> Any:
398
+ kwargs: dict[str, Any] = {}
399
+ if args.limit:
400
+ kwargs["limit"] = args.limit
401
+
402
+ sections = flatten_paged(api.get_sections(project_id=args.project_id, **kwargs))
403
+ tasks = flatten_paged(api.get_tasks(project_id=args.project_id, **kwargs))
404
+
405
+ ordered_sections = sorted(
406
+ sections,
407
+ key=lambda section: (
408
+ getattr(section, "order", 0),
409
+ str(getattr(section, "name", "")),
410
+ ),
411
+ )
412
+
413
+ columns: list[dict[str, Any]] = []
414
+ columns_by_section_id: dict[Any, dict[str, Any]] = {}
415
+
416
+ for section in ordered_sections:
417
+ section_id = getattr(section, "id", None)
418
+ column = {
419
+ "section_id": section_id,
420
+ "name": getattr(section, "name", ""),
421
+ "order": getattr(section, "order", None),
422
+ "tasks": [],
423
+ }
424
+ columns.append(column)
425
+ columns_by_section_id[section_id] = column
426
+
427
+ no_section_column: dict[str, Any] = {
428
+ "section_id": None,
429
+ "name": "No section",
430
+ "order": None,
431
+ "tasks": [],
432
+ }
433
+
434
+ for task in tasks:
435
+ task_section_id = getattr(task, "section_id", None)
436
+ target_column = columns_by_section_id.get(task_section_id, no_section_column)
437
+ if args.json:
438
+ task_data = to_dict(task)
439
+ if "priority" in task_data:
440
+ task_data["priority"] = api_priority_to_user(task_data["priority"])
441
+ target_column["tasks"].append(task_data)
442
+ else:
443
+ target_column["tasks"].append(task)
444
+
445
+ if no_section_column["tasks"]:
446
+ columns.append(no_section_column)
447
+
448
+ if args.json:
449
+ return {"project_id": args.project_id, "columns": columns}
450
+
451
+ if not columns:
452
+ return "No sections or tasks found."
453
+
454
+ lines = []
455
+ for column in columns:
456
+ section_id = column["section_id"]
457
+ section_name = column["name"]
458
+ column_tasks = column["tasks"]
459
+ section_id_text = section_id if section_id is not None else "-"
460
+ lines.append(f"{section_id_text}\t{section_name} ({len(column_tasks)})")
461
+ if column_tasks:
462
+ lines.extend(f" {render_task_line(task)}" for task in column_tasks)
463
+ else:
464
+ lines.append(" (empty)")
465
+ return "\n".join(lines)
466
+
467
+
468
+ def board_move(api: TodoistAPI, args: argparse.Namespace) -> Any:
469
+ api.move_task(args.task_id, section_id=args.section_id)
470
+ if args.json:
471
+ return {"status": "ok", "task_id": args.task_id, "section_id": args.section_id}
472
+ return f"Moved task {args.task_id} to section {args.section_id}"
473
+
474
+
475
+ def calendar_today(api: TodoistAPI, args: argparse.Namespace) -> Any:
476
+ today = date.today()
477
+ return calendar_tasks_in_range(api, args, today, today)
478
+
479
+
480
+ def calendar_week(api: TodoistAPI, args: argparse.Namespace) -> Any:
481
+ start = date.today()
482
+ end = start + timedelta(days=6)
483
+ return calendar_tasks_in_range(api, args, start, end)
484
+
485
+
486
+ def calendar_range(api: TodoistAPI, args: argparse.Namespace) -> Any:
487
+ if args.date_from > args.date_to:
488
+ raise SystemExit("--from must be less than or equal to --to.")
489
+ return calendar_tasks_in_range(api, args, args.date_from, args.date_to)
490
+
491
+
492
+ def calendar_reschedule(api: TodoistAPI, args: argparse.Namespace) -> Any:
493
+ kwargs: dict[str, Any] = {}
494
+ if args.due_string is not None:
495
+ kwargs["due_string"] = args.due_string
496
+ if args.due_date is not None:
497
+ kwargs["due_date"] = args.due_date
498
+ task = api.update_task(args.task_id, **kwargs)
499
+ if args.json:
500
+ return to_dict(task)
501
+ if args.due_string is not None:
502
+ return f"Rescheduled task {args.task_id} to '{args.due_string}'"
503
+ return f"Rescheduled task {args.task_id} to {args.due_date.isoformat()}"
504
+
505
+
506
+ def build_parser() -> argparse.ArgumentParser:
507
+ parser = argparse.ArgumentParser(prog="todoist", description="Todoist CLI powered by todoist-api-python.")
508
+ parser.add_argument("--token", help="Todoist API token. Defaults to TODOIST_API_TOKEN env var.")
509
+ parser.add_argument("--json", action="store_true", help="Print output as JSON.")
510
+
511
+ resources = parser.add_subparsers(dest="resource", required=True)
512
+
513
+ tasks = resources.add_parser("tasks", help="Manage tasks.")
514
+ task_cmds = tasks.add_subparsers(dest="action", required=True)
515
+
516
+ task_list_p = task_cmds.add_parser("list", help="List active tasks.")
517
+ task_list_p.add_argument("--project-id")
518
+ task_list_p.add_argument("--label", help="Filter by label name.")
519
+ task_list_p.add_argument("--limit", type=int, help="Max items per page (1-200).")
520
+ task_list_p.set_defaults(handler=task_list)
521
+
522
+ task_get_p = task_cmds.add_parser("get", help="Get a task.")
523
+ task_get_p.add_argument("task_id")
524
+ task_get_p.set_defaults(handler=task_get)
525
+
526
+ task_add_p = task_cmds.add_parser("add", help="Create a task.")
527
+ task_add_p.add_argument("content")
528
+ task_add_p.add_argument("--project-id")
529
+ task_add_p.add_argument("--description")
530
+ task_add_p.add_argument("--due-string")
531
+ task_add_p.add_argument(
532
+ "--priority",
533
+ type=int,
534
+ choices=[1, 2, 3, 4],
535
+ help="Priority value where p1 is highest and p4 is lowest.",
536
+ )
537
+ task_add_p.add_argument("--labels", help="Comma-separated labels.")
538
+ task_add_p.set_defaults(handler=task_add)
539
+
540
+ task_up_p = task_cmds.add_parser("update", help="Update a task.")
541
+ task_up_p.add_argument("task_id")
542
+ task_up_p.add_argument("--content")
543
+ task_up_p.add_argument("--description")
544
+ task_up_p.add_argument("--due-string")
545
+ task_up_p.add_argument(
546
+ "--priority",
547
+ type=int,
548
+ choices=[1, 2, 3, 4],
549
+ help="Priority value where p1 is highest and p4 is lowest.",
550
+ )
551
+ task_up_p.add_argument("--labels", help="Comma-separated labels. Use empty string to clear.")
552
+ task_up_p.set_defaults(handler=task_update)
553
+
554
+ task_complete_p = task_cmds.add_parser("complete", help="Complete a task.")
555
+ task_complete_p.add_argument("task_id")
556
+ task_complete_p.set_defaults(handler=task_complete)
557
+
558
+ task_delete_p = task_cmds.add_parser("delete", help="Delete a task.")
559
+ task_delete_p.add_argument("task_id")
560
+ task_delete_p.set_defaults(handler=task_delete)
561
+
562
+ projects = resources.add_parser("projects", help="Manage projects.")
563
+ project_cmds = projects.add_subparsers(dest="action", required=True)
564
+
565
+ project_list_p = project_cmds.add_parser("list", help="List projects.")
566
+ project_list_p.set_defaults(handler=project_list)
567
+
568
+ project_add_p = project_cmds.add_parser("add", help="Create a project.")
569
+ project_add_p.add_argument("name")
570
+ project_add_p.add_argument("--view-style", choices=["list", "board"])
571
+ project_add_p.set_defaults(handler=project_add)
572
+
573
+ project_up_p = project_cmds.add_parser("update", help="Update a project name.")
574
+ project_up_p.add_argument("project_id")
575
+ project_up_p.add_argument("name")
576
+ project_up_p.set_defaults(handler=project_update)
577
+
578
+ project_view_style_p = project_cmds.add_parser(
579
+ "view-style",
580
+ help="Set a project's view style (list or board).",
581
+ )
582
+ project_view_style_p.add_argument("project_id")
583
+ project_view_style_p.add_argument("style", choices=["list", "board"])
584
+ project_view_style_p.set_defaults(handler=project_view_style)
585
+
586
+ sections = resources.add_parser("sections", help="Manage sections.")
587
+ section_cmds = sections.add_subparsers(dest="action", required=True)
588
+
589
+ section_list_p = section_cmds.add_parser("list", help="List sections.")
590
+ section_list_p.add_argument("--project-id")
591
+ section_list_p.add_argument("--limit", type=int, help="Max items per page (1-200).")
592
+ section_list_p.set_defaults(handler=section_list)
593
+
594
+ section_add_p = section_cmds.add_parser("add", help="Create a section.")
595
+ section_add_p.add_argument("--project-id", required=True)
596
+ section_add_p.add_argument("name")
597
+ section_add_p.add_argument("--order", type=int, help="Sort order within project.")
598
+ section_add_p.set_defaults(handler=section_add)
599
+
600
+ section_up_p = section_cmds.add_parser("update", help="Rename a section.")
601
+ section_up_p.add_argument("section_id")
602
+ section_up_p.add_argument("name")
603
+ section_up_p.set_defaults(handler=section_update)
604
+
605
+ section_del_p = section_cmds.add_parser("delete", help="Delete a section.")
606
+ section_del_p.add_argument("section_id")
607
+ section_del_p.set_defaults(handler=section_delete)
608
+
609
+ comments = resources.add_parser("comments", help="Manage comments.")
610
+ comment_cmds = comments.add_subparsers(dest="action", required=True)
611
+
612
+ comment_list_p = comment_cmds.add_parser("list", help="List comments for a task.")
613
+ comment_list_p.add_argument("--task-id", required=True)
614
+ comment_list_p.set_defaults(handler=comment_list)
615
+
616
+ comment_add_p = comment_cmds.add_parser("add", help="Add comment to a task.")
617
+ comment_add_p.add_argument("--task-id", required=True)
618
+ comment_add_p.add_argument("content")
619
+ comment_add_p.set_defaults(handler=comment_add)
620
+
621
+ labels = resources.add_parser("labels", help="List labels.")
622
+ label_cmds = labels.add_subparsers(dest="action", required=True)
623
+ label_list_p = label_cmds.add_parser("list", help="List labels.")
624
+ label_list_p.set_defaults(handler=label_list)
625
+
626
+ boards = resources.add_parser("boards", help="Board view helpers.")
627
+ board_cmds = boards.add_subparsers(dest="action", required=True)
628
+
629
+ board_show_p = board_cmds.add_parser("show", help="Show project tasks grouped by section.")
630
+ board_show_p.add_argument("--project-id", required=True)
631
+ board_show_p.add_argument("--limit", type=int, help="Max items per page (1-200).")
632
+ board_show_p.set_defaults(handler=board_show)
633
+
634
+ board_move_p = board_cmds.add_parser("move", help="Move a task to a board section.")
635
+ board_move_p.add_argument("task_id")
636
+ board_move_p.add_argument("--section-id", required=True)
637
+ board_move_p.set_defaults(handler=board_move)
638
+
639
+ calendar = resources.add_parser("calendar", help="Calendar and scheduling helpers.")
640
+ calendar_cmds = calendar.add_subparsers(dest="action", required=True)
641
+
642
+ calendar_today_p = calendar_cmds.add_parser("today", help="Show tasks due today.")
643
+ calendar_today_p.add_argument("--project-id")
644
+ calendar_today_p.add_argument("--label", help="Filter by label name.")
645
+ calendar_today_p.add_argument("--limit", type=int, help="Max items per page (1-200).")
646
+ calendar_today_p.set_defaults(handler=calendar_today)
647
+
648
+ calendar_week_p = calendar_cmds.add_parser("week", help="Show tasks due in the next 7 days.")
649
+ calendar_week_p.add_argument("--project-id")
650
+ calendar_week_p.add_argument("--label", help="Filter by label name.")
651
+ calendar_week_p.add_argument("--limit", type=int, help="Max items per page (1-200).")
652
+ calendar_week_p.set_defaults(handler=calendar_week)
653
+
654
+ calendar_range_p = calendar_cmds.add_parser("range", help="Show tasks due in a date range.")
655
+ calendar_range_p.add_argument("--from", dest="date_from", required=True, type=parse_date_arg)
656
+ calendar_range_p.add_argument("--to", dest="date_to", required=True, type=parse_date_arg)
657
+ calendar_range_p.add_argument("--project-id")
658
+ calendar_range_p.add_argument("--label", help="Filter by label name.")
659
+ calendar_range_p.add_argument("--limit", type=int, help="Max items per page (1-200).")
660
+ calendar_range_p.set_defaults(handler=calendar_range)
661
+
662
+ calendar_reschedule_p = calendar_cmds.add_parser("reschedule", help="Reschedule a task.")
663
+ calendar_reschedule_p.add_argument("task_id")
664
+ reschedule_target = calendar_reschedule_p.add_mutually_exclusive_group(required=True)
665
+ reschedule_target.add_argument("--due-string", help="Natural language due value, e.g. 'tomorrow 9am'.")
666
+ reschedule_target.add_argument("--to", dest="due_date", type=parse_date_arg, help="Due date as YYYY-MM-DD.")
667
+ calendar_reschedule_p.set_defaults(handler=calendar_reschedule)
668
+
669
+ return parser
670
+
671
+
672
+ def main(argv: list[str] | None = None) -> int:
673
+ parser = build_parser()
674
+ args = parser.parse_args(argv)
675
+
676
+ try:
677
+ token = require_token(args)
678
+ with TodoistAPI(token) as api:
679
+ result = args.handler(api, args)
680
+ if args.json:
681
+ print_json(result)
682
+ elif result is not None:
683
+ print(result)
684
+ return 0
685
+ except HTTPError as err:
686
+ response = getattr(err, "response", None)
687
+ status = getattr(response, "status_code", "unknown")
688
+ text = getattr(response, "text", "") if response is not None else str(err)
689
+ print(f"Todoist API error (status {status}): {text}", file=sys.stderr)
690
+ return 1
691
+ except KeyboardInterrupt:
692
+ print("Interrupted.", file=sys.stderr)
693
+ return 130
694
+ except Exception as err:
695
+ print(f"Error: {err}", file=sys.stderr)
696
+ return 1
697
+
698
+
699
+ if __name__ == "__main__":
700
+ raise SystemExit(main())