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
3 changes: 1 addition & 2 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -13485,7 +13485,6 @@ FLAG basecamp tools --verbose type=count
FLAG basecamp tools create --account type=string
FLAG basecamp tools create --agent type=bool
FLAG basecamp tools create --cache-dir type=string
FLAG basecamp tools create --clone type=string
FLAG basecamp tools create --count type=bool
FLAG basecamp tools create --help type=bool
FLAG basecamp tools create --hints type=bool
Expand All @@ -13500,10 +13499,10 @@ FLAG basecamp tools create --no-stats type=bool
FLAG basecamp tools create --profile type=string
FLAG basecamp tools create --project type=string
FLAG basecamp tools create --quiet type=bool
FLAG basecamp tools create --source type=string
FLAG basecamp tools create --stats type=bool
FLAG basecamp tools create --styled type=bool
FLAG basecamp tools create --todolist type=string
FLAG basecamp tools create --type type=string
FLAG basecamp tools create --verbose type=count
FLAG basecamp tools delete --account type=string
FLAG basecamp tools delete --agent type=bool
Expand Down
2 changes: 2 additions & 0 deletions .surface-breaking
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,8 @@ FLAG basecamp todos --status type=string
FLAG basecamp todos --todoset type=string
FLAG basecamp todos create --content type=string
FLAG basecamp todosets --todoset type=string
FLAG basecamp tools create --clone type=string
FLAG basecamp tools create --source type=string
FLAG basecamp tools create --title type=string
FLAG basecamp tools rename --title type=string
FLAG basecamp tools update --title type=string
Expand Down
11 changes: 6 additions & 5 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github.

| Status | Sections | Endpoints |
|--------|----------|-----------|
| ✅ Implemented | 42 | 155 |
| ✅ Implemented | 43 | 162 |
| ⏭️ Out of scope | 4 | 12 |
| **Total (docs)** | **46** | **167** |
| **Total (docs)** | **47** | **174** |

**100% coverage of in-scope API** (155/155 endpoints)
**100% coverage of in-scope API** (162/162 endpoints)

Out-of-scope sections are excluded from parity totals and scripts: chatbots (different auth), legacy Clientside (deprecated)

> Note: the per-row `Endpoints` column in the Coverage by Section table sums higher than the Summary totals above. The discrepancy predates the BC5 baseline; the row count (46 sections) is authoritative for the `Since` column. Reconciling endpoint counts is pre-existing maintenance, tracked separately.
> Note: the per-row `Endpoints` column in the Coverage by Section table sums higher than the Summary totals above. The discrepancy predates the BC5 baseline; the row count (47 sections) is authoritative for the `Since` column. Reconciling endpoint counts is pre-existing maintenance, tracked separately.

**SDK version:** v0.7.3 — maintenance bump: API date advanced to 2026-03-23, transitive dependency updates. No new services or methods.
**SDK version:** v0.7.4 (`f2086f5e`) — reshapes `Tools.Create` to the BC5 bucket-scoped create-by-type endpoint (`POST /buckets/{id}/dock/tools.json` with `{tool_type, title?}`), replacing the removed account-scoped clone call. API date advanced to 2026-07-22.

## Coverage by Section

Expand All @@ -30,6 +30,7 @@ The **Since** column tags each row with the Basecamp version that introduced its
| todolists | 8 | `todolists` | ✅ | BC4 | - | list, show, create, update |
| 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Split the BC5 tools create coverage row

The coverage guide above says BC5 endpoints added to an existing BC4 section should be split into a separate BC5 row, but this new dock_tools row counts all 7 endpoints as BC4 while also noting that create is BC5-only. That makes the BC4/BC5 parity matrix ambiguous because the BC4 row now includes a BC5-only endpoint; please split create-by-type into a BC5 dock_tools row or keep the BC4 count limited to the BC4 endpoints.

Useful? React with 👍 / 👎.

| **Hill Charts** |
| hill_charts | 2 | `hillcharts` | ✅ | BC4 | - | show, track/untrack todolists |
| gauges | 7 | `gauges` | ✅ | BC4 | - | list, needles, needle, create, update, delete, enable/disable |
Expand Down
4 changes: 1 addition & 3 deletions e2e/smoke/smoke_tools.bats
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@ setup_file() {
}

