Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7ad9c98
Bump basecamp-sdk to card-moves-filings for recording moves and copies
robzolkos Oct 27, 2026
769a9ff
Add a bounded backoff wait for background card filings
robzolkos Oct 27, 2026
8f771a0
Move a card to another project's card table with cards move --to-project
robzolkos Oct 27, 2026
42db95f
Add cards copy, onto the card's own table or another project's
robzolkos Oct 27, 2026
333b42c
Show where a card teleported through a wormhole landed
robzolkos Oct 27, 2026
de0e6b7
Point a not-found cards move --to at the cross-project moves
robzolkos Oct 27, 2026
c6ee2bf
Document cross-project card moves and copies, and refresh the surface
robzolkos Oct 27, 2026
5e2769a
Name the project when --to-project has no card table to file into
robzolkos Oct 27, 2026
afe17a5
Never invite a retry of a copy that may have started, and refuse a ca…
robzolkos Oct 27, 2026
1a47d1b
Keep an accepted copy's no-retry error when the wait hits the rate-li…
robzolkos Oct 27, 2026
7bd65f6
Follow a moved card or to-do to its new id in show and steps
robzolkos Oct 27, 2026
12d41d2
Refuse a cross-table move or copy when --in or the card URL names ano…
robzolkos Oct 27, 2026
f552b84
Refuse a card URL from another host, account or recording type in cro…
robzolkos Oct 27, 2026
93e7f1f
Hold a --to-card-table URL to the host and account checks, and to --t…
robzolkos Oct 27, 2026
79a1a2f
Warn that a copy may have started unless Basecamp answered with a 4xx
robzolkos Oct 27, 2026
a276330
Refuse to move or copy a record that the card read shows is not a card
robzolkos Oct 27, 2026
941beca
Stop calling a wormhole the only way to move a card across projects
robzolkos Oct 27, 2026
3a1d3d1
Refuse a --to-card-table URL whose project is not the card table's
robzolkos Oct 27, 2026
539bc14
Bump basecamp-sdk to v0.30.0
robzolkos Oct 27, 2026
6e58403
Bump basecamp-sdk to v0.31.0
robzolkos Oct 28, 2026
d0c4cac
refactor(commands): share the move and copy flow across recording typ…
robzolkos Oct 28, 2026
2f4c9a7
feat(projects): set and remove a project's logo (#974)
robzolkos Oct 28, 2026
42b1bf8
Merge remote-tracking branch 'origin/main' into card-moves-cross-project
robzolkos Oct 28, 2026
044700e
Say a destination's To-dos is turned off, rather than that it has no …
robzolkos Oct 28, 2026
dabb088
Name the schedule in a recurring project event's entries follow-up
robzolkos Oct 28, 2026
4aa1277
Let todos move --list take the project's To-dos again
robzolkos Oct 28, 2026
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
1,125 changes: 1,114 additions & 11 deletions .surface

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions .surface-breaking
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ ARG basecamp message 01 [body]
ARG basecamp react 00 <content>
ARG basecamp reopen 00 <id|url>...
ARG basecamp todo 00 <content>
ARG basecamp todos move 00 <id|url>
ARG basecamp todos position 00 <id|url>
ARG basecamp todos reorder 00 <id|url>
ARG basecamp unassign 00 <id>
ARG basecamp unassign 00 <todo_id>
ARG basecamp upload archive 00 <id|url>
Expand Down Expand Up @@ -1502,3 +1505,11 @@ FLAG basecamp checkin question notify --digest-include-unanswered type=bool
FLAG basecamp checkin question notify --no-digest-include-unanswered type=bool
FLAG basecamp checkins question notify --digest-include-unanswered type=bool
FLAG basecamp checkins question notify --no-digest-include-unanswered type=bool
ARG basecamp docs move 00 <id|url>
ARG basecamp documents move 00 <id|url>
ARG basecamp file move 00 <id|url>
ARG basecamp files move 00 <id|url>
ARG basecamp folders move 00 <id|url>
ARG basecamp vault move 00 <id|url>
ARG basecamp vaults move 00 <id|url>
ARG basecamp tools move 00 <id>
35 changes: 24 additions & 11 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github.

