Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
649 changes: 649 additions & 0 deletions .surface

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions ACCOUNT-WIDE-LISTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,33 @@ rows that differ for stated reasons. `--all` is how you ask for the account.
| `files list` | cap 100 | **changed** from "all pages" |
| `todos list --overdue` | cap 100 | unpaginated endpoint; accepts `--all`, rejects `--page` |
| `cards list --overdue` | cap 100 | **changed** from uncapped; same rules as above |
| `bookmarks list` | cap 100 | personal feed — see below |
| `drafts list` | cap 100 | personal feed; server caps the full listing at 250 |

Project-scoped defaults are untouched throughout.

**The two personal feeds.** `bookmarks list` and `drafts list` are not
`EverythingService` methods, and they belong to no project-scoped group — they
are `/my/` listings, private to the authenticated user, and there is no
`--all-projects` to pass because there is no project scope to leave.

They are recorded here anyway, because the invariants are this document's. Both
`Bookmarks().List` and `Drafts().List` take a `page int32` where **0 means the
SDK follows the Link header across every page** — the same spelling, and so the
same trap. A default of "fetch page 0, then trim to `--limit`" would reintroduce
fetch-everything-then-truncate through a door the aggregates no longer have.

So both reuse `accountWideCollect` unchanged, and follow the I5 flag table
exactly: bounded walk to 100 by default, `--limit N` walks to N, `--page N` is
exactly one request, and `--all` is the only path that reaches page 0. Leaving
them undocumented is how the contract erodes — the next `/my/` feed would have
no precedent to copy.

`checkins reminders` is a third `/my/` feed but sits outside this table: its
options struct does not honor a page number at all, so it takes `--limit` (a
real SDK-side bound) and deliberately registers no `--page`. Under I3 a flag
that cannot act on its value is worse than an absent one.

**The two overdue rows.** Both endpoints are unpaginated, so `--page` has
nothing to address and stays an error. `--all` is a different question: it means
"skip the cap", and since the complete array is already in hand it costs no
Expand Down
70 changes: 54 additions & 16 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,53 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github.

| Status | Sections | Endpoints |
|--------|----------|-----------|
| ✅ Implemented | 45 | 167 |
| ✅ Implemented | 50 | 184 |
| ⏭️ Out of scope | 4 | 12 |
| **Total tracked** | **49** | **179** |
| **Total tracked** | **54** | **196** |