@test "tools create creates a tool" {
ensure_messageboard || return 0

run_smoke basecamp tools create "Smoke tool $(date +%s)" \
--source "$QA_MESSAGEBOARD" -p "$QA_PROJECT" --json
--type message_board -p "$QA_PROJECT" --json
assert_success
assert_json_value '.ok' 'true'
assert_json_not_null '.data.id'
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260722045210-81660dc101d5
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260722082856-f2086f5e57f6
github.com/basecamp/cli v0.2.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260722045210-81660dc101d5 h1:pOXigTfLk9GNEQ5sZEOIo7/122goh5LabvsdjhbZ04o=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260722045210-81660dc101d5/go.mod h1:eX5mEKCdtxSfEL4P/n5AwOl21JVA/K+gRPic/Hd8W/Y=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260722082856-f2086f5e57f6 h1:JFKzHPWkDq0I9nSheEYxaxIBzL+WvJKFg0j/A3n2t9s=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260722082856-f2086f5e57f6/go.mod h1:eX5mEKCdtxSfEL4P/n5AwOl21JVA/K+gRPic/Hd8W/Y=
github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ=
github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
163 changes: 140 additions & 23 deletions internal/commands/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
Expand All @@ -25,9 +26,13 @@ Every project has a "dock" with tools like Message Board, To-dos, Docs & Files,
Chat, Schedule, etc. Tool IDs can be found in the project's dock array
(see 'basecamp projects show <id>').

Tools can be created by cloning existing ones (e.g., create a second Chat).
Tools are created by type (e.g., add a second chat with --type chat).
Disabling a tool hides it from the dock but preserves its content.`,
Annotations: map[string]string{"agent_notes": "Dock tools are the sidebar navigation items in a project\nEnable/disable controls visibility without deleting\nEach tool has a type (e.g., Todoset, Schedule, MessageBoard, Vault, Chat::Campfire)"},
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"+
"Create by type with --type: %s (create-by-type is BC5-only)",
strings.Join(toolTypeFriendlyNames(), ", "))},
}

cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name (for breadcrumbs)")
Expand Down Expand Up @@ -150,18 +155,135 @@ func newToolsShowCmd(project *string) *cobra.Command {
}
}

// toolTypes is the single source of truth for the closed set of dock tool
// types create-by-type accepts. Its order is deterministic and drives three
// things that must never drift: normalization, --type completion, and the
// unknown-type error listing. friendly is the primary dock noun surfaced in
// completion and errors; canonical is the Rails class-name sent to the API.
var toolTypes = []struct {
canonical string // Rails class → the tool_type posted to the API
friendly string // primary dock noun (completion + error list)
aliases []string // extra accepted forms (matched after collapse)
}{
{"Chat::Transcript", "chat", []string{"campfire"}},
{"Inbox", "inbox", []string{"forwards", "email"}},
{"Kanban::Board", "kanban_board", []string{"kanban", "cardtable", "cards", "card"}},
{"Message::Board", "message_board", []string{"messageboard", "messages", "message"}},
{"Questionnaire", "questionnaire", []string{"questions", "checkin", "checkins", "automaticcheckins"}},
{"Schedule", "schedule", []string{"calendar"}},
{"Todoset", "todoset", []string{"todosets", "todos", "todo", "todolist"}},
{"Vault", "vault", []string{"docs", "doc", "documents", "files"}},
}

// collapseToolType reduces an input to a comparison key: lowercased with all
// separators (::, -, _, spaces) removed. This lets "Message::Board",
// "message_board", and "message-board" all match the same entry.
func collapseToolType(input string) string {
var b strings.Builder
for _, r := range strings.ToLower(input) {
switch r {
case ':', '-', '_', ' ':
continue
default:
b.WriteRune(r)
}
}
return b.String()
}

// normalizeToolType maps a friendly noun, canonical class-name, or accepted
// degenerate spelling to the canonical tool_type. Strict: unknown input returns
// ok=false. The closed 8-set plus the API's opaque 400 on a bad value justify
// rejecting rather than passing input through (unlike normalizeRecordingType).
func normalizeToolType(input string) (canonical string, ok bool) {
key := collapseToolType(input)
if key == "" {
return "", false
}
for _, t := range toolTypes {
if key == collapseToolType(t.canonical) || key == collapseToolType(t.friendly) {
return t.canonical, true
}
for _, alias := range t.aliases {
if key == collapseToolType(alias) {
return t.canonical, true
}
}
}
return "", false
}

// toolTypeFriendlyNames returns the friendly nouns in stable slice order, used
// for both --type completion and the unknown-type error listing.
func toolTypeFriendlyNames() []string {
names := make([]string, len(toolTypes))
for i, t := range toolTypes {
names[i] = t.friendly
}
return names
}

// resolveToolBucketID resolves the numeric destination bucket for create.
// Unlike the breadcrumb-only resolveToolsProject, this bucket is sent to the
// API, so a project is required. Precedence: --in/--project > --project global
// flag > config default > interactive. Returns the bucket ID and the resolved
// project ID string (for breadcrumbs).
func resolveToolBucketID(cmd *cobra.Command, app *appctx.App, project string) (int64, string, error) {
projectID := project
if projectID == "" {
projectID = app.Flags.Project
}
if projectID == "" {
projectID = app.Config.ProjectID
}
if projectID == "" {
if err := ensureProject(cmd, app); err != nil {
return 0, "", err
}
projectID = app.Config.ProjectID
}

resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID)
if err != nil {
return 0, "", err
}

bucketID, err := strconv.ParseInt(resolvedProjectID, 10, 64)
if err != nil {
return 0, "", output.ErrUsage("Project ID must be numeric")
}
return bucketID, resolvedProjectID, nil
}

func newToolsCreateCmd(project *string) *cobra.Command {
var sourceID string
var toolType string

cmd := &cobra.Command{
Use: "create [title]",
Short: "Create a new dock tool by cloning",
Long: `Create a new dock tool by cloning an existing one.
Short: "Create a new dock tool by type",
Long: fmt.Sprintf(`Create a new dock tool by type in a project's dock.

For example, add a second chat with --type chat, or a Message Board with
--type message_board. An optional title may be given; without one, Basecamp
assigns the default title for the type.

For example, clone a Chat to create a second chat room in the same project.`,
Accepted types: %s. Create-by-type is BC5-only.`,
strings.Join(toolTypeFriendlyNames(), ", ")),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if sourceID == "" {
return output.ErrUsage("--source or --clone is required (ID of tool to clone)")
if toolType == "" {
return output.ErrUsage("--type is required")
}

canonicalType, ok := normalizeToolType(toolType)
if !ok {
if collapseToolType(toolType) == "board" {
return output.ErrUsage(fmt.Sprintf(
"Ambiguous --type %q — use message_board or kanban_board (accepted: %s)",
toolType, strings.Join(toolTypeFriendlyNames(), ", ")))
}
return output.ErrUsage(fmt.Sprintf("Unknown --type %q (accepted: %s)",
toolType, strings.Join(toolTypeFriendlyNames(), ", ")))
}

app := appctx.FromContext(cmd.Context())
Expand All @@ -170,11 +292,6 @@ For example, clone a Chat to create a second chat room in the same project.`,
return err
}

sourceToolID, err := strconv.ParseInt(sourceID, 10, 64)
if err != nil {
return output.ErrUsage("Invalid source tool ID")
}

title := ""
if len(args) > 0 {
title = args[0]
Expand All @@ -186,18 +303,18 @@ For example, clone a Chat to create a second chat room in the same project.`,
}
}

resolvedProjectID, err := resolveToolsProject(cmd, app, *project)
bucketID, resolvedProjectID, err := resolveToolBucketID(cmd, app, *project)
if err != nil {
return err
}
inFlag := toolBreadcrumbFlag(resolvedProjectID)

var cloneOpts *basecamp.CloneToolOptions
var opts *basecamp.CreateToolOptions
if title != "" {
cloneOpts = &basecamp.CloneToolOptions{Title: title}
opts = &basecamp.CreateToolOptions{Title: title}
}

created, err := app.Account().Tools().Create(cmd.Context(), sourceToolID, cloneOpts)
created, err := app.Account().Tools().Create(cmd.Context(), bucketID, canonicalType, opts)
if err != nil {
return convertSDKError(err)
}
Expand All @@ -208,13 +325,11 @@ For example, clone a Chat to create a second chat room in the same project.`,
Cmd: fmt.Sprintf("basecamp tools show %d%s", created.ID, inFlag),
Description: "View tool",
},
}
if resolvedProjectID != "" {
crumbs = append(crumbs, output.Breadcrumb{
{
Action: "project",
Cmd: fmt.Sprintf("basecamp projects show %s", resolvedProjectID),
Description: "View project",
})
},
}

return app.OK(created,
Expand All @@ -224,8 +339,10 @@ For example, clone a Chat to create a second chat room in the same project.`,
},
}

cmd.Flags().StringVarP(&sourceID, "source", "s", "", "Source tool ID to clone (required)")
cmd.Flags().StringVar(&sourceID, "clone", "", "Source tool ID (alias for --source)")
cmd.Flags().StringVarP(&toolType, "type", "t", "", "Tool type to create (required)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the Basecamp skill for the new tool flag

Adding required --type and removing --source/--clone changes the command surface, but skills/basecamp/SKILL.md was not updated and still advertises Full CLI coverage: 155 endpoints with no tools-create guidance. Agents that rely on the skill can keep using stale coverage/syntax after this change; update the skill alongside the surface change.

Useful? React with 👍 / 👎.

_ = cmd.RegisterFlagCompletionFunc("type", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return toolTypeFriendlyNames(), cobra.ShellCompDirectiveNoFileComp
})

return cmd
}
Expand Down
Loading