Repository navigation
feat(cards): move and copy cards to other projects - #973
Conversation
…rd table name two tables share A 5xx or dropped connection on the copy request can follow a copy Basecamp already started, so the error is no longer retryable and says how to check. A rate limit stays retryable: Basecamp refuses it before copying. A --to-card-table name that matches two tables now lists both instead of taking the first.
…mit gate output.AsError renders a gate error found anywhere in the cause chain in place of the error around it, so a gate trip while polling turned "do not copy it again" into a retryable rate limit. The in-progress and read-back errors now keep their cause as text.
Reading a moved record's old id redirects to the record at its new id, but cards show, cards steps and todos show kept using the old id for the full step list, comments and follow-up commands, which then 404.
There was a problem hiding this comment.
🟡 Changes recommended
URL validation and explicit source-project checks remain incomplete, and uncertain copy failures can omit duplicate-copy warnings.
4 open issues
What changed in this PR
Adds cross-project card moves and copies to the CLI, including background-operation tracking and support for reading moved records through their old IDs.
Changes:
- Adds
cards copyand destination flags for card moves, with optional comment copying. - Reports landed cards after moves and follows updated IDs when reading cards, to-dos and steps.
- Updates the SDK dependency, MCP catalog, documentation and tests. The SDK release and repinning remain prerequisites for merging.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
| File | Description |
|---|---|
nix/package.nix |
Updates the dependency vendor hash. |
internal/version/sdk-provenance.json |
Records the pinned SDK revision. |
internal/output/codes.go |
Adds the background-work status code. |
internal/mcpserver/testdata/catalog_snapshot.txt |
Lists three new recording actions. |
internal/mcpserver/model/PROVENANCE.json |
Updates model source provenance. |
internal/mcpserver/model/openapi.json |
Adds move, copy and filing schemas. |
internal/mcpserver/model/behavior-model.json |
Adds operation behavior metadata. |
internal/mcpserver/catalog_test.go |
Updates the served operation count. |
internal/commands/todos.go |
Uses moved to-dos’ new IDs. |
internal/commands/subtasks_test.go |
Covers step reads after record moves. |
internal/commands/commands.go |
Registers the copy action in the catalog. |
internal/commands/cards.go |
Implements card filing, polling and destination resolution. |
internal/commands/cards_test.go |
Tests landed and pending wormhole moves. |
internal/commands/cards_filing_test.go |
Covers filing destinations, failures and polling. |
go.sum |
Updates SDK checksums. |
go.mod |
Pins the SDK commit providing filing APIs. |
e2e/smoke/smoke_cards_write.bats |
Adds a card-copy smoke test. |
API-COVERAGE.md |
Documents filing endpoint coverage. |
.surface |
Records the new command and flags. |
Review effort: Balanced
…ther project cards move --to-project/--to-card-table and cards copy took the card id and ignored an explicit --in, --project or card URL bucket, so a card in project 123 moved even when the caller said 888. They now check those against the card's own project before filing it, as the wormhole move already did. The check is renamed rejectCardProjectConflict and its hint no longer names wormholes. Config and interactive defaults are not constraints.
…ss-table moves and copies cards copy and cards move --to-project/--to-card-table reduced a pasted URL to its trailing number, so a look-alike host, another account's card, a to-do or a cards listing filed whichever card in this account had that number. The URL must now be a single card on a trusted Basecamp host in the session's account, checked before any request, as files replace and boost update do.
…o-project A destination card table URL was reduced to its number without checking the host or account, and its project was ignored when --to-project named another. It is now held to the same checks as the card URL, a project that disagrees with --to-project is refused, and the destination is resolved before the card is read so a bad URL is refused before any request.
A dropped connection or an unreadable answer to the copy request came back as a plain error, which skipped the "may have started the copy" warning because only retryable errors got it. The rule is inverted: every failed copy request gets the warning and is not retryable, except a 4xx answer, which shows Basecamp refused it, and a rate limit, which stays retryable. Moves are unchanged.
A moved message's old id redirects through the card endpoint to the message, so the read alone does not prove the id names a card.
|
Repinned to the released basecamp-sdk v0.31.0 ( |
…es (#975) * Extract the move and copy flow into a shared recording filing Cards move and copy start a filing through Basecamp's generic recording moves and copies, wait for it, and read back what it made. None of that is specific to cards, and the next families (to-dos, messages, Docs & Files, schedule entries, dock tools) file the same way. recordingFiling now carries one filing as data: kind, source id and noun, destination container or (for a dock tool) destination project, include comments, and the per-type strings and read-back hook. runFiling owns the flow and its invariants: start once, a copy that fails without a 4xx or rate limit is reported non-retryable with where to look, a bounded wait, an in_progress error whose cause cannot turn retryable, and a read-back that names the new id when it fails. parseFilingRef and the project conflict check move with it; the conflict check takes the noun. Cards keep their output, errors, hints and exit codes unchanged. * feat(todos): move and copy to-dos, lists and groups to other projects (#976) * Move to-dos into a list in another project, and stop a foreign --list passing todos move (todos position) gains --to-project and --to-list, which file to-dos into a to-do list in any project through Basecamp's recording moves, as the web's Move… does. --to-list takes a list ID, URL, or a name looked up in --to-project; --to-project alone needs a project with one list, and several lists or a name two lists share are refused with their IDs. A group or an archived list is refused, as Basecamp refuses it, and so is the list the to-do is already in (or the list holding its group), with the reorder command as the hint. Several to-dos file one by one, as the web's bulk Move does: every one is read before any is filed, each is reported, and any failure fails the command naming what moved by its new id. A --list naming a list in another project used to reach the reposition endpoint, which older Basecamp answered with success while nothing moved and current Basecamp answers with a bare 422. The command now reads the to-do and the list first and refuses, naming the todos move --to-project --to-list command that does it. Reordering, and moving to another list in the same project with --to/--list, are unchanged; --to with several to-dos is refused instead of silently using the first. The shared filing gains fileRecording, which files one recording and returns what it made, and runFilings, which files several and reports each. * Copy to-dos into a list in this or another project todos copy files copies of to-dos into a to-do list, as the web's Copy… does, with the same --to-project and --to-list as todos move. With no destination each copy goes into its to-do's own list; a to-do in a group is copied into the group's list, since Basecamp files into lists, not groups. --include-comments copies the comments too. Several to-dos copy one by one and each is reported; no copy is ever retried, and one that fails without a 4xx says where to look before copying again. * Move a to-do list or group to another project todolists move (todolists position) and todolistgroups move (todolistgroups position) gain --to-project, --to-todoset and --to-list, which file a list or a group through Basecamp's recording moves, as the web's Move… does. Which it is decides where it goes: a top-level list into a project's To-dos (the only enabled one, or --to-todoset when there are several), a group into a to-do list, where it stays a group. --to-list for a list and --to-todoset for a group are refused with the flag that fits, as are the reorder flags alongside them and the group's own --list. Moving onto the To-dos or list it is already in is refused with the reorder command. A project with its To-dos turned off is named, with the tools enable command. Every alias group (todolist, todolistgroup, tlgroups, tlgroup) reaches the same command. Reordering without the new flags is unchanged. * Copy a to-do list or group to this or another project todolists copy, reached from every to-do list and group alias, files a copy of a list or a group with its to-dos, as the web's Copy… does. A list goes into a project's To-dos, a group into a to-do list, with the same flags as todolists move; with no destination the copy goes next to the original. --include-comments copies the comments too, and a copy is never retried. * Record the to-do moves and copies in the surface, coverage, smoke and evals .surface takes the new flags and copy leaves. todos move/position/reorder now take several IDs, so their old single-ID ARG lines are acknowledged in .surface-breaking, and the e2e usage error now reads "<id|url>... required". API-COVERAGE lists the to-do, list and group filings under recordings moves and copies. Smoke covers todos copy, todolists copy and todolistgroups copy with same-project copies on QA data, trashing the to-do and list copies. Skill-eval cases ask, in plain words, to move a to-do across projects by name, copy one with comments, move three at once, move a group and move a list. * Ask to run a bulk move or copy again only for what Basecamp refused A bulk copy labelled a copy that was made but not read back, or one that may have started, as failed, and its hint said to run the failed ones again, which makes another copy. Each filing now reports how far it got: filed, filed but not read back, not settled, or refused before filing. The hint tells the agent not to repeat the first three, naming each, and offers a rerun only for the refusals, by id. * Move and copy loose to-dos into a list A loose to-do sits on its project's To-dos rather than in a list, and reading its parent as a to-do list answered 404 before anything was filed. A loose to-do now has no list to exclude, so it moves or copies into any list, as Basecamp allows. A copy with no destination has no list of its own to go into, so it is refused with the --to-list command to run. * Refuse a to-do named twice by its old and new ids A moved to-do's old id reads as its new one, so after a move from 111 to 222, copying 111 and 222 filed to-do 222 twice: the duplicate check compared the ids as typed. The check now compares the to-dos read before anything is filed, and names both arguments. * Point copy recovery at a listing that shows the copy A copy keeps the source's completion and status, and the list commands show only incomplete, active items one page at a time unless asked. The hint now asks for every page in the source's state, so a copy that did land is found. * Point every copy recovery at one listing that finds the copy in any status Patching each list command for completed, archived and trashed copies kept missing a state. Basecamp's recordings listing filters by type and status, so a copy of a to-do, list, group or card is found newest first whatever state it was made in. * Show a to-do list by its ID without a project todolists show read the list by its account-wide ID but still demanded a project, so the filing recovery that points at it failed with no default project configured. * Refuse a group's --list on a copy with no destination todolistgroups copy 311 --list 901 copied the group next to the original and ignored --list, so a caller who meant list 901 got a copy somewhere else and a success. --list names the group's own list on that command group, so a filing now refuses it whether or not a destination is set, with the --to-list hint, before anything is read or created. cards copy already refuses --card-table the same way. * Carry --profile and --account into every command a filing prints A copy run with --account or --profile that failed ambiguously told the caller to check a recordings listing that, pasted into a fresh shell, ran against the default profile and account. It could miss a copy Basecamp had made and invite a second one. The filing helper now takes the follow-up scope, read before the command resolves its account as the template copy does, and adds it to every command it prints: the copy recovery listing, the show commands in failure, still-running and not-read-back hints, the bulk report, and the breadcrumbs of what it filed. runFiling and runFilings require the scope, so a family that files through them cannot leave it out. * Keep the auth remedy for the credential sent when several filings fail A 401 while filing several to-dos ended in a report that kept only each failure's text, so App.Err no longer saw the SDK error and left the bare "Run: basecamp auth login" in the hint. Under --profile work that logs into the default profile; under BASECAMP_TOKEN no login helps. runFilings now settles the remedy on each failure before the report drops the cause, through App.WithAuthRemedy, which App.Err already used. The report stays non-retryable. A 401 does not stop the remaining to-dos. Every to-do is read before any is filed, so a rejected credential fails there, before the loop; one rejected mid-run is refused before anything is filed for each later to-do too, and the report already asks to run those again. * Find an unanswered copy by its title, since it keeps its original's dates A copy whose start got no answer told the caller to list the destination's recordings sorted by created_at, newest first, as if the copy would top the list. Basecamp's Recording::Copier copies created_at from the original, so the copy sorts where the original would, and on a busy list or card table the caller looked at the top, saw nothing, and could copy again. The hint now lists the destination's recordings in the source's status without a sort, then says to look for the original's title in the destination container by name and id. The --profile and --account scope stays on the listing command, ahead of that sentence. * Refuse a container flag that a move or copy would ignore todos copy 456 --todolist 901 copied the to-do into its own list and reported success: --todolist is a root flag todos copy never reads. todolistgroups copy --list failed the same way and was fixed on its own, as were cards copy --card-table and files move --vault. Every command that moves or copies recordings now goes through guardFilingFlags, which names the container flags the command inherits and reads. Any other one that was set, the root's --todolist or a flag the command's group adds (--card-table, --vault, --folder, --list), is refused before anything is read or sent, with the command's own --to-<container> flags as the hint. The three one-off refusals go. --project and --in stay with each command, which holds a filed recording to them. The same-project modes are covered too: todos move --to 1 --todolist and todolistgroups position --list, which ignored the flag, now refuse it. cards move and cards column move read --card-table, so they keep it. The test walks the whole command tree: every move or copy command declares what it reads, and each container flag it inherits but does not read is refused with no request sent. * feat: move and copy messages, files, columns, events, check-ins and forwards to other projects (#977) * Generalize the recording filing for every family the web moves and copies The next families (messages, Docs & Files, columns, schedule entries, check-in questions, email forwards) file the same way cards and to-do lists do, into a tool on a project's dock. The filing helper grows what they share: - resolveFilingTarget finds the container named by --to-project and the family's own --to-<container> flag: a project's only enabled tool of the kind, one picked by ID or title, or an ID or URL alone. Ambiguous and duplicate titles list the choices; a project without one is named, and a disabled one says how to turn it on. The To-dos tool a to-do list goes into is found the same way, so resolveFilingTodoset is now a container description over it. - filingFamily describes a family as data (its noun, types, URL kinds, destination, read), and newFilingCmd builds its move and copy commands from it: own-container flags are refused, a move onto the container the recording is in is refused, a copy with no destination goes onto it. Several recordings go through runFilings, as to-dos do. - A filing carries parent_id for a folder inside Docs & Files, and parseFilingRefOf accepts a URL of several kinds. * Move and copy messages to another project's message board messages move <id|url> --to-project <project> [--to-board <board>] files a message onto a message board in another project, as the web's Move... does, and messages copy does the same as Copy..., onto the message's own board when no destination is given, with --include-comments. Both wait for Basecamp's background filing and print the new message with its source_id. A move onto the board the message is on, the group's --message-board (which names the message's own board), an id that reads back as another type, and a project the --in flag contradicts are refused before anything is sent. The old id redirects to the new one, so the helper's move-still-running hint holds for messages. * Move and copy Docs & Files items to another project files move <id|url>... --to-project <project> [--to-folder <folder>] files documents, uploads, cloud files, Google documents and folders into another project's Docs & Files, as the web's Move... does, and files copy does the same as Copy..., into the original's own folder when no destination is given. Several IDs file one by one, as the web's bulk Move/Copy does, and the run fails naming what each became if any failed. --to-folder takes any folder by ID or URL, nested ones included: the filing goes to the project's Docs & Files with the folder as parent_id, found by walking the folder's parents to the docked vault. With --to-project it also takes a top-level folder's title, and a number that names no folder is read as a title, since folders can be called 2026. files move --to keeps moving within the project in place, unchanged. A --to folder in another project now points at --to-folder instead of the web's Move menu, and --to-folder in the item's own project points back at --to, which keeps the item's ID. The same commands are on uploads, docs, vaults and their aliases, so an agent reaching for any of them lands on them. The old ids of moved documents and uploads redirect, so the move-still-running hint holds. * Move and copy card table columns to another project cards column move <id|url> --to-project <project> [--to-card-table <table>] moves a column with its cards onto a card table in another project, as the web's Move... does, and cards column copy does the same as Copy..., onto the column's own table when no destination is given. --position keeps repositioning a column within its table, unchanged, and cannot be combined with the new flags. Triage, Not now, Done and On hold, which Basecamp refuses to file alone, are refused before the request with a pointer at moving their cards; moving onto the column's own table points at --position. The moved column's old id redirects, so the move-still-running hint holds. * Move and copy schedule entries to another project's schedule schedule move <id|url> --to-project <project> [--to-schedule <schedule>] moves an event onto a schedule in another project, as the web's Move... does, and schedule copy does the same as Copy..., onto the event's own schedule when no destination is given. A recurring event moves and copies as a whole series. Basecamp answers its read with an occurrence (redirecting, and minting one), whose parent is the event, so the event files by that parent and the output names the new event by its id with the occurrence read as occurrence_id; its own schedule is its project's. An occurrence URL files its series. The group's --schedule, which names the event's own schedule, is refused. The old id of a moved event redirects, so the move-still-running hint holds. * Move and copy check-in questions to another project's check-ins checkins question move <id|url> --to-project <project> [--to-questionnaire <id|url>] moves a question, with its answers, into another project's Automatic Check-ins, as the web's Move... does, and checkins question copy does the same as Copy..., into the question's own Automatic Check-ins when no destination is given. The group's --questionnaire, which names the question's own check-ins, is refused, as is a move into the check-ins the question is in. The old id of a moved question redirects, so the move-still-running hint holds. * Move and copy email forwards to another project's Email Forwards forwards move <id|url> --to-project <project> [--to-inbox <id|url>] moves an email forward, with its replies, into another project's Email Forwards, as the web's Move... does, and forwards copy does the same as Copy..., into the forward's own Email Forwards when no destination is given. Email Forwards must be turned on in the destination. When it is off, the command says so and names the tools enable command that turns it on, rather than reporting that the project has none. The group's --inbox, which names the forward's own Email Forwards, is refused, as is a move into the one it is in. The old id of a moved forward redirects, so the move-still-running hint holds. * Move a whole dock tool to another project with tools move --to-project tools move <id|url> --to-project <project> moves a Message Board, Card Table, To-dos, Docs & Files, Schedule, Automatic Check-ins, Chat or Email Forwards, with everything in it, to another project's dock, as the web's Move... does, sending destination_project_id. Tools are not copied, as on the web. tools move was an alias of tools reposition. It is now its own command: with --position (and no --to-project) it still reorders the dock exactly as before, and the two cannot be combined. Doors, which Basecamp refuses to move, a nested folder that is not on the dock, a move to the tool's own project, and an old id whose tool already moved are refused before the request. A moved tool's old id does not redirect: /dock/tools/<old> reads it as deleted. So the filing gains movedHint, and a tool move still running points at the destination project's dock rather than at tools show <old id>, which would never find it. * Refuse a recording named twice in any bulk move or copy files copy 456 456 --to-project Dest made two copies, and naming a moved file by its old id beside its new one filed it twice: only to-dos checked for duplicates. The check to-dos made, comparing the recordings each argument read as rather than the arguments themselves, is now filingNames, shared by to-dos and every family's move and copy, and runs before anything is filed. * Find a copied cloud file or Google document, and every copy in any status files show probed only folders, uploads and documents, and files list lists only those, so a cloud file or Google document that a move or copy made could not be shown by the breadcrumb the command printed, and the "check before copying again" listing left the copy out. files show now also reads cloud files and Google documents (and takes them as --type). A copy of a cloud file is looked for in its project's timeline, every page, where it shows as reposted. No API listing or timeline shows a Google document, so a copy of one points at its folder's app_url to check in Basecamp. A copy keeps its source's status, and the listings the other families pointed at showed only active items one page at a time. Messages, Docs & Files items and schedule entries now point at recordingCopyLookFor in the source's status. Check-in questions and email forwards, which the recordings listing does not serve, ask for every page of their own listing, or the timeline for an archived one, which their listing leaves out. The Docs & Files help no longer tells agents to copy again what "failed": only what Basecamp refused. * File a recurring event from the schedule it is actually on A recurring event's read answers with an occurrence, which names the event but not its schedule, and the schedule was taken from the project's dock through /projects/<bucket>.json. An employee calendar is a Calendar bucket, not a project, so a recurring calendar event could not be moved or copied, and filing a recurring event into a calendar failed at the read-back after Basecamp had already moved it. A project with several schedules gave no schedule, so a copy with no destination was refused. seriesSchedule now reads a calendar's schedule from the calendar, and of a project's several schedules picks the one whose entries list the event. A copy into a calendar is looked for in that schedule's entries, since the recordings listing does not serve calendars. * Refuse a --to-folder title shared by the Docs & Files and a folder --to-folder "<title>" with --to-project matched the Docs & Files' own title before looking at its folders, so a folder named like the tool was never picked and the item silently landed at the top. The Docs & Files is now one candidate among the top-level folders: one match files there, several are refused listing each ID, the top marked as such. * Follow up every item a bulk move or copy filed, not only the first With no destination, files copy puts each copy in its own original's folder, so a bulk run can land items in several folders. The summary already names where each landed; the breadcrumbs came from the first item alone, which pointed the next step at one folder. They now gather each filed item's follow-ups, without repeats. * Find an archived recurring series' schedule among several A recurring event's read names no schedule, so with several schedules in its project the series' schedule is the one whose entries list it. Those listings show active entries unless asked for another status, so an archived (or trashed) series was found on none of them, and copying or moving it without a destination failed with "Could not tell which schedule". The lookup now lists entries in the event's own status. * Look for a drafted copy among drafts, not in an unsupported listing A copy keeps its source's status, and the copy recovery hints listed the destination's recordings in that status. The recordings listing serves only active, archived and trashed, so a drafted message's or document's copy that timed out pointed at a command Basecamp answers with 400. recordingCopyLookFor now emits only those three statuses and sends a drafted message, document or upload to basecamp drafts list --all, where the copy shows. No API listing shows a drafted schedule entry, so its copy, on a project schedule or a calendar, is to be checked in Basecamp. * Show a filed record by its ID without a project files show, schedule show, cards column show, checkins question show and forwards show read the record by its account-wide ID but still demanded a project, so the move and copy hints that point at them ("once it lands, … show <id> returns it", "show it with: …") failed with "Project ID required" when no default project was configured. Each now reads by ID alone and takes the project its breadcrumbs name from the record, as todolists show does. * Follow up a moved record on the id its show read back Reading a moved record's old id redirects to the record at its new id, but cards column show, files show, schedule show, checkins question show, forwards show, messages show and todolists show still named the requested old id in their follow-ups and comment reads, and the old id takes no writes. Each now uses the id the read returned, as cards show and todos show already do; a recurring event's follow-ups name the event, not the occurrence its read answers with. * Add filings show to read a move or copy by its filing A move or copy runs in the background, and when the command's wait ends first there was no way to read where it got to. filings show reads GET /filings/:id with no project and reports the status, type and source, and once completed the recording it made with the command that shows it. A failed move reads as safe to try again, as Basecamp's docs say a move is; a failed copy does not, since the docs say nothing of what one leaves behind. * Check a still-running move or copy by its filing, not by a listing Three review rounds in a row found a copy-recovery listing that missed some status for some type. When a move or copy is still running as the wait ends, Basecamp has already named its filing, which says where it got to whatever the recording's type and status, so every family's in_progress hint now names it and says to check it with basecamp filings show <id>. That replaces the per-type listings there, the moved-tool dock hint and the copy landing text. Listings remain only where no filing is known: a copy whose start request went unanswered. Columns, check-in questions and email forwards keep their own listings for an active copy and the timeline for an archived question or forward; where no listing shows the copy's status (an archived column, a trashed question or forward) the hint says to open the destination in Basecamp at its app_url. * Find an unanswered copy from one table of where each copy shows A copy whose start got no answer is checked for before copying again, and each family chose its own listing for that. Four review rounds each found a type and status whose listing could not show the copy; the latest was a trashed cloud file sent to the timeline, which leaves out trashed recordings. Checking the rest against bc3 found another: the timeline never shows a copied email forward, archived or not. copyListings in filing.go now holds every answer: for a recordable type, the status the copy keeps, and whether it is on a calendar, a listing known to show that copy, with the reason beside each listing. Every other place, a new type or status included, says to open the destination in Basecamp at its app_url, which for a nested folder is now the folder's own. The families' lookFor functions are gone. TestEveryCopyRecoveryCanShowTheCopy walks every type each family files in every status, on a project and a calendar, and accepts only the app_url form or a listing whose own evidence covers that place. * Refuse a destination URL that names nothing, never reading it as a title files copy --to-project Dest --to-folder https://3.basecampapi.com/99999/vaults/2026.json filed into Dest's folder titled "2026" when vault 2026 answered 404: the fallback to a title was kept from URLs that name a project, and a flat API URL names none. The same reading happened without any 404 in the two other resolvers that take an ID, URL or title with --to-project: a URL's id was matched against dock tool titles (--to-board, --to-schedule, --to-questionnaire, --to-inbox, --to-todoset, a column's --to-card-table) and against card table titles (a card's --to-card-table). A URL now names only an id. When that id is not a destination, the command fails with not_found naming the URL, and files nothing. * Tell a copy apart by its title, since it keeps its original's dates Basecamp's copier keeps the original's created_at and updated_at (read back live for a to-do, cloud file, question and email forward), so the recovery listing's --sort created_at --direction desc never put the copy first, and reading its top row as the copy was wrong. The listing drops the sort, and every recovery, listing or app_url, ends with what to match on: the original's title in the container the copy lands in. * Say once in a bulk line not to move or copy a still-running item again A still-running item's bulk line read "Do not move #11 again yet. Do not move it again. Check it with: basecamp filings show 11…": the bulk prefix repeated what the filing's own hint already says. A line whose hint already says it (a still-running filing's) now names the item and gives that hint: "#11: Do not move it again. Check it with: basecamp filings show 11…". Other unsettled lines keep their prefix. * Carry --profile and --account into what filings show prints A move or copy still running when its wait ended pointed at basecamp filings show <id>, and that command's own follow-ups (check again, or show the record it made) named neither profile nor account. Pasted into a fresh shell they read the default account, where the filing is not found, and a caller could take that as nothing having started. filings show now reads the follow-up scope before resolving its account, as every filing command does, and adds it to its breadcrumbs. The still-running hints already carry it, and each listing in the copy recovery table carries it ahead of the title to look for; the destination-page fallback needs none. The follow-ups test covers a message family's copy recovery and a still-running move and copy, and the table enumeration checks the scope on every listing. * Take a moved file's kind from what its old id reads back as files show <old id> of a moved document reported a folder. Basecamp redirects every kind's read of a moved item's old id to the item at its new id, so the folder read files show tried first answered with the document, decoded as a vault. The show dropped the document's content and advertised a folder-contents breadcrumb, and moved items are read by their old id whenever a caller follows up on a move. Auto-detection now goes through getFileItem, which the move and copy commands already use: it tries each kind and keeps the one whose record comes back as that kind. * Send a drafted copy missing from drafts list to the destination too A drafted message, document or upload whose copy got no answer was looked for in basecamp drafts list --all, which holds only the 250 most recently updated drafts. A copy keeps its original's updated_at, so the copy of an older draft can sit past that cap: the listing showed nothing, and a caller could copy again. A row of copyListings can now be marked capped. The drafts row is, and its hint adds that a copy missing from the listing is checked for on the destination's page before copying again. The table enumeration asserts that only the drafts listing carries it. * Refuse a container flag every web-parity move or copy would ignore Each family's move and copy refused the group flags in its own ownFlags list (--message-board, --schedule, --inbox, --questionnaire, --vault/--folder, --card-table) and let the root's --todolist through, which messages copy ignored and still reported success. newFilingCmd and tools move now go through guardFilingFlags, which refuses every container flag a command inherits but does not read, so ownFlags and its loop go. cards column move reads --card-table to reposition a column, so it keeps a refusal of that flag when moving to another card table, shared with cards move. The guard's table test now covers all 28 move and copy commands. * Name a calendar event's schedule in its entries follow-up schedule show of an event in an employee calendar suggested schedule entries --project <calendar>, which looks for the schedule on a dock the calendar does not have, and fails. The follow-up now names the schedule: the event's parent, or for a recurring event, whose parent is its series, the schedule the calendar links to. A project's events keep the follow-up as it was. * Ask projects show for a project ID in tools move's help and hints tools move's help, agent notes and two refusals sent agents to basecamp projects show <project>, but projects show takes only a numeric project ID. In a live agent eval, agents passed project names there and failed five times. The help and notes now say projects show <id> and point at basecamp projects list for the ID. The refusal of a recording that is not a dock tool names its project's ID, which the read already has. The refusal of a deleted or moved tool cannot know where the tool went, so it says <id> and names projects list too. * Say a card table is turned off, not missing, on a cross-project card move cards move and copy with --to-project into a project whose card table is turned off said the project has no card table and to add one there first, so an agent following it would make a second card table. Every other family's move and copy says the tool is turned off and how to turn it back on with basecamp tools enable. resolveFilingTable, written for cards before the shared filing helper existed, now goes through resolveFilingTarget with the card table container that cards column move already uses, then reads the card table in full as before. Cards get the shared refusals: turned off versus absent, ambiguous and unmatched names. Naming a card table by ID or URL alone now reads it twice, once to place it and once in full. * Name several To-dos tools without a doubled s, and make every container spell its plural A cross-project to-do list move into a project with two To-dos tools refused with "Dest has 2 To-doss that match": the To-dos container set no plural, so the message appended "s" to "To-dos". The container came in with todo-moves (#976); it is fixed here, at the top of the stack, rather than on that branch. The container now says "To-dos tools". The fallback that appended "s" is gone: every filingContainer spells its plural out, and a test reads the package's source and fails on any filingContainer literal without one, so the next container cannot fall into the same message. * Say what the schedule commands do when a project has several schedules The schedule group's agent notes said each project has one schedule. A project can have several Schedule tools, and the live agent eval met one. With one, the commands find it; with several, they refuse and list the schedule IDs to pass with --schedule, or --to-schedule for move and copy. The notes now say so. * Name the schedule in an event's entries command whenever its parent is one A project can have several Schedule tools, so after a --to-schedule move the suggested `schedule entries --project P` failed with "Multiple schedules found". The calendar-only lookup for a recurring event's schedule is unchanged.
* Share the wallpaper's file check and project read-back with other project images
The size cap and the noun in its refusal become arguments, and the
helpers lose their wallpaper names, so a project logo can use them as
they are.
* Add projects logo set and remove
bc3#14174 gives a project a logo, the image beside its name, through
PUT and DELETE /projects/:id/logo.json, and SDK v0.30.0 wraps both.
They follow projects wallpaper: the file is checked for size (5 MB) and
image type before the upload, the project comes from --project, --in or
the default, and each command answers with the project read back, which
now carries .logo {url} when one is attached.
* Say on a project's detail view whether it has a logo
The logo's only field is a signed-in redirect URL, no use to read in a
terminal, so the view says the logo is set and leaves the URL to JSON.
* Document project logos in the skill and API coverage
…list Moving or copying a to-do into a project whose To-dos tool is turned off said "has no to-do list" and hinted `todolists create`, which fails there too. It now gives the shared turned-off message and its `tools enable` hint, as every other family does.
A recurring event's parent is its series, not its schedule, so in a project the entries follow-up from schedule show (and show --date) left out --schedule. With several schedules, running it failed with "Multiple schedules found". It now looks the schedule up for project events as it already did for calendar events: one project read, plus a listing per schedule only when the project has several. Main resolved the project on every show, so this costs no more than main did, and only for recurring events.
On main, todos move <id> --to <n> --list <To-dos id> passed the id to the reposition, which takes a project's To-dos and moves the to-do out of its list. The same-project check read the destination as a list, so it 404ed and refused that move. It now falls back to reading To-dos, keeps the same-project check for it, and refuses To-dos in another project with the command that does move a to-do there: into a list. --to-list names a list for Basecamp's generic move and copy, which file a to-do or group into a list and never onto To-dos. A To-dos id there used to read as "not found"; it is now refused as To-dos, with the command that lists the lists in it.


Why
On the web, a card's Move… and Copy… menus send it to a card table in any project. The CLI could only cross projects through a wormhole someone had already set up. It couldn't copy a card, and after a wormhole move it never said where the card landed. CLI users miss all three.
What changed
cards move <card> --to-project <project>(or--to-card-table) moves the card to that project's card table.cards copy <card>copies it onto its own table or, with--to-project, another project's, and--include-commentsbrings the comments. Both wait for Basecamp to finish and print the new card. As on the web, the card lands in Triage, or in Done if it's completed.--to-wormholenow prints the card that landed, and--towith another project's column points to--to-project.cards show,cards stepsandtodos showfollow a moved card or to-do from its old id to its new one.Scope
reposition_todo's description changes. The built-in MCP server servesremove_project_logoand skips the upload, as it skips the other uploads. CLI logo commands come in a separate PR stacked on this one.move_recording,copy_recordingandget_filingto the built-in MCP server's catalog.Tradeoffs
A copy is never retried. A copy that fails to start with a 5xx, or that's still running when the wait ends, says "don't copy again" and is not marked retryable, because Basecamp may already be making it.
Verification
bin/cipasses.--include-commentscarried a comment and a completed card landed in Done.Summary by cubic
Adds cross-project moves and copies to the CLI for every recording type the web's Move… and Copy… menus handle, plus
filings showto read a move or copy still running, andprojects logoto set and remove a project's logo.cards,todos,todolists/todolistgroups,messages,files(anddocs/uploads/vaults),schedule,checkins question,forwards, andtoolsgainmove/copywith--to-projectand a destination flag. A copy with no destination goes next to the original;--include-commentscarries comments. Cards land in Triage, or Done if completed, under a new id.todos move --liststill repositions a to-do out of its list by the project's To-dos;--to-listrefuses a To-dos id.source_id, or reportsin_progressnaming the filing when the wait ends first.filings show <id>reads where the filing got to, carrying the original--profile/--accountinto its follow-ups. A recurring project event'sschedule showfollow-up names the schedule when the project has several.--in/--projectmust match the record's own project, a record that reads back as another type is refused, a destination URL must name an existing id rather than a title, a destination whose tool is turned off says how to turn it back on, and container flags a move or copy would ignore are refused.Scope
Bumps
basecamp-sdkto v0.31.0 (move, copy, and filing operations; API version 2026-10-27). The built-in MCP server servesremove_project_logo, skips the multipart upload, and keeps its catalog at 286 operations.projects logo set/removefollowprojects wallpaper: the file is checked for size and type before upload, and each answers with the project read back.Written for commit 4aa1277. Summary will update on new commits.