**100% coverage of tracked in-scope API** (167/167 endpoints). This is not a
complete bc-api parity figure. The other five BC5 sections introduced by
bc-api#410 remain untracked and outside this coverage matrix. The pinned SDK's
`EverythingService` is now fully reached — see [Account-wide
**100% coverage of tracked in-scope API** (184/184 endpoints). This is not a
complete bc-api parity figure. The five BC5 sections introduced by bc-api#410
that were previously untracked — `my_bookmarks`, `drafts`, `my_notes`,
`calendars`, and `question_reminders` — are now tracked and implemented. The
pinned SDK's `EverythingService` is fully reached — see [Account-wide
aggregates](#account-wide-aggregates).

Two corrections rode along with that count. The `questions` row claimed 5
endpoints while listing four actions, and the section carries pause, resume,
notification settings, and answerers besides — so the row was undercounting the
very section the 100%-of-tracked claim rests on. It now reads 8. And
`card_table_columns` gained `subscribe`/`unsubscribe` operations in the SDK that
the CLI deliberately does not spell twice; see that row.

Out-of-scope sections are excluded from parity totals and scripts: chatbots (different auth), legacy Clientside (deprecated)

> Note: the per-row `Endpoints` column in the Coverage by Section table sums higher than the Summary totals above. The discrepancy predates the BC5 baseline; the row count (48 sections) is authoritative for the `Since` column. Reconciling endpoint counts is pre-existing maintenance, tracked separately.

**SDK version:** v0.11.0 — carries `EverythingService`
(`AccountClient.Everything()`, basecamp/basecamp-sdk#435 and #438), a
16-method account-wide aggregate family covering cross-project messages,
comments, checkins, forwards, files, and the
**SDK version:** v0.12.0 — adds 20 exported Go methods over 13 new backend
operations. The extra seven wrap endpoints that already existed but were
reachable only through the raw generated client, which the andon-cord rule
forbids the CLI from calling.

Those methods land as four new command groups (`bookmarks`, `drafts`, `notes`,
`calendars`) and three extensions (`assignments` gains the Up Next verbs,
`todos create` gains `--loose`, `checkins` gains question pause/resume/notify/
answerers plus an account-wide `reminders` feed).

v0.12.0 also gave 11 `EverythingService` methods a trailing
`*EverythingTaskFilters` parameter — the nine paginated todo and card selectors
plus the two unpaginated overdue endpoints. The family is 5 unchanged + 11
changed = 16.

One v0.12.0 defect shapes a command rather than just a call: `parseErrorBody`
reads only `error`/`error_description`, so a calendar 422 carrying
`{"errors":{"color":[…]}}` arrives as a bare `validation error` naming neither
field nor value. `calendars update` therefore validates its eleven colors
client-side. The SDK fixes this past this pin (#541 returns a `fieldErrors`
map), so a later bump could surface the server's own message.

It carries `EverythingService` (`AccountClient.Everything()`,
basecamp/basecamp-sdk#435 and #438), a 16-method account-wide aggregate family
covering cross-project messages, comments, checkins, forwards, files, and the
open/completed/unassigned/overdue/no-due-date todo and card rollups. **All 16
are reached from the CLI** — see [Account-wide
aggregates](#account-wide-aggregates).
Expand Down Expand Up @@ -57,8 +86,11 @@ Model and transport changes riding along:
and `TodolistGroup` the tag carries no `omitempty`, so the key is always
present in machine output.
- HTTP 400 now maps to the `validation` error code rather than `api_error` (#482).
Since `convertSDKError` passes the SDK code straight through, **a 400's exit
code moves from 7 to 9**; 422 was already validation.
`convertSDKError` passes the SDK code straight through, so a 400's JSON `code`
changes `api_error` → `validation`. **Its exit code does not move: a 400 still
exits 7.** `internal/output` defines no `validation` mapping, and `clioutput`
defaults an unrecognised code to `ExitAPI` — so the new code lands on the same
exit status the old one did. Exit 9 is not reachable from the CLI at all.
- Retry behavior: per-operation `retry.max` is honored as a ceiling (#483),
`*WithBody` request bodies replay across retries (#481), and the declared
`retry_on` status set is honored (#486).
Expand Down Expand Up @@ -124,7 +156,7 @@ The **Since** column tags each row with the Basecamp version that introduced its
|---------|-----------|-------------|--------|-------|----------|-------|
| **Core** |
| projects | 9 | `projects` | ✅ | BC4 | - | list, show, create, update, delete |
| todos | 11 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`; edit via `cards step`) |
| todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`; edit via `cards step`). `todos create --loose` creates on the to-do set, outside any list |
| todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position |
| todosets | 3 | `todosets` | ✅ | BC4 | - | Container for todolists, accessed via project dock (BC5: `todos_count`, `completed_loose_todos_count`, `todos_url`, `app_todos_url`) |
| todolist_groups | 8 | `todolistgroups` | ✅ | BC4 | - | list, show, create, update, position |
Expand All @@ -144,13 +176,17 @@ The **Since** column tags each row with the Basecamp version that introduced its
| **Cards (Kanban)** |
| card_tables | 3 | `cards` | ✅ | BC4 | - | Accessed via project dock |
| card_table_cards | 9 | `cards` | ✅ | BC4 | - | list, show, create, update, move |
| card_table_columns | 11 | `cards columns` | ✅ | BC4 | - | list columns |
| card_table_columns | 11 | `cards columns` | ✅ | BC4 | - | list columns. SDK v0.12.0 added `Subscribe`/`Unsubscribe`; `cards column watch\|unwatch` already performs the same action through the generic recording-subscription endpoint and returns the resulting subscription details the specific endpoint does not, so the CLI keeps one spelling |
| card_table_steps | 4 | `cards steps` | ✅ | BC4 | - | Workflow steps on cards |
| card_table_wormholes | 3 | `cards wormholes` | ✅ | BC5 | - | list (via `wormholes[]` on card table), create, update, delete; `cards move --to-wormhole` teleports a card across projects (async, new id) |
| **Personal (My)** |
| my_bookmarks | 4 | `bookmarks` | ✅ | BC5 | - | list, check, add, remove. Private to the authenticated user; `add`/`remove` are idempotent, and `check` returns a bool reported in the payload rather than through the exit code. Bounded like the account-wide listings |
| drafts | 1 | `drafts` | ✅ | BC5 | - | list unpublished drafts across projects (server caps at 250). Bounded like the account-wide listings; publishing happens through the command for the draft's type |
| my_notes | 2 | `notes` | ✅ | BC5 | - | show, set. A singleton per person, so no id and no listing. Pre-first-write the record does not exist yet and renders as empty rather than 404. `set` writes Markdown as HTML; attachments are out of scope |
| **People** |
| people | 12 | `people`, `me` | ✅ | BC4 | - | list, show, pingable, add, remove (BC5: `tagline` alias of `bio` on person output) |
| **Search & Recordings** |
| my_assignments | 3 | `assignments` | ✅ | BC4 | - | list (priorities/non-priorities), completed, due (with scope filter) |
| my_assignments | 6 | `assignments` | ✅ | BC4 | - | list (priorities/non-priorities), completed, due (with scope filter), prioritize, deprioritize, reorder. `list` surfaces `priority_recording_id`, which is the only way to address a prioritized card-table step — it appears in no URL |
| search | 2 | `search` | ✅ | BC4 | - | Full-text search + metadata. Filters: `--project`/`--in`, `--type`, `--creator`, `--since` (BC5-only), `--file-type`, `--exclude-chat`. Metadata lists recording/file search types |
| recordings | 4 | `recordings` | ✅ | BC4 | - | Browse by type/status, trash/archive/restore |
| **Files & Documents** |
Expand All @@ -159,6 +195,7 @@ The **Since** column tags each row with the Basecamp version that introduced its
| documents | 8 | `files`, `docs` | ✅ | BC4 | - | list, show, create, update. Create supports `--subscribe`/`--no-subscribe`, `--visible-to-clients` (root vault only) |
| attachments | 1 | `uploads`, `attachments` | ✅ | BC4 | - | Upload via `attach`; list embedded attachments via `attachments list` (parses `<bc-attachment>` from content) |
| **Schedule** |
| calendars | 2 | `calendars` | ✅ | BC5 | - | show, update (color only). No index endpoint, so there is no `calendars list` — address one by id or pasted URL. The eleven colors are validated client-side, because the SDK at this pin cannot carry the server's 422 field message |
| schedules | 2 | `schedule` | ✅ | BC4 | - | Schedule container + settings |
| schedule_entries | 5 | `schedule` | ✅ | BC4 | - | list, show, create, update, occurrences. Create supports `--subscribe`/`--no-subscribe` |
| events | 1 | `events` | ✅ | BC4 | - | Recording change audit trail |
Expand All @@ -172,8 +209,9 @@ The **Since** column tags each row with the Basecamp version that introduced its
| subscriptions | 4 | `subscriptions` | ✅ | BC4 | - | show, subscribe, unsubscribe, add/remove |
| **Check-ins (Automatic)** |
| questionnaires | 2 | `checkins` | ✅ | BC4 | - | Container for check-in questions |
| questions | 5 | `checkins` | ✅ | BC4 | - | list, show, create, update |
| questions | 8 | `checkins` | ✅ | BC4 | - | list, show, create, update, pause, resume, notification settings, answerers (`checkins question notify` is tri-state per setting; `answerers` takes no `--page`, since the SDK does not honor one) |
| question_answers | 4 | `checkins` | ✅ | BC4 | - | list, show |
| question_reminders | 1 | `checkins reminders` | ✅ | BC5 | - | Account-wide pending-reminder feed (`GET /my/question_reminders.json`). `--limit` is a real SDK-side bound; no `--page`, since the options struct does not honor a page number |
| **Inbox (Email Forwards)** |
| inboxes | 1 | `forwards` | ✅ | BC4 | - | Inbox container |
| forwards | 2 | `forwards` | ✅ | BC4 | - | list, show |
Expand Down
23 changes: 23 additions & 0 deletions e2e/smoke/smoke_assignments.bats
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,26 @@ setup_file() {
assert_success
assert_json_value '.ok' 'true'
}

@test "assignments list surfaces priority_recording_id when present" {
# This listing is the only place priority_recording_id appears — it is in no
# URL and no other command's output — so reorder and deprioritize depend on
# it to address a prioritized card-table step.
run_smoke basecamp assignments list --json
assert_success
assert_json_value '.ok' 'true'
}

@test "assignments reorder rejects a zero position" {
# Positions are 1-based and refused rather than clamped.
run_smoke basecamp assignments reorder 999999 --position 0 --json
assert_failure
}

@test "assignments prioritize is out of scope" {
mark_out_of_scope "Mutating - covered by the live card-step priority sequence"
}

@test "assignments deprioritize is out of scope" {
mark_out_of_scope "Mutating - covered by the live card-step priority sequence"
}
51 changes: 51 additions & 0 deletions e2e/smoke/smoke_bookmarks.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bats
# smoke_bookmarks.bats - Level 0: Personal bookmark operations

load smoke_helper

setup_file() {
ensure_token || return 1
}

@test "bookmarks list returns bookmarks" {
run_smoke basecamp bookmarks list --json
assert_success
assert_json_value '.ok' 'true'
}

@test "bookmarks list honors --limit" {
run_smoke basecamp bookmarks list --limit 1 --json
assert_success
assert_json_value '.ok' 'true'
}

@test "bookmarks list rejects --all with --limit" {
run_smoke basecamp bookmarks list --all --limit 1 --json
assert_failure
}

@test "bookmarks check reports a boolean and exits 0" {
# check answers a question rather than signalling through the exit code, so
# a bookmarked recording must come back true *and* exit 0.
run_smoke basecamp bookmarks list --limit 1 --json
assert_success
local id
id=$(printf '%s' "$output" | jq -r '.data[0].recording.id // empty')
[[ -z "$id" ]] && mark_unverifiable "No bookmark exists to check against"
run_smoke basecamp bookmarks check "$id" --json
assert_success
assert_json_value '.data.bookmarked' 'true'
}

@test "bookmarks check rejects a non-id argument" {
run_smoke basecamp bookmarks check not-an-id --json
assert_failure
}

@test "bookmarks add is out of scope" {
mark_out_of_scope "Mutating - exercised by the live add/check/remove round-trip"
}

@test "bookmarks remove is out of scope" {
mark_out_of_scope "Mutating - exercised by the live add/check/remove round-trip"
}
25 changes: 25 additions & 0 deletions e2e/smoke/smoke_calendars.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bats
# smoke_calendars.bats - Level 0: Calendar read and recolor

load smoke_helper

setup_file() {
ensure_token || return 1
}

@test "calendars show rejects a non-id argument" {
run_smoke basecamp calendars show not-an-id --json
assert_failure
}

@test "calendars show requires a discoverable calendar" {
# There is no index endpoint, so nothing here can discover a calendar id.
mark_unverifiable "No calendars index endpoint to discover an id from"
}

@test "calendars update rejects an unknown color" {
# Client-side validation: this must fail without issuing a request, since
# the SDK cannot surface the server's own field message.
run_smoke basecamp calendars update 999999 --color chartreuse --json
assert_failure
}
32 changes: 32 additions & 0 deletions e2e/smoke/smoke_checkins.bats
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,35 @@ setup_file() {
assert_json_value '.ok' 'true'
assert_json_not_null '.data.id'
}

@test "checkins reminders lists pending reminders" {
run_smoke basecamp checkins reminders --json
assert_success
assert_json_value '.ok' 'true'
}

@test "checkins reminders honors --limit" {
run_smoke basecamp checkins reminders --limit 1 --json
assert_success
assert_json_value '.ok' 'true'
}

@test "checkins question answerers rejects a non-id" {
run_smoke basecamp checkins question answerers not-an-id --json
assert_failure
}

@test "checkins question notify requires a setting" {
# Naming no setting would be a no-op write, so it is refused before the
# request rather than sent as an empty update.
run_smoke basecamp checkins question notify 999999 --json
assert_failure
}

@test "checkins question pause is out of scope" {
mark_out_of_scope "Mutating - pauses a live recurring question"
}

@test "checkins question resume is out of scope" {
mark_out_of_scope "Mutating - resumes a live recurring question"
}
6 changes: 6 additions & 0 deletions e2e/smoke/smoke_core.bats
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,9 @@ setup_file() {
assert_success
assert_output_contains "USAGE"
}

@test "todos create --loose rejects --list" {
# --loose creates outside any list, so naming one contradicts it.
run_smoke basecamp todos create "smoke loose conflict" --loose --list 999999 --json
assert_failure
}
32 changes: 32 additions & 0 deletions e2e/smoke/smoke_drafts.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bats
# smoke_drafts.bats - Level 0: Personal draft listing

load smoke_helper

setup_file() {
ensure_token || return 1
}

@test "drafts list returns drafts" {
run_smoke basecamp drafts list --json
assert_success
assert_json_value '.ok' 'true'
}

@test "drafts list honors --limit" {
run_smoke basecamp drafts list --limit 1 --json
assert_success
assert_json_value '.ok' 'true'
}

@test "drafts list rejects --page 0" {
# Page 0 is the SDK's "fetch every page" spelling. Only --all may reach it,
# so asking for it by number is a usage error rather than a full crawl.
run_smoke basecamp drafts list --page 0 --json
assert_failure
}

@test "drafts list rejects --page with --all" {
run_smoke basecamp drafts list --page 1 --all --json
assert_failure
}
20 changes: 20 additions & 0 deletions e2e/smoke/smoke_notes.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bats
# smoke_notes.bats - Level 0: Personal note

load smoke_helper

setup_file() {
ensure_token || return 1
}

@test "notes show returns the note" {
# An account that has never written a note returns an empty one rather than
# a 404, so this must succeed either way.
run_smoke basecamp notes show --json
assert_success
assert_json_value '.ok' 'true'
}

@test "notes set is out of scope" {
mark_out_of_scope "Mutating - set replaces the whole note; covered by the live round-trip"
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/basecamp/basecamp-sdk/go v0.11.0
github.com/basecamp/basecamp-sdk/go v0.12.0
github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.11.0 h1:bjbwcjEZIAUh93PF/Mr/+KSnQStmhL5yB903OBTVBoM=
github.com/basecamp/basecamp-sdk/go v0.11.0/go.mod h1:r83ralDQ0q9vbAby5qQ5x9hgCgUdJLDLHYpiU6jaFjE=
github.com/basecamp/basecamp-sdk/go v0.12.0 h1:N+sdsz109J5PUu8AZM5UT7vS00Z76s+zG4ItFDeKXkM=
github.com/basecamp/basecamp-sdk/go v0.12.0/go.mod h1:r83ralDQ0q9vbAby5qQ5x9hgCgUdJLDLHYpiU6jaFjE=
github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c h1:+5sQBl8sqYoD1Qhwsibn8sBCKWPyZ9NDez6mnuo9Afo=
github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c/go.mod h1:EK1Dba6DEw8ZAilVBpf/jri3ONDV7LQkLACSDe73f/c=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
Loading
Loading