td-ai-tools 1.0.2 → 1.0.4

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.
@@ -27,6 +27,7 @@ WHITESPACE_RE = re.compile(r"\s+")
27
27
  class TodoItem:
28
28
  id: str
29
29
  title: str
30
+ api_url: str | None = None
30
31
 
31
32
 
32
33
  @dataclass(frozen=True)
@@ -200,6 +201,13 @@ def extract_title(record: dict[str, Any]) -> str:
200
201
  raise ScriptError(f"Could not determine a title from Basecamp payload: {record}")
201
202
 
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
+
203
211
  def extract_todo_objects(payload: Any) -> list[dict[str, Any]]:
204
212
  if isinstance(payload, list):
205
213
  candidates = [item for item in payload if is_todo_like(item)]
@@ -258,17 +266,46 @@ def is_todo_completed(record: dict[str, Any]) -> bool:
258
266
  def load_basecamp_todos(basecamp_bin: str, basecamp_url: str) -> TodoSelection:
259
267
  parsed = run_basecamp_json(basecamp_bin, "url", "parse", basecamp_url)
260
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()
261
271
 
262
272
  if resource_type in {"todo", "todos"}:
263
273
  payload = run_basecamp_json(basecamp_bin, "todos", "show", basecamp_url)
264
- todo = TodoItem(id=str(payload["id"]), title=extract_title(payload))
274
+ todo = TodoItem(
275
+ id=str(payload["id"]),
276
+ title=extract_title(payload),
277
+ api_url=extract_api_url(payload),
278
+ )
265
279
  if is_todo_completed(payload):
266
280
  return TodoSelection(active=[], skipped_completed=[todo])
267
281
  return TodoSelection(active=[todo], skipped_completed=[])
268
282
 
269
283
  if resource_type in {"todolist", "todolists"}:
270
- payload = run_basecamp_json(basecamp_bin, "todolists", "show", basecamp_url)
271
- todo_objects = extract_todo_objects(payload)
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
+
272
309
  if not todo_objects:
273
310
  raise ScriptError(
274
311
  "The Basecamp todolist payload did not include any todo items. "
@@ -277,7 +314,11 @@ def load_basecamp_todos(basecamp_bin: str, basecamp_url: str) -> TodoSelection:
277
314
  active: list[TodoItem] = []
278
315
  skipped_completed: list[TodoItem] = []
279
316
  for item in todo_objects:
280
- todo = TodoItem(id=str(item["id"]), title=extract_title(item))
317
+ todo = TodoItem(
318
+ id=str(item["id"]),
319
+ title=extract_title(item),
320
+ api_url=extract_api_url(item),
321
+ )
281
322
  if is_todo_completed(item):
282
323
  skipped_completed.append(todo)
283
324
  else:
@@ -370,14 +411,12 @@ class EverhourClient:
370
411
  raise ScriptError("Everhour task search response was not a list.")
371
412
  return [task for task in tasks if isinstance(task, dict)]
372
413
 
373
- def match_task(self, title: str) -> dict[str, Any]:
414
+ def match_task(self, title: str, source_id: str) -> dict[str, Any]:
374
415
  normalized = normalize_title(title)
375
416
  open_matches = self.list_open_tasks().get(normalized, [])
376
- if len(open_matches) == 1:
377
- return open_matches[0]
378
- if len(open_matches) > 1:
379
- matches = ", ".join(str(task.get("id")) for task in open_matches)
380
- raise ScriptError(f"Ambiguous Everhour matches for '{title}': {matches}")
417
+ open_match = select_single_task_match(title, open_matches, source_id)
418
+ if open_match is not None:
419
+ return open_match
381
420
 
382
421
  search_matches = [
383
422
  task
@@ -385,13 +424,12 @@ class EverhourClient:
385
424
  if normalize_title(str(task.get("name", ""))) == normalized
386
425
  ]
387
426
  if not search_matches:
388
- raise ScriptError(
389
- f"No Everhour task match found for Basecamp title '{title}' in project {self.project_id}."
390
- )
391
- if len(search_matches) > 1:
392
- matches = ", ".join(str(task.get("id")) for task in search_matches)
393
- raise ScriptError(f"Ambiguous Everhour matches for '{title}': {matches}")
394
- return search_matches[0]
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))
395
433
 
396
434
  def update_task_estimate(self, task_id: str, total_seconds: int) -> None:
397
435
  self._request(
@@ -401,8 +439,106 @@ class EverhourClient:
401
439
  )
402
440
 
403
441
 
404
- def update_basecamp_title(basecamp_bin: str, todo_id: str, title: str) -> None:
405
- run_basecamp_json(basecamp_bin, "todos", "update", todo_id, "--title", title)
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
406
542
 