| Status | Sections | Endpoints |
|--------|----------|-----------|
| ✅ Implemented | 55 | 213 |
| ✅ Implemented | 57 | 218 |
| ⚠️ Blocked | 0 | 0 |
| ⏭️ Out of scope | 4 | 12 |
| **Total tracked** | **59** | **225** |

**213 of 213 tracked in-scope endpoints.** Uploading and removing a project's
| **Total tracked** | **61** | **230** |

**218 of 218 tracked in-scope endpoints.** Uploading and removing a project's
logo (`PUT`/`DELETE /projects/:id/logo.json`, added by
[bc3#14174](https://github.com/basecamp/bc3/pull/14174) and shipped in
[SDK v0.30.0](https://github.com/basecamp/basecamp-sdk/releases/tag/v0.30.0))
land as `projects logo`. Moving and copying a recording
into another project (`POST /recordings/:id/moves.json`,
`POST /recordings/:id/copies.json`) and reading the background filing they
start (`GET /filings/:id.json`, also read on its own by `filings show`), modelled by
[basecamp/basecamp-sdk#1053](https://github.com/basecamp/basecamp-sdk/pull/1053),
land for cards as `cards move --to-project`/`--to-card-table` and `cards copy`, and for
to-dos, to-do lists and groups as `todos move|copy`, `todolists move|copy` and
`todolistgroups move|copy` with `--to-project`/`--to-list`/`--to-todoset`. Uploading and removing a project's
wallpaper (`PUT`/`DELETE /projects/:id/wallpaper.json`, added by
[bc3#14062](https://github.com/basecamp/bc3/pull/14062) and shipped in
[SDK v0.28.0](https://github.com/basecamp/basecamp-sdk/releases/tag/v0.28.0))
Expand Down Expand Up @@ -205,18 +216,19 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A
|---------|-----------|-------------|--------|-------|----------|-------|
| **Core** |
| projects | 9 | `projects` | ✅ | BC4 | - | list, show, create, update, delete |
| projects (logo) | 2 | `projects logo` | ✅ | BC5 | - | set (`PUT /projects/:id/logo.json`, multipart `logo`), remove (`DELETE` on the same route; 204 with or without a logo). PNG, JPEG, GIF, WebP, AVIF, HEIF or HEIC up to 5 MB, checked before the upload using a local MIME hint; the server applies its shared image validations. People and agents who can edit the project may change it; others get 403. Project JSON carries `logo {url}` when one is attached, so `projects show` shows it. `GET /projects/:id/logo.json` only redirects to the image `logo.url` already names, so the CLI does not wrap it. Project-scoped via `--in`/`--project` |
| projects (wallpaper) | 2 | `projects wallpaper` | ✅ | BC5 | - | set (`PUT /projects/:id/wallpaper.json`, multipart `wallpaper` with an optional `mode`), remove (`DELETE` on the same route; 204 with or without a wallpaper). `mode` sends `wallpaper_mode` through the existing `PUT /projects/:id.json`, with the current name the SDK requires. PNG, JPEG, GIF, WebP, AVIF, HEIF or HEIC up to 10 MB, checked before the upload using a local MIME hint; the server applies its shared image validations. People and personal assistants follow project-edit permissions; shared agents are refused with 403. Project JSON carries `wallpaper {url, mode}` when one is attached, so `projects show` shows it. Project-scoped via `--in`/`--project` |
| todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`, plus `subtasks_count`/`subtasks_completed_count`/`subtasks_url`; edit via `subtasks`). `todos create --loose` creates on the to-do set, outside any list |
| todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position, copy (BC5: `steps` shown on `todos show`, plus `subtasks_count`/`subtasks_completed_count`/`subtasks_url`; edit via `subtasks`). `todos create --loose` creates on the to-do set, outside any list |
| subtasks | 8 | `subtasks` | ✅ | BC5 | - | list (`GET /recordings/:id/subtasks.json`, paginated), show, create (`POST` on the same list route), update (partial; `--no-due`/`--no-assignees` clear), complete/uncomplete (`POST`/`DELETE /subtasks/:id/completion.json`), move (`PUT /subtasks/:id/position.json`, 1-based), delete. Account-scoped flat routes (bc3#12659), so no `--in`. Parents are to-dos and cards only. Same `Kanban::Step` records as `card_table_steps` |
| todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position |
| todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position, copy |
| 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 |
| dock_tools | 7 | `tools` | ✅ | BC4 | - | Dock tool management: show, update, trash, enable, disable, reposition. `create` is BC5-only (create-by-type: `POST /buckets/{id}/dock/tools.json`), replacing the removed clone call; create-time `visible_to_clients` behind `tools create --visible-to-clients` (chat/kanban only) |
| todolist_groups | 8 | `todolistgroups` | ✅ | BC4 | - | list, show, create, update, position, copy |
| dock_tools | 7 | `tools` | ✅ | BC4 | - | Dock tool management: show, update, trash, enable, disable, reposition (also `tools move --position`; `tools move --to-project` moves a tool to another project through recordings (moves, copies)). `create` is BC5-only (create-by-type: `POST /buckets/{id}/dock/tools.json`), replacing the removed clone call; create-time `visible_to_clients` behind `tools create --visible-to-clients` (chat/kanban only) |
| **Hill Charts** |
| hill_charts | 2 | `hillcharts` | ✅ | BC4 | - | show, track/untrack todolists |
| gauges | 7 | `gauges` | ✅ | BC4 | - | list, needles, needle, create, update, delete, enable/disable |
| **Communication** |
| messages | 10 | `messages`, `message` | ✅ | BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active |
| messages | 10 | `messages`, `message` | ✅ | BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active. `move`/`copy` file a message onto a message board in another project (see recordings (moves, copies)) |
| message_boards | 3 | `messageboards` | ✅ | BC4 | - | Container, accessed via project dock |
| message_types | 5 | `messagetypes` | ✅ | BC4 | - | list, show, create, update, delete. Bucket-scoped (`/buckets/{id}/categories…`); commands are project-scoped via `--in`/`--project` |
| campfires | 14 | `chat` | ✅ | BC4 | - | list, messages, post, line show/update/delete. @mentions in content |
Expand All @@ -231,7 +243,8 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A
| card_table_cards | 9 | `cards` | ✅ | BC4 | - | list, show, create, update, move |
| 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. The card-scoped aliases of `subtasks`, which bc3 keeps serving; `cards step move --position` is 1-based (the SDK refuses 0 since basecamp/basecamp-sdk#883) |
| 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) |
| 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 and reports the new card once it lands |
| recordings (moves, copies) | 3 | `cards move --to-project`, `cards copy`, `todos move --to-project`/`--to-list`, `todos copy`, `todolists move --to-project`, `todolists copy`, `todolistgroups move --to-list`, `todolistgroups copy`, `messages move`, `messages copy`, `files move --to-project`, `files copy`, `cards column move --to-project`, `cards column copy`, `schedule move`, `schedule copy`, `checkins question move`, `checkins question copy`, `forwards move`, `forwards copy`, `tools move --to-project`, `filings show` | ✅ | BC5 | - | `POST /recordings/:id/moves.json` and `POST /recordings/:id/copies.json` file a card into a card table in this or another project, landing in Triage (Done if completed); `GET /filings/:id.json` reports the background filing, which the commands wait on before showing the new card, and which `filings show <id>` reads when a command's wait ends first (status, type, source, and once completed the recording it made). `--to-project` picks the project's only card table on its dock, `--to-card-table` one by ID, URL or name. `cards copy --include-comments` sends `include_comments`; a copy is never retried. To-dos file into a to-do list in any project (several IDs file one by one, and any failure fails the command naming what moved); a to-do list files into a project's To-dos (`--to-todoset` when it has several); a group files into a to-do list and stays a group. `--to-list`/`--to-todoset` take an ID, URL or a name looked up in `--to-project`; Basecamp refuses a group or an archived list as the destination. `messages move`/`copy` file a message onto a message board (`--to-project`, `--to-board`); a copy with no destination goes onto the message's own board. `files move --to-project`/`--to-folder` and `files copy` (also on `uploads`, `docs`, `vaults` and their aliases) file documents, uploads, cloud files, Google documents and folders into a project's Docs & Files, a folder inside it as `parent_id`; several IDs are filed one by one, as the web's bulk Move/Copy does, and `files show` reads the cloud files and Google documents they make. `cards column move --to-project`/`--to-card-table` and `cards column copy` file a column with its cards onto a card table; Triage, Not now, Done and On hold are refused before the request. `schedule move`/`copy` file an event onto a schedule (`--to-project`, `--to-schedule`); a recurring event, whose read answers with an occurrence, files as its whole series. `checkins question move`/`copy` file a question, with its answers on a move, into a project's Automatic Check-ins (`--to-project`, `--to-questionnaire`). `forwards move`/`copy` file an email forward into a project's Email Forwards (`--to-project`, `--to-inbox`), which must be turned on there; a turned-off one is named with the `tools enable` command. `tools move --to-project` moves a whole dock tool to another project with `destination_project_id`; tools are not copied, as on the web, and doors are refused |
| **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 |
Expand All @@ -245,7 +258,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A
| **Files & Documents** |
| uploads | 8 | `files`, `uploads` | ✅ | BC4 | - | list, show, create, update, download, versions (`files versions <id>`), replace (`files replace <id> <file>`); trash/archive/restore go through `recordings`. Create supports `--visible-to-clients` (root vault only) |
| vaults | 8 | `files`, `vaults` | ✅ | BC4 | - | list, show, create |
| vaults (move) | 1 | `files move` | ✅ | BC5 | - | `POST /recordings/:id/filing.json`: moves a document, upload or folder into another folder in the same project, in place (same id and comments). `--to` takes a folder ID or URL; `--position` is 1-indexed. Flat route, so no `--in` |
| vaults (move) | 1 | `files move` | ✅ | BC5 | - | `POST /recordings/:id/filing.json`: moves a document, upload or folder into another folder in the same project, in place (same id and comments). `--to` takes a folder ID or URL; `--position` is 1-indexed. Flat route, so no `--in`. Moves and copies into another project go through recordings (moves, copies) with `--to-project`/`--to-folder` |
| 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) |
| embeds | 2 | `embeds` | ✅ | BC5 | - | create, show. Rich-text commands also embed a URL that stands alone on its line in Markdown content, through the same create |
Expand Down
9 changes: 0 additions & 9 deletions e2e/cards_columns_steps.bats
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,6 @@ load test_helper
assert_output_contains "ID required"
}

@test "cards column show without project shows error" {
create_credentials
create_global_config '{"account_id": 99999}'

run basecamp cards column show 456
assert_failure
assert_output_contains "project"
}


# Column create errors

Expand Down
2 changes: 1 addition & 1 deletion e2e/errors.bats
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ load test_helper

run basecamp todos position --to 1
assert_failure
assert_json_value '.error' '<id|url> required'
assert_json_value '.error' '<id|url>... required'
assert_json_value '.code' 'usage'
}

Expand Down
54 changes: 54 additions & 0 deletions e2e/projects.bats
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,57 @@ load test_helper
run basecamp projects wallpaper remove skyline.png
assert_failure
}


@test "projects logo without subcommand shows help" {
run basecamp projects logo
assert_success
assert_output_contains "COMMANDS"
assert_output_contains "set"
assert_output_contains "remove"
}

@test "projects logo set help names the formats and the size cap" {
run basecamp projects logo set --help
assert_success
assert_output_contains "PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC files up to 5 MB"
}

@test "projects logo set without a file shows error" {
create_credentials
create_global_config '{"account_id": 99999, "project_id": 123}'

run basecamp projects logo set
assert_failure
assert_json_value '.error' '<file> required'
assert_json_value '.code' 'usage'
}

@test "projects logo set refuses an SVG" {
create_credentials
create_global_config '{"account_id": 99999, "project_id": 123}'
printf '<svg></svg>' > brand.svg

run basecamp projects logo set brand.svg
assert_failure
assert_json_value '.error' 'brand.svg: unsupported image type image/svg+xml (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)'
assert_json_value '.code' 'usage'
}

@test "projects logo set refuses a file over 5 MB" {
create_credentials
create_global_config '{"account_id": 99999, "project_id": 123}'
dd if=/dev/zero of=big.png bs=1 count=1 seek=5242880 2>/dev/null

run basecamp projects logo set big.png
assert_failure
assert_json_value '.error' 'big.png exceeds maximum logo size of 5 MB'
}

@test "projects logo remove takes no arguments" {
create_credentials
create_global_config '{"account_id": 99999, "project_id": 123}'

run basecamp projects logo remove brand.png
assert_failure
}
4 changes: 0 additions & 4 deletions e2e/smoke/smoke_aliases.bats
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,6 @@ load smoke_helper
mark_out_of_scope "Alias for tools trash — tested via canonical form"
}

@test "tools move is out of scope" {
mark_out_of_scope "Alias for tools reposition — tested via canonical form"
}

@test "tools rename is out of scope" {
mark_out_of_scope "Alias for tools update — tested via canonical form"
}
Expand Down
24 changes: 24 additions & 0 deletions e2e/smoke/smoke_cards_column_write.bats
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ setup_file() {
assert_json_value '.ok' 'true'
}

@test "cards column copy copies a column onto its own card table" {
local id_file="$BATS_FILE_TMPDIR/column_id"
[[ -f "$id_file" ]] || mark_unverifiable "No column created in prior test"
local col_id
col_id=$(<"$id_file")

run_smoke basecamp cards column copy "$col_id" --json
assert_success
assert_json_value '.ok' 'true'
assert_json_value '.data.source_id' "$col_id"
assert_json_not_null '.data.id'
}

@test "cards column move refuses the card table the column is on" {
local id_file="$BATS_FILE_TMPDIR/column_id"
[[ -f "$id_file" ]] || mark_unverifiable "No column created in prior test"
local col_id
col_id=$(<"$id_file")

run_smoke basecamp cards column move "$col_id" --to-card-table "$QA_CARDTABLE" --json
assert_failure
assert_json_value '.ok' 'false'
}

@test "cards column watch watches a column" {
local id_file="$BATS_FILE_TMPDIR/column_id"
[[ -f "$id_file" ]] || mark_unverifiable "No column created in prior test"
Expand Down
13 changes: 13 additions & 0 deletions e2e/smoke/smoke_cards_write.bats
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ setup_file() {
assert_json_not_null '.data.id'
}

@test "cards copy copies a card onto its own card table" {
local id_file="$BATS_FILE_TMPDIR/card_id"
[[ -f "$id_file" ]] || mark_unverifiable "No card created in prior test"
local card_id
card_id=$(<"$id_file")

run_smoke basecamp cards copy "$card_id" --json
assert_success
assert_json_value '.ok' 'true'
assert_json_value '.data.source_id' "$card_id"
assert_json_not_null '.data.id'
}

@test "cards columns lists columns" {
run_smoke basecamp cards columns --card-table "$QA_CARDTABLE" -p "$QA_PROJECT" --json
assert_success
Expand Down
26 changes: 26 additions & 0 deletions e2e/smoke/smoke_checkins_write.bats
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,29 @@ setup_file() {
assert_success
assert_json_value '.ok' 'true'
}

@test "checkins question copy copies a question into its own check-ins" {
local id_file="$BATS_FILE_TMPDIR/question_id"
[[ -f "$id_file" ]] || mark_unverifiable "No question created in prior test"
local qid
qid=$(<"$id_file")

run_smoke basecamp checkins question copy "$qid" --json
assert_success
assert_json_value '.ok' 'true'
assert_json_value '.data.source_id' "$qid"
assert_json_not_null '.data.id'
}

@test "checkins question move refuses the check-ins the question is in" {
local id_file="$BATS_FILE_TMPDIR/question_id"
[[ -f "$id_file" ]] || mark_unverifiable "No question created in prior test"
local qid
qid=$(<"$id_file")

# A cross-project move would take the QA question away; the move itself
# is covered against local bc3.
run_smoke basecamp checkins question move "$qid" --to-project "$QA_PROJECT" --json
assert_failure
assert_json_value '.ok' 'false'
}
Loading
Loading