From 9b93ddc86a1a9a8922441751d8add7b42f1e48fb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 22 Oct 2026 11:31:27 -0700 Subject: [PATCH 1/5] Say what tools trash really does: delete or disable, never trash DELETE /dock/tools/:id deletes an external link or an empty extra tool of a type, and disables every other tool. Neither goes to the trash, and both answer 204. The help promised permanent removal of the tool and all its content, and the summary said "trashed". Describe both outcomes in the help, word the summary as "removed from the dock (deleted or disabled)", and point a breadcrumb at tools show, which tells them apart. The data key stays "trashed" for existing scripts. --- internal/commands/tools.go | 31 +++++++++++++++++++++++++------ internal/commands/tools_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/internal/commands/tools.go b/internal/commands/tools.go index 5839b0ef5..c5ce73528 100644 --- a/internal/commands/tools.go +++ b/internal/commands/tools.go @@ -31,6 +31,7 @@ Disabling a tool hides it from the dock but preserves its content.`, Annotations: map[string]string{"agent_notes": fmt.Sprintf( "Dock tools are the sidebar navigation items in a project\n"+ "Enable/disable controls visibility without deleting\n"+ + "trash deletes an external link or an empty extra tool of a type, disables any other tool, and never uses the trash\n"+ "Create by type with --type: %s (create-by-type is BC5-only)", strings.Join(toolTypeFriendlyNames(), ", "))}, } @@ -433,10 +434,22 @@ func newToolsTrashCmd(project *string) *cobra.Command { cmd := &cobra.Command{ Use: "trash ", Aliases: []string{"delete"}, - Short: "Permanently trash a dock tool", - Long: `Permanently trash a dock tool. - -WARNING: This permanently removes the tool and all its content.`, + Short: "Remove a dock tool (deletes or disables it, never trashes it)", + Long: `Remove a tool from the project dock. Despite the command's name, the tool +never goes to the trash. Basecamp either deletes it or disables it: + + Deleted An external link, or a tool that is empty while the project has + another tool of the same type. Items already in the trash don't + count as content. Basecamp permanently destroys a deleted tool, + with everything inside it, about five days later. It can't be + restored. + + Disabled Every other tool, such as the only tool of its type or a tool + with content. This is the same as 'basecamp tools disable': the + content is kept, and 'basecamp tools enable' restores it. + +Basecamp answers both cases the same way. To see which happened, run +'basecamp tools show ': a deleted tool has status "deleted".`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -460,7 +473,11 @@ WARNING: This permanently removes the tool and all its content.`, return convertSDKError(err) } - var crumbs []output.Breadcrumb + crumbs := []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp tools show %d%s", toolID, toolBreadcrumbFlag(resolvedProjectID)), + Description: "See whether the tool was deleted or disabled", + }} if resolvedProjectID != "" { crumbs = append(crumbs, output.Breadcrumb{ Action: "project", @@ -469,8 +486,10 @@ WARNING: This permanently removes the tool and all its content.`, }) } + // "trashed" stays as the data key for existing scripts. Basecamp + // deletes or disables the tool; it never trashes it. return app.OK(map[string]any{"trashed": true}, - output.WithSummary(fmt.Sprintf("Tool %d trashed", toolID)), + output.WithSummary(fmt.Sprintf("Tool %d removed from the dock (deleted or disabled)", toolID)), output.WithBreadcrumbs(crumbs...), ) }, diff --git a/internal/commands/tools_test.go b/internal/commands/tools_test.go index 713cca98e..a343cd008 100644 --- a/internal/commands/tools_test.go +++ b/internal/commands/tools_test.go @@ -678,3 +678,33 @@ func TestToolsShowSummaryOmitsMissingName(t *testing.T) { }) } } + +// TestToolsTrashSummaryDoesNotClaimTrash pins the outcome wording. DELETE on a +// dock tool deletes or disables it and answers 204 either way, so the summary +// must not claim the tool went to the trash, and a breadcrumb must point at +// `tools show`, which tells the two outcomes apart. +func TestToolsTrashSummaryDoesNotClaimTrash(t *testing.T) { + app, buf := newTestAppWithTransport(t, &mockToolTransport{}) + app.Config.ProjectID = "" + app.Flags.Hints = true + + project := "123" + cmd := newToolsTrashCmd(&project) + require.NoError(t, executeCommand(cmd, app, "557")) + + var envelope struct { + Summary string `json:"summary"` + Breadcrumbs []struct { + Action string `json:"action"` + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + + assert.Equal(t, "Tool 557 removed from the dock (deleted or disabled)", envelope.Summary) + assert.NotContains(t, strings.ToLower(envelope.Summary), "trash") + + require.NotEmpty(t, envelope.Breadcrumbs) + assert.Equal(t, "show", envelope.Breadcrumbs[0].Action) + assert.Equal(t, "basecamp tools show 557 --in 123", envelope.Breadcrumbs[0].Cmd) +} From ff641684e631d2afe50d16953c33bdfdc9343de3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 22 Oct 2026 11:37:07 -0700 Subject: [PATCH 2/5] Define empty as bc3 does, and keep the external-link restore path Codex review: a Card Table counts only cards, so one with columns and no cards is empty and gets deleted; and a deleted external link can still be restored in the web app until it is destroyed. Say both. --- internal/commands/tools.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/commands/tools.go b/internal/commands/tools.go index c5ce73528..bf2b3a5bd 100644 --- a/internal/commands/tools.go +++ b/internal/commands/tools.go @@ -439,10 +439,12 @@ func newToolsTrashCmd(project *string) *cobra.Command { never goes to the trash. Basecamp either deletes it or disables it: Deleted An external link, or a tool that is empty while the project has - another tool of the same type. Items already in the trash don't - count as content. Basecamp permanently destroys a deleted tool, - with everything inside it, about five days later. It can't be - restored. + another tool of the same type. Empty means nothing inside it is + still in place: items already in the trash don't count, archived + and draft items do, and for a Card Table only cards count, not + columns. Basecamp permanently destroys a deleted tool, with + everything inside it, about five days later. Until then, a + deleted external link can be restored in the Basecamp web app. Disabled Every other tool, such as the only tool of its type or a tool with content. This is the same as 'basecamp tools disable': the From 64d1b99039e274a370f6f83c931202f842f7f859 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 22 Oct 2026 14:19:52 -0700 Subject: [PATCH 3/5] Sync the MCP model to basecamp-sdk d69e9669 for DeleteTool's corrected text The built-in MCP server's delete_tool still described itself as "Delete a tool (trash it)", from the vendored SDK model. basecamp-sdk#1031 corrected the description; pin the SDK at that merge (scripts/bump-sdk.sh) and re-vendor the model from it (scripts/sync-mcp-model.sh). The bump also brings in the SDK's other change since v0.27.0: reading a pixel dimension one ulp off an integer as that integer (basecamp-sdk#1028). --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/mcpserver/model/openapi.json | 2 +- internal/mcpserver/testdata/catalog_snapshot.txt | 2 +- internal/version/sdk-provenance.json | 6 +++--- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 97018b094..8980b2adb 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.27.0 + github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022210749-d69e9669709e github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index 62ea780c7..bfe832e3e 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.27.0 h1:Shq2lyAMBDvvBtmVLZZes42UyKkFMIvpR1Pa8xyWOVo= -github.com/basecamp/basecamp-sdk/go v0.27.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022210749-d69e9669709e h1:NTjvf3v/uIGLApp9Gs5YsWkGBHyMAz7CONtIr855SBE= +github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022210749-d69e9669709e/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 03c32f4a5..baca637d3 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "5e6961286780c6d0d8b2335fa1364d8e16d1b585", - "ref": "v0.27.0", + "commit": "d69e9669709e121b5336560e2476c460d8f06d04", + "ref": "v0.27.1-1-gd69e9669", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 6169b4a06..b4eb6ed67 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -8517,7 +8517,7 @@ }, "/{accountId}/dock/tools/{toolId}": { "delete": { - "description": "Delete a tool (trash it)", + "description": "Remove a tool from the dock: deletes an external link or an empty extra tool, and disables any other tool (returns 204 No Content either way).\nDeleted: an external link, or a tool that is empty while the project has another\ntool of the same type, enabled or disabled. Empty means nothing inside it is still\nin place: items already in the trash don't count, archived and draft items do, and\nfor a Card Table only cards count, not columns. A deleted tool does not go to the\ntrash: Basecamp permanently destroys it, with everything inside it, about five days\nlater. Until then, a deleted external link can be restored in the Basecamp web app.\nDisabled: every other tool, such as the only tool of its type or a tool with\ncontent, exactly as DisableTool does. It keeps its content, and EnableTool restores it.\nTo tell which happened, get the tool afterwards: a deleted tool has status \"deleted\",\nwhile a disabled one keeps its status and has no position.", "operationId": "DeleteTool", "parameters": [ { diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index 509ee8e44..f2a6e0635 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -252,7 +252,7 @@ ACTIONS (RO = read-only): - create_webhook: Create a new webhook for a project - delete_lineup_marker: Delete a lineup marker - delete_template: Delete a template (trash it) -- delete_tool: Delete a tool (trash it) +- delete_tool: Remove a tool from the dock: deletes an external link or an empty extra tool, and disables any other tool (returns 204 No Content either way) - delete_webhook: Delete a webhook - disable_tool: Disable a tool (hide it from the project dock) - enable_tool: Enable a tool (show it on the project dock) diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 10525c72d..b7d18e6d8 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.27.0", - "revision": "5e6961286780", - "updated_at": "2026-10-21T16:35:15Z" + "version": "v0.27.1-0.20261022210749-d69e9669709e", + "revision": "d69e9669709e", + "updated_at": "2026-10-22T21:07:49Z" }, "api": { "repo": "basecamp/bc3", From f80a0ea8a01ea6d587b5d72c443da3e59747e765 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 22 Oct 2026 14:30:00 -0700 Subject: [PATCH 4/5] Update the Nix vendorHash for the basecamp-sdk bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nix flake build computed sha256-U8sN… for the go modules at the new pin. --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 911e2a14b..72064e058 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-IeUWPtO52gtx8YwSwMXUWWyMMGC1NwoRurcNafM2ZSo="; + vendorHash = "sha256-U8sNbyU3V12EDPls/wzFVEBhXAOjGFriXrZnYHckeIs="; subPackages = [ "cmd/basecamp" ]; From 7c57d9a89b04944aa220ae40cef6ddc4d97f8ffd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 22 Oct 2026 14:59:06 -0700 Subject: [PATCH 5/5] Say a deleted tool is deleted in tools show tools trash points to tools show to tell its outcomes apart, but show read every missing position as disabled, so a deleted tool came back "at position disabled": the opposite outcome. Check status first. Codex review on #940. --- internal/commands/tools.go | 20 +++++++++++++------ internal/commands/tools_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/internal/commands/tools.go b/internal/commands/tools.go index bf2b3a5bd..b282e6d9f 100644 --- a/internal/commands/tools.go +++ b/internal/commands/tools.go @@ -122,15 +122,23 @@ func newToolsShowCmd(project *string) *cobra.Command { return convertSDKError(err) } - posStr := "disabled" - if tool.Position != nil { - posStr = fmt.Sprintf("%d", *tool.Position) - } // Name is nil for the Get projection in SDK v0.15.0; only render // the parenthetical when there is something to put in it. - summary := fmt.Sprintf("%s at position %s", tool.Title, posStr) + label := tool.Title if tool.Name != nil { - summary = fmt.Sprintf("%s (%s) at position %s", tool.Title, *tool.Name, posStr) + label = fmt.Sprintf("%s (%s)", tool.Title, *tool.Name) + } + // tools trash points here to tell its two outcomes apart. A + // deleted tool has no position either, so check status first: + // reading it as disabled would report the opposite outcome. + var summary string + switch { + case tool.Status == "deleted": + summary = fmt.Sprintf("%s: deleted, not in the trash", label) + case tool.Position != nil: + summary = fmt.Sprintf("%s at position %d", label, *tool.Position) + default: + summary = fmt.Sprintf("%s at position disabled", label) } crumbs := []output.Breadcrumb{ diff --git a/internal/commands/tools_test.go b/internal/commands/tools_test.go index a343cd008..953949370 100644 --- a/internal/commands/tools_test.go +++ b/internal/commands/tools_test.go @@ -529,6 +529,16 @@ func (t *mockToolTransport) RoundTrip(req *http.Request) (*http.Response, error) body = `{"id": 556, "title": "Chat", "enabled": true, "position": 2,` + `"status": "active", "url": "https://example.com", "app_url": "https://example.com",` + `"created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"}` + case strings.HasSuffix(req.URL.Path, "/tools/558"): + // A tool DELETE took down: status deleted, no position. + body = `{"id": 558, "title": "Chat", "name": "chat", "enabled": false, "position": null,` + + `"status": "deleted", "url": "https://example.com", "app_url": "https://example.com",` + + `"created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"}` + case strings.HasSuffix(req.URL.Path, "/tools/559"): + // A tool DELETE disabled: still active, no position. + body = `{"id": 559, "title": "Chat", "name": "chat", "enabled": false, "position": null,` + + `"status": "active", "url": "https://example.com", "app_url": "https://example.com",` + + `"created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"}` case strings.HasSuffix(req.URL.Path, "/tools/555"): body = `{"id": 555, "title": "Chat", "name": "chat", "enabled": true, "position": 2,` + `"status": "active", "url": "https://example.com", "app_url": "https://example.com",` + @@ -708,3 +718,28 @@ func TestToolsTrashSummaryDoesNotClaimTrash(t *testing.T) { assert.Equal(t, "show", envelope.Breadcrumbs[0].Action) assert.Equal(t, "basecamp tools show 557 --in 123", envelope.Breadcrumbs[0].Cmd) } + +// TestToolsShowSummaryTellsDeletedFromDisabled pins the distinction tools +// trash's breadcrumb sends people here for. Both outcomes leave the tool with +// no position; only status tells them apart. +func TestToolsShowSummaryTellsDeletedFromDisabled(t *testing.T) { + for _, tc := range []struct{ id, want string }{ + {"558", "Chat (chat): deleted, not in the trash"}, + {"559", "Chat (chat) at position disabled"}, + } { + t.Run("tool "+tc.id, func(t *testing.T) { + app, buf := newTestAppWithTransport(t, &mockToolTransport{}) + app.Config.ProjectID = "" + + project := "" + cmd := newToolsShowCmd(&project) + require.NoError(t, executeCommand(cmd, app, tc.id)) + + var envelope struct { + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + assert.Equal(t, tc.want, envelope.Summary) + }) + } +}