td-ai-tools 1.0.2 → 1.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "scripts": {
6
6
  "smoke:install": "./scripts/smoke-install.sh"
@@ -0,0 +1,651 @@
1
+ ---
2
+ name: basecamp
3
+ description: |
4
+ Interact with Basecamp via the Basecamp CLI. Full API coverage: projects, todos, cards,
5
+ messages, files, schedule, check-ins, timeline, recordings, templates, webhooks,
6
+ subscriptions, lineup, and campfire. Use for ANY Basecamp question or action.
7
+ invocable: true
8
+ argument-hint: '[action] [args...]'
9
+ ---
10
+
11
+ # /basecamp - Basecamp Workflow Command
12
+
13
+ Full CLI coverage: 130 endpoints across todos, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, subscriptions, lineup, and campfire.
14
+
15
+ ## Agent Invariants
16
+
17
+ **MUST follow these rules:**
18
+
19
+ 1. **Choose the right output mode** — `--json` when you need to parse data; `--md` when presenting results to a human (see Output Modes below)
20
+ 2. **Parse URLs first** with `basecamp url parse "<url>"` to extract IDs
21
+ 3. **Comments are flat** - reply to parent recording, not to comments
22
+ 4. **Check context** via `.basecamp/config.json` before assuming project
23
+ 5. **Content fields accept Markdown** — message body and comment content accept Markdown syntax; the CLI converts to HTML automatically. Use Markdown formatting (lists, bold, links, code blocks) for rich content. For todos, documents, and cards, content is sent as-is — use plain text or HTML directly.
24
+ 6. **Project scope is mandatory for most commands** — via `--in <project>` or `.basecamp/config.json`. Cross-project exceptions: `basecamp reports assigned` for assigned work, `basecamp reports overdue` for overdue todos, `basecamp recordings <type>` for browsing by type.
25
+
26
+ ### Output Modes
27
+
28
+ **Choosing a mode:**
29
+
30
+ | Goal | Flag | Format |
31
+ | ---------------------- | ------------- | --------------------------------------------------------------------------------------------- |
32
+ | Parse data, pipe to jq | `--json` | JSON envelope: `{ok, data, summary, breadcrumbs, meta}` |
33
+ | Show results to a user | `--md` / `-m` | GFM tables, task lists, structured Markdown |
34
+ | Automation / scripting | `--agent` | Success: raw JSON data (no envelope); errors: `{ok:false,...}` object; no interactive prompts |
35
+
36
+ Always pass `--json` or `--md` explicitly — auto-detection depends on config and may not produce the format you expect. Use `--md` when composing reports, summarizing data, or displaying results inline. `--agent` is for headless integration scripts.
37
+
38
+ **Other modes:** `--quiet` (success: raw JSON, no envelope; errors: `{ok:false,...}`), `--ids-only`, `--count`, `--stats` (session statistics), `--styled` (force ANSI), `-v` / `-vv` (verbose/trace).
39
+
40
+ ### CLI Introspection
41
+
42
+ Navigate unfamiliar commands with `--agent --help` — returns structured JSON describing any command:
43
+
44
+ ```bash
45
+ basecamp todos --agent --help
46
+ ```
47
+
48
+ ```json
49
+ {
50
+ "command": "todos",
51
+ "path": "basecamp todos",
52
+ "short": "...",
53
+ "long": "...",
54
+ "usage": "...",
55
+ "notes": ["..."],
56
+ "subcommands": [{ "name": "sweep", "short": "...", "path": "basecamp todos sweep" }],
57
+ "flags": [{ "name": "assignee", "type": "string", "default": "", "usage": "..." }],
58
+ "inherited_flags": [
59
+ { "name": "json", "shorthand": "j", "type": "bool", "default": "false", "usage": "..." }
60
+ ]
61
+ }
62
+ ```
63
+
64
+ Walk the tree: start at `basecamp --agent --help` for top-level commands, then drill into any subcommand. Commands include `notes` with domain-specific agent hints (e.g., "Cards do NOT support --assignee filtering").
65
+
66
+ ### Pagination
67
+
68
+ ```bash
69
+ basecamp <cmd> --limit 50 # Cap results (default varies by resource)
70
+ basecamp <cmd> --all # Fetch all (may be slow for large datasets)
71
+ basecamp <cmd> --page 1 # First page only, no auto-pagination
72
+ ```
73
+
74
+ `--all` and `--limit` are mutually exclusive. `--page` cannot combine with either.
75
+
76
+ ### Smart Defaults
77
+
78
+ - `--assignee me` resolves to current user
79
+ - `--due tomorrow` / `--due +3` / `--due "next week"` - natural date parsing
80
+ - Project from `.basecamp/config.json` if `--in` not specified
81
+
82
+ ## Quick Reference
83
+
84
+ > **Note:** Most queries require project scope (via `--in <project>` or `.basecamp/config.json`). Cross-project exceptions: `basecamp reports assigned`, `basecamp reports overdue`, `basecamp recordings <type>`.
85
+
86
+ | Task | Command |
87
+ | ----------------------------- | ------------------------------------------------------------------------------- |
88
+ | List projects | `basecamp projects list --json` |
89
+ | My todos (in project) | `basecamp todos list --assignee me --in <project> --json` |
90
+ | My todos (cross-project) | `basecamp reports assigned --json` (defaults to "me") |
91
+ | All todos (cross-project) | `basecamp recordings todos --json` (no assignee data — cannot filter by person) |
92
+ | Overdue todos (in project) | `basecamp todos list --overdue --in <project> --json` |
93
+ | Overdue todos (cross-project) | `basecamp reports overdue --json` |
94
+ | Assign todo | `basecamp assign <id> --to <person> --in <project> --json` |
95
+ | Create todo | `basecamp todo "Task" --in <project> --list <list> --json` |
96
+ | Create todolist | `basecamp todolists create "Name" --in <project> --json` |
97
+ | Complete todo | `basecamp done <id> --json` |
98
+ | List cards | `basecamp cards list --in <project> --json` |
99
+ | Create card | `basecamp card "Title" --in <project> --json` |
100
+ | Move card | `basecamp cards move <id> --to <column> --in <project> --json` |
101
+ | Post message | `basecamp message "Title" "Body" --in <project> --json` |
102
+ | Post silently | `basecamp message "Title" "Body" --no-subscribe --in <project> --json` |
103
+ | Post to campfire | `basecamp campfire post "Message" --in <project> --json` |
104
+ | Add comment | `basecamp comment <recording_id> "Text" --in <project> --json` |
105
+ | Search | `basecamp search "query" --json` |
106
+ | Parse URL | `basecamp url parse "<url>" --json` |
107
+ | Download file | `basecamp files download <id> --in <project>` |
108
+ | Watch timeline | `basecamp timeline --watch` |
109
+
110
+ ## URL Parsing
111
+
112
+ **Always parse URLs before acting on them:**
113
+
114
+ ```bash
115
+ basecamp url parse "https://3.basecamp.com/2914079/buckets/41746046/messages/9478142982#__recording_9488783598" --json
116
+ ```
117
+
118
+ Returns: `account_id`, `project_id`, `type`, `recording_id`, `comment_id` (from fragment).
119
+
120
+ **URL patterns:**
121
+
122
+ - `/buckets/27/messages/123` - Message 123 in project 27
123
+ - `/buckets/27/messages/123#__recording_456` - Comment 456 on message 123
124
+ - `/buckets/27/card_tables/cards/789` - Card 789
125
+ - `/buckets/27/card_tables/columns/456` - Column 456 (for creating cards)
126
+ - `/buckets/27/todos/101` - Todo 101
127
+ - `/buckets/27/uploads/202` - Upload/file 202
128
+ - `/buckets/27/documents/303` - Document 303
129
+ - `/buckets/27/schedule_entries/404` - Schedule entry 404
130
+
131
+ **Replying to comments:**
132
+
133
+ ```bash
134
+ # Comments are flat - reply to the parent recording_id, not the comment_id
135
+ basecamp url parse "https://...messages/123#__recording_456" --json
136
+ # Returns recording_id: 123 (parent), comment_id: 456 (fragment) - comment on 123, not 456
137
+ basecamp comment 123 "Reply" --in <project>
138
+ ```
139
+
140
+ ## Decision Trees
141
+
142
+ ### Finding Content
143
+
144
+ ```
145
+ Need to find something?
146
+ ├── Know the type + project? → basecamp <type> list --in <project> --json
147
+ │ (some groups have default list behavior; use --agent --help if unsure)
148
+ ├── My assigned work? → basecamp reports assigned --json (defaults to "me")
149
+ ├── Overdue across projects? → basecamp reports overdue --json
150
+ ├── Browse by type cross-project? → basecamp recordings <type> --json
151
+ │ (types: todos, messages, documents, comments, cards, uploads)
152
+ │ Note: Defaults to active status; use --status archived for archived items
153
+ │ ⚠ No assignee data — cannot filter by person; use reports assigned instead
154
+ ├── Full-text search? → basecamp search "query" --json
155
+ └── Have a URL? → basecamp url parse "<url>" --json
156
+ ```
157
+
158
+ ### Modifying Content
159
+
160
+ ```
161
+ Want to change something?
162
+ ├── Have URL? → basecamp url parse "<url>" → use extracted IDs
163
+ ├── Have ID? → basecamp <resource> update <id> --field value
164
+ ├── Change status? → basecamp recordings trash|archive|restore <id>
165
+ └── Complete todo? → basecamp done <id>
166
+ ```
167
+
168
+ ## Common Workflows
169
+
170
+ ### Link Code to Basecamp Todo
171
+
172
+ ```bash
173
+ # Get commit info and comment on todo (use printf %q for safe quoting)
174
+ COMMIT=$(git rev-parse --short HEAD)
175
+ MSG=$(git log -1 --format=%s)
176
+ basecamp comment <todo_id> "Commit $COMMIT: $(printf '%s' "$MSG")" --in <project>
177
+
178
+ # Complete when done
179
+ basecamp done <todo_id>
180
+ ```
181
+
182
+ ### Track PR in Basecamp
183
+
184
+ ```bash
185
+ # Create todo for PR work
186
+ basecamp todo "Review PR #42" --in <project> --assignee me --due tomorrow
187
+
188
+ # When merged
189
+ basecamp done <todo_id>
190
+ basecamp campfire post "Merged PR #42" --in <project>
191
+ ```
192
+
193
+ ### Bulk Process Overdue Todos
194
+
195
+ ```bash
196
+ # Preview overdue todos
197
+ basecamp todos sweep --overdue --dry-run --in <project>
198
+
199
+ # Complete all with comment
200
+ basecamp todos sweep --overdue --complete --comment "Cleaning up" --in <project>
201
+ ```
202
+
203
+ ### Move Card Through Workflow
204
+
205
+ ```bash
206
+ # List columns to get IDs
207
+ basecamp cards columns --in <project> --json
208
+
209
+ # Move card to column
210
+ basecamp cards move <card_id> --to <column_id> --in <project>
211
+ ```
212
+
213
+ ### Download File from Basecamp
214
+
215
+ ```bash
216
+ basecamp files download <upload_id> --in <project> --out ./downloads
217
+ ```
218
+
219
+ ## Resource Reference
220
+
221
+ ### Projects
222
+
223
+ ```bash
224
+ basecamp projects list --json # List all
225
+ basecamp projects show <id> --json # Show details
226
+ basecamp projects create "Name" --json # Create
227
+ basecamp projects update <id> --name "New" # Update
228
+ ```
229
+
230
+ ### Todos
231
+
232
+ ```bash
233
+ basecamp todos list --in <project> --json # List in project
234
+ basecamp todos list --assignee me --in <project> # My todos
235
+ basecamp todos list --overdue --in <project> # Overdue only
236
+ basecamp todos list --status completed --in <project> # Completed
237
+ basecamp todos list --list <todolist_id> --in <project> # In specific list
238
+ basecamp todo "Task" --in <project> --list <list> --assignee me --due tomorrow
239
+ basecamp done <id> [id...] # Complete (multiple OK)
240
+ basecamp reopen <id> # Uncomplete
241
+ basecamp assign <id> --to <person> --in <project> # Assign (person: ID, email, or "me")
242
+ basecamp unassign <id> --from <person> --in <project> # Remove assignee
243
+ basecamp todos position <id> --to 1 # Move to top
244
+ basecamp todos sweep --overdue --complete --comment "Done" --in <project>
245
+ ```
246
+
247
+ **Flags:** `--assignee` (todos only - not available on cards/messages), `--status` (completed/pending), `--overdue`, `--list`, `--due`, `--limit`, `--all`
248
+
249
+ ### Todolists
250
+
251
+ Todolists are containers for todos. Create a todolist before adding todos.
252
+
253
+ ```bash
254
+ basecamp todolists list --in <project> --json # List todolists
255
+ basecamp todolists show <id> --in <project> # Show details
256
+ basecamp todolists create "Name" --in <project> --json # Create
257
+ basecamp todolists create "Name" --description "Desc" --in <project>
258
+ basecamp todolists update <id> --name "New" --in <project> # Update
259
+ ```
260
+
261
+ ### Cards (Kanban)
262
+
263
+ **Note:** Cards do NOT support `--assignee` filtering like todos. Fetch all cards and filter client-side if needed. If a project has multiple card tables, you must specify `--card-table <id>`. When you get an "Ambiguous card table" error, the hint shows available table IDs and names.
264
+
265
+ ```bash
266
+ basecamp cards list --in <project> --json # All cards
267
+ basecamp cards list --card-table <id> --in <project> # Specific table (required if multiple)
268
+ basecamp cards list --column <id> --in <project> # Cards in column
269
+ basecamp cards columns --in <project> --json # List columns (needs --card-table if multiple)
270
+ basecamp cards show <id> --in <project> # Card details
271
+ basecamp card "Title" "<p>Body</p>" --in <project> --column <id>
272
+ basecamp cards update <id> --title "New" --due tomorrow --assignee me
273
+ basecamp cards move <id> --to <column_id> # Move to column (numeric ID)
274
+ basecamp cards move <id> --to "Done" --card-table <table_id> # Move by name (needs table)
275
+ ```
276
+
277
+ **Identifying completed cards:** Cards in Done columns have `parent.type: "Kanban::DoneColumn"` and `completed: true`. Use this to identify completed cards that haven't been archived.
278
+
279
+ **Limitation:** Basecamp does not track when cards are moved between columns. The `updated_at` field updates on any modification and cannot reliably indicate when a card was completed.
280
+
281
+ **Card Steps (checklists):**
282
+
283
+ ```bash
284
+ basecamp cards steps <card_id> --in <project> # List steps
285
+ basecamp cards step create "Step" --card <id> --in <project>
286
+ basecamp cards step complete <step_id> --in <project>
287
+ basecamp cards step uncomplete <step_id>
288
+ ```
289
+
290
+ **Column management:**
291
+
292
+ ```bash
293
+ basecamp cards column show <id> --in <project>
294
+ basecamp cards column create "Name" --in <project>
295
+ basecamp cards column update <id> --title "New"
296
+ basecamp cards column move <id> --position 2
297
+ basecamp cards column color <id> --color blue
298
+ basecamp cards column on-hold <id> # Enable on-hold section
299
+ basecamp cards column watch <id> # Subscribe to column
300
+ ```
301
+
302
+ ### Messages
303
+
304
+ ```bash
305
+ basecamp messages list --in <project> --json # List messages
306
+ basecamp messages show <id> --in <project> # Show message
307
+ basecamp message "Title" "Body" --in <project>
308
+ basecamp messages update <id> --title "New" --body "Updated"
309
+ basecamp messages pin <id> --in <project> # Pin to top
310
+ basecamp messages unpin <id> # Unpin
311
+ ```
312
+
313
+ **Flags:** `--draft` (create as draft), `--no-subscribe` (silent, no notifications), `--subscribe "people"` (comma-separated names, emails, IDs, or "me"; mutually exclusive with `--no-subscribe`), `--message-board <id>` (if multiple boards)
314
+
315
+ ```bash
316
+ basecamp message "Bot update" "Done" --no-subscribe --in <project>
317
+ basecamp message "FYI" "Note" --subscribe "Alice,bob@x.com" --in <project>
318
+ ```
319
+
320
+ ### Comments
321
+
322
+ ```bash
323
+ basecamp comments list <recording_id> --in <project> --json
324
+ basecamp comment <recording_id> "Text" --in <project>
325
+ basecamp comments update <id> "Updated" --in <project>
326
+ ```
327
+
328
+ ### Files & Documents
329
+
330
+ ```bash
331
+ basecamp files list --in <project> --json # List all (folders, files, docs)
332
+ basecamp files list --vault <folder_id> --in <project> # List folder contents
333
+ basecamp files show <id> --in <project> # Show item (auto-detects type)
334
+ basecamp files download <id> --in <project> # Download file
335
+ basecamp files download <id> --out ./dir # Download to specific dir
336
+ basecamp files folder create "Folder" --in <project>
337
+ basecamp files doc create "Doc" "Body" --in <project>
338
+ basecamp files doc create "Draft" --draft --in <project>
339
+ basecamp files doc create "Notes" "..." --no-subscribe --in <project>
340
+ basecamp files update <id> --title "New" --content "Updated"
341
+ ```
342
+
343
+ **Subcommands:** `folders`, `uploads`, `documents` (each with pagination flags)
344
+
345
+ ### Schedule
346
+
347
+ ```bash
348
+ basecamp schedule --in <project> --json # Schedule info
349
+ basecamp schedule entries --in <project> --json # List entries
350
+ basecamp schedule show <id> --in <project> # Entry details
351
+ basecamp schedule show <id> --date 20240315 # Specific occurrence (recurring)
352
+ basecamp schedule create "Event" --starts-at "2024-03-15T09:00:00Z" --ends-at "2024-03-15T10:00:00Z" --in <project>
353
+ basecamp schedule create "Meeting" --all-day --notify --participants 1,2,3 --in <project>
354
+ basecamp schedule create "Sync" --starts-at "..." --ends-at "..." --no-subscribe --in <project>
355
+ basecamp schedule update <id> --summary "New title" --starts-at "..."
356
+ basecamp schedule settings --include-due --in <project> # Include todos/cards due dates
357
+ ```
358
+
359
+ **Flags:** `--all-day`, `--notify`, `--participants <ids>`, `--no-subscribe`, `--subscribe "people"` (mutually exclusive), `--status` (active/archived/trashed)
360
+
361
+ ### Check-ins
362
+
363
+ ```bash
364
+ basecamp checkins --in <project> --json # Questionnaire info
365
+ basecamp checkins questions --in <project> # List questions
366
+ basecamp checkins question <id> --in <project> # Question details
367
+ basecamp checkins answers <question_id> --in <project> # List answers
368
+ basecamp checkins answer <id> --in <project> # Answer details
369
+ basecamp checkins question create "What did you work on?" --in <project>
370
+ basecamp checkins question update <id> "New question" --frequency every_week
371
+ basecamp checkins answer create <question-id> "My answer" --in <project>
372
+ basecamp checkins answer update <id> "Updated" --in <project>
373
+ ```
374
+
375
+ **Schedule options:** `--frequency` (every_day, every_week, every_other_week, every_month, on_certain_days), `--days 1,2,3,4,5` (0=Sun), `--time "5:00pm"`
376
+
377
+ ### Timeline
378
+
379
+ ```bash
380
+ basecamp timeline --json # Account-wide activity
381
+ basecamp timeline --in <project> --json # Project activity
382
+ basecamp timeline me --json # Your activity
383
+ basecamp timeline --person <id> --json # Person's activity
384
+ basecamp timeline --watch # Live monitoring (TUI)
385
+ basecamp timeline --watch --interval 60 # Poll every 60 seconds
386
+ ```
387
+
388
+ **Note:** `basecamp timeline` (account-wide) works reliably. The `--limit` flag is not supported on timeline commands.
389
+
390
+ ### Recordings (Cross-project)
391
+
392
+ Use `basecamp recordings <type>` for cross-project type browsing. **For assigned todos, prefer `basecamp reports assigned`** — recordings do not include assignee data and cannot be filtered by person.
393
+
394
+ ```bash
395
+ basecamp recordings todos --json # All todos across projects
396
+ basecamp recordings todos --all --json # All todos (paginate through all)
397
+ basecamp recordings messages --in <project> # Messages in project
398
+ basecamp recordings documents --status archived # Archived docs
399
+ basecamp recordings cards --sort created_at --direction asc
400
+ basecamp recordings cards --status archived --all --json # Include archived cards
401
+ ```
402
+
403
+ **Types:** `todos`, `messages`, `documents`, `comments`, `cards`, `uploads`
404
+
405
+ **Status filtering:** By default, only `active` recordings are returned. Use `--status archived` or `--status trashed` to query other statuses. You may need separate queries to get complete data (e.g., active + archived).
406
+
407
+ **Status management:**
408
+
409
+ ```bash
410
+ basecamp recordings trash <id> --in <project> # Move to trash
411
+ basecamp recordings archive <id> --in <project> # Archive
412
+ basecamp recordings restore <id> --in <project> # Restore to active
413
+ basecamp recordings visibility <id> --visible --in <project> # Show to clients
414
+ basecamp recordings visibility <id> --hidden # Hide from clients
415
+ ```
416
+
417
+ ### Templates
418
+
419
+ ```bash
420
+ basecamp templates --json # List templates
421
+ basecamp templates show <id> --json # Template details
422
+ basecamp templates create "Template Name" # Create empty template
423
+ basecamp templates update <id> --name "New Name"
424
+ basecamp templates delete <id> # Trash template
425
+ basecamp templates construct <id> --name "New Project" # Create project (async)
426
+ basecamp templates construction <template_id> <construction_id> # Check status
427
+ ```
428
+
429
+ **Construct returns construction_id - poll until status="completed" to get project.**
430
+
431
+ ### Webhooks
432
+
433
+ ```bash
434
+ basecamp webhooks list --in <project> --json # List webhooks
435
+ basecamp webhooks show <id> --in <project> # Webhook details
436
+ basecamp webhooks create "https://..." --in <project>
437
+ basecamp webhooks create "https://..." --types "Todo,Comment" --in <project>
438
+ basecamp webhooks update <id> --active --in <project>
439
+ basecamp webhooks update <id> --inactive # Disable
440
+ basecamp webhooks delete <id> --in <project>
441
+ ```
442
+
443
+ **Event types:** Todo, Todolist, Message, Comment, Document, Upload, Vault, Schedule::Entry, Kanban::Card, Question, Question::Answer
444
+
445
+ ### Subscriptions
446
+
447
+ ```bash
448
+ basecamp subscriptions <recording_id> # Who's subscribed
449
+ basecamp subscriptions subscribe <id> # Subscribe yourself
450
+ basecamp subscriptions unsubscribe <id> # Unsubscribe
451
+ basecamp subscriptions add <id> --people 1,2,3 # Add people
452
+ basecamp subscriptions remove <id> --people 1,2,3 # Remove people
453
+ ```
454
+
455
+ ### Lineup (Account-wide Markers)
456
+
457
+ ```bash
458
+ basecamp lineup create "Milestone" "2024-03-15" # Create marker
459
+ basecamp lineup create "Launch" tomorrow # Natural date parsing
460
+ basecamp lineup update <id> "New Name" "+7"
461
+ basecamp lineup delete <id>
462
+ ```
463
+
464
+ **Note:** Lineup markers are account-wide, not project-scoped.
465
+
466
+ ### Campfire
467
+
468
+ ```bash
469
+ basecamp campfire --in <project> --json # List campfires
470
+ basecamp campfire messages --in <project> --json # List messages
471
+ basecamp campfire post "Hello!" --in <project>
472
+ basecamp campfire line <line_id> --in <project> # Show line
473
+ basecamp campfire delete <line_id> --in <project> # Delete line
474
+ ```
475
+
476
+ ### People
477
+
478
+ ```bash
479
+ basecamp people list --json # All people in account
480
+ basecamp people list --project <project> --json # People on project
481
+ basecamp me --json # Current user
482
+ basecamp people show <id> --json # Person details
483
+ basecamp people add <id> --project <project> # Add to project
484
+ basecamp people remove <id> --project <project> # Remove from project
485
+ ```
486
+
487
+ ### Search
488
+
489
+ ```bash
490
+ basecamp search "query" --json # Full-text search
491
+ basecamp search "query" --sort updated_at --limit 20
492
+ basecamp search metadata --json # Available search scopes
493
+ ```
494
+
495
+ ### Generic Show
496
+
497
+ ```bash
498
+ basecamp show <type> <id> --in <project> --json # Show any recording type
499
+ # Types: todo, todolist, message, comment, card, card-table, document (or omit <type> for generic lookup)
500
+ ```
501
+
502
+ ## Configuration
503
+
504
+ The CLI uses two directory namespaces: `basecamp` for your Basecamp identity and project relationships, `basecamp` for tool-specific operational data.
505
+
506
+ ```
507
+ ~/.config/basecamp/ # Basecamp identity (DO NOT read credentials)
508
+ ├── credentials.json # OAuth tokens — NEVER read or log
509
+ ├── client.json # DCR client registration
510
+ └── config.json # Global preferences (account_id, base_url, format)
511
+
512
+ ~/.cache/basecamp/ # Tool cache (ephemeral, auto-managed)
513
+ ├── completion.json # Tab completion cache
514
+ └── resilience/ # Circuit breaker state
515
+
516
+ .basecamp/ # Per-repo config (committed to git)
517
+ └── config.json # Project defaults (project_id, account_id, todolist_id)
518
+ ```
519
+
520
+ **Per-repo config:** `.basecamp/config.json`
521
+
522
+ ```json
523
+ {
524
+ "project_id": "12345",
525
+ "todolist_id": "67890"
526
+ }
527
+ ```
528
+
529
+ **Initialize:**
530
+
531
+ ```bash
532
+ basecamp config init
533
+ basecamp config set project_id <id>
534
+ basecamp config set todolist_id <id>
535
+ ```
536
+
537
+ **Config Trust:**
538
+
539
+ Authority keys (`base_url`, `default_profile`, `profiles`) in local/repo configs are blocked until explicitly trusted. This prevents a cloned repo's config from redirecting OAuth tokens.
540
+
541
+ ```bash
542
+ basecamp config trust # Trust nearest .basecamp/config.json
543
+ basecamp config trust /path/to/.basecamp/config.json # Trust specific config file
544
+ basecamp config trust --list # Show all trusted configs
545
+ basecamp config untrust # Revoke trust for nearest config
546
+ basecamp config untrust /path/to/.basecamp/config.json # Revoke trust for specific path
547
+ ```
548
+
549
+ **Check context:**
550
+
551
+ ```bash
552
+ cat .basecamp/config.json 2>/dev/null || echo "No project configured"
553
+ ```
554
+
555
+ **Global config:** `~/.config/basecamp/config.json` (account_id, base_url, format preferences)
556
+
557
+ ## Error Handling
558
+
559
+ **General diagnostics:**
560
+
561
+ ```bash
562
+ basecamp doctor --json # Check CLI health, auth, connectivity
563
+ ```
564
+
565
+ **Rate limiting (429):** The CLI handles backoff automatically. If you see 429 errors, reduce request frequency.
566
+
567
+ **Authentication errors:**
568
+
569
+ ```bash
570
+ basecamp auth status # Check auth
571
+ basecamp auth login # Re-authenticate
572
+ basecamp auth login --scope full # Full access (BC3 OAuth only)
573
+ ```
574
+
575
+ **Network errors / localhost URLs:**
576
+
577
+ ```bash
578
+ # Check for dev config
579
+ cat ~/.config/basecamp/config.json
580
+ # Should only contain: {"account_id": "<id>"}
581
+ # Remove base_url/api_url if pointing to localhost
582
+ ```
583
+
584
+ **Not found errors:**
585
+
586
+ ```bash
587
+ basecamp auth status # Verify auth working
588
+ cat ~/.config/basecamp/accounts.json # Check available accounts
589
+ ```
590
+
591
+ **Required arguments are positional (not flags):**
592
+
593
+ - `basecamp todo "Buy milk"` (not `--content`)
594
+ - `basecamp card "New feature"` (not `--title`)
595
+ - `basecamp message "Subject" "Body"` (not `--subject`)
596
+ - `basecamp campfire post "Hello"` (not `--content`)
597
+ - `basecamp comment <id> "Text"` (not a flag)
598
+ - `basecamp webhooks create "https://..." --in <project>` (not `--url`)
599
+ - `basecamp checkins answer create <question-id> "content"` (not `--question`)
600
+
601
+ **Missing argument errors (code: "usage"):**
602
+ When a required positional argument is missing, the CLI returns a structured error naming
603
+ the specific argument. Use this for elicitation:
604
+
605
+ ```bash
606
+ $ basecamp todo --json
607
+ {"ok": false, "error": "<content> required", "code": "usage",
608
+ "hint": "Usage: basecamp todo <content>"}
609
+
610
+ $ basecamp comments create 123 --json
611
+ {"ok": false, "error": "<content> required", "code": "usage", ...}
612
+ ```
613
+
614
+ The `error` field names the missing `<arg>` — use it to prompt the user for the specific value.
615
+
616
+ **URL malformed (curl exit 3):** Special characters in content. Use plain text or properly escaped HTML.
617
+
618
+ ## jq Patterns
619
+
620
+ Common data extraction patterns for the output envelope:
621
+
622
+ ```bash
623
+ # Extract fields from data array
624
+ basecamp todos list --in <project> --json | jq '.data[] | select(.completed == false) | .title'
625
+ basecamp todos list --in <project> --json | jq '.data | length'
626
+ basecamp todos list --in <project> --json | jq '.data[] | {id, title, status}'
627
+
628
+ # Access envelope metadata
629
+ basecamp todos list --in <project> --json | jq '.breadcrumbs[0].cmd'
630
+ basecamp todos list --in <project> --json | jq '.meta.stats.requests'
631
+ ```
632
+
633
+ ## Exit Codes
634
+
635
+ | Exit | Meaning | Fix |
636
+ | ---- | ------------- | ------------------------------------------------------------------- |
637
+ | 0 | OK | — |
638
+ | 1 | Usage error | Check `basecamp <cmd> --help` |
639
+ | 2 | Not found | Verify ID/URL exists |
640
+ | 3 | Auth error | `basecamp auth login` |
641
+ | 4 | Forbidden | Check account/project permissions |
642
+ | 5 | Rate limit | Wait and retry (resilience layer handles Retry-After automatically) |
643
+ | 6 | Network error | Check connectivity, `basecamp doctor` |
644
+ | 7 | API error | Retry; if persistent, check `basecamp doctor` |
645
+ | 8 | Ambiguous | Be more specific (use ID instead of name) |
646
+
647
+ ## Learn More
648
+
649
+ - API concepts: https://github.com/basecamp/bc3-api#key-concepts
650
+ - CLI repo: https://github.com/basecamp/basecamp-cli
651
+ - API coverage: See API-COVERAGE.md in the CLI repo
@@ -56,6 +56,7 @@ python3 .agents/skills/everhour-basecamp-estimates/scripts/update_estimates.py \
56
56
  - A scalar estimate is applied to each active todo.
57
57
  - A JSON estimate array is applied in active-todo order and must match the active todo count exactly.
58
58
  - It matches Basecamp titles against Everhour task names inside the configured project.
59
+ - If multiple Everhour tasks have the same normalized title, it selects the task whose Everhour task id or URL maps to the Basecamp todo id.
59
60
  - Matching is exact after normalization:
60
61
  - trim whitespace
61
62
  - collapse repeated spaces
@@ -67,7 +68,7 @@ python3 .agents/skills/everhour-basecamp-estimates/scripts/update_estimates.py \
67
68
  ## Guardrails
68
69
 
69
70
  - If any Basecamp title has no Everhour match, stop and report it.
70
- - If any Basecamp title matches more than one Everhour task, stop and report the ambiguity.
71
+ - If any Basecamp title matches more than one Everhour task and the Basecamp todo id cannot resolve one task, stop and report the ambiguity.
71
72
  - If an estimate array length does not match the active todo count, stop before any updates.
72
73
  - If the provided todo URL is already completed, stop without updating anything.
73
74
  - Do not update Basecamp titles before Everhour updates complete.
@@ -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()
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: pull request
2
+ name: pull-request
3
3
  description: Generates well-structured GitHub pull request descriptions for Shopify theme development teams by analyzing git diffs and gathering context. Use this skill whenever a developer asks to create a PR, open a pull request, write a PR description, or submit code for review — even if they just say "make a PR" or "create a pull request". Also trigger when someone mentions needing a PR title, PR summary, or PR testing steps.
4
4
  ---
5
5
 
@@ -0,0 +1,216 @@
1
+ ---
2
+ name: pull-request-statamic
3
+ description: Generates GitHub pull request descriptions for Statamic and Laravel development by analyzing git diffs and gathering context.
4
+ ---
5
+
6
+ # PR Description Generator
7
+
8
+ Helps Statamic and Laravel developers create well-structured pull request descriptions by analyzing code changes and collecting context. Follow these steps in order.
9
+
10
+ ---
11
+
12
+ ## Step 1: Get Branch Info
13
+
14
+ Ask the developer for:
15
+
16
+ 1. **Source branch** — the feature/fix branch with their changes
17
+ 2. **Target branch** — the branch to merge into (e.g. `main`, `develop`, `release/x.x`)
18
+
19
+ ---
20
+
21
+ ## Step 2: Analyze the Diff
22
+
23
+ Run this command to fetch and diff the branches:
24
+
25
+ ```bash
26
+ git fetch origin && git diff origin/{target_branch}...origin/{source_branch}
27
+ ```
28
+
29
+ Analyze the diff for:
30
+
31
+ - Files changed, added, or deleted
32
+ - Nature of changes (feature, bug fix, refactor, styling, etc.)
33
+ - Key implementation details and patterns
34
+ - Potential impacts or side effects
35
+ - Testable and user-facing changes
36
+
37
+ ### Statamic-Specific Analysis Guide
38
+
39
+ | File Type | What to Look For | Testing Implications |
40
+ | --------- | ---------------- | -------------------- |
41
+ | `resources/views/**/*.antlers.html` | Template conditionals, partial usage, cascade values, collection/global references | Tests cover each affected page type and content state |
42
+ | `resources/fieldsets/*.yaml`, `resources/blueprints/**/*.yaml` | New fields, validation rules, display conditions, defaults | Render tests confirm content |
43
+ | `config/*.php`, `routes/*.php` | Environment-sensitive behavior, routing changes, feature flags | Test affected routes and relevant config-dependent scenarios |
44
+ | `app/**/*.php` | Controllers, listeners, view models, tags, commands, queries | Exercise the feature path and likely failure states |
45
+ | `resources/js/**/*`, `resources/css/**/*`, `vite.config.*` | Interactive behavior, asset bundling, responsive styling | Test interactions, breakpoints |
46
+ | `lang/**/*`, `resources/lang/**/*` | New translation keys or copy changes | Verify translated strings appear correctly in each locale |
47
+
48
+ ---
49
+
50
+ ## Step 3: Generate PR Content
51
+
52
+ Based on the diff, generate:
53
+
54
+ ### PR Title
55
+
56
+ Concise and descriptive. Use conventional commit style where appropriate (`feat:`, `fix:`, `refactor:`, `chore:`).
57
+
58
+ ### PR Summary
59
+
60
+ 1–2 sentences in plain, non-technical language describing what the changes accomplish from a user/business perspective.
61
+
62
+ ### Approach
63
+
64
+ Technical description covering:
65
+
66
+ - Key files modified and why
67
+ - Implementation strategy
68
+ - Notable patterns or techniques
69
+ - Trade-offs or decisions made
70
+
71
+ ### Testing Steps
72
+
73
+ Generate comprehensive, grouped testing steps as checkboxes. Cover all relevant categories:
74
+
75
+ **Functional Testing**
76
+
77
+ - New features or modified behavior
78
+ - Form inputs, buttons, interactive elements
79
+ - Conditional logic and different states
80
+
81
+ **Visual/UI Testing**
82
+
83
+ - Layout across breakpoints (mobile, tablet, desktop)
84
+ - New or modified CSS
85
+ - Template output across affected entry types and content states
86
+ - Hover states, transitions, animations
87
+
88
+ **Control Panel Testing**
89
+
90
+ - New blueprint or fieldset fields
91
+ - Validation rules and default values
92
+ - Replicator/grid/set add/remove/reorder behavior
93
+
94
+ **Edge Cases**
95
+
96
+ - Empty states (no content, no images)
97
+ - Maximum content (long text, many items)
98
+ - Missing or broken assets
99
+
100
+ **Accessibility**
101
+
102
+ - Keyboard navigation
103
+ - Screen reader compatibility
104
+ - Focus states and tab order
105
+ - Color contrast
106
+
107
+ **Browser/Device**
108
+
109
+ - Cross-browser compatibility
110
+ - Mobile touch interactions
111
+
112
+ Format example:
113
+
114
+ ```markdown
115
+ **Feature Functionality**
116
+
117
+ - [ ] Verify the "Show price" toggle appears in the relevant blueprint in the Control Panel
118
+ - [ ] Confirm integration and unit tests pass
119
+ - [ ] Confirm integration and unit tests cover the new feature and any edge cases
120
+
121
+ **Responsive Behavior**
122
+
123
+ - [ ] Test on mobile (< 768px) — items should stack vertically
124
+ - [ ] Test on desktop (≥ 768px) — items should display in a grid
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Step 4: Collect Additional Info
130
+
131
+ Ask the developer for the following (cannot be inferred from code):
132
+
133
+ 1. **Basecamp Links** — Links to relevant Basecamp Cards or Todos
134
+ 2. **Other Considerations** — Edge cases, known limitations, notes for reviewers
135
+ 3. **Demo Links** — Preview URL
136
+
137
+ Then present the generated testing steps and ask:
138
+
139
+ - Any steps to remove (not applicable)?
140
+ - Any additional scenarios to add?
141
+ - Any specific test data or configuration needed?
142
+
143
+ ---
144
+
145
+ ## Step 5: Review & Confirm
146
+
147
+ Present the complete PR description to the developer for final review. Ask them to confirm it's accurate or request changes.
148
+
149
+ ---
150
+
151
+ ## Step 6: Create the Pull Request
152
+
153
+ Once confirmed, create the PR with GitHub CLI:
154
+
155
+ ```bash
156
+ gh pr create \
157
+ --base "{target_branch}" \
158
+ --head "{source_branch}" \
159
+ --title "{auto_generated_title}" \
160
+ --body "{generated_pr_body}"
161
+ ```
162
+
163
+ - **On success**: Share the PR URL with the developer.
164
+ - **On failure**: Display the error and troubleshoot:
165
+ - Not authenticated → `gh auth status` / `gh auth login`
166
+ - Branch not pushed → `git push -u origin {source_branch}`
167
+ - No commits between branches → inform developer
168
+ - PR already exists → provide link to existing PR
169
+
170
+ ---
171
+
172
+ ## PR Body Template
173
+
174
+ Use this exact format for the `--body` parameter:
175
+
176
+ ```markdown
177
+ ### PR Summary:
178
+
179
+ {auto_generated_summary}
180
+
181
+ ### Tasks Included?
182
+
183
+ {basecamp_links}
184
+
185
+ ### What approach did you take?
186
+
187
+ {auto_generated_approach}
188
+
189
+ ### Other considerations
190
+
191
+ {considerations}
192
+
193
+ ### Testing steps/scenarios
194
+
195
+ {auto_generated_and_reviewed_testing_steps_as_checkboxes}
196
+
197
+ ### Demo links
198
+
199
+ - [Site]({site_url})
200
+ - [Editor]({site_url/cp})
201
+
202
+ ### Additional Information
203
+
204
+ {additional_info}
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Workflow Summary
210
+
211
+ 1. Ask for source and target branches
212
+ 2. Fetch and analyze the diff
213
+ 3. Generate title, summary, approach, and testing steps
214
+ 4. Collect Basecamp links, demo URLs, and other info; review testing steps
215
+ 5. Present complete PR for final confirmation
216
+ 6. Create the PR with `gh pr create` and share the URL
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: 'Pull Request (Statamic)'
3
+ short_description: 'Create a pull request'
4
+ default_prompt: 'Use $pull-request to create a pull request.'