From 0b3060c30632e5e76afbf954214748a282043640 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 10:02:24 -0700 Subject: [PATCH 01/11] feat(bubble-up): add bubble-up add/remove commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the basecamp-sdk BubbleUps write ops as a personal command group, mirroring `basecamp bookmarks`: basecamp bubble-up add [--at ] basecamp bubble-up remove `add` bubbles up now by default; --at schedules ("today", "tomorrow", "weekend", "next_week", or an ISO8601 date). bc3 requires a value for `at` (an omitted param raises server-side), so the command sends "now" when --at is absent. Both verbs are idempotent. There is no status read — the per-recording bubble-up GET is an unrenderable API gap — and the full list lives under `basecamp notifications`. Depends on basecamp-sdk PR #839 (BubbleUps ops). The SDK is pinned to that branch as a pseudo-version placeholder; before merge it must be re-pinned to the released go/vX.Y.Z tag and the vendored MCP model re-synced from it. The model re-sync also pulls SDK-main operations merged since go/v0.15.0 (recent-projects, delegated-events), which is why the MCP catalog count moves 6 rather than 2; that resolves cleanly on the tag re-sync. --- .surface | 72 ++ go.mod | 10 +- go.sum | 24 +- internal/cli/root.go | 1 + internal/commands/bubble_up.go | 169 ++++ internal/commands/bubble_up_test.go | 80 ++ internal/commands/commands.go | 1 + internal/commands/commands_test.go | 1 + internal/mcpserver/catalog_test.go | 2 +- internal/mcpserver/domains.go | 4 +- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/behavior-model.json | 72 ++ internal/mcpserver/model/openapi.json | 812 ++++++++++++++++-- .../mcpserver/testdata/catalog_snapshot.txt | 8 +- 14 files changed, 1142 insertions(+), 118 deletions(-) create mode 100644 internal/commands/bubble_up.go create mode 100644 internal/commands/bubble_up_test.go diff --git a/.surface b/.surface index cacb7f11..efc2ef0a 100644 --- a/.surface +++ b/.surface @@ -33,6 +33,8 @@ ARG basecamp boosts create 01 ARG basecamp boosts delete 00 ARG basecamp boosts list 00 ARG basecamp boosts show 00 +ARG basecamp bubble-up add 00 +ARG basecamp bubble-up remove 00 ARG basecamp calendars show 00 ARG basecamp calendars update 00 ARG basecamp calendars update 01 @@ -536,6 +538,9 @@ CMD basecamp boosts create CMD basecamp boosts delete CMD basecamp boosts list CMD basecamp boosts show +CMD basecamp bubble-up +CMD basecamp bubble-up add +CMD basecamp bubble-up remove CMD basecamp calendars CMD basecamp calendars show CMD basecamp calendars update @@ -2483,6 +2488,70 @@ FLAG basecamp boosts show --stats type=bool FLAG basecamp boosts show --styled type=bool FLAG basecamp boosts show --todolist type=string FLAG basecamp boosts show --verbose type=count +FLAG basecamp bubble-up --account type=string +FLAG basecamp bubble-up --agent type=bool +FLAG basecamp bubble-up --cache-dir type=string +FLAG basecamp bubble-up --count type=bool +FLAG basecamp bubble-up --help type=bool +FLAG basecamp bubble-up --hints type=bool +FLAG basecamp bubble-up --ids-only type=bool +FLAG basecamp bubble-up --in type=string +FLAG basecamp bubble-up --jq type=string +FLAG basecamp bubble-up --json type=bool +FLAG basecamp bubble-up --markdown type=bool +FLAG basecamp bubble-up --md type=bool +FLAG basecamp bubble-up --no-hints type=bool +FLAG basecamp bubble-up --no-stats type=bool +FLAG basecamp bubble-up --profile type=string +FLAG basecamp bubble-up --project type=string +FLAG basecamp bubble-up --quiet type=bool +FLAG basecamp bubble-up --stats type=bool +FLAG basecamp bubble-up --styled type=bool +FLAG basecamp bubble-up --todolist type=string +FLAG basecamp bubble-up --verbose type=count +FLAG basecamp bubble-up add --account type=string +FLAG basecamp bubble-up add --agent type=bool +FLAG basecamp bubble-up add --at type=string +FLAG basecamp bubble-up add --cache-dir type=string +FLAG basecamp bubble-up add --count type=bool +FLAG basecamp bubble-up add --help type=bool +FLAG basecamp bubble-up add --hints type=bool +FLAG basecamp bubble-up add --ids-only type=bool +FLAG basecamp bubble-up add --in type=string +FLAG basecamp bubble-up add --jq type=string +FLAG basecamp bubble-up add --json type=bool +FLAG basecamp bubble-up add --markdown type=bool +FLAG basecamp bubble-up add --md type=bool +FLAG basecamp bubble-up add --no-hints type=bool +FLAG basecamp bubble-up add --no-stats type=bool +FLAG basecamp bubble-up add --profile type=string +FLAG basecamp bubble-up add --project type=string +FLAG basecamp bubble-up add --quiet type=bool +FLAG basecamp bubble-up add --stats type=bool +FLAG basecamp bubble-up add --styled type=bool +FLAG basecamp bubble-up add --todolist type=string +FLAG basecamp bubble-up add --verbose type=count +FLAG basecamp bubble-up remove --account type=string +FLAG basecamp bubble-up remove --agent type=bool +FLAG basecamp bubble-up remove --cache-dir type=string +FLAG basecamp bubble-up remove --count type=bool +FLAG basecamp bubble-up remove --help type=bool +FLAG basecamp bubble-up remove --hints type=bool +FLAG basecamp bubble-up remove --ids-only type=bool +FLAG basecamp bubble-up remove --in type=string +FLAG basecamp bubble-up remove --jq type=string +FLAG basecamp bubble-up remove --json type=bool +FLAG basecamp bubble-up remove --markdown type=bool +FLAG basecamp bubble-up remove --md type=bool +FLAG basecamp bubble-up remove --no-hints type=bool +FLAG basecamp bubble-up remove --no-stats type=bool +FLAG basecamp bubble-up remove --profile type=string +FLAG basecamp bubble-up remove --project type=string +FLAG basecamp bubble-up remove --quiet type=bool +FLAG basecamp bubble-up remove --stats type=bool +FLAG basecamp bubble-up remove --styled type=bool +FLAG basecamp bubble-up remove --todolist type=string +FLAG basecamp bubble-up remove --verbose type=count FLAG basecamp calendars --account type=string FLAG basecamp calendars --agent type=bool FLAG basecamp calendars --cache-dir type=string @@ -17826,6 +17895,9 @@ SUB basecamp boosts create SUB basecamp boosts delete SUB basecamp boosts list SUB basecamp boosts show +SUB basecamp bubble-up +SUB basecamp bubble-up add +SUB basecamp bubble-up remove SUB basecamp calendars SUB basecamp calendars show SUB basecamp calendars update diff --git a/go.mod b/go.mod index 0c674877..af6b5822 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.9 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.15.0 + github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902165153-4b2a7cddbd0f 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 @@ -108,7 +108,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/oapi-codegen/runtime v1.6.0 // indirect + github.com/oapi-codegen/runtime v1.7.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -130,9 +130,9 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.45.0 // indirect - go.opentelemetry.io/otel/metric v1.45.0 // indirect - go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/go.sum b/go.sum index dda78c99..535c7015 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,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.15.0 h1:Yxp3WM7rZ7PDcXrOTc4dI9EFOqwHojpqVJ/zbdXnoVg= -github.com/basecamp/basecamp-sdk/go v0.15.0/go.mod h1:00mgcmi89PlnHnLJNwcJjwryo4W5bYJtA2FoqVgHP54= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902165153-4b2a7cddbd0f h1:r8mOXhSQlOhGrJSo7hgZWC99z8FnlpDdfZhG2YTS1/w= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902165153-4b2a7cddbd0f/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= 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= @@ -348,8 +348,8 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= -github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -451,16 +451,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= -go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= -go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= -go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= -go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= -go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/internal/cli/root.go b/internal/cli/root.go index cee08844..aca2f9df 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -355,6 +355,7 @@ func Execute() { cmd.AddCommand(commands.NewGaugesCmd()) cmd.AddCommand(commands.NewAssignmentsCmd()) cmd.AddCommand(commands.NewBookmarksCmd()) + cmd.AddCommand(commands.NewBubbleUpCmd()) cmd.AddCommand(commands.NewDraftsCmd()) cmd.AddCommand(commands.NewNotesCmd()) cmd.AddCommand(commands.NewCalendarsCmd()) diff --git a/internal/commands/bubble_up.go b/internal/commands/bubble_up.go new file mode 100644 index 00000000..db3d5ebf --- /dev/null +++ b/internal/commands/bubble_up.go @@ -0,0 +1,169 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// NewBubbleUpCmd creates the bubble-up command for resurfacing a recording in +// the current user's readings — the BC5 successor to "save". +// +// Bubble-up is per-person, like bookmarks: it links the current user to one +// recording, so there is no project to scope it to and no --in flag. Every leaf +// addresses a recording by id or URL. +func NewBubbleUpCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "bubble-up", + Short: "Bubble a recording up in your readings", + Long: `Bubble a recording up so it resurfaces in your readings. + +Bubble-up is private to you and points at a single recording (a to-do, message, +document, card, and so on), addressed by its id or by pasting its Basecamp URL. + + basecamp bubble-up add 42 + basecamp bubble-up add 42 --at tomorrow + basecamp bubble-up remove 42`, + Annotations: map[string]string{ + "agent_notes": "Account-wide and personal — no --in needed.\n" + + "add/remove are idempotent. add takes --at to schedule; bc3 requires a\n" + + "value, so add sends \"now\" when --at is omitted. There is no status\n" + + "read (per-recording GET is an unrenderable API gap); the full list is\n" + + "basecamp notifications.", + }, + } + + cmd.AddCommand( + newBubbleUpAddCmd(), + newBubbleUpRemoveCmd(), + ) + + return cmd +} + +func newBubbleUpAddCmd() *cobra.Command { + var at string + + cmd := &cobra.Command{ + Use: "add ", + Short: "Bubble a recording up", + Long: `Bubble a recording up so it resurfaces in your readings. + +By default it bubbles up now. Pass --at to schedule it instead: a keyword +("today", "tomorrow", "weekend", "next_week") or an ISO8601 date. + +Idempotent: bubbling up something already bubbled up is a no-op that still +succeeds. + + basecamp bubble-up add 42 + basecamp bubble-up add 42 --at tomorrow + basecamp bubble-up add https://3.basecamp.com/1234567/buckets/89/todos/42`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + recordingID, err := bubbleUpRecordingID(args[0]) + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { + return err + } + + // bc3 requires a value for `at` (an omitted param raises server-side + // on Date.iso8601(nil)), so default the immediate case to "now" + // rather than sending nothing. + when := at + if when == "" { + when = "now" + } + + if err := app.Account().BubbleUps().Create(cmd.Context(), recordingID, &when); err != nil { + return convertSDKError(err) + } + + summary := fmt.Sprintf("Bubbled up recording %d", recordingID) + if at != "" { + summary = fmt.Sprintf("Scheduled recording %d to bubble up %s", recordingID, at) + } + + return app.OK(map[string]any{"id": recordingID, "bubbled_up": true, "at": when}, + output.WithSummary(summary), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "remove", + Cmd: fmt.Sprintf("basecamp bubble-up remove %d", recordingID), + Description: "Pop this bubble-up", + }, + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp recordings show %d", recordingID), + Description: "View the recording", + }, + ), + ) + }, + } + + cmd.Flags().StringVar(&at, "at", "", `When to bubble up: "now" (default), a keyword ("today", "tomorrow", "weekend", "next_week"), or an ISO8601 date`) + + return cmd +} + +func newBubbleUpRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Pop a bubble-up", + Long: `Remove a recording's bubble-up from your readings. + +Idempotent: popping something not bubbled up also succeeds, so this is safe to +run without checking first. + + basecamp bubble-up remove 42`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + recordingID, err := bubbleUpRecordingID(args[0]) + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { + return err + } + + if err := app.Account().BubbleUps().Delete(cmd.Context(), recordingID); err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"id": recordingID, "bubbled_up": false}, + output.WithSummary(fmt.Sprintf("Popped bubble-up on recording %d", recordingID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "add", + Cmd: fmt.Sprintf("basecamp bubble-up add %d", recordingID), + Description: "Bubble it up again", + }, + ), + ) + }, + } +} + +// bubbleUpRecordingID resolves the positional every bubble-up verb +// takes. With no way to browse bubble-uppable recordings from this group, a +// pasted URL is the natural way to name one. +func bubbleUpRecordingID(arg string) (int64, error) { + id, err := strconv.ParseInt(extractID(arg), 10, 64) + if err != nil { + return 0, output.ErrUsageHint( + fmt.Sprintf("%q is not a recording id or Basecamp URL", arg), + "Pass a numeric recording id, or paste the recording's Basecamp URL", + ) + } + return id, nil +} diff --git a/internal/commands/bubble_up_test.go b/internal/commands/bubble_up_test.go new file mode 100644 index 00000000..be09824a --- /dev/null +++ b/internal/commands/bubble_up_test.go @@ -0,0 +1,80 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func bubbleUpRecordingPath(id int64) string { + return fmt.Sprintf("/99999/recordings/%d/bubble_up.json", id) +} + +func bubbleUpRoute(id int64, method string) stubRoute { + return stubRoute{ + method: method, + path: bubbleUpRecordingPath(id), + status: http.StatusNoContent, + body: "", + } +} + +// Default add bubbles up now: bc3 requires a value for `at`, so the command +// sends "now" rather than omitting it. +func TestBubbleUpAddSendsNowByDefault(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42")) + + call := transport.last(t) + assert.Equal(t, http.MethodPost, call.Method) + assert.Equal(t, bubbleUpRecordingPath(42), call.Path) + + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(call.Body), &body)) + assert.Equal(t, "now", body["at"]) +} + +// --at schedules: the keyword reaches the wire verbatim. +func TestBubbleUpAddSchedulesWithAt(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "tomorrow")) + + call := transport.last(t) + assert.Equal(t, http.MethodPost, call.Method) + + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(call.Body), &body)) + assert.Equal(t, "tomorrow", body["at"]) +} + +func TestBubbleUpAddAcceptsAURL(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", + "https://3.basecamp.com/1234567/buckets/89/todos/42")) + + assert.Equal(t, bubbleUpRecordingPath(42), transport.last(t).Path) +} + +func TestBubbleUpRemoveDeletesTheBubbleUp(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodDelete)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "remove", "42")) + + call := transport.last(t) + assert.Equal(t, http.MethodDelete, call.Method) + assert.Equal(t, bubbleUpRecordingPath(42), call.Path) +} + +func TestBubbleUpVerbsRejectANonID(t *testing.T) { + app, _, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "not-an-id") + requireBookmarksUsageError(t, err) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 849d4830..e321c891 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -80,6 +80,7 @@ func CommandCategories() []CommandCategory { Name: "Personal", Commands: []CommandInfo{ {Name: "bookmarks", Category: "personal", Description: "Manage your personal bookmarks", Actions: []string{"list", "add", "remove", "check"}}, + {Name: "bubble-up", Category: "personal", Description: "Bubble a recording up in your readings", Actions: []string{"add", "remove"}}, {Name: "drafts", Category: "personal", Description: "List your unpublished drafts", Actions: []string{"list"}}, {Name: "notes", Category: "personal", Description: "Read and write your personal note", Actions: []string{"show", "set"}}, }, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 50e4da03..d295c335 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -112,6 +112,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewGaugesCmd()) root.AddCommand(commands.NewAssignmentsCmd()) root.AddCommand(commands.NewBookmarksCmd()) + root.AddCommand(commands.NewBubbleUpCmd()) root.AddCommand(commands.NewDraftsCmd()) root.AddCommand(commands.NewNotesCmd()) root.AddCommand(commands.NewCalendarsCmd()) diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index 28f80cbf..b2cf4be9 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -61,7 +61,7 @@ func TestCatalogExcludesBinaryUploads(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 247, total, "served operation count") + assert.Equal(t, 253, total, "served operation count") } // TestCatalogIsAccountScoped pins the rescope: the CLI's account-scoped SDK diff --git a/internal/mcpserver/domains.go b/internal/mcpserver/domains.go index a65d461b..ad9e03d3 100644 --- a/internal/mcpserver/domains.go +++ b/internal/mcpserver/domains.go @@ -91,7 +91,7 @@ var DomainSpecs = []catalog.DomainSpec{ }, { Key: "account", - Tags: []string{"Account", "Gauges", "MyAssignments", "MyNotes", "MyNotifications", "Bookmarks", "Drafts"}, - Blurb: "Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, personal note, and drafts.", + Tags: []string{"Account", "Gauges", "MyAssignments", "MyNotes", "MyNotifications", "Bookmarks", "BubbleUps", "Drafts"}, + Blurb: "Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, bubbling recordings up, personal note, and drafts.", }, } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 26a3cad0..2ee0328b 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "1dd547b3fd85bcdbd491ac85a66566c87f295549", - "ref": "go/v0.15.0", + "commit": "4b2a7cddbd0f25ca2c2bd292e5e5d46ddac38c66", + "ref": "go/v0.15.1-0.20260902165153-4b2a7cddbd0f", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "tags assigned to operations the export leaves untagged (PATCHED_TAGS); binary-upload operations dropped (EXCLUDED_OPERATIONS) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index 047211c0..f555b397 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -62,6 +62,18 @@ ] } }, + "CreateBubbleUp": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateCampfireLine": { "retry": { "max": 2, @@ -438,6 +450,18 @@ ] } }, + "DeleteBubbleUp": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "DeleteCampfireLine": { "idempotent": true, "retry": { @@ -2204,6 +2228,18 @@ ] } }, + "ListRecentProjects": { + "readonly": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "ListRecordingBoosts": { "readonly": true, "pagination": { @@ -2449,6 +2485,18 @@ ] } }, + "RecordProjectVisit": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "RemoveAccountLogo": { "idempotent": true, "retry": { @@ -2648,6 +2696,18 @@ ] } }, + "SpotlightRecording": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "Subscribe": { "idempotent": true, "retry": { @@ -2756,6 +2816,18 @@ ] } }, + "UnspotlightRecording": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "Unsubscribe": { "idempotent": true, "retry": { diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index db362eef..3509d438 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-08-11", + "version": "2026-08-31", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -12190,6 +12190,89 @@ } } }, + "/{accountId}/my/recent_projects.json": { + "get": { + "description": "List the projects the current user has most recently visited, most recent visit first.\nReads the per-user visit log \u2014 capped at the 50 most recent visits, keeping\nonly active projects the user can still access \u2014 not the home grid's\npinned-exclusion and padding. This endpoint is not paginated. Each entry is\nthe standard project projection plus the current user's bookmarked flag. A\nvisit is recorded when the user opens a project in Basecamp, when they\ncreate one, and by RecordProjectVisit.", + "operationId": "ListRecentProjects", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "ListRecentProjects 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRecentProjectsResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Projects" + ], + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/my/unreads.json": { "put": { "description": "Mark specified items as read", @@ -14221,10 +14304,10 @@ } } }, - "/{accountId}/projects/{projectId}/status/active.json": { - "put": { - "description": "Restore a project to active status from trash as well as from the archive (returns 204 No Content).\nThis is the inverse of both ArchiveProject and TrashProject. Restoring counts against\nthe account's project limit, so it answers 507 when that limit is already reached.", - "operationId": "UnarchiveProject", + "/{accountId}/projects/{projectId}/recent_visit.json": { + "post": { + "description": "Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content).\nIdempotent: re-recording a visit refreshes the same entry. Visits to\narchived or trashed projects are accepted but not recorded, and an\ninaccessible project answers 404.", + "operationId": "RecordProjectVisit", "parameters": [ { "name": "accountId", @@ -14249,7 +14332,7 @@ ], "responses": { "204": { - "description": "UnarchiveProject 204 response" + "description": "RecordProjectVisit 204 response" }, "401": { "description": "UnauthorizedError 401 response", @@ -14281,22 +14364,22 @@ } } }, - "500": { - "description": "InternalServerError 500 response", + "429": { + "description": "RateLimitError 429 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InternalServerErrorResponseContent" + "$ref": "#/components/schemas/RateLimitErrorResponseContent" } } } }, - "507": { - "description": "ProjectLimitError 507 response", + "500": { + "description": "InternalServerError 500 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectLimitErrorResponseContent" + "$ref": "#/components/schemas/InternalServerErrorResponseContent" } } } @@ -14319,10 +14402,10 @@ } } }, - "/{accountId}/projects/{projectId}/status/archived.json": { + "/{accountId}/projects/{projectId}/status/active.json": { "put": { - "description": "Archive a project, removing it from the active project list (returns 204 No Content).\nAccounts on the admin pro pack may restrict archiving to admins and the project's\ncreator, which answers 403.", - "operationId": "ArchiveProject", + "description": "Restore a project to active status from trash as well as from the archive (returns 204 No Content).\nThis is the inverse of both ArchiveProject and TrashProject. Restoring counts against\nthe account's project limit, so it answers 507 when that limit is already reached.", + "operationId": "UnarchiveProject", "parameters": [ { "name": "accountId", @@ -14347,7 +14430,7 @@ ], "responses": { "204": { - "description": "ArchiveProject 204 response" + "description": "UnarchiveProject 204 response" }, "401": { "description": "UnauthorizedError 401 response", @@ -14388,6 +14471,16 @@ } } } + }, + "507": { + "description": "ProjectLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -14407,10 +14500,10 @@ } } }, - "/{accountId}/projects/{projectId}/timeline.json": { - "get": { - "description": "Get project timeline", - "operationId": "GetProjectTimeline", + "/{accountId}/projects/{projectId}/status/archived.json": { + "put": { + "description": "Archive a project, removing it from the active project list (returns 204 No Content).\nAccounts on the admin pro pack may restrict archiving to admins and the project's\ncreator, which answers 403.", + "operationId": "ArchiveProject", "parameters": [ { "name": "accountId", @@ -14431,28 +14524,11 @@ "format": "int64" }, "required": true - }, - { - "name": "page", - "in": "query", - "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", - "schema": { - "type": "integer", - "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", - "format": "int32" - } } ], "responses": { - "200": { - "description": "GetProjectTimeline 200 response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetProjectTimelineResponseContent" - } - } - } + "204": { + "description": "ArchiveProject 204 response" }, "401": { "description": "UnauthorizedError 401 response", @@ -14484,16 +14560,6 @@ } } }, - "429": { - "description": "RateLimitError 429 response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RateLimitErrorResponseContent" - } - } - } - }, "500": { "description": "InternalServerError 500 response", "content": { @@ -14505,10 +14571,11 @@ } } }, - "x-basecamp-pagination": { - "style": "link", - "totalCountHeader": "X-Total-Count", - "maxPageSize": 50 + "tags": [ + "Projects" + ], + "x-basecamp-idempotent": { + "natural": true }, "x-basecamp-retry": { "maxAttempts": 3, @@ -14518,16 +14585,13 @@ 429, 503 ] - }, - "tags": [ - "Reports" - ] + } } }, - "/{accountId}/projects/{projectId}/timesheet.json": { + "/{accountId}/projects/{projectId}/timeline.json": { "get": { - "description": "Get timesheet for a specific project", - "operationId": "GetProjectTimesheet", + "description": "Get project timeline", + "operationId": "GetProjectTimeline", "parameters": [ { "name": "accountId", @@ -14549,28 +14613,6 @@ }, "required": true }, - { - "name": "from", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "person_id", - "in": "query", - "schema": { - "type": "integer", - "format": "int64" - } - }, { "name": "page", "in": "query", @@ -14584,11 +14626,11 @@ ], "responses": { "200": { - "description": "GetProjectTimesheet 200 response", + "description": "GetProjectTimeline 200 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetProjectTimesheetResponseContent" + "$ref": "#/components/schemas/GetProjectTimelineResponseContent" } } } @@ -14623,6 +14665,16 @@ } } }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, "500": { "description": "InternalServerError 500 response", "content": { @@ -14634,9 +14686,6 @@ } } }, - "tags": [ - "Schedule" - ], "x-basecamp-pagination": { "style": "link", "totalCountHeader": "X-Total-Count", @@ -14650,13 +14699,16 @@ 429, 503 ] - } + }, + "tags": [ + "Reports" + ] } }, - "/{accountId}/question_answers/{answerId}": { + "/{accountId}/projects/{projectId}/timesheet.json": { "get": { - "description": "Get a single answer by id", - "operationId": "GetAnswer", + "description": "Get timesheet for a specific project", + "operationId": "GetProjectTimesheet", "parameters": [ { "name": "accountId", @@ -14670,22 +14722,151 @@ "required": true }, { - "name": "answerId", + "name": "projectId", "in": "path", "schema": { "type": "integer", "format": "int64" }, "required": true + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "person_id", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", + "schema": { + "type": "integer", + "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", + "format": "int32" + } } ], "responses": { "200": { - "description": "GetAnswer 200 response", + "description": "GetProjectTimesheet 200 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetAnswerResponseContent" + "$ref": "#/components/schemas/GetProjectTimesheetResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Schedule" + ], + "x-basecamp-pagination": { + "style": "link", + "totalCountHeader": "X-Total-Count", + "maxPageSize": 50 + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, + "/{accountId}/question_answers/{answerId}": { + "get": { + "description": "Get a single answer by id", + "operationId": "GetAnswer", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "answerId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetAnswer 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAnswerResponseContent" } } } @@ -16823,6 +17004,209 @@ } } }, + "/{accountId}/recordings/{recordingId}/bubble_up.json": { + "delete": { + "description": "Remove the current user's bubble-up from a recording (returns 204 No Content).\nIdempotent: popping an absent bubble-up also returns 204.", + "operationId": "DeleteBubbleUp", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "DeleteBubbleUp 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "BubbleUps" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, + "post": { + "description": "Bubble up a recording for the current user, resurfacing it in the current\nuser's readings (the BC5 successor to \"save\"). Returns 204 No Content with\nno body.\n\nThe `at` field controls timing. Send `\"now\"` to bubble up immediately, or a\nscheduling keyword (`\"today\"`, `\"tomorrow\"`, `\"weekend\"`, `\"next_week\"`) or\nan ISO8601 date (e.g. `\"2026-09-10\"`) to schedule it to resurface later.\nNOTE: bc3 currently requires `at` \u2014 omitting it raises on the server\n(`Date.iso8601(nil)`), so send `\"now\"` for the immediate case. The field is\nmodeled optional (not `@required`) so a future bc3 default (`params[:at] ||=\n\"now\"`) makes omission mean \"now\" without an SDK change.\n\nIdempotent: bubbling up an already-bubbled recording is set-membership and\nstill returns 204.", + "operationId": "CreateBubbleUp", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBubbleUpRequestContent" + } + } + } + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "CreateBubbleUp 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "BubbleUps" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/recordings/{recordingId}/client_visibility.json": { "put": { "description": "Set client visibility for a recording", @@ -17497,6 +17881,217 @@ } } }, + "/{accountId}/recordings/{recordingId}/spotlight.json": { + "delete": { + "description": "Remove a recording from the spotlight area.\nIdempotent: removing an absent spotlight also returns 204.", + "operationId": "UnspotlightRecording", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "UnspotlightRecording 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Automation" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, + "post": { + "description": "Put a recording's card in the spotlight area on its project or template home page.\nIdempotent: spotlighting an already-spotlighted recording still returns 201.", + "operationId": "SpotlightRecording", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "SpotlightRecording 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpotlightRecordingResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Automation" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/recordings/{recordingId}/status/active.json": { "put": { "description": "Unarchive a recording (restore to active status)", @@ -28081,6 +28676,15 @@ "CreateBookmarkResponseContent": { "$ref": "#/components/schemas/Bookmark" }, + "CreateBubbleUpRequestContent": { + "type": "object", + "properties": { + "at": { + "type": "string", + "description": "Timing for the bubble-up. `\"now\"` bubbles up immediately; a scheduling\nkeyword (`\"today\"`, `\"tomorrow\"`, `\"weekend\"`, `\"next_week\"`) or an ISO8601\ndate (e.g. `\"2026-09-10\"`) schedules it to resurface later. bc3 requires a\nvalue \u2014 omitting `at` errors server-side (`Date.iso8601(nil)`) \u2014 so send\n`\"now\"` for the immediate case." + } + } + }, "CreateCampfireLineRequestContent": { "type": "object", "properties": { @@ -31030,6 +31634,12 @@ "$ref": "#/components/schemas/Question" } }, + "ListRecentProjectsResponseContent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + }, "ListRecordingBoostsResponseContent": { "type": "array", "items": { @@ -32058,6 +32668,10 @@ "bookmark_url": { "type": "string" }, + "star_url": { + "type": "string", + "description": "Bucket stars collection for this project (`/buckets/{id}/stars.json`)." + }, "url": { "type": "string" }, @@ -32072,7 +32686,12 @@ "x-go-type-skip-optional-pointer": true }, "bookmarked": { - "type": "boolean" + "type": "boolean", + "description": "True when the project is pinned on the current user's home page at all,\nwhether starred or filed into a stack." + }, + "starred": { + "type": "boolean", + "description": "True only when the project carries a star on the current user's home page." }, "client_company": { "$ref": "#/components/schemas/ClientCompany" @@ -33720,6 +34339,9 @@ "SetClientVisibilityResponseContent": { "$ref": "#/components/schemas/Recording" }, + "SpotlightRecordingResponseContent": { + "$ref": "#/components/schemas/Recording" + }, "StorageLimitErrorResponseContent": { "type": "object", "description": "The account has reached its file storage limit.\n\nRaised by ResourceLimits#ensure_account_can_upload_files ahead of any operation\nthat stores new bytes. No retry can satisfy it: the account needs more storage,\nso this maps to `limit_exceeded` rather than a retryable server error.", diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index a9a1024f..0eedc297 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -9,6 +9,8 @@ ACTIONS (RO = read-only): - create_project: Create a new project - get_project (RO): Get a single project by id - list_projects (RO, paginated): List projects (active by default; optionally archived/trashed) +- list_recent_projects (RO): List the projects the current user has most recently visited, most recent visit first +- record_project_visit: Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content) - trash_project: Trash a project (returns 204 No Content) - unarchive_project: Restore a project to active status from trash as well as from the archive (returns 204 No Content) - update_project: Update an existing project @@ -254,8 +256,10 @@ ACTIONS (RO = read-only): - reposition_tool: Reposition a tool on the project dock - resume_question: Resume a paused check-in question (resumes sending reminders) - search (RO, paginated): Search for content across the account +- spotlight_recording: Put a recording's card in the spotlight area on its project or template home page - trash_recording: Trash a recording - unarchive_recording: Unarchive a recording (restore to active status) +- unspotlight_recording: Remove a recording from the spotlight area - update_answer: Update an existing answer - update_lineup_marker: Update an existing lineup marker - update_question: Update an existing question @@ -332,15 +336,17 @@ ACTIONS (RO = read-only): - list_forwards (RO, paginated): List all forwards in an inbox == basecamp_account -Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, personal note, and drafts. +Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, bubbling recordings up, personal note, and drafts. Gateway tool: call with {"action": "...", "params": {...}}. Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. ACTIONS (RO = read-only): - create_bookmark: Bookmark a recording for the current user +- create_bubble_up: Bubble up a recording for the current user, resurfacing it in the current user's readings (the BC5 successor to "save") - create_gauge_needle: Create a gauge needle (progress update) for a project - delete_bookmark: Remove the current user's bookmark from a recording (returns 204 No Content) +- delete_bubble_up: Remove the current user's bubble-up from a recording (returns 204 No Content) - deprioritize_assignment: Remove a recording from Up Next (returns 204 No Content) - destroy_gauge_needle: Destroy a gauge needle - get_account (RO): Get the account for the current access token From a6b181f0eb8f1402a6ab3a212cf74076ed71c09e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 10:24:47 -0700 Subject: [PATCH 02/11] Refresh SDK pin to bubble-up head and clear dependency advisories Bump the basecamp-sdk/go pin to the current feat/recording-bubble-up head via `make bump-sdk`, syncing go.mod and internal/version/sdk-provenance.json (the prior manual pin left provenance at v0.15.0, tripping provenance-check). Bump google.golang.org/grpc v1.82.1 -> v1.83.1 to clear GHSA-vp52-pcj8-j9qc (HIGH: gRPC-Go HTTP/2 DATA-frame OOM), which Trivy flags on the module graph; grpc is compiled in via sigstore-go's verify path. Regenerate the Nix vendorHash for the go.mod/go.sum change (verified building via the pinned nixos/nix image). --- go.mod | 2 +- go.sum | 4 ++-- internal/version/sdk-provenance.json | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index af6b5822..0a5db8b8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.9 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902165153-4b2a7cddbd0f + github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902171637-e03865568c6f 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 535c7015..0f212c3d 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,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.15.1-0.20260902165153-4b2a7cddbd0f h1:r8mOXhSQlOhGrJSo7hgZWC99z8FnlpDdfZhG2YTS1/w= -github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902165153-4b2a7cddbd0f/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902171637-e03865568c6f h1:llFEWL5QzgB+0JX+2oRshHJMzhyCW+TiT8ZaWBmX+p0= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902171637-e03865568c6f/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= 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/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 3ccb5bcf..cacf723f 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,13 +1,13 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.15.0", - "revision": "1dd547b3fd85", - "updated_at": "2026-08-22T09:57:22Z" + "version": "v0.15.1-0.20260902171637-e03865568c6f", + "revision": "e03865568c6f", + "updated_at": "2026-09-02T17:16:37Z" }, "api": { "repo": "basecamp/bc3", - "revision": "71b43f3d9fa90a6c26e5af21b02697e33e7f0bae", - "synced_at": "2026-08-11" + "revision": "88549ca619eafc43e88639d0533f0d02e442edec", + "synced_at": "2026-08-31" } } From 66a3cc1d51a01467fd305df4d927c6b897a52e69 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 10:30:24 -0700 Subject: [PATCH 03/11] Sync vendored MCP model provenance to the pinned SDK head TestCatalogModelProvenance pins the vendored model's PROVENANCE ref to the basecamp-sdk version go.mod resolves. Bumping the SDK pin to the bubble-up head left the ref at the prior commit; refresh it to the go.mod pseudo-version for e0386556. The vendored behavior-model.json/openapi.json are byte-identical (the SDK head added only tests and docs, no operations), so only the provenance pointer moves. --- internal/mcpserver/model/PROVENANCE.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 2ee0328b..742a873d 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "4b2a7cddbd0f25ca2c2bd292e5e5d46ddac38c66", - "ref": "go/v0.15.1-0.20260902165153-4b2a7cddbd0f", + "commit": "e03865568c6fba744c141216f191427d1bf74180", + "ref": "go/v0.15.1-0.20260902171637-e03865568c6f", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "tags assigned to operations the export leaves untagged (PATCHED_TAGS); binary-upload operations dropped (EXCLUDED_OPERATIONS) — see the sync script" From 096058c0df7a0333ff836a9b348a3be2205cea1c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 10:49:30 -0700 Subject: [PATCH 04/11] bubble-up: validate --at, fix immediate-vs-scheduled reporting, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command fixes from review: - Validate --at locally before account resolution — a typo like "tomorow" is now a usage error instead of a server-side Date.iso8601 raise. - Report an explicit `--at now` as immediate (bubbled_up:true, "Bubbled up" summary), matching the omitted default rather than claiming it was scheduled. - Set bubbled_up from the effective timing, so a future --at reports bubbled_up:false (only in the scheduled set). - Reject a non-positive recording id as a usage error. - Point the post-add "view" breadcrumb at the registered `basecamp show`; `basecamp recordings show` does not exist. - Cover remove's non-id rejection and the new --at/id paths in tests; the URL-acceptance test no longer pastes a cross-account URL. Docs: API-COVERAGE.md bubble_ups row now lists the add/remove write ops and the SDK version; the basecamp agent skill gains bubble-up triggers, quick reference, and a Personal-section entry. --- API-COVERAGE.md | 7 ++-- internal/commands/bubble_up.go | 35 +++++++++++++--- internal/commands/bubble_up_test.go | 64 +++++++++++++++++++++++++++-- skills/basecamp/SKILL.md | 21 +++++++++- 4 files changed, 114 insertions(+), 13 deletions(-) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ec02b5da..7468908a 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -43,8 +43,9 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif > 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 (48 sections) is authoritative for the `Since` column. Reconciling endpoint counts is pre-existing maintenance, tracked separately. -**SDK version:** v0.15.0 (`internal/version/sdk-provenance.json` is -authoritative). The command surface below largely dates to the v0.12.0 bump, +**SDK version:** v0.15.1 (adds Bubble Up write support; pinned to the +pre-release bubble-up SDK head until the v0.15.1 tag — +`internal/version/sdk-provenance.json` is authoritative). The command surface below largely dates to the v0.12.0 bump, which added 20 exported Go methods over 13 new backend operations; the extra seven wrapped endpoints that already existed but were reachable only through the raw generated client, which the andon-cord rule forbids the CLI from @@ -197,7 +198,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | comments | 8 | `comment`, `comments` | ✅ | BC4 | - | list, show, thread, create, update. @mentions in content. `show` surfaces `reply_target` + paste-ready `mention` from its single Get (no new calls). `thread` composes Get + parent recording (via type endpoint) + List into a deterministic reply-ready context (no new endpoints) | | boosts | 6 | `boost`, `react` | ✅ | BC4 | - | list (recording + event), show, create (recording + event), delete. No account-wide listing — BC5 withdrew `/boosts.json` (basecamp/bc3#12464); temporary, returns via basecamp/bc3#12463 | | notifications | 2 | `notifications` | ✅ | BC4 | - | list, mark as read (BC5: `bubble_ups`/`scheduled_bubble_ups` sections; `memories` is BC4-only) | -| bubble_ups | 1 | `notifications bubbleups` | ✅ | BC5 | - | Dedicated Bubble Ups list (`GET /my/readings/bubble_ups.json`, paginated) plus the `limit_bubble_ups` variant behind `notifications list --limit-bubble-ups` | +| bubble_ups | 3 | `bubble-up`, `notifications bubbleups` | ✅ | BC5 | - | `bubble-up add`/`remove` create and delete a per-recording bubble-up (`POST`/`DELETE /recordings/{id}/bubble_up.json`); `add --at` schedules. Dedicated list is `notifications bubbleups` (`GET /my/readings/bubble_ups.json`, paginated) plus the `limit_bubble_ups` variant behind `notifications list --limit-bubble-ups`. Per-recording GET is an unrenderable API gap, so there is no `check`. | | **Cards (Kanban)** | | card_tables | 3 | `cards` | ✅ | BC4 | - | Accessed via project dock | | card_table_cards | 9 | `cards` | ✅ | BC4 | - | list, show, create, update, move | diff --git a/internal/commands/bubble_up.go b/internal/commands/bubble_up.go index db3d5ebf..fa1c3f2b 100644 --- a/internal/commands/bubble_up.go +++ b/internal/commands/bubble_up.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "strconv" + "time" "github.com/spf13/cobra" @@ -70,6 +71,9 @@ succeeds. if err != nil { return err } + if err := validateBubbleUpAt(at); err != nil { + return err + } if err := ensureAccount(cmd, app); err != nil { return err } @@ -86,12 +90,13 @@ succeeds. return convertSDKError(err) } + immediate := when == "now" summary := fmt.Sprintf("Bubbled up recording %d", recordingID) - if at != "" { - summary = fmt.Sprintf("Scheduled recording %d to bubble up %s", recordingID, at) + if !immediate { + summary = fmt.Sprintf("Scheduled recording %d to bubble up %s", recordingID, when) } - return app.OK(map[string]any{"id": recordingID, "bubbled_up": true, "at": when}, + return app.OK(map[string]any{"id": recordingID, "bubbled_up": immediate, "at": when}, output.WithSummary(summary), output.WithBreadcrumbs( output.Breadcrumb{ @@ -101,7 +106,7 @@ succeeds. }, output.Breadcrumb{ Action: "show", - Cmd: fmt.Sprintf("basecamp recordings show %d", recordingID), + Cmd: fmt.Sprintf("basecamp show %d", recordingID), Description: "View the recording", }, ), @@ -159,7 +164,7 @@ run without checking first. // pasted URL is the natural way to name one. func bubbleUpRecordingID(arg string) (int64, error) { id, err := strconv.ParseInt(extractID(arg), 10, 64) - if err != nil { + if err != nil || id <= 0 { return 0, output.ErrUsageHint( fmt.Sprintf("%q is not a recording id or Basecamp URL", arg), "Pass a numeric recording id, or paste the recording's Basecamp URL", @@ -167,3 +172,23 @@ func bubbleUpRecordingID(arg string) (int64, error) { } return id, nil } + +// validateBubbleUpAt rejects an --at value bc3 cannot parse before any account +// resolution or network call, so a typo like "tomorow" is a local usage error +// rather than a server-side Date.iso8601 raise. Valid values are the empty +// default, "now", the schedule keywords, or an ISO8601 date. +func validateBubbleUpAt(at string) error { + switch at { + case "", "now", "today", "tomorrow", "weekend", "next_week": + return nil + } + for _, layout := range []string{"2006-01-02", time.RFC3339, "2006-01-02T15:04:05"} { + if _, err := time.Parse(layout, at); err == nil { + return nil + } + } + return output.ErrUsageHint( + fmt.Sprintf("%q is not a valid --at value", at), + `Use "now", a keyword ("today", "tomorrow", "weekend", "next_week"), or an ISO8601 date`, + ) +} diff --git a/internal/commands/bubble_up_test.go b/internal/commands/bubble_up_test.go index be09824a..e8d83657 100644 --- a/internal/commands/bubble_up_test.go +++ b/internal/commands/bubble_up_test.go @@ -1,6 +1,7 @@ package commands import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -57,11 +58,66 @@ func TestBubbleUpAddAcceptsAURL(t *testing.T) { app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", - "https://3.basecamp.com/1234567/buckets/89/todos/42")) + "https://3.basecamp.com/99999/buckets/89/todos/42")) assert.Equal(t, bubbleUpRecordingPath(42), transport.last(t).Path) } +// bubbleUpData reads the single-object success envelope the add/remove verbs emit. +func bubbleUpData(t *testing.T, out *bytes.Buffer) map[string]any { + t.Helper() + var envelope struct { + Data map[string]any `json:"data"` + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + return map[string]any{"data": envelope.Data, "summary": envelope.Summary} +} + +// A scheduled add reports bubbled_up:false (only in the scheduled set) and a +// scheduling summary. +func TestBubbleUpAddScheduledReportsNotYetBubbled(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "tomorrow")) + + env := bubbleUpData(t, out) + data := env["data"].(map[string]any) + assert.Equal(t, false, data["bubbled_up"]) + assert.Equal(t, "tomorrow", data["at"]) + assert.Equal(t, "Scheduled recording 42 to bubble up tomorrow", env["summary"]) +} + +// An explicit --at now is immediate, matching the omitted default: bubbled_up:true +// and a "Bubbled up" summary, not a scheduling one. +func TestBubbleUpAddExplicitNowReportsImmediate(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "now")) + + env := bubbleUpData(t, out) + data := env["data"].(map[string]any) + assert.Equal(t, true, data["bubbled_up"]) + assert.Equal(t, "Bubbled up recording 42", env["summary"]) +} + +// A malformed --at is a local usage error before any request is issued. +func TestBubbleUpAddRejectsInvalidAt(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "tomorow") + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "no request should be made for an invalid --at") +} + +// A non-positive id is rejected as a usage error, not sent to the API. +func TestBubbleUpAddRejectsNonPositiveID(t *testing.T) { + app, _, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "0") + requireBookmarksUsageError(t, err) +} + func TestBubbleUpRemoveDeletesTheBubbleUp(t *testing.T) { app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodDelete)) @@ -75,6 +131,8 @@ func TestBubbleUpRemoveDeletesTheBubbleUp(t *testing.T) { func TestBubbleUpVerbsRejectANonID(t *testing.T) { app, _, _ := setupPersonalFeedApp(t) - err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "not-an-id") - requireBookmarksUsageError(t, err) + for _, verb := range []string{"add", "remove"} { + err := executeRecordingCommand(NewBubbleUpCmd(), app, verb, "not-an-id") + requireBookmarksUsageError(t, err) + } } diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index ca867626..762f5c24 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -4,7 +4,7 @@ description: | Interact with Basecamp via the Basecamp CLI. Full API coverage: projects, todos, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, subscriptions, lineup, chat, pings, gauges, assignments, notifications, bookmarks, - drafts, notes, calendars, and accounts. + bubble-up, drafts, notes, calendars, and accounts. Use for ANY Basecamp question or action. triggers: # Direct invocations @@ -20,6 +20,7 @@ triggers: - basecamp file - basecamp document - basecamp bookmarks + - basecamp bubble-up - basecamp drafts - basecamp notes - basecamp calendars @@ -215,6 +216,9 @@ basecamp --page 1 # First page only, no auto-pagination | My bookmarks | `basecamp bookmarks list --json` | | Bookmark something | `basecamp bookmarks add --json` | | Is it bookmarked? | `basecamp bookmarks check --json` (always exits 0) | +| Bubble a recording up | `basecamp bubble-up add --json` | +| Schedule a bubble-up | `basecamp bubble-up add --at tomorrow --json` | +| Pop a bubble-up | `basecamp bubble-up remove --json` | | My unpublished drafts | `basecamp drafts list --json` | | Read my personal note | `basecamp notes show --json` | | Replace my personal note | `basecamp notes set "" --json` | @@ -260,6 +264,9 @@ basecamp --page 1 # First page only, no auto-pagination | Notifications | `basecamp notifications --json` | | Mark notification read | `basecamp notifications read --json` | | All bubble-ups (BC5) | `basecamp notifications bubbleups --json` | +| Bubble a recording up | `basecamp bubble-up add --json` | +| Bubble up on a schedule | `basecamp bubble-up add --at tomorrow --json` | +| Pop a bubble-up | `basecamp bubble-up remove --json` | | Gauges (account-wide) | `basecamp gauges list --json` | | Gauge needles | `basecamp gauges needles --in --json` | | Create needle | `basecamp gauges create --position 75 --color green --in --json` | @@ -1084,7 +1091,7 @@ success while changing nothing. If two steps on one card are prioritized, the listing shows the card once with a single `priority_recording_id` and the siblings are not separately addressable. -### Personal (bookmarks, drafts, notes) +### Personal (bookmarks, bubble-up, drafts, notes) Private to you, spanning every project — no `--in `. @@ -1093,11 +1100,21 @@ basecamp bookmarks list --json basecamp bookmarks add --json basecamp bookmarks remove --json basecamp bookmarks check --json +basecamp bubble-up add --json +basecamp bubble-up add --at tomorrow --json +basecamp bubble-up remove --json basecamp drafts list --json basecamp notes show --json basecamp notes set "" --json ``` +`bubble-up add`/`remove` resurface a recording in your readings (the BC5 +successor to "save"), addressed by id or pasted URL. `add` bubbles up now by +default; `--at` schedules it — a keyword (`today`, `tomorrow`, `weekend`, +`next_week`) or an ISO8601 date. Both verbs are idempotent. There is no +per-recording status read (that GET is an unrenderable API gap); the full list +is `basecamp notifications bubbleups`. + `bookmarks add` and `remove` are idempotent — re-adding returns the existing bookmark, removing an absent one still succeeds. `check` reports `{"bookmarked": true|false}` and **always exits 0**: both answers are successes, From cf5cfdb7b0160e67a3ebc9a89154c5451d43299b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 11:03:37 -0700 Subject: [PATCH 05/11] bubble-up: pin --at to calendar-date granularity; coverage/skill tidy-ups Round-two review fixes: - validateBubbleUpAt now accepts only "now", the keywords, or YYYY-MM-DD. bc3 schedules at day granularity (keywords map to fixed hours; Date.iso8601 reduces any value to a calendar day), so a timestamp with a time component would be silently truncated and echoing it back would misreport the honored time. Rejecting timestamps and ISO basic dates keeps the contract to the one form bc3 honors as written; help text updated to match. Tests cover a bare date reaching the wire and rejection of a timestamp / basic date. - API-COVERAGE.md summary totals move 184 -> 186 implemented and 196 -> 198 tracked for the two new bubble-up write endpoints, keeping the aggregate consistent with the per-row change. - Drop the duplicated bubble-up rows from the agent skill's quick reference (kept the personal-commands set next to bookmarks). --- API-COVERAGE.md | 6 +++--- internal/commands/bubble_up.go | 20 ++++++++++++-------- internal/commands/bubble_up_test.go | 24 +++++++++++++++++++----- skills/basecamp/SKILL.md | 3 --- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 7468908a..859db67a 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,12 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 50 | 184 | +| ✅ Implemented | 50 | 186 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **54** | **196** | +| **Total tracked** | **54** | **198** | -**184 of 184 tracked in-scope endpoints.** The last gap — `GET +**186 of 186 tracked in-scope endpoints.** The last gap — `GET /uploads/:id/versions.json` — closed with the v0.14.0 SDK bump. The command (`files versions`) was written earlier but held: the SDK's `UploadsService.ListVersions` decoded the response as `[]Upload` when the API diff --git a/internal/commands/bubble_up.go b/internal/commands/bubble_up.go index fa1c3f2b..7993d916 100644 --- a/internal/commands/bubble_up.go +++ b/internal/commands/bubble_up.go @@ -55,7 +55,8 @@ func newBubbleUpAddCmd() *cobra.Command { Long: `Bubble a recording up so it resurfaces in your readings. By default it bubbles up now. Pass --at to schedule it instead: a keyword -("today", "tomorrow", "weekend", "next_week") or an ISO8601 date. +("today", "tomorrow", "weekend", "next_week") or a calendar date (YYYY-MM-DD). +bc3 schedules at day granularity, so a date is honored to the day, not the hour. Idempotent: bubbling up something already bubbled up is a no-op that still succeeds. @@ -114,7 +115,7 @@ succeeds. }, } - cmd.Flags().StringVar(&at, "at", "", `When to bubble up: "now" (default), a keyword ("today", "tomorrow", "weekend", "next_week"), or an ISO8601 date`) + cmd.Flags().StringVar(&at, "at", "", `When to bubble up: "now" (default), a keyword ("today", "tomorrow", "weekend", "next_week"), or a calendar date (YYYY-MM-DD)`) return cmd } @@ -176,19 +177,22 @@ func bubbleUpRecordingID(arg string) (int64, error) { // validateBubbleUpAt rejects an --at value bc3 cannot parse before any account // resolution or network call, so a typo like "tomorow" is a local usage error // rather than a server-side Date.iso8601 raise. Valid values are the empty -// default, "now", the schedule keywords, or an ISO8601 date. +// default, "now", the schedule keywords, or a calendar date (YYYY-MM-DD). +// +// bc3 schedules bubble-ups at a date granularity — a keyword maps to a fixed +// morning/afternoon hour and Date.iso8601 reduces any value to a calendar day — +// so a timestamp with a time component would be silently truncated. We accept +// only YYYY-MM-DD rather than echo an exact time the server never honors. func validateBubbleUpAt(at string) error { switch at { case "", "now", "today", "tomorrow", "weekend", "next_week": return nil } - for _, layout := range []string{"2006-01-02", time.RFC3339, "2006-01-02T15:04:05"} { - if _, err := time.Parse(layout, at); err == nil { - return nil - } + if _, err := time.Parse("2006-01-02", at); err == nil { + return nil } return output.ErrUsageHint( fmt.Sprintf("%q is not a valid --at value", at), - `Use "now", a keyword ("today", "tomorrow", "weekend", "next_week"), or an ISO8601 date`, + `Use "now", a keyword ("today", "tomorrow", "weekend", "next_week"), or a calendar date (YYYY-MM-DD)`, ) } diff --git a/internal/commands/bubble_up_test.go b/internal/commands/bubble_up_test.go index e8d83657..e27ef399 100644 --- a/internal/commands/bubble_up_test.go +++ b/internal/commands/bubble_up_test.go @@ -101,13 +101,27 @@ func TestBubbleUpAddExplicitNowReportsImmediate(t *testing.T) { assert.Equal(t, "Bubbled up recording 42", env["summary"]) } -// A malformed --at is a local usage error before any request is issued. +// A calendar date reaches the wire verbatim. +func TestBubbleUpAddAcceptsACalendarDate(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "2026-09-10")) + + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(transport.last(t).Body), &body)) + assert.Equal(t, "2026-09-10", body["at"]) +} + +// A malformed --at, and a timestamp bc3 would truncate to a date, are both local +// usage errors before any request is issued. func TestBubbleUpAddRejectsInvalidAt(t *testing.T) { - app, transport, _ := setupPersonalFeedApp(t) + for _, bad := range []string{"tomorow", "2026-09-10T09:00:00Z", "20260910"} { + app, transport, _ := setupPersonalFeedApp(t) - err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "tomorow") - requireBookmarksUsageError(t, err) - assert.Empty(t, transport.recorded(), "no request should be made for an invalid --at") + err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", bad) + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "no request should be made for --at %q", bad) + } } // A non-positive id is rejected as a usage error, not sent to the API. diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 762f5c24..6d3ac6f5 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -264,9 +264,6 @@ basecamp --page 1 # First page only, no auto-pagination | Notifications | `basecamp notifications --json` | | Mark notification read | `basecamp notifications read --json` | | All bubble-ups (BC5) | `basecamp notifications bubbleups --json` | -| Bubble a recording up | `basecamp bubble-up add --json` | -| Bubble up on a schedule | `basecamp bubble-up add --at tomorrow --json` | -| Pop a bubble-up | `basecamp bubble-up remove --json` | | Gauges (account-wide) | `basecamp gauges list --json` | | Gauge needles | `basecamp gauges needles --in --json` | | Create needle | `basecamp gauges create --position 75 --color green --in --json` | From 6bc6000eb68519f672efd6c9ff7fa3f4bc413677 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 14:04:11 -0700 Subject: [PATCH 06/11] Bump basecamp-sdk to the merged bubble-up support (#839) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0a5db8b8..66ffb47b 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.9 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902171637-e03865568c6f + github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2 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 0f212c3d..ded8979f 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,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.15.1-0.20260902171637-e03865568c6f h1:llFEWL5QzgB+0JX+2oRshHJMzhyCW+TiT8ZaWBmX+p0= -github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902171637-e03865568c6f/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2 h1:bIaTwvIoWTNirPigLsXsC912abkIYDTFHuOrkSyB96U= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= 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= From 59f46a0f30128e161b782006a0e682f2208a75de Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 14:32:46 -0700 Subject: [PATCH 07/11] Sync SDK provenance and Nix vendorHash to the pinned bubble-up head go.mod pins basecamp-sdk f439f493; bring internal/version/sdk-provenance.json, the vendored model PROVENANCE.json, and the Nix vendorHash into lockstep with that commit so TestCatalogModelProvenance, provenance-check, and the flake build all resolve against one SDK version. --- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/version/sdk-provenance.json | 6 +++--- nix/package.nix | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 742a873d..c6ef1f8f 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "e03865568c6fba744c141216f191427d1bf74180", - "ref": "go/v0.15.1-0.20260902171637-e03865568c6f", + "commit": "f439f493caf24ec026d7efe4649eed87b82a30a8", + "ref": "go/v0.15.1-0.20260902205921-f439f493caf2", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "tags assigned to operations the export leaves untagged (PATCHED_TAGS); binary-upload operations dropped (EXCLUDED_OPERATIONS) — see the sync script" diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index cacf723f..589d7f38 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.15.1-0.20260902171637-e03865568c6f", - "revision": "e03865568c6f", - "updated_at": "2026-09-02T17:16:37Z" + "version": "v0.15.1-0.20260902205921-f439f493caf2", + "revision": "f439f493caf2", + "updated_at": "2026-09-02T20:59:21Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 76114178..9b811eb5 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-2N7ar+f9S2WjppdHFbeiOhoREM81+yhTPn4bJZBGG/U="; + vendorHash = "sha256-OFz67aZDTfQZMS+4MmZM960KquS9IoXXH3WN6cn778Y="; subPackages = [ "cmd/basecamp" ]; From ba6ee1a9c1c78a6093d8b2e6c934670f7c7ca825 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 14:32:46 -0700 Subject: [PATCH 08/11] bubble-up: report the requested action; project star toggle tracks Starred add reports the requested timing rather than asserting a resulting state: bc3 answers with a bare 204 and add is idempotent, so an add over an already bubbled-up recording is indistinguishable from a fresh one. Drop the bubbled_up state claim and summarize as a request. The agent note points to "basecamp notifications bubbleups", the full current-and-scheduled listing, instead of the general notifications inbox. Projects view carries Starred through ProjectInfo and uses it for the star marker and the /projects/{id}/star.json toggle. A project filed into a stack is bookmarked but not starred, so keying the toggle off Bookmarked tried to unstar an already-unstarred project. --- internal/commands/bubble_up.go | 14 +++++++---- internal/commands/bubble_up_test.go | 24 +++++++++++-------- internal/tui/workspace/data/hub_global.go | 1 + internal/tui/workspace/data/types.go | 1 + internal/tui/workspace/views/dock.go | 1 + internal/tui/workspace/views/projects.go | 14 ++++++----- internal/tui/workspace/views/projects_test.go | 10 ++++++++ 7 files changed, 44 insertions(+), 21 deletions(-) diff --git a/internal/commands/bubble_up.go b/internal/commands/bubble_up.go index 7993d916..8b3abd79 100644 --- a/internal/commands/bubble_up.go +++ b/internal/commands/bubble_up.go @@ -33,8 +33,8 @@ document, card, and so on), addressed by its id or by pasting its Basecamp URL. "agent_notes": "Account-wide and personal — no --in needed.\n" + "add/remove are idempotent. add takes --at to schedule; bc3 requires a\n" + "value, so add sends \"now\" when --at is omitted. There is no status\n" + - "read (per-recording GET is an unrenderable API gap); the full list is\n" + - "basecamp notifications.", + "read (per-recording GET is an unrenderable API gap); the full\n" + + "current-and-scheduled list is basecamp notifications bubbleups.", }, } @@ -91,13 +91,17 @@ succeeds. return convertSDKError(err) } + // add is idempotent and bc3 answers with a bare 204: adding over + // an already bubbled-up recording is a no-op reported identically, + // so there is no readable post-state. Report the requested timing + // rather than assert a resulting state we can't confirm. immediate := when == "now" - summary := fmt.Sprintf("Bubbled up recording %d", recordingID) + summary := fmt.Sprintf("Requested bubble-up for recording %d", recordingID) if !immediate { - summary = fmt.Sprintf("Scheduled recording %d to bubble up %s", recordingID, when) + summary = fmt.Sprintf("Requested bubble-up for recording %d at %s", recordingID, when) } - return app.OK(map[string]any{"id": recordingID, "bubbled_up": immediate, "at": when}, + return app.OK(map[string]any{"id": recordingID, "at": when}, output.WithSummary(summary), output.WithBreadcrumbs( output.Breadcrumb{ diff --git a/internal/commands/bubble_up_test.go b/internal/commands/bubble_up_test.go index e27ef399..2515b0c1 100644 --- a/internal/commands/bubble_up_test.go +++ b/internal/commands/bubble_up_test.go @@ -74,31 +74,35 @@ func bubbleUpData(t *testing.T, out *bytes.Buffer) map[string]any { return map[string]any{"data": envelope.Data, "summary": envelope.Summary} } -// A scheduled add reports bubbled_up:false (only in the scheduled set) and a -// scheduling summary. -func TestBubbleUpAddScheduledReportsNotYetBubbled(t *testing.T) { +// A scheduled add reports the requested timing, not a resulting state: bc3 +// returns a bare 204 and add is idempotent, so the command must not claim the +// bubble-up now sits in the scheduled set (it may have been a no-op over an +// existing immediate bubble-up). It echoes the requested `at` and no +// bubbled_up state assertion. +func TestBubbleUpAddScheduledReportsRequestedTiming(t *testing.T) { app, _, out := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "tomorrow")) env := bubbleUpData(t, out) data := env["data"].(map[string]any) - assert.Equal(t, false, data["bubbled_up"]) + assert.NotContains(t, data, "bubbled_up", "add must not assert a resulting bubbled-up state") assert.Equal(t, "tomorrow", data["at"]) - assert.Equal(t, "Scheduled recording 42 to bubble up tomorrow", env["summary"]) + assert.Equal(t, "Requested bubble-up for recording 42 at tomorrow", env["summary"]) } -// An explicit --at now is immediate, matching the omitted default: bubbled_up:true -// and a "Bubbled up" summary, not a scheduling one. -func TestBubbleUpAddExplicitNowReportsImmediate(t *testing.T) { +// An immediate add (the omitted default, or an explicit --at now) reports the +// request without a resulting-state claim. +func TestBubbleUpAddImmediateReportsRequest(t *testing.T) { app, _, out := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "now")) env := bubbleUpData(t, out) data := env["data"].(map[string]any) - assert.Equal(t, true, data["bubbled_up"]) - assert.Equal(t, "Bubbled up recording 42", env["summary"]) + assert.NotContains(t, data, "bubbled_up", "add must not assert a resulting bubbled-up state") + assert.Equal(t, "now", data["at"]) + assert.Equal(t, "Requested bubble-up for recording 42", env["summary"]) } // A calendar date reaches the wire verbatim. diff --git a/internal/tui/workspace/data/hub_global.go b/internal/tui/workspace/data/hub_global.go index f89caf69..6e975482 100644 --- a/internal/tui/workspace/data/hub_global.go +++ b/internal/tui/workspace/data/hub_global.go @@ -779,6 +779,7 @@ func projectsToInfos(projects []basecamp.Project, acct AccountInfo) []ProjectInf Description: p.Description, Purpose: p.Purpose, Bookmarked: p.Bookmarked, + Starred: p.Starred, AccountID: acct.ID, AccountName: acct.Name, Dock: dock, diff --git a/internal/tui/workspace/data/types.go b/internal/tui/workspace/data/types.go index ce7ecf72..feec3554 100644 --- a/internal/tui/workspace/data/types.go +++ b/internal/tui/workspace/data/types.go @@ -215,6 +215,7 @@ type ProjectInfo struct { Description string Purpose string Bookmarked bool + Starred bool AccountID string AccountName string Dock []DockToolInfo diff --git a/internal/tui/workspace/views/dock.go b/internal/tui/workspace/views/dock.go index d4048f28..9edd76f4 100644 --- a/internal/tui/workspace/views/dock.go +++ b/internal/tui/workspace/views/dock.go @@ -195,6 +195,7 @@ func (v *Dock) Update(msg tea.Msg) (workspace.View, tea.Cmd) { Description: msg.Project.Description, Purpose: msg.Project.Purpose, Bookmarked: msg.Project.Bookmarked, + Starred: msg.Project.Starred, Dock: dock, } v.syncTools() diff --git a/internal/tui/workspace/views/projects.go b/internal/tui/workspace/views/projects.go index 4173cdaa..87db74ce 100644 --- a/internal/tui/workspace/views/projects.go +++ b/internal/tui/workspace/views/projects.go @@ -216,10 +216,10 @@ func (v *Projects) Update(msg tea.Msg) (workspace.View, tea.Cmd) { // Revert optimistic update p := v.findProject(msg.ProjectID) if p != nil { - p.Bookmarked = !msg.Bookmarked + p.Starred = !msg.Bookmarked v.syncProjectList() } - return v, workspace.ReportError(msg.Err, "toggling bookmark") + return v, workspace.ReportError(msg.Err, "toggling star") } // On success, invalidate pool so other views (Home bookmarks) get updated data v.pool.Invalidate() @@ -509,7 +509,7 @@ func projectInfoToListItem(p data.ProjectInfo) widget.ListItem { ID: fmt.Sprintf("%d", p.ID), Title: p.Name, Description: desc, - Marked: p.Bookmarked, + Marked: p.Starred, } } @@ -734,12 +734,14 @@ func (v *Projects) toggleBookmark() tea.Cmd { return nil } - newBookmarked := !p.Bookmarked + // The toggle drives /projects/{id}/star.json, so it tracks Starred — a + // project filed into a stack is Bookmarked but not necessarily Starred. + newStarred := !p.Starred // Optimistic: flip in local data, re-sort - p.Bookmarked = newBookmarked + p.Starred = newStarred v.syncProjectList() - return v.setBookmark(projectID, newBookmarked) + return v.setBookmark(projectID, newStarred) } func (v *Projects) setBookmark(projectID int64, bookmarked bool) tea.Cmd { diff --git a/internal/tui/workspace/views/projects_test.go b/internal/tui/workspace/views/projects_test.go index 7e58a61f..3439b52e 100644 --- a/internal/tui/workspace/views/projects_test.go +++ b/internal/tui/workspace/views/projects_test.go @@ -397,6 +397,16 @@ func TestProjectInfoToListItem_UnicodeDescription(t *testing.T) { assert.Equal(t, item.Description, string([]rune(item.Description))) } +// The list star marker reflects Starred, not Bookmarked: a project filed into a +// stack is Bookmarked but unstarred, and the /star.json toggle acts on the star. +func TestProjectInfoToListItem_MarkedReflectsStarred(t *testing.T) { + stackedNotStarred := projectInfoToListItem(data.ProjectInfo{ID: 1, Name: "Stacked", Bookmarked: true, Starred: false}) + assert.False(t, stackedNotStarred.Marked, "a bookmarked-but-unstarred project is not marked") + + starred := projectInfoToListItem(data.ProjectInfo{ID: 2, Name: "Starred", Bookmarked: true, Starred: true}) + assert.True(t, starred.Marked, "a starred project is marked") +} + func TestToolNameToView(t *testing.T) { tests := []struct { name string From b6de9dbb66466bdda3f34588bf5b12cb245c1e07 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 14:43:45 -0700 Subject: [PATCH 09/11] Star-labeled project surfaces track Starred, not Bookmarked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick Jump renders its project category as "Starred" and the interactive project picker prefixes a ★ glyph, but both keyed off Bookmarked, so a project filed into a stack but not starred was mislabeled as starred. Point both at Starred; ranking and the Home "Bookmarks" section keep Bookmarked, which is the aggregate pinned-or-stacked state they mean. --- internal/tui/resolve/project.go | 2 +- internal/tui/resolve/project_test.go | 19 ++++++++++++++ internal/tui/workspace/chrome/quickjump.go | 4 +-- .../tui/workspace/chrome/quickjump_test.go | 25 +++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 internal/tui/resolve/project_test.go diff --git a/internal/tui/resolve/project.go b/internal/tui/resolve/project.go index df103cbd..12ea5edf 100644 --- a/internal/tui/resolve/project.go +++ b/internal/tui/resolve/project.go @@ -112,7 +112,7 @@ func (r *Resolver) ProjectWithPersist(ctx context.Context) (*ResolvedValue, erro // projectToPickerItem converts a Basecamp project to a picker item. func projectToPickerItem(proj basecamp.Project) tui.PickerItem { title := proj.Name - if proj.Bookmarked { + if proj.Starred { title = "★ " + title } diff --git a/internal/tui/resolve/project_test.go b/internal/tui/resolve/project_test.go new file mode 100644 index 00000000..c6ac588f --- /dev/null +++ b/internal/tui/resolve/project_test.go @@ -0,0 +1,19 @@ +package resolve + +import ( + "strings" + "testing" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/stretchr/testify/assert" +) + +// The ★ prefix marks a starred project, so it reflects Starred, not Bookmarked: +// a project filed into a stack is bookmarked but unstarred and gets no star. +func TestProjectToPickerItem_StarMarkerReflectsStarred(t *testing.T) { + stackedNotStarred := projectToPickerItem(basecamp.Project{ID: 1, Name: "Stacked", Bookmarked: true, Starred: false}) + assert.False(t, strings.HasPrefix(stackedNotStarred.Title, "★"), "a bookmarked-but-unstarred project gets no star") + + starred := projectToPickerItem(basecamp.Project{ID: 2, Name: "Starred", Bookmarked: true, Starred: true}) + assert.True(t, strings.HasPrefix(starred.Title, "★"), "a starred project gets a star prefix") +} diff --git a/internal/tui/workspace/chrome/quickjump.go b/internal/tui/workspace/chrome/quickjump.go index 4bb79d2e..2cce6a94 100644 --- a/internal/tui/workspace/chrome/quickjump.go +++ b/internal/tui/workspace/chrome/quickjump.go @@ -195,10 +195,10 @@ func (q *QuickJump) populateItems(src QuickJumpSource) { }) } - // 3. Bookmarked projects + // 3. Starred projects (this category renders as "Starred") for _, p := range src.Projects { id := fmt.Sprintf("%d", p.ID) - if seen[id] || !p.Bookmarked { + if seen[id] || !p.Starred { continue } seen[id] = true diff --git a/internal/tui/workspace/chrome/quickjump_test.go b/internal/tui/workspace/chrome/quickjump_test.go index d2509124..a4f5d538 100644 --- a/internal/tui/workspace/chrome/quickjump_test.go +++ b/internal/tui/workspace/chrome/quickjump_test.go @@ -49,6 +49,31 @@ func testQuickJumpSource() QuickJumpSource { } } +// The "bookmark" category renders as "Starred", so it must be populated from +// Starred, not Bookmarked: a project filed into a stack is bookmarked but +// unstarred and must not appear there. +func TestQuickJump_StarredCategoryUsesStarred(t *testing.T) { + styles := tui.NewStyles() + qj := NewQuickJump(styles) + qj.SetSize(80, 24) + + src := testQuickJumpSource() + src.Projects = []data.ProjectInfo{ + {ID: 300, Name: "Starred Project", AccountID: "acct1", Bookmarked: true, Starred: true}, + {ID: 301, Name: "Stacked Only", AccountID: "acct1", Bookmarked: true, Starred: false}, + } + qj.Focus(src) + + inStarred := map[string]bool{} + for _, item := range qj.items { + if item.Category == "bookmark" { + inStarred[item.Title] = true + } + } + assert.True(t, inStarred["Starred Project"], "a starred project belongs in the Starred category") + assert.False(t, inStarred["Stacked Only"], "a bookmarked-but-unstarred project must not appear as Starred") +} + func TestQuickJump_ToolItems_Appear(t *testing.T) { styles := tui.NewStyles() qj := NewQuickJump(styles) From afae17864a15d735f49dde0f9f3266a38232ef39 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 14:52:42 -0700 Subject: [PATCH 10/11] bubble-up docs: name the accepted --at date form; keep bookmark copy consistent The skill note called --at an "ISO8601 date", which also promises the basic form (20260910) that validateBubbleUpAt rejects; name the accepted calendar form (YYYY-MM-DD) to match the command contract. Restore the projects-view toggle's error copy to "bookmark" so the surface's user-facing wording stays consistent with the "Bookmark updated" success text. --- internal/tui/workspace/views/projects.go | 2 +- skills/basecamp/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/workspace/views/projects.go b/internal/tui/workspace/views/projects.go index 87db74ce..23fc1fb2 100644 --- a/internal/tui/workspace/views/projects.go +++ b/internal/tui/workspace/views/projects.go @@ -219,7 +219,7 @@ func (v *Projects) Update(msg tea.Msg) (workspace.View, tea.Cmd) { p.Starred = !msg.Bookmarked v.syncProjectList() } - return v, workspace.ReportError(msg.Err, "toggling star") + return v, workspace.ReportError(msg.Err, "toggling bookmark") } // On success, invalidate pool so other views (Home bookmarks) get updated data v.pool.Invalidate() diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 6d3ac6f5..a09c2b8c 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -1108,7 +1108,7 @@ basecamp notes set "" --json `bubble-up add`/`remove` resurface a recording in your readings (the BC5 successor to "save"), addressed by id or pasted URL. `add` bubbles up now by default; `--at` schedules it — a keyword (`today`, `tomorrow`, `weekend`, -`next_week`) or an ISO8601 date. Both verbs are idempotent. There is no +`next_week`) or a calendar date (`YYYY-MM-DD`). Both verbs are idempotent. There is no per-recording status read (that GET is an unrenderable API gap); the full list is `basecamp notifications bubbleups`. From b8d69c081e67d5f209f5d3aeb2546edd7ce5bdab Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 2 Sep 2026 15:04:52 -0700 Subject: [PATCH 11/11] Test the project star toggle exercises the star field and /star.json The marker tests covered display; none drove the toggle itself, so a regression from Starred back to Bookmarked could POST/DELETE the wrong endpoint for a stacked-but-unstarred project and still pass. Add a test that toggles such a project and asserts it flips Starred, POSTs /star.json (never DELETE), and rolls the star back on error, backed by a new NewTestSessionWithClient helper that injects a stub-transport SDK. --- internal/tui/workspace/session.go | 13 ++++ internal/tui/workspace/views/projects_test.go | 64 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/internal/tui/workspace/session.go b/internal/tui/workspace/session.go index 22316060..d0c5d7e4 100644 --- a/internal/tui/workspace/session.go +++ b/internal/tui/workspace/session.go @@ -224,6 +224,19 @@ func NewTestSessionWithScope(scope Scope) *Session { return s } +// NewTestSessionWithClient returns a test Session whose AccountClient is backed +// by the given SDK client — typically wired to a stub transport — under the +// given scope, so tests can exercise view actions that issue account-scoped +// HTTP requests (e.g. toggling a project's star). +func NewTestSessionWithClient(sdk *basecamp.Client, scope Scope) *Session { + s := NewTestSession() + s.app = &appctx.App{SDK: sdk} + s.multiStore = data.NewMultiStore(sdk) + s.hub = data.NewHub(s.multiStore, scope.AccountID) + s.scope = scope + return s +} + // NewTestSessionWithRecents is like NewTestSession but includes a recents store. func NewTestSessionWithRecents(r *recents.Store) *Session { s := NewTestSession() diff --git a/internal/tui/workspace/views/projects_test.go b/internal/tui/workspace/views/projects_test.go index 3439b52e..d7160395 100644 --- a/internal/tui/workspace/views/projects_test.go +++ b/internal/tui/workspace/views/projects_test.go @@ -1,18 +1,82 @@ package views import ( + "bytes" + "context" "fmt" + "io" + "net/http" + "sync" "testing" tea "charm.land/bubbletea/v2" + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/basecamp/basecamp-cli/internal/tui" + "github.com/basecamp/basecamp-cli/internal/tui/workspace" "github.com/basecamp/basecamp-cli/internal/tui/workspace/data" "github.com/basecamp/basecamp-cli/internal/tui/workspace/widget" ) +// starToggleTransport records the account-scoped requests the star toggle issues +// and answers each with a bare 204, matching bc3's /star.json contract. +type starToggleTransport struct { + mu sync.Mutex + requests []string +} + +func (s *starToggleTransport) RoundTrip(req *http.Request) (*http.Response, error) { + s.mu.Lock() + s.requests = append(s.requests, req.Method+" "+req.URL.Path) + s.mu.Unlock() + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: make(http.Header), + Request: req, + }, nil +} + +type starTestToken struct{} + +func (starTestToken) AccessToken(context.Context) (string, error) { return "test-token", nil } + +// Toggling a project filed into a stack (Bookmarked, not Starred) stars it: it +// flips Starred (not Bookmarked), POSTs /star.json (never DELETE), and rolls the +// star back on error. This guards the star-vs-bookmark field identity through +// the actual toggle path, not just the marker. +func TestProjects_ToggleStarsStackedProject(t *testing.T) { + transport := &starToggleTransport{} + sdk := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, starTestToken{}, + basecamp.WithTransport(transport), basecamp.WithMaxRetries(1)) + sess := workspace.NewTestSessionWithClient(sdk, workspace.Scope{AccountID: "99999"}) + + v := testProjectsView([]data.ProjectInfo{ + {ID: 42, Name: "Stacked", AccountID: "99999", Bookmarked: true, Starred: false}, + }) + v.session = sess + + cmd := v.toggleBookmark() + require.NotNil(t, cmd) + + p := v.findProject(42) + require.NotNil(t, p) + assert.True(t, p.Starred, "toggling a stacked-but-unstarred project stars it") + assert.True(t, p.Bookmarked, "the pinned/bookmark state is untouched") + + msg := cmd() + bm, ok := msg.(workspace.ProjectBookmarkedMsg) + require.True(t, ok, "toggle dispatch returns a ProjectBookmarkedMsg") + require.NoError(t, bm.Err) + assert.Equal(t, []string{"POST /99999/projects/42/star.json"}, transport.requests, + "starring POSTs to /star.json (a regression to Bookmarked would DELETE an already-unstarred project)") + + v.Update(workspace.ProjectBookmarkedMsg{ProjectID: 42, Bookmarked: true, Err: assert.AnError}) + assert.False(t, v.findProject(42).Starred, "a failed toggle rolls the star back") +} + // testProjectsView builds a Projects view with pre-populated data for unit // testing focus management and key routing. Session is nil — tests that // trigger navigation (openTool, navigateToTool) are not covered here.