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/commands/tools.go b/internal/commands/tools.go index 5839b0ef5..b282e6d9f 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(), ", "))}, } @@ -121,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{ @@ -433,10 +442,24 @@ 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. 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 + 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 +483,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 +496,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..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",` + @@ -678,3 +688,58 @@ 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) +} + +// 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) + }) + } +} 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", 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" ];