td-ai-tools 1.2.2 → 1.2.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.
@@ -1,286 +0,0 @@
1
- import importlib.util
2
- import json
3
- import sys
4
- import unittest
5
- from decimal import Decimal
6
- from pathlib import Path
7
- from unittest import mock
8
-
9
-
10
- SCRIPT_PATH = (
11
- Path(__file__).resolve().parents[1] / "scripts" / "update_estimates.py"
12
- )
13
- SPEC = importlib.util.spec_from_file_location("update_estimates", SCRIPT_PATH)
14
- MODULE = importlib.util.module_from_spec(SPEC)
15
- assert SPEC.loader is not None
16
- sys.modules[SPEC.name] = MODULE
17
- SPEC.loader.exec_module(MODULE)
18
-
19
-
20
- class ParseHoursInputTest(unittest.TestCase):
21
- def test_scalar_hours_are_reused_for_each_todo(self) -> None:
22
- estimates = MODULE.parse_hours_input("2.5", 3)
23
-
24
- self.assertEqual(
25
- estimates,
26
- [
27
- (Decimal("2.5"), 9000),
28
- (Decimal("2.5"), 9000),
29
- (Decimal("2.5"), 9000),
30
- ],
31
- )
32
-
33
- def test_json_array_hours_map_one_to_one(self) -> None:
34
- estimates = MODULE.parse_hours_input("[1,2.5,\"4\"]", 3)
35
-
36
- self.assertEqual(
37
- estimates,
38
- [
39
- (Decimal("1"), 3600),
40
- (Decimal("2.5"), 9000),
41
- (Decimal("4"), 14400),
42
- ],
43
- )
44
-
45
- def test_json_array_length_must_match_active_todos(self) -> None:
46
- with self.assertRaises(MODULE.ScriptError) as caught:
47
- MODULE.parse_hours_input("[1,2.5]", 3)
48
-
49
- self.assertIn("expected 3, got 2", str(caught.exception))
50
-
51
-
52
- class BuildPlanTest(unittest.TestCase):
53
- def test_rejects_duplicate_normalized_basecamp_titles(self) -> None:
54
- todos = [
55
- MODULE.TodoItem(id="1", title="Ship estimate"),
56
- MODULE.TodoItem(id="2", title="Ship estimate [1h]"),
57
- ]
58
- estimates = [
59
- (Decimal("1"), 3600),
60
- (Decimal("2"), 7200),
61
- ]
62
- everhour = mock.Mock()
63
- everhour.match_task.return_value = {"id": "abc123", "name": "Ship estimate"}
64
-
65
- with self.assertRaises(MODULE.ScriptError) as caught:
66
- MODULE.build_plan(todos, estimates, everhour)
67
-
68
- self.assertIn("duplicates todo 1", str(caught.exception))
69
- everhour.match_task.assert_called_once_with("Ship estimate", source_id="1")
70
-
71
- def test_rejects_duplicate_everhour_task_matches(self) -> None:
72
- todos = [
73
- MODULE.TodoItem(id="1", title="First task"),
74
- MODULE.TodoItem(id="2", title="Second task"),
75
- ]
76
- estimates = [
77
- (Decimal("1"), 3600),
78
- (Decimal("2"), 7200),
79
- ]
80
- everhour = mock.Mock()
81
- everhour.match_task.side_effect = [
82
- {"id": "abc123", "name": "Shared task"},
83
- {"id": "abc123", "name": "Shared task"},
84
- ]
85
-
86
- with self.assertRaises(MODULE.ScriptError) as caught:
87
- MODULE.build_plan(todos, estimates, everhour)
88
-
89
- self.assertIn("todo 1 and todo 2", str(caught.exception))
90
- self.assertEqual(everhour.match_task.call_count, 2)
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
-
284
-
285
- if __name__ == "__main__":
286
- unittest.main()
@@ -1,122 +0,0 @@
1
- # playwright-cli — first-run primer
2
-
3
- Read this once per machine/clone before using the `playwright-cli` skill. It
4
- covers the one gotcha that breaks the first run, how to configure each agent
5
- runtime, and how to verify everything works. Delete this file and any reference
6
- to it after following its instructions.
7
-
8
- ## TL;DR
9
-
10
- ```bash
11
- # 1. binary present?
12
- command -v playwright-cli || npm i -g @playwright/cli
13
-
14
- # 2. browser present? (Playwright-managed chromium, not branded Chrome)
15
- ls "${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}"/chromium-* >/dev/null 2>&1 \
16
- || npx playwright install chromium
17
-
18
- # 3. write per-agent config (choose one)
19
- .agents/skills/playwright-cli/setup.sh # shared .agents + Claude settings
20
- .agents/skills/playwright-cli/setup.sh --all # shared .agents + Claude + Codex
21
-
22
- # 4. smoke test
23
- PLAYWRIGHT_MCP_BROWSER=chromium playwright-cli open
24
- playwright-cli goto https://example.com
25
- playwright-cli eval "document.title" # -> "Example Domain"
26
- playwright-cli close
27
- ```
28
-
29
- ## The one gotcha
30
-
31
- `playwright-cli open` defaults to **branded Chrome** at
32
- `/opt/google/chrome/chrome`. On most dev boxes (including WSL) that binary does
33
- not exist, so the bare command fails with:
34
-
35
- ```
36
- Error: Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome
37
- ```
38
-
39
- What *is* installed is the **Playwright-managed chromium** under
40
- `~/.cache/ms-playwright`. Point the default browser at it in one of two ways:
41
-
42
- - per-command flag: `playwright-cli open --browser=chromium`
43
- - environment variable: `PLAYWRIGHT_MCP_BROWSER=chromium` (this is what
44
- `setup.sh` pins for you — `BROWSER=chromium` does **not** work, only
45
- `PLAYWRIGHT_MCP_BROWSER`).
46
-
47
- If chromium isn't installed yet: `npx playwright install chromium`. (This repo
48
- already uses `@playwright/test`, so it's normally present.)
49
-
50
- ## Per-agent configuration
51
-
52
- Run `setup.sh` from the repo root. The script is installed into both
53
- `.agents/skills/playwright-cli/setup.sh` and
54
- `.claude/skills/playwright-cli/setup.sh`; prefer the `.agents` path when working
55
- provider-agnostically. With no flags it writes `.claude` + `.agents`; pass
56
- `--codex` or `--all` to also write the Codex sandbox config.
57
-
58
- | Target | File written | What it does |
59
- |---|---|---|
60
- | `--claude` | `.claude/settings.json` | Sets `env.PLAYWRIGHT_MCP_BROWSER=chromium` and adds `Bash(playwright-cli:*)` to `permissions.allow`. Merges into any existing file (invalid JSON is backed up to `.bak`). Applies on the **next** Claude Code session. |
61
- | `--agents` | `.agents/playwright-cli.env` | Shareable env file. Load with `set -a; . .agents/playwright-cli.env; set +a`. |
62
- | `--codex` | `.codex/config.toml` | Codex sandbox: `network_access`, `writable_roots` (browser cache + artifact dirs), and `PLAYWRIGHT_MCP_BROWSER=chromium`. Generated from `config.toml.template`. |
63
-
64
- All three output dirs (`.claude/`, `.agents/`, `.codex/`) are gitignored, so
65
- each clone generates its own — nothing machine-specific is committed.
66
-
67
- ### Why config differs by agent
68
-
69
- - **Claude Code** reads `.claude/settings.json` (`env` + `permissions`). It does
70
- not read `.codex/config.toml`. The env var is all it needs; the permission is
71
- belt-and-suspenders since `SKILL.md` already declares `allowed-tools`.
72
- - **Codex** parses `config.toml` as static TOML and does **not** expand `~`,
73
- `$HOME`, or `${VAR}` in paths — hence the generator that substitutes absolute
74
- paths at setup time.
75
- - **`.agents/`** is the agent-agnostic home; any tool/shell can source the env
76
- file.
77
-
78
- ## Sub-agent registration
79
-
80
- Installing this skill registers `playwright-investigator.md` in both agent
81
- layouts:
82
-
83
- - `.claude/agents/playwright-investigator.md`
84
- - `.agents/agents/playwright-investigator.md`
85
-
86
- Claude Code discovers the `.claude` copy as a custom agent. Codex and other
87
- `.agents`-aware runtimes should use the `.agents` copy as the sub-agent
88
- definition when a multi-agent/sub-agent tool is available. The prompt is written
89
- as a shared contract: the agent needs shell command execution, file reads, and
90
- access to this `playwright-cli` skill; it returns only the final investigation
91
- report.
92
-
93
- ## What the old setup got wrong
94
-
95
- The previous `setup-codex.sh` / template were Codex-only **and** misconfigured
96
- for this machine:
97
-
98
- - redirected `PLAYWRIGHT_BROWSERS_PATH` to an empty project dir while the real
99
- browsers live in `~/.cache/ms-playwright`;
100
- - pinned `PLAYWRIGHT_MCP_EXECUTABLE_PATH` to `/usr/bin/chromium`, which doesn't
101
- exist (there is no system chromium binary — it's Playwright-managed);
102
- - overrode `HOME`, moving Playwright away from its own browser cache;
103
- - never addressed the actual first-run failure (the `chrome` default).
104
-
105
- The current setup drops all of that: it keeps the managed browsers where they
106
- are and only pins the default-browser name.
107
-
108
- ## Artifacts & cleanup
109
-
110
- `playwright-cli` writes snapshots and console/network logs to `.playwright-cli/`
111
- in the repo root (gitignored). Always `playwright-cli close` when done; use
112
- `playwright-cli list` / `close-all` / `kill-all` to manage stray browsers.
113
-
114
- ## Troubleshooting
115
-
116
- | Symptom | Fix |
117
- |---|---|
118
- | `'chrome' is not found at /opt/google/chrome/chrome` | Use `--browser=chromium` or set `PLAYWRIGHT_MCP_BROWSER=chromium` (run `setup.sh`). |
119
- | `Executable doesn't exist … run "playwright install"` | `npx playwright install chromium`. |
120
- | `command not found: playwright-cli` | `npm i -g @playwright/cli`, or use `npx playwright-cli …`. |
121
- | Env var ignored in Claude Code | `.claude/settings.json` is read at session start — restart the session, or pass `--browser=chromium` inline this session. |
122
- | Codex sandbox can't launch browser | Re-run `setup.sh --codex` so `writable_roots` includes the current `~/.cache/ms-playwright`. |