407
543
 
408
544
  def build_plan(
@@ -430,7 +566,7 @@ def build_plan(
430
566
  )
431
567
  seen_titles[normalized_title] = todo
432
568
 
433
- task = everhour.match_task(todo.title)
569
+ task = everhour.match_task(todo.title, source_id=todo.id)
434
570
  task_id = str(task.get("id", "")).strip()
435
571
  task_name = str(task.get("name", "")).strip()
436
572
  if not task_id or not task_name:
@@ -504,7 +640,7 @@ def main() -> int:
504
640
  everhour.update_task_estimate(plan.everhour_task_id, plan.seconds)
505
641
 
506
642
  for plan in plans:
507
- update_basecamp_title(args.basecamp_bin, plan.todo.id, plan.new_basecamp_title)
643
+ update_basecamp_title(args.basecamp_bin, plan.todo, plan.new_basecamp_title)
508
644
 
509
645
  print("Completed Everhour estimate updates and Basecamp title updates.")
510
646
  return 0
@@ -1,4 +1,5 @@
1
1
  import importlib.util
2
+ import json
2
3
  import sys
3
4
  import unittest
4
5
  from decimal import Decimal
@@ -65,7 +66,7 @@ class BuildPlanTest(unittest.TestCase):
65
66
  MODULE.build_plan(todos, estimates, everhour)
66
67
 
67
68
  self.assertIn("duplicates todo 1", str(caught.exception))
68
- everhour.match_task.assert_called_once_with("Ship estimate")
69
+ everhour.match_task.assert_called_once_with("Ship estimate", source_id="1")
69
70
 
70
71
  def test_rejects_duplicate_everhour_task_matches(self) -> None:
71
72
  todos = [
@@ -88,6 +89,198 @@ class BuildPlanTest(unittest.TestCase):
88
89
  self.assertIn("todo 1 and todo 2", str(caught.exception))
89
90
  self.assertEqual(everhour.match_task.call_count, 2)
90
91
 
92
+ def test_passes_basecamp_todo_id_to_everhour_matcher(self) -> None:
93
+ todos = [MODULE.TodoItem(id="1", title="Ship estimate")]
94
+ estimates = [(Decimal("1"), 3600)]
95
+ everhour = mock.Mock()
96
+ everhour.match_task.return_value = {
97
+ "id": "abc123",
98
+ "name": "Ship estimate",
99
+ }
100
+
101
+ plans = MODULE.build_plan(todos, estimates, everhour)
102
+
103
+ everhour.match_task.assert_called_once_with("Ship estimate", source_id="1")
104
+ self.assertEqual(plans[0].everhour_task_id, "abc123")
105
+
106
+
107
+ class EverhourClientMatchTaskTest(unittest.TestCase):
108
+ def test_duplicate_open_matches_are_resolved_by_basecamp_todo_id(self) -> None:
109
+ client = MODULE.EverhourClient(api_key="key", project_id="project")
110
+ client.list_open_tasks = mock.Mock(
111
+ return_value={
112
+ "ship estimate": [
113
+ {"id": "b3:1", "name": "Ship estimate"},
114
+ {"id": "b3:2", "name": "Ship estimate"},
115
+ ],
116
+ }
117
+ )
118
+ client.search_tasks = mock.Mock(return_value=[])
119
+
120
+ task = client.match_task("Ship estimate", source_id="2")
121
+
122
+ self.assertEqual(task["id"], "b3:2")
123
+ client.search_tasks.assert_not_called()
124
+
125
+ def test_duplicate_open_matches_still_fail_without_todo_id_match(self) -> None:
126
+ client = MODULE.EverhourClient(api_key="key", project_id="project")
127
+ client.list_open_tasks = mock.Mock(
128
+ return_value={
129
+ "ship estimate": [
130
+ {"id": "b3:1", "name": "Ship estimate"},
131
+ {"id": "b3:2", "name": "Ship estimate"},
132
+ ],
133
+ }
134
+ )
135
+ client.search_tasks = mock.Mock(
136
+ return_value=[
137
+ {"id": "b3:1", "name": "Ship estimate"},
138
+ {"id": "b3:2", "name": "Ship estimate"},
139
+ ]
140
+ )
141
+
142
+ with self.assertRaises(MODULE.ScriptError) as caught:
143
+ client.match_task("Ship estimate", source_id="3")
144
+
145
+ self.assertIn("Basecamp todo 3", str(caught.exception))
146
+
147
+ def test_duplicate_search_matches_are_resolved_by_basecamp_todo_id(self) -> None:
148
+ client = MODULE.EverhourClient(api_key="key", project_id="project")
149
+ client.list_open_tasks = mock.Mock(return_value={})
150
+ client.search_tasks = mock.Mock(
151
+ return_value=[
152
+ {"id": "b3:1", "name": "Ship estimate"},
153
+ {"id": "b3:2", "name": "Ship estimate"},
154
+ ]
155
+ )
156
+
157
+ task = client.match_task("Ship estimate", source_id="2")
158
+
159
+ self.assertEqual(task["id"], "b3:2")
160
+ client.search_tasks.assert_called_once_with("Ship estimate")
161
+
162
+ def test_basecamp_todo_id_can_match_task_url(self) -> None:
163
+ task = {
164
+ "id": "opaque",
165
+ "name": "Ship estimate",
166
+ "url": "https://3.basecamp.com/1/buckets/2/todos/123",
167
+ }
168
+
169
+ self.assertTrue(MODULE.task_matches_source_id(task, "123"))
170
+
171
+
172
+ class UpdateBasecampTitleTest(unittest.TestCase):
173
+ def test_fetches_current_todo_then_puts_with_only_changed_content(self) -> None:
174
+ todo = MODULE.TodoItem(
175
+ id="123",
176
+ title="Ship estimate",
177
+ api_url="https://3.basecampapi.com/1/buckets/2/todos/123.json",
178
+ )
179
+ current_payload = {
180
+ "id": 123,
181
+ "content": "Ship estimate",
182
+ "description": "<div>Original notes with image</div>",
183
+ "assignees": [{"id": 7}, {"id": 8}],
184
+ "completion_subscribers": [{"id": 9}],
185
+ "due_on": "2026-05-01",
186
+ "starts_on": None,
187
+ }
188
+
189
+ with mock.patch.object(MODULE, "run_basecamp_json") as run_basecamp_json:
190
+ run_basecamp_json.side_effect = [current_payload, {}]
191
+ MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")
192
+
193
+ self.assertEqual(run_basecamp_json.call_count, 2)
194
+ get_call, put_call = run_basecamp_json.call_args_list
195
+
196
+ self.assertEqual(
197
+ get_call.args,
198
+ (
199
+ "basecamp",
200
+ "api",
201
+ "get",
202
+ "https://3.basecampapi.com/1/buckets/2/todos/123.json",
203
+ ),
204
+ )
205
+
206
+ self.assertEqual(put_call.args[:5], (
207
+ "basecamp",
208
+ "api",
209
+ "put",
210
+ "https://3.basecampapi.com/1/buckets/2/todos/123.json",
211
+ "-d",
212
+ ))
213
+ body = json.loads(put_call.args[5])
214
+ self.assertEqual(body["content"], "Ship estimate [2h]")
215
+ self.assertEqual(body["description"], "<div>Original notes with image</div>")
216
+ self.assertEqual(body["assignee_ids"], [7, 8])
217
+ self.assertEqual(body["completion_subscriber_ids"], [9])
218
+ self.assertEqual(body["due_on"], "2026-05-01")
219
+ self.assertNotIn("starts_on", body)
220
+
221
+ def test_omits_fields_absent_from_current_todo(self) -> None:
222
+ todo = MODULE.TodoItem(
223
+ id="123",
224
+ title="Ship estimate",
225
+ api_url="https://3.basecampapi.com/1/buckets/2/todos/123.json",
226
+ )
227
+ current_payload = {
228
+ "id": 123,
229
+ "content": "Ship estimate",
230
+ "description": "",
231
+ "assignees": [],
232
+ "completion_subscribers": [],
233
+ "due_on": None,
234
+ "starts_on": None,
235
+ }
236
+
237
+ with mock.patch.object(MODULE, "run_basecamp_json") as run_basecamp_json:
238
+ run_basecamp_json.side_effect = [current_payload, {}]
239
+ MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")
240
+
241
+ put_call = run_basecamp_json.call_args_list[1]
242
+ body = json.loads(put_call.args[5])
243
+ self.assertEqual(body, {"content": "Ship estimate [2h]"})
244
+
245
+ def test_rejects_missing_basecamp_api_url(self) -> None:
246
+ todo = MODULE.TodoItem(id="123", title="Ship estimate")
247
+
248
+ with self.assertRaises(MODULE.ScriptError) as caught:
249
+ MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")
250
+
251
+ self.assertIn("did not include an API URL", str(caught.exception))
252
+
253
+
254
+ class BuildBasecampUpdatePayloadTest(unittest.TestCase):
255
+ def test_extracts_assignee_and_subscriber_ids_and_preserves_dates(self) -> None:
256
+ current = {
257
+ "content": "Old",
258
+ "description": "<p>note</p>",
259
+ "assignees": [{"id": 1}, {"id": 2}],
260
+ "completion_subscribers": [{"id": 3}],
261
+ "due_on": "2026-05-01",
262
+ "starts_on": "2026-04-01",
263
+ }
264
+
265
+ payload = MODULE.build_basecamp_update_payload(current, "New title")
266
+
267
+ self.assertEqual(
268
+ payload,
269
+ {
270
+ "content": "New title",
271
+ "description": "<p>note</p>",
272
+ "assignee_ids": [1, 2],
273
+ "completion_subscriber_ids": [3],
274
+ "due_on": "2026-05-01",
275
+ "starts_on": "2026-04-01",
276
+ },
277
+ )
278
+
279
+ def test_skips_empty_or_missing_fields(self) -> None:
280
+ payload = MODULE.build_basecamp_update_payload({"content": "Old"}, "New title")
281
+
282
+ self.assertEqual(payload, {"content": "New title"})
283
+
91
284
 
92
285
  if __name__ == "__main__":
93
286
  unittest.main()
@@ -0,0 +1,79 @@
1
+ # Codex sandbox config bootstrap
2
+
3
+ This directory contains a template + generator that produces `<repo>/.codex/config.toml`
4
+ on demand, so the file does not need to be committed with hardcoded paths tied to one
5
+ machine or one project clone location.
6
+
7
+ ## Files
8
+
9
+ - `config.toml.template` — the source of truth. Uses two placeholders:
10
+ - `__PROJECT_ROOT__` — replaced with the absolute path to the repo root
11
+ - `__CHROME_PATH__` — replaced with the absolute path to the Chrome/Chromium binary
12
+ - `setup-codex.sh` — reads the template, substitutes the placeholders, writes
13
+ `<repo>/.codex/config.toml`.
14
+
15
+ The generated `.codex/` directory is gitignored, so each clone gets its own copy.
16
+
17
+ ## Usage
18
+
19
+ Run once after cloning the repo (or after moving the clone to a new path):
20
+
21
+ ```bash
22
+ .agents/skills/playwright-cli/setup-codex.sh
23
+ ```
24
+
25
+ The script prints the values it resolved, e.g.:
26
+
27
+ ```
28
+ wrote /home/you/code/lio/.codex/config.toml
29
+ PROJECT_ROOT=/home/you/code/lio
30
+ CHROME_PATH=/usr/bin/google-chrome
31
+ ```
32
+
33
+ Re-run it any time the template changes or you move the repo.
34
+
35
+ ## How paths are resolved
36
+
37
+ **`PROJECT_ROOT`** — derived in this order:
38
+
39
+ 1. `git rev-parse --show-toplevel` from the script's directory (works for any clone
40
+ location, on any machine, regardless of username)
41
+ 2. Falls back to walking three directories up from the script
42
+ (`.agents/skills/playwright-cli` → repo root) if the repo isn't a git checkout
43
+
44
+ **`CHROME_PATH`** — derived in this order:
45
+
46
+ 1. The `CHROME_PATH` environment variable, if set
47
+ 2. First match from `PATH` of: `google-chrome`, `google-chrome-stable`, `chromium`,
48
+ `chromium-browser`
49
+ 3. Falls back to `/usr/bin/google-chrome` with a warning if nothing is found
50
+
51
+ Override the Chrome binary explicitly when needed:
52
+
53
+ ```bash
54
+ CHROME_PATH=/opt/google/chrome/chrome .agents/skills/playwright-cli/setup-codex.sh
55
+ ```
56
+
57
+ ## Why a generator instead of variable expansion
58
+
59
+ Codex CLI parses `config.toml` as static TOML and does not expand `~`, `$HOME`, or
60
+ `${VAR}` in path values — they would be treated as literal strings. Generating the
61
+ file at setup time is the simplest way to keep the template portable while still
62
+ producing the absolute paths Codex expects at runtime.
63
+
64
+ ## What the generated config does
65
+
66
+ The output `.codex/config.toml` configures Codex's sandbox so Playwright can run
67
+ inside it:
68
+
69
+ - `sandbox_workspace_write.writable_roots` — directories Playwright is allowed to
70
+ write to (browser cache, artifacts, test reports, daemon sockets)
71
+ - `shell_environment_policy.set` — pins `PLAYWRIGHT_*`, `XDG_*`, and `TMPDIR`-family
72
+ env vars to repo-local paths so Playwright stores its browser, cache, and runtime
73
+ state under the project rather than the user's home directory
74
+
75
+ ## Editing
76
+
77
+ Edit `config.toml.template` (not the generated file) and re-run `setup-codex.sh`.
78
+ Changes to `.codex/config.toml` directly will be overwritten the next time the
79
+ generator runs.