cctodo 0.1.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.
cctodo/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Command line tool and Python client for ccToDo (https://www.cctodo.com/)."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import DONE, IN_PROGRESS, TODO, Client, Record # noqa: E402
6
+ from .errors import (ApiError, CctodoError, ConfigError, Conflict, ConnectionFailed, Invalid, # noqa: E402
7
+ NotAllowed, NotFound, RateLimited, Unauthorized)
8
+
9
+ __all__ = ["Client", "Record", "TODO", "IN_PROGRESS", "DONE", "CctodoError", "ApiError", "ConfigError",
10
+ "ConnectionFailed", "Unauthorized", "NotAllowed", "NotFound", "Invalid", "Conflict", "RateLimited"]
cctodo/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
cctodo/cli.py ADDED
@@ -0,0 +1,569 @@
1
+ """The `cctodo` command.
2
+
3
+ cctodo mytasks
4
+ cctodo new -group "work group" "my task"
5
+ cctodo done 123
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import getpass
12
+ import json
13
+ import os
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import webbrowser
19
+ from datetime import datetime
20
+
21
+ from . import __version__, config
22
+ from .client import DONE, IN_PROGRESS, TODO, Client
23
+ from .errors import CctodoError, ConfigError, Invalid, Unauthorized
24
+
25
+ STATE_MARK = {TODO: "[ ]", IN_PROGRESS: "[~]", DONE: "[x]"}
26
+ STATE_WORDS = {TODO: "to do", IN_PROGRESS: "in progress", DONE: "done"}
27
+
28
+
29
+ # ── Output helpers ──────────────────────────────────────────────────────────
30
+
31
+ class Style:
32
+ def __init__(self, enabled: bool):
33
+ self.enabled = enabled
34
+
35
+ def _wrap(self, code, text):
36
+ return f"\033[{code}m{text}\033[0m" if self.enabled else text
37
+
38
+ def bold(self, text):
39
+ return self._wrap("1", text)
40
+
41
+ def dim(self, text):
42
+ return self._wrap("2", text)
43
+
44
+ def orange(self, text):
45
+ return self._wrap("38;5;208", text)
46
+
47
+ def green(self, text):
48
+ return self._wrap("32", text)
49
+
50
+
51
+ def _use_color() -> bool:
52
+ return sys.stdout.isatty() and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
53
+
54
+
55
+ def _local(ts: str | None) -> str:
56
+ if not ts:
57
+ return ""
58
+ try:
59
+ return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone().strftime("%Y-%m-%d %H:%M")
60
+ except ValueError:
61
+ return ts
62
+
63
+
64
+ def _who(user) -> str:
65
+ return (user.name or user.email) if user else ""
66
+
67
+
68
+ class Output:
69
+ def __init__(self, as_json: bool):
70
+ self.json = as_json
71
+ self.s = Style(_use_color() and not as_json)
72
+
73
+ def data(self, value):
74
+ print(json.dumps(value, indent=2, ensure_ascii=False))
75
+
76
+ def line(self, text=""):
77
+ print(text)
78
+
79
+ def ok(self, text):
80
+ if not self.json:
81
+ print(self.s.green("✓ ") + text)
82
+
83
+ def task_line(self, task, group_title: str | None = None, me_id: int | None = None):
84
+ s = self.s
85
+ mark = STATE_MARK.get(task.state, "[?]")
86
+ if task.state == IN_PROGRESS:
87
+ mark = s.orange(mark)
88
+ extras = []
89
+ if task.assignee:
90
+ name = "me" if task.assignee.id == me_id else _who(task.assignee)
91
+ extras.append(f"@{name}")
92
+ if group_title:
93
+ extras.append(group_title)
94
+ if task.body.strip():
95
+ extras.append("…")
96
+ tail = (" " + s.dim(" · ".join(extras))) if extras else ""
97
+ self.line(f" {s.dim(f'#{task.id}'.rjust(6))} {mark} {task.title}{tail}")
98
+
99
+ def task_list(self, title, tasks, me_id=None, groups_by_id=None, empty="Nothing to do. 🎉"):
100
+ if self.json:
101
+ return self.data(tasks)
102
+ self.line(self.s.bold(title) + self.s.dim(f" ({len(tasks)})"))
103
+ if not tasks:
104
+ self.line(self.s.dim(f" {empty}"))
105
+ for task in tasks:
106
+ group = groups_by_id.get(task.group) if groups_by_id else None
107
+ self.task_line(task, group.title if group else None, me_id)
108
+
109
+
110
+ # ── Commands ────────────────────────────────────────────────────────────────
111
+
112
+ def cmd_login(args, client: Client, out: Output):
113
+ cfg = config.load()
114
+ api_url = (args.api_url or config.api_url(cfg)).rstrip("/")
115
+ token = args.token_value
116
+ if not token:
117
+ settings_url = f"{config.web_url(api_url)}/app/settings/#tokens"
118
+ print(f"Create a token under Settings → API tokens:\n {settings_url}")
119
+ if not args.no_browser:
120
+ try:
121
+ webbrowser.open(settings_url)
122
+ except webbrowser.Error:
123
+ pass
124
+ token = getpass.getpass("Paste the token: ").strip()
125
+ if not token:
126
+ raise Invalid("No token given.")
127
+ me = Client(token=token, api_url=api_url).me()
128
+ cfg.update({"token": token})
129
+ if api_url != config.DEFAULT_API_URL:
130
+ cfg["api_url"] = api_url
131
+ else:
132
+ cfg.pop("api_url", None)
133
+ path = config.save(cfg)
134
+ if out.json:
135
+ return out.data(me)
136
+ out.ok(f"Logged in as {me.email}. Token saved in {path}")
137
+
138
+
139
+ def cmd_logout(args, client, out):
140
+ cfg = config.load()
141
+ had = cfg.pop("token", None)
142
+ config.save(cfg)
143
+ out.ok("Logged out. (Revoke the token under Settings → API tokens if you no longer need it.)" if had
144
+ else "You weren't logged in.")
145
+
146
+
147
+ def cmd_whoami(args, client, out):
148
+ me = client.me()
149
+ if out.json:
150
+ return out.data(me)
151
+ out.line(f"{me.name} <{me.email}>" if me.name and me.name != me.email else me.email)
152
+ out.line(out.s.dim(f"API: {client.api_url}"))
153
+
154
+
155
+ def cmd_groups(args, client, out):
156
+ groups = client.groups()
157
+ if out.json:
158
+ return out.data(groups)
159
+ for g in groups:
160
+ role = "" if g.is_personal else f"{g.role} · {g.member_count} member{'s' if g.member_count != 1 else ''}"
161
+ pending = f"{g.pending_count} pending" if g.get("pending_count") is not None else ""
162
+ info = " · ".join(x for x in (pending, role) if x)
163
+ out.line(f" {out.s.dim(f'#{g.id}'.rjust(6))} {out.s.bold(g.title)} {out.s.dim(info)}")
164
+
165
+
166
+ def _group(client, name):
167
+ return client.personal_group() if name is None else client.find_group(name)
168
+
169
+
170
+ def _show_group_tasks(client, out, group, completed=False, limit=50):
171
+ me_id = client.me().id
172
+ if completed:
173
+ tasks = list(client.completed_tasks(group.id, limit=limit))
174
+ return out.task_list(f"{group.title} · completed", tasks, me_id, empty="No completed tasks yet.")
175
+ out.task_list(group.title, client.tasks(group.id), me_id)
176
+
177
+
178
+ def cmd_mytasks(args, client, out):
179
+ _show_group_tasks(client, out, client.personal_group(), args.completed, args.limit)
180
+
181
+
182
+ def cmd_list(args, client, out):
183
+ if args.all:
184
+ groups = client.groups()
185
+ if out.json:
186
+ return out.data({g.title: client.tasks(g.id) for g in groups})
187
+ for i, g in enumerate(groups):
188
+ if i:
189
+ out.line()
190
+ _show_group_tasks(client, out, g)
191
+ return
192
+ _show_group_tasks(client, out, _group(client, args.group), args.completed, args.limit)
193
+
194
+
195
+ def cmd_assigned(args, client, out):
196
+ groups = {g.id: g for g in client.groups()}
197
+ out.task_list("Assigned to me", client.assigned_to_me(), client.me().id, groups)
198
+
199
+
200
+ def _edit_text(initial: str, suffix=".md") -> str:
201
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if sys.platform == "win32" else "vi")
202
+ with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as f:
203
+ f.write(initial)
204
+ path = f.name
205
+ try:
206
+ subprocess.run([*shlex.split(editor), path], check=True)
207
+ with open(path, encoding="utf-8") as f:
208
+ return f.read()
209
+ finally:
210
+ os.unlink(path)
211
+
212
+
213
+ def _split_title_body(text: str) -> tuple[str, str]:
214
+ title, _, body = text.strip().partition("\n")
215
+ return title.strip(), body.strip()
216
+
217
+
218
+ def _resolve_assignee(client, group_id, who):
219
+ if who is None:
220
+ return None
221
+ if who.lower() in ("me", "myself"):
222
+ return client.me().id
223
+ if who.lower() in ("none", "nobody", "-"):
224
+ return None
225
+ return client.find_member(group_id, who).id
226
+
227
+
228
+ def cmd_new(args, client, out):
229
+ group = _group(client, args.group)
230
+ title = " ".join(args.title).strip()
231
+ body = args.body or ""
232
+ if args.body == "-":
233
+ body = sys.stdin.read()
234
+ if args.edit or not title:
235
+ text = _edit_text(f"{title}\n\n{body}".strip() + "\n" if title or body else "")
236
+ title, body = _split_title_body(text)
237
+ if not title:
238
+ raise Invalid("A task needs a title.", field="title")
239
+ task = client.create_task(group.id, title, body=body, assignee_id=_resolve_assignee(client, group.id, args.assign))
240
+ if out.json:
241
+ return out.data(task)
242
+ out.ok(f"Added #{task.id} to {group.title}: {task.title}")
243
+
244
+
245
+ def cmd_show(args, client, out):
246
+ task = client.task(args.id)
247
+ comments = client.comments(task.id)
248
+ if out.json:
249
+ return out.data({**task, "comments": comments})
250
+ s = out.s
251
+ group = next((g for g in client.groups() if g.id == task.group), None)
252
+ out.line(f"{s.dim(f'#{task.id}')} {s.bold(task.title)}")
253
+ info = [STATE_WORDS.get(task.state, task.state), group.title if group else f"group #{task.group}"]
254
+ if task.assignee:
255
+ info.append(f"assigned to {_who(task.assignee)}")
256
+ out.line(s.dim(" · ".join(info)))
257
+ created = f"Created {_local(task.created_at)}" + (f" by {_who(task.created_by)}" if task.created_by else "")
258
+ if task.completed_at:
259
+ created += f" · completed {_local(task.completed_at)}" + (f" by {_who(task.completed_by)}" if task.completed_by else "")
260
+ out.line(s.dim(created))
261
+ if task.body.strip():
262
+ out.line()
263
+ out.line(task.body.rstrip())
264
+ if comments:
265
+ out.line()
266
+ out.line(s.bold(f"Comments ({sum(1 + len(c.replies) for c in comments)})"))
267
+ for c in comments:
268
+ _comment_line(out, c)
269
+ for r in c.replies:
270
+ _comment_line(out, r, indent=" ")
271
+
272
+
273
+ def _comment_line(out, c, indent=" "):
274
+ s = out.s
275
+ text = s.dim("Comment deleted") if c.is_deleted else c.text
276
+ edited = " · edited" if c.edited_at else ""
277
+ out.line(f"{indent}{s.dim(f'#{c.id}')} {s.bold(_who(c.author) or 'someone')} {s.dim(_local(c.created_at) + edited)}")
278
+ out.line(f"{indent} {text}")
279
+
280
+
281
+ def _state_command(state, verb):
282
+ def run(args, client, out):
283
+ results = [client.set_state(task_id, state) for task_id in args.ids]
284
+ if out.json:
285
+ return out.data(results)
286
+ for task in results:
287
+ out.ok(f"{verb} #{task.id}: {task.title}")
288
+ return run
289
+
290
+
291
+ def cmd_edit(args, client, out):
292
+ task = client.task(args.id)
293
+ title, body = args.title, args.body
294
+ if args.body == "-":
295
+ body = sys.stdin.read()
296
+ if title is None and body is None:
297
+ edited = _edit_text(f"{task.title}\n\n{task.body}".rstrip() + "\n")
298
+ title, body = _split_title_body(edited)
299
+ if (title, body) == (task.title, task.body.strip()):
300
+ return out.ok("No changes.")
301
+ task = client.update_task(task.id, title=title, body=body, expected_updated_at=task.updated_at)
302
+ if out.json:
303
+ return out.data(task)
304
+ out.ok(f"Saved #{task.id}: {task.title}")
305
+
306
+
307
+ def cmd_assign(args, client, out):
308
+ task = client.task(args.id)
309
+ task = client.assign(task.id, _resolve_assignee(client, task.group, args.who))
310
+ if out.json:
311
+ return out.data(task)
312
+ out.ok(f"#{task.id} assigned to {_who(task.assignee)}" if task.assignee else f"#{task.id} is unassigned")
313
+
314
+
315
+ def cmd_mv(args, client, out):
316
+ if not (args.group or args.before or args.after):
317
+ raise Invalid("Say where to: -g GROUP, --before ID or --after ID.")
318
+ task = client.task(args.id)
319
+ if args.group:
320
+ target = client.find_group(args.group)
321
+ if target.id != task.group:
322
+ task = client.move_task_to_group(task.id, target.id)
323
+ if args.before or args.after:
324
+ task = client.move_task(task.id, before_id=args.before, after_id=args.after)
325
+ if out.json:
326
+ return out.data(task)
327
+ out.ok(f"Moved #{task.id}: {task.title}")
328
+
329
+
330
+ def cmd_rm(args, client, out):
331
+ tasks = [client.task(task_id) for task_id in args.ids]
332
+ if not args.yes:
333
+ if not sys.stdin.isatty():
334
+ raise Invalid("Add --yes to delete without asking.")
335
+ titles = "\n".join(f" #{t.id} {t.title}" for t in tasks)
336
+ answer = input(f"Delete {'these tasks' if len(tasks) > 1 else 'this task'}?\n{titles}\n[y/N] ")
337
+ if answer.strip().lower() not in ("y", "yes"):
338
+ return out.line("Kept.")
339
+ for task in tasks:
340
+ client.delete_task(task.id)
341
+ out.ok(f"Deleted #{task.id}: {task.title}")
342
+
343
+
344
+ def cmd_comment(args, client, out):
345
+ text = " ".join(args.text).strip()
346
+ if not text or text == "-":
347
+ text = sys.stdin.read().strip()
348
+ comment = client.add_comment(args.id, text, parent_id=args.reply_to)
349
+ if out.json:
350
+ return out.data(comment)
351
+ out.ok(f"Commented on #{args.id}")
352
+
353
+
354
+ def cmd_group_new(args, client, out):
355
+ group = client.create_group(" ".join(args.title), icon=args.icon, color=args.color)
356
+ if out.json:
357
+ return out.data(group)
358
+ out.ok(f"Created group #{group.id}: {group.title}")
359
+
360
+
361
+ def cmd_group_rename(args, client, out):
362
+ group = client.find_group(args.group)
363
+ group = client.update_group(group.id, title=" ".join(args.title))
364
+ if out.json:
365
+ return out.data(group)
366
+ out.ok(f"Renamed #{group.id} to {group.title}")
367
+
368
+
369
+ def cmd_group_members(args, client, out):
370
+ members = client.members(client.find_group(args.group).id)
371
+ if out.json:
372
+ return out.data(members)
373
+ for m in members:
374
+ name = f"{m.user.name} <{m.user.email}>" if m.user.name and m.user.name != m.user.email else m.user.email
375
+ out.line(f" {name} {out.s.dim(m.role)}")
376
+
377
+
378
+ def cmd_group_leave(args, client, out):
379
+ group = client.find_group(args.group)
380
+ client.leave_group(group.id)
381
+ out.ok(f"Left {group.title}")
382
+
383
+
384
+ def cmd_group_rm(args, client, out):
385
+ group = client.find_group(args.group)
386
+ if not args.yes:
387
+ if not sys.stdin.isatty():
388
+ raise Invalid("Add --yes to delete without asking.")
389
+ answer = input(f"Delete “{group.title}” and all its tasks for everyone? [y/N] ")
390
+ if answer.strip().lower() not in ("y", "yes"):
391
+ return out.line("Kept.")
392
+ client.delete_group(group.id)
393
+ out.ok(f"Deleted {group.title}")
394
+
395
+
396
+ def cmd_invite(args, client, out):
397
+ group = client.find_group(args.group)
398
+ invite = client.invite(group.id, args.email, args.message or "")
399
+ if out.json:
400
+ return out.data(invite)
401
+ if invite.status == "requested":
402
+ out.ok(f"Asked the owners of {group.title} to invite {invite.email}")
403
+ else:
404
+ out.ok(f"Invited {invite.email} to {group.title}")
405
+
406
+
407
+ def cmd_invites(args, client, out):
408
+ invites = client.pending_invites()
409
+ if out.json:
410
+ return out.data(invites)
411
+ if not invites:
412
+ return out.line(out.s.dim("No invites."))
413
+ for i in invites:
414
+ out.line(f" {out.s.dim(f'#{i.id}'.rjust(6))} {out.s.bold(i.group.title)} {out.s.dim('from ' + _who(i.invited_by))}")
415
+ out.line(out.s.dim("\nAccept with: cctodo accept <id>"))
416
+
417
+
418
+ def cmd_accept(args, client, out):
419
+ group = client.accept_invite(args.id)
420
+ if out.json:
421
+ return out.data(group)
422
+ out.ok(f"Joined {group.title}")
423
+
424
+
425
+ def cmd_decline(args, client, out):
426
+ client.decline_invite(args.id)
427
+ out.ok("Invite declined")
428
+
429
+
430
+ # ── Parser ──────────────────────────────────────────────────────────────────
431
+
432
+ def _group_option(parser, help_text="group: id, title or the start of it (default: My Tasks)"):
433
+ parser.add_argument("-g", "-group", "--group", dest="group", metavar="GROUP", help=help_text)
434
+
435
+
436
+ def build_parser() -> argparse.ArgumentParser:
437
+ common = argparse.ArgumentParser(add_help=False)
438
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="print raw JSON")
439
+ common.add_argument("--api-url", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
440
+
441
+ parser = argparse.ArgumentParser(
442
+ prog="cctodo", parents=[common],
443
+ description="ccToDo from the command line. Task ids are shown as #123.",
444
+ epilog='Examples:\n cctodo mytasks\n cctodo new -group "work group" "my task"\n cctodo done 123\n\n'
445
+ "Run `cctodo <command> -h` for a command's options.",
446
+ formatter_class=argparse.RawDescriptionHelpFormatter)
447
+ parser.add_argument("-V", "--version", action="version", version=f"cctodo {__version__}")
448
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
449
+
450
+ def add(name, func, help_text, aliases=()):
451
+ p = sub.add_parser(name, help=help_text, description=help_text, aliases=list(aliases), parents=[common])
452
+ p.set_defaults(func=func)
453
+ return p
454
+
455
+ p = add("login", cmd_login, "log in with an API token (Settings → API tokens)")
456
+ p.add_argument("--token", dest="token_value", help="the token (otherwise you're asked for it)")
457
+ p.add_argument("--no-browser", action="store_true", help="don't open the settings page")
458
+ add("logout", cmd_logout, "forget the saved token")
459
+ add("whoami", cmd_whoami, "show who you're logged in as")
460
+
461
+ add("groups", cmd_groups, "list your groups")
462
+ p = add("mytasks", cmd_mytasks, "list your My Tasks", aliases=["my"])
463
+ p.add_argument("-c", "--completed", action="store_true", help="completed tasks instead")
464
+ p.add_argument("-n", "--limit", type=int, default=50, help="max completed tasks to show (default 50)")
465
+ p = add("list", cmd_list, "list the pending tasks in a group", aliases=["ls"])
466
+ p.add_argument("group_pos", nargs="?", metavar="GROUP", help=argparse.SUPPRESS)
467
+ _group_option(p)
468
+ p.add_argument("-a", "--all", action="store_true", help="every group")
469
+ p.add_argument("-c", "--completed", action="store_true", help="completed tasks instead")
470
+ p.add_argument("-n", "--limit", type=int, default=50, help="max completed tasks to show (default 50)")
471
+ add("assigned", cmd_assigned, "list pending tasks assigned to you, in all groups")
472
+
473
+ p = add("new", cmd_new, "add a task", aliases=["add"])
474
+ p.add_argument("title", nargs="*", help="the task title (quotes optional)")
475
+ _group_option(p)
476
+ p.add_argument("-b", "--body", help="details (markdown); '-' reads them from stdin")
477
+ p.add_argument("-a", "--assign", metavar="WHO", help="assign to 'me' or a member's email")
478
+ p.add_argument("-e", "--edit", action="store_true", help="write title and details in $EDITOR")
479
+ p = add("show", cmd_show, "show a task with its details and comments")
480
+ p.add_argument("id", type=int)
481
+ for name, state, verb, text, aliases in [
482
+ ("done", DONE, "Completed", "mark tasks as done", ["complete", "check"]),
483
+ ("start", IN_PROGRESS, "Started", "mark tasks as in progress", []),
484
+ ("reopen", TODO, "Reopened", "mark tasks as to do again", ["todo", "uncheck"]),
485
+ ]:
486
+ p = add(name, _state_command(state, verb), text, aliases)
487
+ p.add_argument("ids", nargs="+", type=int, metavar="ID")
488
+ p = add("edit", cmd_edit, "edit a task's title or details (opens $EDITOR without options)")
489
+ p.add_argument("id", type=int)
490
+ p.add_argument("-t", "--title")
491
+ p.add_argument("-b", "--body", help="new details (markdown); '-' reads them from stdin")
492
+ p = add("assign", cmd_assign, "assign a task to 'me', a member's email, or 'none'")
493
+ p.add_argument("id", type=int)
494
+ p.add_argument("who")
495
+ p = add("mv", cmd_mv, "move a task to another group, or reorder it", aliases=["move"])
496
+ p.add_argument("id", type=int)
497
+ _group_option(p, "the group to move it to")
498
+ p.add_argument("--before", type=int, metavar="ID", help="place it just before this task")
499
+ p.add_argument("--after", type=int, metavar="ID", help="place it just after this task")
500
+ p = add("rm", cmd_rm, "delete tasks", aliases=["delete"])
501
+ p.add_argument("ids", nargs="+", type=int, metavar="ID")
502
+ p.add_argument("-y", "--yes", action="store_true", help="don't ask")
503
+ p = add("comment", cmd_comment, "comment on a task")
504
+ p.add_argument("id", type=int)
505
+ p.add_argument("text", nargs="*", help="the comment ('-' or nothing reads stdin)")
506
+ p.add_argument("-r", "--reply-to", type=int, metavar="COMMENT_ID")
507
+
508
+ p = add("group", None, "create, rename, leave or delete groups; list members")
509
+ gsub = p.add_subparsers(dest="group_command", metavar="<action>", required=True)
510
+
511
+ def gadd(name, func, help_text):
512
+ gp = gsub.add_parser(name, help=help_text, description=help_text, parents=[common])
513
+ gp.set_defaults(func=func)
514
+ return gp
515
+
516
+ gp = gadd("new", cmd_group_new, "create a group (you become its owner)")
517
+ gp.add_argument("title", nargs="+")
518
+ gp.add_argument("--icon", help="a Lucide icon name, e.g. briefcase")
519
+ gp.add_argument("--color", help="e.g. orange, blue, teal")
520
+ gp = gadd("rename", cmd_group_rename, "rename a group")
521
+ gp.add_argument("group")
522
+ gp.add_argument("title", nargs="+")
523
+ gp = gadd("members", cmd_group_members, "list a group's members")
524
+ gp.add_argument("group")
525
+ gp = gadd("leave", cmd_group_leave, "leave a group")
526
+ gp.add_argument("group")
527
+ gp = gadd("rm", cmd_group_rm, "delete a group and its tasks (owners)")
528
+ gp.add_argument("group")
529
+ gp.add_argument("-y", "--yes", action="store_true", help="don't ask")
530
+
531
+ p = add("invite", cmd_invite, "invite someone to a group by email")
532
+ p.add_argument("group")
533
+ p.add_argument("email")
534
+ p.add_argument("-m", "--message", help="a personal message for the invite email")
535
+ add("invites", cmd_invites, "list invites addressed to you")
536
+ p = add("accept", cmd_accept, "accept an invite")
537
+ p.add_argument("id", type=int)
538
+ p = add("decline", cmd_decline, "decline an invite")
539
+ p.add_argument("id", type=int)
540
+ return parser
541
+
542
+
543
+ def main(argv: list[str] | None = None) -> int:
544
+ parser = build_parser()
545
+ args = parser.parse_args(argv)
546
+ if not getattr(args, "func", None):
547
+ parser.print_help()
548
+ return 0
549
+ if getattr(args, "group_pos", None) and not args.group:
550
+ args.group = args.group_pos
551
+ args.json = getattr(args, "json", False)
552
+ args.api_url = getattr(args, "api_url", None)
553
+ out = Output(args.json)
554
+ try:
555
+ client = Client(api_url=args.api_url) if args.func is not cmd_login else None
556
+ args.func(args, client, out)
557
+ except KeyboardInterrupt:
558
+ print(file=sys.stderr)
559
+ return 130
560
+ except (Unauthorized, ConfigError) as e: # exit 3: log in first
561
+ print(f"cctodo: {e}", file=sys.stderr)
562
+ return 3
563
+ except CctodoError as e:
564
+ print(f"cctodo: {e}", file=sys.stderr)
565
+ return 1
566
+ except subprocess.CalledProcessError as e:
567
+ print(f"cctodo: the editor exited with status {e.returncode}; nothing saved.", file=sys.stderr)
568
+ return 1
569
+ return 0
cctodo/client.py ADDED
@@ -0,0 +1,315 @@
1
+ """A small client for the ccToDo REST API (https://api.cctodo.com/v1/).
2
+
3
+ from cctodo import Client
4
+
5
+ todo = Client() # token from $CCTODO_TOKEN or `cctodo login`
6
+ work = todo.find_group("work")
7
+ task = todo.create_task(work.id, "Write the report")
8
+ todo.complete(task.id)
9
+
10
+ Results are :class:`Record` objects: plain dicts that also allow attribute access
11
+ (``task.title`` as well as ``task["title"]``), exactly as the API returns them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from typing import Any, Iterator
21
+
22
+ from . import __version__, config
23
+ from .errors import BY_STATUS, ApiError, ConfigError, ConnectionFailed, Invalid, NotFound
24
+
25
+ TODO, IN_PROGRESS, DONE = "todo", "in_progress", "done"
26
+ PERSONAL_ALIASES = {"my tasks", "mytasks", "my", "me", "personal"}
27
+
28
+
29
+ class Record(dict):
30
+ """A dict with attribute access; nested dicts and lists are converted too."""
31
+
32
+ def __getattr__(self, name):
33
+ try:
34
+ return self[name]
35
+ except KeyError:
36
+ raise AttributeError(name) from None
37
+
38
+ @classmethod
39
+ def wrap(cls, value):
40
+ if isinstance(value, dict):
41
+ return cls({k: cls.wrap(v) for k, v in value.items()})
42
+ if isinstance(value, list):
43
+ return [cls.wrap(v) for v in value]
44
+ return value
45
+
46
+
47
+ class Client:
48
+ def __init__(self, token: str | None = None, api_url: str | None = None, timeout: float = 30):
49
+ cfg = config.load() if token is None or api_url is None else {}
50
+ self.token = token or config.token(cfg)
51
+ self.api_url = (api_url or config.api_url(cfg)).rstrip("/")
52
+ self.timeout = timeout
53
+ self._me = None
54
+
55
+ # ── Transport ───────────────────────────────────────────────────────────
56
+
57
+ def request(self, method: str, path: str, data: dict | None = None, params: dict | None = None) -> Any:
58
+ """Calls ``/v1/<path>`` and returns the decoded JSON (None for 204)."""
59
+ if not self.token:
60
+ raise ConfigError("No API token. Run `cctodo login`, or set CCTODO_TOKEN.")
61
+ url = f"{self.api_url}/v1/{path.lstrip('/')}"
62
+ if params:
63
+ url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
64
+ body = json.dumps(data).encode() if data is not None else None
65
+ req = urllib.request.Request(url, data=body, method=method, headers={
66
+ "Authorization": f"Bearer {self.token}",
67
+ "Accept": "application/json",
68
+ "User-Agent": f"cctodo-python/{__version__}",
69
+ **({"Content-Type": "application/json"} if body is not None else {}),
70
+ })
71
+ try:
72
+ with self._open(req) as resp:
73
+ raw = resp.read()
74
+ except urllib.error.HTTPError as e:
75
+ raise self._error(e.code, e.read()) from None
76
+ except (urllib.error.URLError, OSError) as e:
77
+ reason = getattr(e, "reason", e)
78
+ raise ConnectionFailed(f"Can't reach {self.api_url}: {reason}") from None
79
+ return Record.wrap(json.loads(raw)) if raw else None
80
+
81
+ def _open(self, req): # replaced in tests
82
+ return urllib.request.urlopen(req, timeout=self.timeout)
83
+
84
+ @staticmethod
85
+ def _error(status: int, raw: bytes) -> ApiError:
86
+ try:
87
+ payload = json.loads(raw)
88
+ except ValueError:
89
+ payload = {}
90
+ if not isinstance(payload, dict):
91
+ payload = {}
92
+ message = payload.get("message") or payload.get("detail") or f"HTTP {status}"
93
+ if status == 401:
94
+ message = "Not logged in, or the token was revoked. Run `cctodo login`."
95
+ cls = BY_STATUS.get(status, ApiError)
96
+ return cls(message, status=status, code=payload.get("error", ""), field=payload.get("field"))
97
+
98
+ def _get(self, path, **params):
99
+ return self.request("GET", path, params=params or None)
100
+
101
+ def _post(self, path, data=None):
102
+ return self.request("POST", path, data if data is not None else {})
103
+
104
+ def _patch(self, path, data):
105
+ return self.request("PATCH", path, data)
106
+
107
+ def _delete(self, path):
108
+ return self.request("DELETE", path)
109
+
110
+ # ── Me ──────────────────────────────────────────────────────────────────
111
+
112
+ def me(self) -> Record:
113
+ """You: id, email, name, personal_group_id (your My Tasks)."""
114
+ if self._me is None:
115
+ self._me = self._get("me/")
116
+ return self._me
117
+
118
+ def notification_settings(self) -> Record:
119
+ return self._get("me/notifications/")
120
+
121
+ def update_notification_settings(self, **flags: bool) -> Record:
122
+ return self._patch("me/notifications/", flags)
123
+
124
+ # ── Groups ──────────────────────────────────────────────────────────────
125
+
126
+ def groups(self) -> list[Record]:
127
+ """Your groups, My Tasks first, each with role, member_count and pending_count."""
128
+ return self._get("groups/")
129
+
130
+ def group(self, group_id: int) -> Record:
131
+ return self._get(f"groups/{group_id}/")
132
+
133
+ def personal_group(self) -> Record:
134
+ """Your private My Tasks group (even if you renamed it)."""
135
+ return self.group(self.me().personal_group_id)
136
+
137
+ def find_group(self, name: str | int) -> Record:
138
+ """A group by id, exact title, or unique title prefix (case-insensitive).
139
+ "My Tasks", "mytasks" and "me" also find your personal group."""
140
+ groups = self.groups()
141
+ text = str(name).strip()
142
+ if text.isdigit():
143
+ for g in groups:
144
+ if g.id == int(text):
145
+ return g
146
+ folded = text.casefold()
147
+ exact = [g for g in groups if g.title.casefold() == folded]
148
+ if len(exact) == 1:
149
+ return exact[0]
150
+ if not exact and folded in PERSONAL_ALIASES:
151
+ return next(g for g in groups if g.is_personal)
152
+ candidates = exact or [g for g in groups if g.title.casefold().startswith(folded)]
153
+ if len(candidates) == 1:
154
+ return candidates[0]
155
+ if not candidates:
156
+ raise NotFound(f"No group matches “{text}”.")
157
+ titles = ", ".join(f"{g.title} (#{g.id})" for g in candidates)
158
+ raise Invalid(f"“{text}” matches several groups: {titles}. Use the id.", field="group")
159
+
160
+ def create_group(self, title: str, icon: str | None = None, color: str | None = None) -> Record:
161
+ data = {"title": title}
162
+ if icon:
163
+ data["icon"] = icon
164
+ if color:
165
+ data["color"] = color
166
+ return self._post("groups/", data)
167
+
168
+ def update_group(self, group_id: int, *, title=None, icon=None, color=None) -> Record:
169
+ data = {k: v for k, v in {"title": title, "icon": icon, "color": color}.items() if v is not None}
170
+ return self._patch(f"groups/{group_id}/", data)
171
+
172
+ def delete_group(self, group_id: int) -> None:
173
+ self._delete(f"groups/{group_id}/")
174
+
175
+ def leave_group(self, group_id: int) -> None:
176
+ self._post(f"groups/{group_id}/leave/")
177
+
178
+ def members(self, group_id: int) -> list[Record]:
179
+ return self._get(f"groups/{group_id}/members/")
180
+
181
+ def set_role(self, group_id: int, user_id: int, role: str) -> Record:
182
+ """role: "owner" or "member" (owners only)."""
183
+ return self._patch(f"groups/{group_id}/members/{user_id}/", {"role": role})
184
+
185
+ def remove_member(self, group_id: int, user_id: int) -> None:
186
+ self._delete(f"groups/{group_id}/members/{user_id}/")
187
+
188
+ def icons(self) -> Record:
189
+ """Allowed group icons and colours."""
190
+ return self._get("meta/icons/")
191
+
192
+ # ── Invites ─────────────────────────────────────────────────────────────
193
+
194
+ def invite(self, group_id: int, email: str, message: str = "") -> Record:
195
+ """Owners' invites are sent at once (status "pending"); a member's waits for an owner ("requested")."""
196
+ return self._post(f"groups/{group_id}/invites/", {"email": email, "message": message})
197
+
198
+ def group_invites(self, group_id: int) -> list[Record]:
199
+ return self._get(f"groups/{group_id}/invites/")
200
+
201
+ def pending_invites(self) -> list[Record]:
202
+ """Invites to groups, addressed to you."""
203
+ return self._get("invites/pending/")
204
+
205
+ def accept_invite(self, invite_id: int) -> Record:
206
+ return self._post(f"invites/{invite_id}/accept/")
207
+
208
+ def decline_invite(self, invite_id: int) -> None:
209
+ self._post(f"invites/{invite_id}/decline/")
210
+
211
+ def revoke_invite(self, invite_id: int) -> None:
212
+ self._delete(f"invites/{invite_id}/")
213
+
214
+ # ── Tasks ───────────────────────────────────────────────────────────────
215
+
216
+ def tasks(self, group_id: int) -> list[Record]:
217
+ """Pending tasks (to do and in progress) in their manual order."""
218
+ return self._get(f"groups/{group_id}/tasks/").results
219
+
220
+ def completed_tasks(self, group_id: int, limit: int | None = None) -> Iterator[Record]:
221
+ """Completed tasks, newest first (fetched page by page)."""
222
+ page, count = 1, 0
223
+ while page:
224
+ data = self._get(f"groups/{group_id}/tasks/", view="completed", page=page)
225
+ for task in data.results:
226
+ if limit is not None and count >= limit:
227
+ return
228
+ count += 1
229
+ yield task
230
+ page = data.next_page
231
+
232
+ def assigned_to_me(self) -> list[Record]:
233
+ """Pending tasks assigned to you, across all groups."""
234
+ return self._get("tasks/assigned-to-me/").results
235
+
236
+ def task(self, task_id: int) -> Record:
237
+ return self._get(f"tasks/{task_id}/")
238
+
239
+ def create_task(self, group_id: int, title: str, body: str = "", assignee_id: int | None = None) -> Record:
240
+ """New tasks go to the bottom of the list. ``body`` is markdown."""
241
+ data = {"title": title, "body": body}
242
+ if assignee_id is not None:
243
+ data["assignee_id"] = assignee_id
244
+ return self._post(f"groups/{group_id}/tasks/", data)
245
+
246
+ def update_task(self, task_id: int, *, title: str | None = None, body: str | None = None,
247
+ expected_updated_at: str | None = None) -> Record:
248
+ """Pass the ``updated_at`` you loaded as ``expected_updated_at`` to get
249
+ :class:`~cctodo.errors.Conflict` instead of overwriting someone else's edit."""
250
+ data = {k: v for k, v in {"title": title, "body": body,
251
+ "expected_updated_at": expected_updated_at}.items() if v is not None}
252
+ return self._patch(f"tasks/{task_id}/", data)
253
+
254
+ def set_state(self, task_id: int, state: str) -> Record:
255
+ """state: "todo", "in_progress" or "done"."""
256
+ return self._patch(f"tasks/{task_id}/", {"state": state})
257
+
258
+ def complete(self, task_id: int) -> Record:
259
+ return self.set_state(task_id, DONE)
260
+
261
+ def start(self, task_id: int) -> Record:
262
+ return self.set_state(task_id, IN_PROGRESS)
263
+
264
+ def reopen(self, task_id: int) -> Record:
265
+ return self.set_state(task_id, TODO)
266
+
267
+ def assign(self, task_id: int, user_id: int | None) -> Record:
268
+ """Assign to a member of the task's group, or None to unassign."""
269
+ return self._patch(f"tasks/{task_id}/", {"assignee_id": user_id})
270
+
271
+ def move_task(self, task_id: int, *, before_id: int | None = None, after_id: int | None = None) -> Record:
272
+ """Reorder: place the task just before or just after another pending task."""
273
+ return self._post(f"tasks/{task_id}/move/", {"before_id": before_id, "after_id": after_id})
274
+
275
+ def move_task_to_group(self, task_id: int, group_id: int) -> Record:
276
+ """Moves the task to the bottom of another group (clears the assignee if they aren't a member there)."""
277
+ return self._post(f"tasks/{task_id}/move-group/", {"group_id": group_id})
278
+
279
+ def delete_task(self, task_id: int) -> None:
280
+ self._delete(f"tasks/{task_id}/")
281
+
282
+ # ── Comments ────────────────────────────────────────────────────────────
283
+
284
+ def comments(self, task_id: int) -> list[Record]:
285
+ """Top-level comments, oldest first, each with its ``replies``."""
286
+ return self._get(f"tasks/{task_id}/comments/")
287
+
288
+ def add_comment(self, task_id: int, text: str, parent_id: int | None = None) -> Record:
289
+ data = {"text": text}
290
+ if parent_id:
291
+ data["parent_id"] = parent_id
292
+ return self._post(f"tasks/{task_id}/comments/", data)
293
+
294
+ def edit_comment(self, comment_id: int, text: str) -> Record:
295
+ return self._patch(f"comments/{comment_id}/", {"text": text})
296
+
297
+ def delete_comment(self, comment_id: int) -> None:
298
+ self._delete(f"comments/{comment_id}/")
299
+
300
+ # ── Helpers ─────────────────────────────────────────────────────────────
301
+
302
+ def find_member(self, group_id: int, who: str) -> Record:
303
+ """A member of the group by email (or unique email/name prefix)."""
304
+ members = [m.user for m in self.members(group_id)]
305
+ folded = who.strip().casefold()
306
+ exact = [u for u in members if u.email.casefold() == folded]
307
+ if exact:
308
+ return exact[0]
309
+ candidates = [u for u in members if u.email.casefold().startswith(folded)
310
+ or (u.name or "").casefold().startswith(folded)]
311
+ if len(candidates) == 1:
312
+ return candidates[0]
313
+ if not candidates:
314
+ raise NotFound(f"No member of this group matches “{who}”.")
315
+ raise Invalid(f"“{who}” matches several members: {', '.join(u.email for u in candidates)}.", field="assignee")
cctodo/config.py ADDED
@@ -0,0 +1,64 @@
1
+ """Where the token lives: $CCTODO_TOKEN, or the config file written by `cctodo login`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ DEFAULT_API_URL = "https://api.cctodo.com"
11
+ DEFAULT_WEB_URL = "https://www.cctodo.com"
12
+
13
+
14
+ def config_path() -> Path:
15
+ if os.environ.get("CCTODO_CONFIG"):
16
+ return Path(os.environ["CCTODO_CONFIG"]).expanduser()
17
+ if sys.platform == "win32" and os.environ.get("APPDATA"):
18
+ base = Path(os.environ["APPDATA"])
19
+ else:
20
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
21
+ return base / "cctodo" / "config.json"
22
+
23
+
24
+ def load() -> dict:
25
+ path = config_path()
26
+ try:
27
+ with path.open(encoding="utf-8") as f:
28
+ data = json.load(f)
29
+ except FileNotFoundError:
30
+ return {}
31
+ except (OSError, ValueError) as e:
32
+ from .errors import ConfigError
33
+ raise ConfigError(f"Can't read {path}: {e}") from e
34
+ return data if isinstance(data, dict) else {}
35
+
36
+
37
+ def save(data: dict) -> Path:
38
+ """Writes the config readable by the current user only."""
39
+ path = config_path()
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ tmp = path.with_suffix(".tmp")
42
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
43
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
44
+ json.dump(data, f, indent=2)
45
+ f.write("\n")
46
+ os.replace(tmp, path)
47
+ return path
48
+
49
+
50
+ def api_url(config: dict | None = None) -> str:
51
+ config = load() if config is None else config
52
+ return (os.environ.get("CCTODO_API_URL") or config.get("api_url") or DEFAULT_API_URL).rstrip("/")
53
+
54
+
55
+ def web_url(api: str) -> str:
56
+ """The website that belongs to an API URL (api.cctodo.com → www.cctodo.com)."""
57
+ if api.rstrip("/") == DEFAULT_API_URL:
58
+ return DEFAULT_WEB_URL
59
+ return api.replace("://api.", "://", 1) # e.g. http://api.localhost:8000 → http://localhost:8000
60
+
61
+
62
+ def token(config: dict | None = None) -> str | None:
63
+ config = load() if config is None else config
64
+ return os.environ.get("CCTODO_TOKEN") or config.get("token")
cctodo/errors.py ADDED
@@ -0,0 +1,63 @@
1
+ """Errors raised by :class:`cctodo.Client`.
2
+
3
+ The API answers errors as ``{"error": code, "message": text, "field": name}``;
4
+ each HTTP status maps to one exception class.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class CctodoError(Exception):
11
+ """Base class for everything this package raises."""
12
+
13
+
14
+ class ConfigError(CctodoError):
15
+ """No token configured, or a broken config file."""
16
+
17
+
18
+ class ConnectionFailed(CctodoError):
19
+ """The API couldn't be reached."""
20
+
21
+
22
+ class ApiError(CctodoError):
23
+ status = 0
24
+
25
+ def __init__(self, message: str, *, status: int = 0, code: str = "", field: str | None = None):
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status = status or self.status
29
+ self.code = code
30
+ self.field = field
31
+
32
+
33
+ class Unauthorized(ApiError):
34
+ """401: missing, invalid or revoked token."""
35
+ status = 401
36
+
37
+
38
+ class NotAllowed(ApiError):
39
+ """403: you're a member, but not allowed to do this (e.g. owners only)."""
40
+ status = 403
41
+
42
+
43
+ class NotFound(ApiError):
44
+ """404: doesn't exist, or you're not a member of its group."""
45
+ status = 404
46
+
47
+
48
+ class Invalid(ApiError):
49
+ """400: bad input. ``field`` names the offending field when known."""
50
+ status = 400
51
+
52
+
53
+ class Conflict(ApiError):
54
+ """409: e.g. the task was changed by someone else since you loaded it."""
55
+ status = 409
56
+
57
+
58
+ class RateLimited(ApiError):
59
+ """429: too many requests (invites, comments, ...). Try again later."""
60
+ status = 429
61
+
62
+
63
+ BY_STATUS = {cls.status: cls for cls in (Unauthorized, NotAllowed, NotFound, Invalid, Conflict, RateLimited)}
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: cctodo
3
+ Version: 0.1.0
4
+ Summary: Command line tool and Python client for ccToDo (www.cctodo.com)
5
+ Author-email: CloudCircus <support@cctodo.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://www.cctodo.com/
8
+ Project-URL: Source, https://github.com/RubenNorgaard/cctodo_py
9
+ Keywords: todo,tasks,cli,cctodo
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Office/Business :: Scheduling
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # cctodo
19
+
20
+ The command line tool and Python client for [ccToDo](https://www.cctodo.com/), made by CloudCircus.
21
+
22
+ ```sh
23
+ pip install cctodo
24
+ cctodo login # paste a token from Settings → API tokens
25
+ cctodo mytasks
26
+ cctodo new -group "work group" "my task"
27
+ cctodo done 123
28
+ ```
29
+
30
+ It uses only the Python standard library and needs Python 3.9 or newer.
31
+
32
+ ## Commands
33
+
34
+ Tasks are shown with their id (`#123`), and the commands take that id. Where a command takes a group,
35
+ you can give its id, its title, or the start of the title (case-insensitive). Without `-g`, commands
36
+ use your **My Tasks**.
37
+
38
+ | Command | What it does |
39
+ |---|---|
40
+ | `cctodo mytasks` (`my`) | Your pending My Tasks. `-c` shows completed tasks instead. |
41
+ | `cctodo list [GROUP]` (`ls`) | A group's pending tasks. `--all` lists every group, `-c` shows completed tasks. |
42
+ | `cctodo assigned` | Pending tasks assigned to you, in all groups. |
43
+ | `cctodo groups` | Your groups, with their pending counts. |
44
+ | `cctodo new TITLE… [-g GROUP] [-a WHO] [-b BODY] [-e]` (`add`) | Adds a task at the bottom of the list. `-b -` reads the details from stdin; `-e` writes the title and details in `$EDITOR`. |
45
+ | `cctodo show ID` | A task with its details (markdown) and comments. |
46
+ | `cctodo done ID…` / `start ID…` / `reopen ID…` | Sets the state to done, in progress, or to do. |
47
+ | `cctodo edit ID [-t TITLE] [-b BODY]` | Edits a task. With no options it opens `$EDITOR`: the first line is the title and the rest is the details. It refuses to overwrite someone else's newer edit. |
48
+ | `cctodo assign ID WHO` | Assigns to `me`, a member's email, or `none`. |
49
+ | `cctodo mv ID -g GROUP` / `mv ID --before ID2` / `--after ID2` | Moves a task to another group, or reorders it. |
50
+ | `cctodo rm ID… [-y]` | Deletes tasks after asking you to confirm. |
51
+ | `cctodo comment ID TEXT… [-r COMMENT_ID]` | Adds a comment, or a reply with `-r`. |
52
+ | `cctodo group new/rename/members/leave/rm` | Manages groups. |
53
+ | `cctodo invite GROUP EMAIL [-m MESSAGE]` | Invites someone. If you're a member rather than an owner, an owner approves the invite first. |
54
+ | `cctodo invites` / `accept ID` / `decline ID` | Lists and answers invites addressed to you. |
55
+ | `cctodo whoami` / `logout` | Shows the account you're logged in as, or forgets the saved token. |
56
+
57
+ Add `--json` to any command for the raw API output. `NO_COLOR` turns colours off.
58
+
59
+ **Exit codes:**
60
+ - `0`: the command succeeded.
61
+ - `1`: an error occurred.
62
+ - `3`: you're not logged in, or your token was revoked.
63
+
64
+ ## Configuration
65
+
66
+ `cctodo login` saves the token in `~/.config/cctodo/config.json`, readable only by you
67
+ (`%APPDATA%\cctodo\config.json` on Windows). You can set these environment variables instead:
68
+
69
+ - `CCTODO_TOKEN`: the API token.
70
+ - `CCTODO_API_URL`: the API address. It defaults to `https://api.cctodo.com`.
71
+ - `CCTODO_CONFIG`: a different config file.
72
+
73
+ To revoke a token, go to Settings → API tokens on www.cctodo.com.
74
+
75
+ ## Python
76
+
77
+ ```python
78
+ from cctodo import Client, Conflict
79
+
80
+ todo = Client() # the same token as the CLI (or Client(token="cct_..."))
81
+ work = todo.find_group("work group")
82
+ task = todo.create_task(work.id, "Write the report", body="Due **Friday**")
83
+ todo.assign(task.id, todo.me().id)
84
+ todo.start(task.id)
85
+
86
+ for t in todo.tasks(work.id): # pending tasks, in order
87
+ print(t.id, t.state, t.title) # attribute or t["title"] access
88
+
89
+ try:
90
+ todo.update_task(task.id, body="…", expected_updated_at=task.updated_at)
91
+ except Conflict:
92
+ print("Someone else changed it first")
93
+ ```
94
+
95
+ **Return values:** results are the API's JSON as `Record` objects (dicts with attribute access).
96
+
97
+ **Errors:** errors raise `cctodo.NotFound` (404; this also covers groups you're not in), `NotAllowed` (403),
98
+ `Invalid` (400), `Conflict` (409), `RateLimited` (429) or `Unauthorized` (401). All of them subclass `cctodo.CctodoError`.
99
+
100
+ ## Development
101
+
102
+ ```sh
103
+ pip install -e .
104
+ python -m unittest discover -s . -t .
105
+ CCTODO_API_URL=http://api.localhost:8000 cctodo whoami # against a local cctodo_web
106
+ ```
107
+
108
+ ## Licence
109
+
110
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ cctodo/__init__.py,sha256=HPYPsUBqlu48vj5xm3CwPYijrvApbNW4lQ4h6huKlP4,572
2
+ cctodo/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ cctodo/cli.py,sha256=PQ2_vb6DPSXWsuJFDXhT_aC_zz494A5xQhAbu6rnpHM,22440
4
+ cctodo/client.py,sha256=gqvwqv4oEN0-hoUEAREM3fJ0xQ0sdvMNqwlTIiBguCk,14361
5
+ cctodo/config.py,sha256=xcgVziIf-WAeJLX6ezyRj_tWhhjL5jBV3vP5sX2zZiA,2110
6
+ cctodo/errors.py,sha256=ocuodQH29r7CKiMznIats16T0tBehxF2NY125kvC2fE,1589
7
+ cctodo-0.1.0.dist-info/licenses/LICENSE,sha256=G24HdYmI4MWIlEUuVqBPItNa01TMjAtlv-e_X0ia7Tc,1068
8
+ cctodo-0.1.0.dist-info/METADATA,sha256=a3BCG6yOqnM8xJafI_WeubuC4pzsw3Y0pIPnzEesSuU,4694
9
+ cctodo-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ cctodo-0.1.0.dist-info/entry_points.txt,sha256=OKSTAxNQEciOtSDur1cKE9rqGWoU56KUBkmjUEYtLj4,43
11
+ cctodo-0.1.0.dist-info/top_level.txt,sha256=6-z-8fTElOm8V6gkwktlo5c64GG1R5t1Fd89kTaobWU,7
12
+ cctodo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cctodo = cctodo.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CloudCircus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cctodo