td-ai-tools 1.2.2 → 1.2.3

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.
@@ -1,654 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import os
7
- import re
8
- import subprocess
9
- import sys
10
- from dataclasses import dataclass
11
- from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
12
- from pathlib import Path
13
- from typing import Any
14
- from urllib.error import HTTPError, URLError
15
- from urllib.parse import quote, urlencode
16
- from urllib.request import Request, urlopen
17
-
18
- SKILL_ROOT = Path(__file__).resolve().parents[1]
19
- DEFAULT_ENV_FILE = SKILL_ROOT / ".env"
20
- EVERHOUR_BASE_URL = "https://api.everhour.com"
21
- TITLE_KEYS = ("title", "name", "content", "text")
22
- TRAILING_ESTIMATE_RE = re.compile(r"\s+\[(\d+(?:\.\d+)?)h\]$", re.IGNORECASE)
23
- WHITESPACE_RE = re.compile(r"\s+")
24
-
25
-
26
- @dataclass(frozen=True)
27
- class TodoItem:
28
- id: str
29
- title: str
30
- api_url: str | None = None
31
-
32
-
33
- @dataclass(frozen=True)
34
- class MatchPlan:
35
- todo: TodoItem
36
- hours: Decimal
37
- seconds: int
38
- everhour_task_id: str
39
- everhour_task_name: str
40
- new_basecamp_title: str
41
-
42
-
43
- @dataclass(frozen=True)
44
- class TodoSelection:
45
- active: list[TodoItem]
46
- skipped_completed: list[TodoItem]
47
-
48
-
49
- class ScriptError(RuntimeError):
50
- pass
51
-
52
-
53
- def parse_args() -> argparse.Namespace:
54
- parser = argparse.ArgumentParser(
55
- description=(
56
- "Update Everhour estimates for tasks matched from a Basecamp todo or "
57
- "todolist URL, then append the estimate to the Basecamp todo titles."
58
- )
59
- )
60
- parser.add_argument("basecamp_url", help="Basecamp todo or todolist URL")
61
- parser.add_argument(
62
- "hours",
63
- help="Estimate in hours, or a JSON array of per-task estimates in hours",
64
- )
65
- parser.add_argument(
66
- "--env-file",
67
- default=str(DEFAULT_ENV_FILE),
68
- help=f"Path to the skill-local .env file (default: {DEFAULT_ENV_FILE})",
69
- )
70
- parser.add_argument(
71
- "--basecamp-bin",
72
- default="basecamp",
73
- help="Basecamp CLI executable name or path",
74
- )
75
- parser.add_argument(
76
- "--dry-run",
77
- action="store_true",
78
- help="Print the planned changes without mutating Everhour or Basecamp",
79
- )
80
- return parser.parse_args()
81
-
82
-
83
- def load_env_file(path: Path) -> dict[str, str]:
84
- if not path.exists():
85
- raise ScriptError(
86
- f"Missing env file: {path}. Copy {SKILL_ROOT / '.env.example'} to "
87
- f"{path} and fill in the values."
88
- )
89
-
90
- values: dict[str, str] = {}
91
- for line in path.read_text(encoding="utf-8").splitlines():
92
- stripped = line.strip()
93
- if not stripped or stripped.startswith("#"):
94
- continue
95
- if "=" not in stripped:
96
- raise ScriptError(f"Invalid line in {path}: {line}")
97
- key, raw_value = stripped.split("=", 1)
98
- values[key.strip()] = raw_value.strip().strip("'").strip('"')
99
-
100
- for key, value in os.environ.items():
101
- if key.startswith("EVERHOUR_"):
102
- values[key] = value
103
-
104
- missing = [key for key in ("EVERHOUR_API_KEY", "EVERHOUR_PROJECT_ID") if not values.get(key)]
105
- if missing:
106
- raise ScriptError(f"Missing required environment values: {', '.join(missing)}")
107
-
108
- return values
109
-
110
-
111
- def parse_hours_to_seconds(raw_hours: str) -> tuple[Decimal, int]:
112
- return parse_hours_value(raw_hours)
113
-
114
-
115
- def parse_hours_value(raw_hours: Any) -> tuple[Decimal, int]:
116
- if isinstance(raw_hours, bool):
117
- raise ScriptError(f"Invalid hours value: {raw_hours}")
118
-
119
- if isinstance(raw_hours, Decimal):
120
- hours = raw_hours
121
- else:
122
- try:
123
- hours = Decimal(str(raw_hours))
124
- except (InvalidOperation, ValueError) as exc:
125
- raise ScriptError(f"Invalid hours value: {raw_hours}") from exc
126
-
127
- if hours <= 0:
128
- raise ScriptError("Hours must be greater than zero.")
129
-
130
- seconds = int((hours * Decimal("3600")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
131
- return hours, seconds
132
-
133
-
134
- def parse_hours_input(raw_hours: str, todo_count: int) -> list[tuple[Decimal, int]]:
135
- stripped = raw_hours.strip()
136
- if stripped.startswith("["):
137
- try:
138
- values = json.loads(stripped, parse_float=Decimal, parse_int=Decimal)
139
- except json.JSONDecodeError as exc:
140
- raise ScriptError(f"Invalid hours array: {raw_hours}") from exc
141
-
142
- if not isinstance(values, list):
143
- raise ScriptError("Hours array input must decode to a JSON array.")
144
- if len(values) != todo_count:
145
- raise ScriptError(
146
- "Hours array length does not match active Basecamp todo count: "
147
- f"expected {todo_count}, got {len(values)}."
148
- )
149
- return [parse_hours_value(value) for value in values]
150
-
151
- hours, seconds = parse_hours_to_seconds(raw_hours)
152
- return [(hours, seconds) for _ in range(todo_count)]
153
-
154
-
155
- def format_hours(hours: Decimal) -> str:
156
- rendered = format(hours.normalize(), "f")
157
- return rendered.rstrip("0").rstrip(".") if "." in rendered else rendered
158
-
159
-
160
- def strip_estimate_suffix(title: str) -> str:
161
- return TRAILING_ESTIMATE_RE.sub("", title).strip()
162
-
163
-
164
- def normalize_title(title: str) -> str:
165
- collapsed = WHITESPACE_RE.sub(" ", strip_estimate_suffix(title)).strip()
166
- return collapsed.casefold()
167
-
168
-
169
- def build_estimated_title(title: str, hours: Decimal) -> str:
170
- return f"{strip_estimate_suffix(title)} [{format_hours(hours)}h]"
171
-
172
-
173
- def run_basecamp_json(basecamp_bin: str, *args: str) -> Any:
174
- command = [basecamp_bin, *args, "--agent"]
175
- completed = subprocess.run(command, capture_output=True, text=True)
176
- if completed.returncode != 0:
177
- stderr = completed.stderr.strip()
178
- stdout = completed.stdout.strip()
179
- raise ScriptError(
180
- f"Basecamp command failed: {' '.join(command)}\n"
181
- f"{stderr or stdout or 'No output returned.'}"
182
- )
183
-
184
- stdout = completed.stdout.strip()
185
- if not stdout:
186
- raise ScriptError(f"Basecamp command returned no JSON: {' '.join(command)}")
187
-
188
- try:
189
- return json.loads(stdout)
190
- except json.JSONDecodeError as exc:
191
- raise ScriptError(
192
- f"Basecamp command returned invalid JSON: {' '.join(command)}\n{stdout}"
193
- ) from exc
194
-
195
-
196
- def extract_title(record: dict[str, Any]) -> str:
197
- for key in TITLE_KEYS:
198
- value = record.get(key)
199
- if isinstance(value, str) and value.strip():
200
- return value.strip()
201
- raise ScriptError(f"Could not determine a title from Basecamp payload: {record}")
202
-
203
-
204
- def extract_api_url(record: dict[str, Any]) -> str | None:
205
- value = record.get("url")
206
- if isinstance(value, str) and value.strip():
207
- return value.strip()
208
- return None
209
-
210
-
211
- def extract_todo_objects(payload: Any) -> list[dict[str, Any]]:
212
- if isinstance(payload, list):
213
- candidates = [item for item in payload if is_todo_like(item)]
214
- if candidates:
215
- return candidates
216
- return []
217
-
218
- if not isinstance(payload, dict):
219
- return []
220
-
221
- for key in ("todos", "items", "entries", "records"):
222
- value = payload.get(key)
223
- if isinstance(value, list):
224
- candidates = [item for item in value if is_todo_like(item)]
225
- if candidates:
226
- return candidates
227
-
228
- for key, value in payload.items():
229
- if "todo" in key.lower():
230
- candidates = extract_todo_objects(value)
231
- if candidates:
232
- return candidates
233
-
234
- for value in payload.values():
235
- candidates = extract_todo_objects(value)
236
- if candidates:
237
- return candidates
238
-
239
- return []
240
-
241
-
242
- def is_todo_like(value: Any) -> bool:
243
- return (
244
- isinstance(value, dict)
245
- and "id" in value
246
- and any(isinstance(value.get(key), str) and value.get(key).strip() for key in TITLE_KEYS)
247
- )
248
-
249
-
250
- def is_todo_completed(record: dict[str, Any]) -> bool:
251
- if record.get("completed") is True:
252
- return True
253
-
254
- status = record.get("status")
255
- if isinstance(status, str) and status.strip().lower() in {"complete", "completed", "done", "closed"}:
256
- return True
257
-
258
- for key in ("completedAt", "completed_at", "completedOn", "completed_on"):
259
- value = record.get(key)
260
- if isinstance(value, str) and value.strip():
261
- return True
262
-
263
- return False
264
-
265
-
266
- def load_basecamp_todos(basecamp_bin: str, basecamp_url: str) -> TodoSelection:
267
- parsed = run_basecamp_json(basecamp_bin, "url", "parse", basecamp_url)
268
- resource_type = str(parsed.get("type", "")).strip().lower()
269
- project_id = str(parsed.get("project_id", "")).strip()
270
- recording_id = str(parsed.get("recording_id", "")).strip()
271
-
272
- if resource_type in {"todo", "todos"}:
273
- payload = run_basecamp_json(basecamp_bin, "todos", "show", basecamp_url)
274
- todo = TodoItem(
275
- id=str(payload["id"]),
276
- title=extract_title(payload),
277
- api_url=extract_api_url(payload),
278
- )
279
- if is_todo_completed(payload):
280
- return TodoSelection(active=[], skipped_completed=[todo])
281
- return TodoSelection(active=[todo], skipped_completed=[])
282
-
283
- if resource_type in {"todolist", "todolists"}:
284
- payload: Any = None
285
- todo_objects: list[dict[str, Any]] = []
286
- try:
287
- payload = run_basecamp_json(basecamp_bin, "todolists", "show", basecamp_url)
288
- except ScriptError:
289
- payload = None
290
-
291
- if payload is not None:
292
- todo_objects = extract_todo_objects(payload)
293
-
294
- # Some Basecamp CLI versions return an empty payload for todolists show even
295
- # though the list exists. Fall back to listing todos scoped to the parsed
296
- # project and todolist IDs.
297
- if not todo_objects and project_id and recording_id:
298
- fallback_payload = run_basecamp_json(
299
- basecamp_bin,
300
- "todos",
301
- "list",
302
- "--in",
303
- project_id,
304
- "--todolist",
305
- recording_id,
306
- )
307
- todo_objects = extract_todo_objects(fallback_payload)
308
-
309
- if not todo_objects:
310
- raise ScriptError(
311
- "The Basecamp todolist payload did not include any todo items. "
312
- "Inspect the todolist manually to confirm the CLI output shape."
313
- )
314
- active: list[TodoItem] = []
315
- skipped_completed: list[TodoItem] = []
316
- for item in todo_objects:
317
- todo = TodoItem(
318
- id=str(item["id"]),
319
- title=extract_title(item),
320
- api_url=extract_api_url(item),
321
- )
322
- if is_todo_completed(item):
323
- skipped_completed.append(todo)
324
- else:
325
- active.append(todo)
326
- return TodoSelection(active=active, skipped_completed=skipped_completed)
327
-
328
- raise ScriptError(
329
- f"Unsupported Basecamp URL type: {resource_type or 'unknown'}. "
330
- "Use a todo or todolist URL."
331
- )
332
-
333
-
334
- class EverhourClient:
335
- def __init__(self, api_key: str, project_id: str) -> None:
336
- self.api_key = api_key
337
- self.project_id = project_id
338
- self._open_tasks_by_title: dict[str, list[dict[str, Any]]] | None = None
339
-
340
- def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
341
- request = Request(
342
- url=f"{EVERHOUR_BASE_URL}{path}",
343
- method=method,
344
- headers={
345
- "Accept": "application/json",
346
- "Content-Type": "application/json",
347
- "X-Api-Key": self.api_key,
348
- },
349
- data=None if payload is None else json.dumps(payload).encode("utf-8"),
350
- )
351
-
352
- try:
353
- with urlopen(request) as response:
354
- raw_body = response.read().decode("utf-8")
355
- except HTTPError as exc:
356
- body = exc.read().decode("utf-8", errors="replace")
357
- raise ScriptError(f"Everhour API error {exc.code} for {method} {path}: {body}") from exc
358
- except URLError as exc:
359
- raise ScriptError(f"Everhour API request failed for {method} {path}: {exc.reason}") from exc
360
-
361
- if not raw_body:
362
- return None
363
-
364
- try:
365
- return json.loads(raw_body)
366
- except json.JSONDecodeError as exc:
367
- raise ScriptError(f"Everhour API returned invalid JSON for {method} {path}: {raw_body}") from exc
368
-
369
- def _project_path(self, suffix: str) -> str:
370
- return f"/projects/{quote(self.project_id, safe='')}{suffix}"
371
-
372
- def _task_path(self, task_id: str, suffix: str = "") -> str:
373
- return f"/tasks/{quote(task_id, safe='')}{suffix}"
374
-
375
- def list_open_tasks(self) -> dict[str, list[dict[str, Any]]]:
376
- if self._open_tasks_by_title is not None:
377
- return self._open_tasks_by_title
378
-
379
- tasks_by_title: dict[str, list[dict[str, Any]]] = {}
380
- page = 1
381
- limit = 100
382
-
383
- while True:
384
- query = urlencode({"limit": limit, "page": page})
385
- tasks = self._request("GET", self._project_path(f"/tasks?{query}"))
386
- if not isinstance(tasks, list):
387
- raise ScriptError("Everhour project tasks response was not a list.")
388
-
389
- for task in tasks:
390
- if not isinstance(task, dict) or "id" not in task:
391
- continue
392
- tasks_by_title.setdefault(normalize_title(str(task.get("name", ""))), []).append(task)
393
-
394
- if len(tasks) < limit:
395
- break
396
- page += 1
397
-
398
- self._open_tasks_by_title = tasks_by_title
399
- return tasks_by_title
400
-
401
- def search_tasks(self, title: str) -> list[dict[str, Any]]:
402
- query = urlencode(
403
- {
404
- "query": title,
405
- "limit": 100,
406
- "searchInClosed": "true",
407
- }
408
- )
409
- tasks = self._request("GET", self._project_path(f"/tasks/search?{query}"))
410
- if not isinstance(tasks, list):
411
- raise ScriptError("Everhour task search response was not a list.")
412
- return [task for task in tasks if isinstance(task, dict)]
413
-
414
- def match_task(self, title: str, source_id: str) -> dict[str, Any]:
415
- normalized = normalize_title(title)
416
- open_matches = self.list_open_tasks().get(normalized, [])
417
- open_match = select_single_task_match(title, open_matches, source_id)
418
- if open_match is not None:
419
- return open_match
420
-
421
- search_matches = [
422
- task
423
- for task in self.search_tasks(strip_estimate_suffix(title))
424
- if normalize_title(str(task.get("name", ""))) == normalized
425
- ]
426
- if not search_matches:
427
- raise ScriptError(format_missing_match_error(title, self.project_id))
428
- search_match = select_single_task_match(title, search_matches, source_id)
429
- if search_match is not None:
430
- return search_match
431
-
432
- raise ScriptError(format_ambiguous_match_error(title, search_matches, source_id))
433
-
434
- def update_task_estimate(self, task_id: str, total_seconds: int) -> None:
435
- self._request(
436
- "PUT",
437
- self._task_path(task_id, "/estimate"),
438
- {"total": total_seconds, "type": "overall"},
439
- )
440
-
441
-
442
- def task_matches_source_id(task: dict[str, Any], source_id: str) -> bool:
443
- normalized_source_id = str(source_id).strip()
444
- if not normalized_source_id:
445
- return False
446
-
447
- task_id = str(task.get("id", "")).strip()
448
- if task_id == normalized_source_id or task_id.endswith(f":{normalized_source_id}"):
449
- return True
450
-
451
- url = str(task.get("url", "")).strip().rstrip("/")
452
- return bool(url) and url.endswith(f"/{normalized_source_id}")
453
-
454
-
455
- def select_single_task_match(
456
- title: str,
457
- tasks: list[dict[str, Any]],
458
- source_id: str,
459
- ) -> dict[str, Any] | None:
460
- if len(tasks) == 1:
461
- return tasks[0]
462
- if len(tasks) == 0:
463
- return None
464
-
465
- source_matches = [task for task in tasks if task_matches_source_id(task, source_id)]
466
- if len(source_matches) == 1:
467
- return source_matches[0]
468
- if len(source_matches) > 1:
469
- raise ScriptError(format_ambiguous_match_error(title, source_matches, source_id))
470
- return None
471
-
472
-
473
- def format_ambiguous_match_error(
474
- title: str,
475
- matches: list[dict[str, Any]],
476
- source_id: str,
477
- ) -> str:
478
- match_ids = ", ".join(str(task.get("id")) for task in matches)
479
- return (
480
- f"Ambiguous Everhour matches for '{title}': {match_ids}. "
481
- f"None could be uniquely matched to Basecamp todo {source_id}."
482
- )
483
-
484
-
485
- def format_missing_match_error(title: str, project_id: str) -> str:
486
- return f"No Everhour task match found for Basecamp title '{title}' in project {project_id}."
487
-
488
-
489
- def update_basecamp_title(basecamp_bin: str, todo: TodoItem, title: str) -> None:
490
- if not todo.api_url:
491
- raise ScriptError(f"Basecamp todo {todo.id} payload did not include an API URL.")
492
- current = run_basecamp_json(basecamp_bin, "api", "get", todo.api_url)
493
- if not isinstance(current, dict):
494
- raise ScriptError(
495
- f"Basecamp todo {todo.id} GET did not return a JSON object: {current!r}"
496
- )
497
- payload = build_basecamp_update_payload(current, title)
498
- run_basecamp_json(
499
- basecamp_bin,
500
- "api",
501
- "put",
502
- todo.api_url,
503
- "-d",
504
- json.dumps(payload),
505
- )
506
-
507
-
508
- def build_basecamp_update_payload(current: dict[str, Any], title: str) -> dict[str, Any]:
509
- # Basecamp's PUT to a todo replaces all writable fields, so any field omitted
510
- # here gets cleared. Carry over the assignees, completion subscribers, notes
511
- # (description), and dates from the current record so we only change `content`.
512
- payload: dict[str, Any] = {"content": title}
513
-
514
- description = current.get("description")
515
- if isinstance(description, str) and description:
516
- payload["description"] = description
517
-
518
- assignee_ids = _extract_person_ids(current.get("assignees"))
519
- if assignee_ids:
520
- payload["assignee_ids"] = assignee_ids
521
-
522
- subscriber_ids = _extract_person_ids(current.get("completion_subscribers"))
523
- if subscriber_ids:
524
- payload["completion_subscriber_ids"] = subscriber_ids
525
-
526
- for key in ("due_on", "starts_on"):
527
- value = current.get(key)
528
- if isinstance(value, str) and value:
529
- payload[key] = value
530
-
531
- return payload
532
-
533
-
534
- def _extract_person_ids(people: Any) -> list[Any]:
535
- if not isinstance(people, list):
536
- return []
537
- ids: list[Any] = []
538
- for person in people:
539
- if isinstance(person, dict) and "id" in person:
540
- ids.append(person["id"])
541
- return ids
542
-
543
-
544
- def build_plan(
545
- todos: list[TodoItem],
546
- estimates: list[tuple[Decimal, int]],
547
- everhour: EverhourClient,
548
- ) -> list[MatchPlan]:
549
- if len(todos) != len(estimates):
550
- raise ScriptError(
551
- "Estimate count does not match todo count: "
552
- f"expected {len(todos)}, got {len(estimates)}."
553
- )
554
-
555
- plans: list[MatchPlan] = []
556
- seen_titles: dict[str, TodoItem] = {}
557
- seen_task_ids: dict[str, MatchPlan] = {}
558
- for todo, (hours, seconds) in zip(todos, estimates):
559
- normalized_title = normalize_title(todo.title)
560
- prior_todo = seen_titles.get(normalized_title)
561
- if prior_todo is not None:
562
- raise ScriptError(
563
- "Cannot map Basecamp todos one-to-one to Everhour tasks because "
564
- f"'{todo.title}' duplicates todo {prior_todo.id} after normalization "
565
- f"(conflicts with todo {todo.id})."
566
- )
567
- seen_titles[normalized_title] = todo
568
-
569
- task = everhour.match_task(todo.title, source_id=todo.id)
570
- task_id = str(task.get("id", "")).strip()
571
- task_name = str(task.get("name", "")).strip()
572
- if not task_id or not task_name:
573
- raise ScriptError(f"Everhour task payload missing id or name: {task}")
574
- plan = MatchPlan(
575
- todo=todo,
576
- hours=hours,
577
- seconds=seconds,
578
- everhour_task_id=task_id,
579
- everhour_task_name=task_name,
580
- new_basecamp_title=build_estimated_title(todo.title, hours),
581
- )
582
- prior_plan = seen_task_ids.get(task_id)
583
- if prior_plan is not None:
584
- raise ScriptError(
585
- "Cannot update the same Everhour task for multiple Basecamp todos: "
586
- f"todo {prior_plan.todo.id} and todo {todo.id} both matched Everhour "
587
- f"task {task_id} ({task_name})."
588
- )
589
- seen_task_ids[task_id] = plan
590
- plans.append(plan)
591
- return plans
592
-
593
-
594
- def print_plan(
595
- plans: list[MatchPlan],
596
- skipped_completed: list[TodoItem],
597
- dry_run: bool,
598
- ) -> None:
599
- mode = "DRY RUN" if dry_run else "PLAN"
600
- distinct_estimates = {(plan.hours, plan.seconds) for plan in plans}
601
- if len(distinct_estimates) == 1:
602
- hours, seconds = next(iter(distinct_estimates))
603
- print(f"{mode}: apply {format_hours(hours)}h ({seconds} seconds) to {len(plans)} task(s)")
604
- else:
605
- print(f"{mode}: apply per-task estimates to {len(plans)} task(s)")
606
- if skipped_completed:
607
- print(f"Skipping {len(skipped_completed)} completed Basecamp todo(s):")
608
- for todo in skipped_completed:
609
- print(f"- Basecamp todo {todo.id}: {todo.title}")
610
- for plan in plans:
611
- print(
612
- f"- Basecamp todo {plan.todo.id}: {plan.todo.title}\n"
613
- f" Everhour task {plan.everhour_task_id}: {plan.everhour_task_name}\n"
614
- f" Estimate: {format_hours(plan.hours)}h ({plan.seconds} seconds)\n"
615
- f" New Basecamp title: {plan.new_basecamp_title}"
616
- )
617
-
618
-
619
- def main() -> int:
620
- args = parse_args()
621
- env = load_env_file(Path(args.env_file))
622
- selection = load_basecamp_todos(args.basecamp_bin, args.basecamp_url)
623
- if not selection.active:
624
- if selection.skipped_completed:
625
- raise ScriptError("All matching Basecamp todos are already completed; nothing to update.")
626
- raise ScriptError("No Basecamp todos were found for the provided URL.")
627
- estimates = parse_hours_input(args.hours, len(selection.active))
628
-
629
- everhour = EverhourClient(
630
- api_key=env["EVERHOUR_API_KEY"],
631
- project_id=env["EVERHOUR_PROJECT_ID"],
632
- )
633
- plans = build_plan(selection.active, estimates, everhour)
634
- print_plan(plans, selection.skipped_completed, dry_run=args.dry_run)
635
-
636
- if args.dry_run:
637
- return 0
638
-
639
- for plan in plans:
640
- everhour.update_task_estimate(plan.everhour_task_id, plan.seconds)
641
-
642
- for plan in plans:
643
- update_basecamp_title(args.basecamp_bin, plan.todo, plan.new_basecamp_title)
644
-
645
- print("Completed Everhour estimate updates and Basecamp title updates.")
646
- return 0
647
-
648
-
649
- if __name__ == "__main__":
650
- try:
651
- raise SystemExit(main())
652
- except ScriptError as exc:
653
- print(f"Error: {exc}", file=sys.stderr)
654
- raise SystemExit(1)