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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
53 changes: 41 additions & 12 deletions internal/commands/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(), ", "))},
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -433,10 +442,24 @@ func newToolsTrashCmd(project *string) *cobra.Command {
cmd := &cobra.Command{
Use: "trash <id>",
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 <id>': a deleted tool has status "deleted".`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
Expand All @@ -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",
Comment thread
jeremy marked this conversation as resolved.
}}
if resolvedProjectID != "" {
crumbs = append(crumbs, output.Breadcrumb{
Action: "project",
Expand All @@ -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...),
)
},
Expand Down
65 changes: 65 additions & 0 deletions internal/commands/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",` +
Expand Down Expand Up @@ -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)
})
}
}
4 changes: 2 additions & 2 deletions internal/mcpserver/model/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/mcpserver/model/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
2 changes: 1 addition & 1 deletion internal/mcpserver/testdata/catalog_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions internal/version/sdk-provenance.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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" ];

Expand Down
Loading