From 21a4bb97905b24d78300df44c81a5eef437fb01a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 22 Oct 2026 17:45:20 -0400 Subject: [PATCH 1/5] Add spotlight and unspotlight basecamp spotlight puts a recording's card in the Spotlights area of its project or template home page, as "Spotlight on project page" does in Basecamp. basecamp unspotlight takes it off, as "Remove Spotlight" does. Both are idempotent and need edit access to the project. spotlight returns the recording; unspotlight returns {id, spotlighted: false}. A comment's URL acts on the item the comment sits on, since comments cannot be spotlighted, and a project URL is refused as an invalid ID rather than sending the project's ID as a recording. When bc3 refuses a spotlight (a comment, or an item that is not active), the error says which recordings can be spotlighted instead of a bare "validation error". No endpoint reads spotlight state or lists spotlights, and the SDK has no operation for reordering them, so neither is offered. --- .surface | 48 +++++++++ API-COVERAGE.md | 9 +- e2e/smoke/smoke_misc_write.bats | 20 +++- e2e/spotlight.bats | 23 +++++ internal/cli/root.go | 2 + internal/commands/commands.go | 2 + internal/commands/commands_test.go | 2 + internal/commands/spotlight.go | 151 ++++++++++++++++++++++++++++ internal/commands/spotlight_test.go | 134 ++++++++++++++++++++++++ skills/basecamp/SKILL.md | 1 + 10 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 e2e/spotlight.bats create mode 100644 internal/commands/spotlight.go create mode 100644 internal/commands/spotlight_test.go diff --git a/.surface b/.surface index 46458088c..41bf791e4 100644 --- a/.surface +++ b/.surface @@ -356,6 +356,7 @@ ARG basecamp schedule update 00 ARG basecamp search 00 ARG basecamp show 00 [type] ARG basecamp show 01 +ARG basecamp spotlight 00 ARG basecamp subscriptions add 00 ARG basecamp subscriptions add 01 [person_ids] ARG basecamp subscriptions remove 00 @@ -463,6 +464,7 @@ ARG basecamp tools update 00 ARG basecamp tools update 01 ARG basecamp tui 00 [url] ARG basecamp unassign 00 <id|url>... +ARG basecamp unspotlight 00 <id|url> ARG basecamp upload 00 <file> ARG basecamp uploads create 00 <file> ARG basecamp uploads show 00 <id|url> @@ -1066,6 +1068,7 @@ CMD basecamp setup grok CMD basecamp show CMD basecamp skill CMD basecamp skill install +CMD basecamp spotlight CMD basecamp subscriptions CMD basecamp subscriptions add CMD basecamp subscriptions remove @@ -1183,6 +1186,7 @@ CMD basecamp tools trash CMD basecamp tools update CMD basecamp tui CMD basecamp unassign +CMD basecamp unspotlight CMD basecamp upgrade CMD basecamp upload CMD basecamp uploads @@ -14171,6 +14175,27 @@ FLAG basecamp skill install --stats type=bool FLAG basecamp skill install --styled type=bool FLAG basecamp skill install --todolist type=string FLAG basecamp skill install --verbose type=count +FLAG basecamp spotlight --account type=string +FLAG basecamp spotlight --agent type=bool +FLAG basecamp spotlight --cache-dir type=string +FLAG basecamp spotlight --count type=bool +FLAG basecamp spotlight --help type=bool +FLAG basecamp spotlight --hints type=bool +FLAG basecamp spotlight --ids-only type=bool +FLAG basecamp spotlight --in type=string +FLAG basecamp spotlight --jq type=string +FLAG basecamp spotlight --json type=bool +FLAG basecamp spotlight --markdown type=bool +FLAG basecamp spotlight --md type=bool +FLAG basecamp spotlight --no-hints type=bool +FLAG basecamp spotlight --no-stats type=bool +FLAG basecamp spotlight --profile type=string +FLAG basecamp spotlight --project type=string +FLAG basecamp spotlight --quiet type=bool +FLAG basecamp spotlight --stats type=bool +FLAG basecamp spotlight --styled type=bool +FLAG basecamp spotlight --todolist type=string +FLAG basecamp spotlight --verbose type=count FLAG basecamp subscriptions --account type=string FLAG basecamp subscriptions --agent type=bool FLAG basecamp subscriptions --cache-dir type=string @@ -16845,6 +16870,27 @@ FLAG basecamp unassign --step type=bool FLAG basecamp unassign --styled type=bool FLAG basecamp unassign --todolist type=string FLAG basecamp unassign --verbose type=count +FLAG basecamp unspotlight --account type=string +FLAG basecamp unspotlight --agent type=bool +FLAG basecamp unspotlight --cache-dir type=string +FLAG basecamp unspotlight --count type=bool +FLAG basecamp unspotlight --help type=bool +FLAG basecamp unspotlight --hints type=bool +FLAG basecamp unspotlight --ids-only type=bool +FLAG basecamp unspotlight --in type=string +FLAG basecamp unspotlight --jq type=string +FLAG basecamp unspotlight --json type=bool +FLAG basecamp unspotlight --markdown type=bool +FLAG basecamp unspotlight --md type=bool +FLAG basecamp unspotlight --no-hints type=bool +FLAG basecamp unspotlight --no-stats type=bool +FLAG basecamp unspotlight --profile type=string +FLAG basecamp unspotlight --project type=string +FLAG basecamp unspotlight --quiet type=bool +FLAG basecamp unspotlight --stats type=bool +FLAG basecamp unspotlight --styled type=bool +FLAG basecamp unspotlight --todolist type=string +FLAG basecamp unspotlight --verbose type=count FLAG basecamp upgrade --account type=string FLAG basecamp upgrade --agent type=bool FLAG basecamp upgrade --cache-dir type=string @@ -19865,6 +19911,7 @@ SUB basecamp setup grok SUB basecamp show SUB basecamp skill SUB basecamp skill install +SUB basecamp spotlight SUB basecamp subscriptions SUB basecamp subscriptions add SUB basecamp subscriptions remove @@ -19982,6 +20029,7 @@ SUB basecamp tools trash SUB basecamp tools update SUB basecamp tui SUB basecamp unassign +SUB basecamp unspotlight SUB basecamp upgrade SUB basecamp upload SUB basecamp uploads diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 58733df26..5a8bc5452 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,14 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 54 | 209 | +| ✅ Implemented | 55 | 211 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **58** | **221** | +| **Total tracked** | **59** | **223** | -**209 of 209 tracked in-scope endpoints.** The two embed endpoints bc3 documents +**211 of 211 tracked in-scope endpoints.** Spotlighting a recording on its +project or template home page (`POST`/`DELETE /recordings/:id/spotlight.json`, +SDK v0.27.0) lands as `spotlight` and `unspotlight`. The two embed endpoints bc3 documents in `embeds.md` — `POST /embeds.json` and `GET /embeds/:sgid.json` — land as `embeds`. The three Pings endpoints bc3#13943 documented — `GET /circles.json`, `GET /circles/:id.json` and `POST @@ -218,6 +220,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | boosts | 7 | `boost`, `react` | ✅ | BC4 | - | list (recording + event), show, create (recording + event), update (`PUT /boosts/{id}`, the creator only; same 16-character cap as create), 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 | 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`. | +| spotlights | 2 | `spotlight`, `unspotlight` | ✅ | BC5 | - | `POST`/`DELETE /recordings/{id}/spotlight.json` put a recording's card on, or take it off, its project or template home page; both idempotent. No endpoint reads spotlight state or lists spotlights, so there is no `check` or list. Reordering spotlights (`PUT /recordings/:id/spotlight/position`) has no SDK operation, so it is not covered | | **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/e2e/smoke/smoke_misc_write.bats b/e2e/smoke/smoke_misc_write.bats index b0fd3d05b..595d5df9e 100644 --- a/e2e/smoke/smoke_misc_write.bats +++ b/e2e/smoke/smoke_misc_write.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# smoke_misc_write.bats - Level 1: Schedule settings, recordings trash/restore +# smoke_misc_write.bats - Level 1: Schedule settings, recordings trash/restore, spotlight load smoke_helper @@ -82,3 +82,21 @@ setup_file() { assert_success assert_json_value '.ok' 'true' } + +@test "spotlight spotlights a recording" { + ensure_todolist || return 0 + + run_smoke basecamp spotlight "$QA_TODOLIST" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.id' "$QA_TODOLIST" +} + +@test "unspotlight removes a recording's spotlight" { + ensure_todolist || return 0 + + run_smoke basecamp unspotlight "$QA_TODOLIST" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.spotlighted' 'false' +} diff --git a/e2e/spotlight.bats b/e2e/spotlight.bats new file mode 100644 index 000000000..5ab91ec42 --- /dev/null +++ b/e2e/spotlight.bats @@ -0,0 +1,23 @@ +#!/usr/bin/env bats +# spotlight.bats - Test spotlight and unspotlight error handling + +load test_helper + + +@test "spotlight without recording id shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp spotlight + assert_failure + assert_output_contains "ID required" +} + +@test "unspotlight without recording id shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp unspotlight + assert_failure + assert_output_contains "ID required" +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 1069c8a3b..218ce71b7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -319,6 +319,8 @@ func Execute() { cmd.AddCommand(commands.NewCommentsCmd()) cmd.AddCommand(commands.NewAssignCmd()) cmd.AddCommand(commands.NewUnassignCmd()) + cmd.AddCommand(commands.NewSpotlightCmd()) + cmd.AddCommand(commands.NewUnspotlightCmd()) cmd.AddCommand(commands.NewMessagesCmd()) cmd.AddCommand(commands.NewCardsCmd()) cmd.AddCommand(commands.NewURLCmd()) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 84f57d6f2..ca6c799cc 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -56,6 +56,8 @@ func CommandCategories() []CommandCategory { Commands: []CommandInfo{ {Name: "assign", Category: "shortcut", Description: "Assign someone to a to-do, card, or step"}, {Name: "unassign", Category: "shortcut", Description: "Remove assignment from a to-do, card, or step"}, + {Name: "spotlight", Category: "shortcut", Description: "Spotlight a recording on its project page"}, + {Name: "unspotlight", Category: "shortcut", Description: "Remove a recording's spotlight"}, {Name: "attach", Category: "shortcut", Description: "Upload and stage an attachment"}, {Name: "upload", Category: "shortcut", Description: "Upload a file to Docs & Files"}, }, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 25de382b7..0339ea7a3 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -70,6 +70,8 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewCommentsCmd()) root.AddCommand(commands.NewAssignCmd()) root.AddCommand(commands.NewUnassignCmd()) + root.AddCommand(commands.NewSpotlightCmd()) + root.AddCommand(commands.NewUnspotlightCmd()) root.AddCommand(commands.NewMessagesCmd()) root.AddCommand(commands.NewCardsCmd()) root.AddCommand(commands.NewURLCmd()) diff --git a/internal/commands/spotlight.go b/internal/commands/spotlight.go new file mode 100644 index 000000000..dfe0a7b3a --- /dev/null +++ b/internal/commands/spotlight.go @@ -0,0 +1,151 @@ +package commands + +import ( + "errors" + "fmt" + "strconv" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +const spotlightAgentNotes = "Idempotent: spotlighting a spotlighted recording, or unspotlighting one that is not, succeeds\n" + + "No API reads spotlight state or lists spotlights; only the project home page shows them\n" + + "Needs edit access to the project; a recording of another type, or one not active, is refused" + +// NewSpotlightCmd creates the spotlight command, which puts a recording's card +// on its project or template home page. +func NewSpotlightCmd() *cobra.Command { + return &cobra.Command{ + Use: "spotlight <id|url>", + Short: "Spotlight a recording on its project page", + Long: `Put a recording's card in the Spotlights area of its project (or template) +home page. Same as "Spotlight on project page" in Basecamp. + +You can pass either an ID or a Basecamp URL: + basecamp spotlight 789 + basecamp spotlight https://3.basecamp.com/123/buckets/456/messages/789 + +Messages, documents, folders, files, cloud files, Google docs, to-do lists, +to-dos, card table columns, cards, check-in questions and schedule entries can +be spotlighted while they are active and in a project or template. A comment +cannot, so a comment's URL (…#__recording_<id>) spotlights the item the +comment sits on. + +Spotlighting a recording that is already spotlighted succeeds and changes +nothing. Requires edit access to the project.`, + Annotations: map[string]string{"agent_notes": spotlightAgentNotes}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + recordingID, err := spotlightTarget(cmd, app, args[0]) + if err != nil { + return err + } + + recording, err := app.Account().Recordings().Spotlight(cmd.Context(), recordingID) + if err != nil { + return spotlightError(err) + } + + return app.OK(recording, + output.WithSummary(fmt.Sprintf("Spotlighted #%d on its project page", recordingID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "unspotlight", + Cmd: fmt.Sprintf("basecamp unspotlight %d", recordingID), + Description: "Remove the spotlight", + }, + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp show %d", recordingID), + Description: "View item", + }, + ), + ) + }, + } +} + +// NewUnspotlightCmd creates the unspotlight command, which takes a recording's +// card off its project or template home page. +func NewUnspotlightCmd() *cobra.Command { + return &cobra.Command{ + Use: "unspotlight <id|url>", + Short: "Remove a recording's spotlight", + Long: `Remove a recording's card from the Spotlights area of its project (or +template) home page. Same as "Remove Spotlight" in Basecamp. + +You can pass either an ID or a Basecamp URL: + basecamp unspotlight 789 + basecamp unspotlight https://3.basecamp.com/123/buckets/456/messages/789 + +A comment's URL (…#__recording_<id>) acts on the item the comment sits on. + +Removing the spotlight from a recording that has none succeeds and changes +nothing. Requires edit access to the project.`, + Annotations: map[string]string{"agent_notes": spotlightAgentNotes}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + recordingID, err := spotlightTarget(cmd, app, args[0]) + if err != nil { + return err + } + + if err := app.Account().Recordings().Unspotlight(cmd.Context(), recordingID); err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"id": recordingID, "spotlighted": false}, + output.WithSummary(fmt.Sprintf("Spotlight removed from #%d", recordingID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "spotlight", + Cmd: fmt.Sprintf("basecamp spotlight %d", recordingID), + Description: "Spotlight it again", + }, + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp show %d", recordingID), + Description: "View item", + }, + ), + ) + }, + } +} + +// spotlightTarget reads the recording a spotlight command acts on. A comment +// link names its parent, since comments cannot be spotlighted, and a project +// link names no recording at all, so its project ID must not go out as one. +func spotlightTarget(cmd *cobra.Command, app *appctx.App, arg string) (int64, error) { + if err := ensureAccount(cmd, app); err != nil { + return 0, err + } + recordingIDStr, _ := extractWithProject(arg) + recordingID, err := strconv.ParseInt(recordingIDStr, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid ID") + } + return recordingID, nil +} + +// spotlightError says why bc3 refused a spotlight. Its 422 carries no reason +// the SDK surfaces, and the usual cause is a comment or a trashed item. +func spotlightError(err error) error { + var sdkErr *basecamp.Error + if !errors.As(err, &sdkErr) || sdkErr.Code != basecamp.CodeValidation { + return convertSDKError(err) + } + return &output.Error{ + Code: output.CodeValidation, + Message: "Basecamp refused the spotlight", + Hint: "Only an active recording in a project or template can be spotlighted, and not a comment; see basecamp spotlight --help for the types", + HTTPStatus: sdkErr.HTTPStatus, + Cause: sdkErr, + } +} diff --git a/internal/commands/spotlight_test.go b/internal/commands/spotlight_test.go new file mode 100644 index 000000000..8d5e46906 --- /dev/null +++ b/internal/commands/spotlight_test.go @@ -0,0 +1,134 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +const spotlightPath = "/99999/recordings/456/spotlight.json" + +// Each argument names recording 456: a bare ID, the item's URL, and a comment's +// URL on it, which must reach the item because comments cannot be spotlighted. +var spotlightArgs = []string{ + "456", + "https://app.basecamp.com/99999/buckets/123/messages/456", + "https://app.basecamp.com/99999/buckets/123/messages/456#__recording_789", +} + +func TestSpotlightPostsTheRecording(t *testing.T) { + for _, arg := range spotlightArgs { + t.Run(arg, func(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodPost, path: spotlightPath, status: http.StatusCreated, + body: `{"id":456,"type":"Message","title":"Kickoff"}`, + }) + buf := captureSpotlightOutput(app) + + require.NoError(t, executeRecordingCommand(NewSpotlightCmd(), app, arg)) + + calls := transport.recorded() + require.Len(t, calls, 1) + assert.Equal(t, http.MethodPost, calls[0].Method) + assert.Equal(t, spotlightPath, calls[0].Path) + + env := decodeSpotlightEnvelope(t, buf) + assert.Equal(t, float64(456), env.Data["id"]) + assert.Equal(t, "Kickoff", env.Data["title"]) + assert.Equal(t, "Spotlighted #456 on its project page", env.Summary) + assert.Contains(t, env.Breadcrumbs, output.Breadcrumb{ + Action: "unspotlight", Cmd: "basecamp unspotlight 456", Description: "Remove the spotlight", + }) + }) + } +} + +func TestUnspotlightDeletesTheSpotlight(t *testing.T) { + for _, arg := range spotlightArgs { + t.Run(arg, func(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodDelete, path: spotlightPath, status: http.StatusNoContent, + }) + buf := captureSpotlightOutput(app) + + require.NoError(t, executeRecordingCommand(NewUnspotlightCmd(), app, arg)) + + calls := transport.recorded() + require.Len(t, calls, 1) + assert.Equal(t, http.MethodDelete, calls[0].Method) + assert.Equal(t, spotlightPath, calls[0].Path) + + env := decodeSpotlightEnvelope(t, buf) + assert.Equal(t, map[string]any{"id": float64(456), "spotlighted": false}, env.Data) + assert.Equal(t, "Spotlight removed from #456", env.Summary) + assert.Contains(t, env.Breadcrumbs, output.Breadcrumb{ + Action: "spotlight", Cmd: "basecamp spotlight 456", Description: "Spotlight it again", + }) + }) + } +} + +// A project URL names no recording, so its project ID must never go out as one. +func TestSpotlightRefusesANonRecordingArgument(t *testing.T) { + cmds := map[string]func() *cobra.Command{ + "spotlight": NewSpotlightCmd, + "unspotlight": NewUnspotlightCmd, + } + for name, newCmd := range cmds { + for _, arg := range []string{"abc", "https://app.basecamp.com/99999/projects/123"} { + t.Run(name+" "+arg, func(t *testing.T) { + app, transport := setupRecordingTestApp(t) + + err := executeRecordingCommand(newCmd(), app, arg) + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Empty(t, transport.recorded()) + }) + } + } +} + +func TestSpotlightSurfacesARefusal(t *testing.T) { + app, _ := setupRecordingTestApp(t, stubRoute{ + method: http.MethodPost, path: spotlightPath, status: http.StatusUnprocessableEntity, + body: `{"error":"Recordable can't be spotlighted"}`, + }) + + err := executeRecordingCommand(NewSpotlightCmd(), app, "456") + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, output.CodeValidation, e.Code) + assert.Contains(t, e.Hint, "not a comment") +} + +func captureSpotlightOutput(app *appctx.App) *bytes.Buffer { + buf := &bytes.Buffer{} + app.Flags.Hints = true + app.Output = output.New(output.Options{Format: output.FormatJSON, Writer: buf}) + return buf +} + +type spotlightEnvelope struct { + Data map[string]any `json:"data"` + Summary string `json:"summary"` + Breadcrumbs []output.Breadcrumb `json:"breadcrumbs"` +} + +func decodeSpotlightEnvelope(t *testing.T, buf *bytes.Buffer) spotlightEnvelope { + t.Helper() + var env spotlightEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &env), buf.String()) + return env +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 8b6ac48af..25a739866 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -20,6 +20,7 @@ triggers: - basecamp document - basecamp bookmarks - basecamp bubble-up + - basecamp spotlight - basecamp drafts - basecamp notes - basecamp calendars From 5c335f8fc71abdb1d2e8520d912fb3fb69125b36 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Thu, 22 Oct 2026 18:23:35 -0400 Subject: [PATCH 2/5] Point agents from pin to spotlight Asked to spotlight a message, an agent following the skill's noun-first discovery read `messages --agent --help`, then `messages pin --agent --help`, pinned the message, and reported that Basecamp has no separate spotlight feature. Nothing on that path named `basecamp spotlight`, a top-level command that no recording type's group lists. The skill now has a Spotlights section saying spotlight is distinct from pin and acts on any spotlightable recording, and the agent notes on `messages pin` and `messages unpin` name spotlight and unspotlight. A skill eval case holds the observed path: spotlighting a message must call `spotlight`, never `messages pin`. --- internal/commands/messages.go | 9 +++++++-- skill-evals/cases/spotlight-message.yml | 6 ++++++ skills/basecamp/SKILL.md | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 skill-evals/cases/spotlight-message.yml diff --git a/internal/commands/messages.go b/internal/commands/messages.go index eb57f937a..013fb3e0a 100644 --- a/internal/commands/messages.go +++ b/internal/commands/messages.go @@ -814,6 +814,9 @@ You can pass either a message ID or a Basecamp URL: return cmd } +const messagesPinAgentNotes = "Pinning is not spotlighting: a pin keeps a message at the top of its message board; " + + "basecamp spotlight puts any recording on its project's home page, and basecamp unspotlight takes it off" + func newMessagesPinCmd() *cobra.Command { cmd := &cobra.Command{ Use: "pin <id|url>", @@ -823,7 +826,8 @@ func newMessagesPinCmd() *cobra.Command { You can pass either a message ID or a Basecamp URL: basecamp messages pin 789 basecamp messages pin https://3.basecamp.com/123/buckets/456/messages/789`, - Args: cobra.ExactArgs(1), + Annotations: map[string]string{"agent_notes": messagesPinAgentNotes}, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -876,7 +880,8 @@ func newMessagesUnpinCmd() *cobra.Command { You can pass either a message ID or a Basecamp URL: basecamp messages unpin 789 basecamp messages unpin https://3.basecamp.com/123/buckets/456/messages/789`, - Args: cobra.ExactArgs(1), + Annotations: map[string]string{"agent_notes": messagesPinAgentNotes}, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) diff --git a/skill-evals/cases/spotlight-message.yml b/skill-evals/cases/spotlight-message.yml new file mode 100644 index 000000000..204fc51a8 --- /dev/null +++ b/skill-evals/cases/spotlight-message.yml @@ -0,0 +1,6 @@ +task: Spotlight message 789 on its project page +tags: [semantics] +accept: + - '^spotlight (?!.*--help)(?=.*\b789\b)' +reject: + - '^messages (un)?pin(?!.*--help)' diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 25a739866..ffd4197ba 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -217,6 +217,20 @@ basecamp pings messages "Jane Smith" --agent `chat line`; edit or delete such a message with `chat update` or `chat delete` on that URL. +## Spotlights + +A spotlight puts a recording's card in the Spotlights area of its project's home +page. It is not a pin: `messages pin` only keeps a message at the top of its +Message Board. `spotlight` is a top-level command, outside every recording +type's group, and takes the ID or URL of a message, document, file, to-do list, +to-do, card, card column, check-in question, schedule entry and more. +`unspotlight` removes it, as "Remove Spotlight" does in Basecamp. + +```bash +basecamp spotlight <id|url> --agent +basecamp unspotlight <id|url> --agent +``` + ## Content and stdin Command help declares content arguments and stdin support. Titles are generally From 7ed24c0d60bb3cf2f0d27602aa137bc2c782fe01 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Thu, 22 Oct 2026 18:58:47 -0400 Subject: [PATCH 3/5] Drop the agent notes on spotlight and unspotlight The notes repeated the long help (idempotent, needs edit access) and added that no API reads spotlight state. Agents read that last line as "the result is unconfirmed" and hedged every successful spotlight. The missing read is a maintainer fact, already recorded in API-COVERAGE.md, not something a caller acts on. --- internal/commands/spotlight.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/commands/spotlight.go b/internal/commands/spotlight.go index dfe0a7b3a..a083f2161 100644 --- a/internal/commands/spotlight.go +++ b/internal/commands/spotlight.go @@ -12,10 +12,6 @@ import ( "github.com/basecamp/basecamp-cli/internal/output" ) -const spotlightAgentNotes = "Idempotent: spotlighting a spotlighted recording, or unspotlighting one that is not, succeeds\n" + - "No API reads spotlight state or lists spotlights; only the project home page shows them\n" + - "Needs edit access to the project; a recording of another type, or one not active, is refused" - // NewSpotlightCmd creates the spotlight command, which puts a recording's card // on its project or template home page. func NewSpotlightCmd() *cobra.Command { @@ -37,8 +33,7 @@ comment sits on. Spotlighting a recording that is already spotlighted succeeds and changes nothing. Requires edit access to the project.`, - Annotations: map[string]string{"agent_notes": spotlightAgentNotes}, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) recordingID, err := spotlightTarget(cmd, app, args[0]) @@ -87,8 +82,7 @@ A comment's URL (…#__recording_<id>) acts on the item the comment sits on. Removing the spotlight from a recording that has none succeeds and changes nothing. Requires edit access to the project.`, - Annotations: map[string]string{"agent_notes": spotlightAgentNotes}, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) recordingID, err := spotlightTarget(cmd, app, args[0]) From bbef8148369896d69fe0f22a9d87b50153aa5b4c Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Thu, 22 Oct 2026 19:21:58 -0400 Subject: [PATCH 4/5] Refuse spotlight URLs that name no single recording spotlight and unspotlight now refuse, before any request, a URL on a host that is not Basecamp's, a collection URL, and a URL whose type cannot be spotlighted (a person, a Campfire), instead of acting on whatever ID the router pulled from it. The smoke test spotlights and unspotlights a throwaway to-do in one sequential test, so it can no longer remove a spotlight someone set or race under bats -j. The spotlight-message eval accepts 789 only as the positional recording argument. --- e2e/smoke/smoke_misc_write.bats | 27 ++++++++++------ internal/commands/spotlight.go | 43 +++++++++++++++++++++---- internal/commands/spotlight_test.go | 11 +++++-- skill-evals/cases/spotlight-message.yml | 2 +- 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/e2e/smoke/smoke_misc_write.bats b/e2e/smoke/smoke_misc_write.bats index 595d5df9e..2243c68b3 100644 --- a/e2e/smoke/smoke_misc_write.bats +++ b/e2e/smoke/smoke_misc_write.bats @@ -83,20 +83,29 @@ setup_file() { assert_json_value '.ok' 'true' } -@test "spotlight spotlights a recording" { - ensure_todolist || return 0 +@test "spotlight and unspotlight a recording" { + ensure_todolist || mark_unverifiable "Cannot discover todolist for spotlight" + + # A throwaway todo: unspotlight would otherwise remove a spotlight someone + # set, and there is no read API to put it back. + local todo_out + todo_out=$(basecamp todos create "Spotlight target $(date +%s)" --list "$QA_TODOLIST" -p "$QA_PROJECT" --json 2>/dev/null) || { + mark_unverifiable "Cannot create todo for spotlight test" + return + } + local todo_id + todo_id=$(echo "$todo_out" | jq -r '.data.id // empty') + [[ -n "$todo_id" ]] || mark_unverifiable "No todo ID returned" - run_smoke basecamp spotlight "$QA_TODOLIST" --json + run_smoke basecamp spotlight "$todo_id" --json assert_success assert_json_value '.ok' 'true' - assert_json_value '.data.id' "$QA_TODOLIST" -} + assert_json_value '.data.id' "$todo_id" -@test "unspotlight removes a recording's spotlight" { - ensure_todolist || return 0 - - run_smoke basecamp unspotlight "$QA_TODOLIST" --json + run_smoke basecamp unspotlight "$todo_id" --json assert_success assert_json_value '.ok' 'true' assert_json_value '.data.spotlighted' 'false' + + basecamp recordings trash "$todo_id" -p "$QA_PROJECT" --json >/dev/null 2>&1 || true } diff --git a/internal/commands/spotlight.go b/internal/commands/spotlight.go index a083f2161..ebfce915c 100644 --- a/internal/commands/spotlight.go +++ b/internal/commands/spotlight.go @@ -3,13 +3,16 @@ package commands import ( "errors" "fmt" + "slices" "strconv" "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/spf13/cobra" "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/hostutil" "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/urlarg" ) // NewSpotlightCmd creates the spotlight command, which puts a recording's card @@ -113,21 +116,47 @@ nothing. Requires edit access to the project.`, } } -// spotlightTarget reads the recording a spotlight command acts on. A comment -// link names its parent, since comments cannot be spotlighted, and a project -// link names no recording at all, so its project ID must not go out as one. +// spotlightKinds are the URL types of the items bc3 can spotlight. bc3 still +// decides each case; the list keeps a URL for something else (a person, a +// Campfire, a path the router only matched by shape) from sending its ID out +// as a recording's. +var spotlightKinds = []string{ + "cards", "cloud_files", "columns", "documents", "google_documents", "messages", + "questions", "schedule_entries", "todolists", "todos", "uploads", "vaults", +} + +// spotlightTarget reads the recording a spotlight command acts on: an ID, or +// the URL of a single item on a trusted host. The URL router is host-agnostic, +// so the host check keeps a look-alike URL from retargeting the configured +// account. A comment's URL names the item it sits on, since comments cannot be +// spotlighted. func spotlightTarget(cmd *cobra.Command, app *appctx.App, arg string) (int64, error) { - if err := ensureAccount(cmd, app); err != nil { - return 0, err + idStr := arg + if urlarg.IsURL(arg) { + if !hostutil.IsTrustedBasecampHost(arg, app.Config.BaseURL) { + return 0, spotlightUsage(cmd, "not a Basecamp URL") + } + parsed := urlarg.Parse(arg) + if parsed == nil || parsed.IsCollection || !slices.Contains(spotlightKinds, parsed.Type) { + return 0, spotlightUsage(cmd, "the URL names something else") + } + idStr = parsed.RecordingID } - recordingIDStr, _ := extractWithProject(arg) - recordingID, err := strconv.ParseInt(recordingIDStr, 10, 64) + recordingID, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return 0, output.ErrUsage("Invalid ID") } + if err := ensureAccount(cmd, app); err != nil { + return 0, err + } return recordingID, nil } +func spotlightUsage(cmd *cobra.Command, reason string) error { + return output.ErrUsageHint(cmd.Name()+" takes a single item: "+reason, + "Pass its ID or its own URL (…/messages/<id>, …/todos/<id>, …); basecamp spotlight --help lists the types") +} + // spotlightError says why bc3 refused a spotlight. Its 422 carries no reason // the SDK surfaces, and the usual cause is a comment or a trashed item. func spotlightError(err error) error { diff --git a/internal/commands/spotlight_test.go b/internal/commands/spotlight_test.go index 8d5e46906..fe6c83166 100644 --- a/internal/commands/spotlight_test.go +++ b/internal/commands/spotlight_test.go @@ -77,14 +77,21 @@ func TestUnspotlightDeletesTheSpotlight(t *testing.T) { } } -// A project URL names no recording, so its project ID must never go out as one. +// None of these names a single item, so no ID from them may go out as a +// recording's: a project, a look-alike host, a to-do list's to-dos, a person. func TestSpotlightRefusesANonRecordingArgument(t *testing.T) { cmds := map[string]func() *cobra.Command{ "spotlight": NewSpotlightCmd, "unspotlight": NewUnspotlightCmd, } for name, newCmd := range cmds { - for _, arg := range []string{"abc", "https://app.basecamp.com/99999/projects/123"} { + for _, arg := range []string{ + "abc", + "https://app.basecamp.com/99999/projects/123", + "https://evil.example/99999/buckets/123/messages/456", + "https://app.basecamp.com/99999/buckets/123/todolists/456/todos", + "https://app.basecamp.com/99999/people/456", + } { t.Run(name+" "+arg, func(t *testing.T) { app, transport := setupRecordingTestApp(t) diff --git a/skill-evals/cases/spotlight-message.yml b/skill-evals/cases/spotlight-message.yml index 204fc51a8..9f94275db 100644 --- a/skill-evals/cases/spotlight-message.yml +++ b/skill-evals/cases/spotlight-message.yml @@ -1,6 +1,6 @@ task: Spotlight message 789 on its project page tags: [semantics] accept: - - '^spotlight (?!.*--help)(?=.*\b789\b)' + - '^spotlight (?!.*--help)(?:-\S+ )*789(?:\s|$)' reject: - '^messages (un)?pin(?!.*--help)' From 7489fc62c7ce3435161377e789ecbb5ec8191e6e Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Thu, 22 Oct 2026 19:33:38 -0400 Subject: [PATCH 5/5] Reconcile spotlight URLs with the account and refuse occurrences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spotlight and unspotlight dropped the account a URL names, so a URL from account 88888 acted on recording 456 in the configured 99999. A URL's account is now adopted when none is configured, and refused with the CLI's usual mismatch error when it differs. Follow-up breadcrumbs carry --account when the account will not persist into a fresh process. An occurrence URL (…/schedule_entries/<id>/occurrences/<date>) carries the recurring event's ID, so the commands acted on the whole series. They now refuse it before any request and point to the occurrence's own ID or the event's. A generic …/recordings/<id> URL was refused while its bare ID worked. It is now accepted, and bc3 decides whether the recording can be spotlighted, as it does for a bare ID. --- internal/commands/spotlight.go | 56 ++++++++++++++++++++--------- internal/commands/spotlight_test.go | 56 ++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/internal/commands/spotlight.go b/internal/commands/spotlight.go index ebfce915c..1b0919baf 100644 --- a/internal/commands/spotlight.go +++ b/internal/commands/spotlight.go @@ -39,6 +39,7 @@ nothing. Requires edit access to the project.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) + persistentAccount := hasPersistentAccount(app.Config) recordingID, err := spotlightTarget(cmd, app, args[0]) if err != nil { return err @@ -49,17 +50,18 @@ nothing. Requires edit access to the project.`, return spotlightError(err) } + accountArg := replyAccountArg(persistentAccount, app.Config.AccountID) return app.OK(recording, output.WithSummary(fmt.Sprintf("Spotlighted #%d on its project page", recordingID)), output.WithBreadcrumbs( output.Breadcrumb{ Action: "unspotlight", - Cmd: fmt.Sprintf("basecamp unspotlight %d", recordingID), + Cmd: fmt.Sprintf("basecamp unspotlight %d%s", recordingID, accountArg), Description: "Remove the spotlight", }, output.Breadcrumb{ Action: "show", - Cmd: fmt.Sprintf("basecamp show %d", recordingID), + Cmd: fmt.Sprintf("basecamp show %d%s", recordingID, accountArg), Description: "View item", }, ), @@ -88,6 +90,7 @@ nothing. Requires edit access to the project.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) + persistentAccount := hasPersistentAccount(app.Config) recordingID, err := spotlightTarget(cmd, app, args[0]) if err != nil { return err @@ -97,17 +100,18 @@ nothing. Requires edit access to the project.`, return convertSDKError(err) } + accountArg := replyAccountArg(persistentAccount, app.Config.AccountID) return app.OK(map[string]any{"id": recordingID, "spotlighted": false}, output.WithSummary(fmt.Sprintf("Spotlight removed from #%d", recordingID)), output.WithBreadcrumbs( output.Breadcrumb{ Action: "spotlight", - Cmd: fmt.Sprintf("basecamp spotlight %d", recordingID), + Cmd: fmt.Sprintf("basecamp spotlight %d%s", recordingID, accountArg), Description: "Spotlight it again", }, output.Breadcrumb{ Action: "show", - Cmd: fmt.Sprintf("basecamp show %d", recordingID), + Cmd: fmt.Sprintf("basecamp show %d%s", recordingID, accountArg), Description: "View item", }, ), @@ -116,45 +120,63 @@ nothing. Requires edit access to the project.`, } } -// spotlightKinds are the URL types of the items bc3 can spotlight. bc3 still -// decides each case; the list keeps a URL for something else (a person, a -// Campfire, a path the router only matched by shape) from sending its ID out -// as a recording's. +// spotlightKinds are the URL types that name a recording bc3 may spotlight: +// one of the spotlightable kinds, or a generic recordings/<id> URL, which is +// as good as its bare ID. bc3 still decides each case; the list keeps a URL +// for something else (a person, a Campfire, a path the router only matched by +// shape) from sending its ID out as a recording's. var spotlightKinds = []string{ "cards", "cloud_files", "columns", "documents", "google_documents", "messages", - "questions", "schedule_entries", "todolists", "todos", "uploads", "vaults", + "questions", "recordings", "schedule_entries", "todolists", "todos", "uploads", "vaults", } // spotlightTarget reads the recording a spotlight command acts on: an ID, or // the URL of a single item on a trusted host. The URL router is host-agnostic, // so the host check keeps a look-alike URL from retargeting the configured -// account. A comment's URL names the item it sits on, since comments cannot be +// account. A URL's account is adopted when none is configured and refused when +// it differs, so a same-numbered recording in another account is never +// touched. A comment's URL names the item it sits on, since comments cannot be // spotlighted. func spotlightTarget(cmd *cobra.Command, app *appctx.App, arg string) (int64, error) { - idStr := arg + idStr, urlAccountID := arg, "" if urlarg.IsURL(arg) { if !hostutil.IsTrustedBasecampHost(arg, app.Config.BaseURL) { - return 0, spotlightUsage(cmd, "not a Basecamp URL") + return 0, spotlightUsage(cmd, "not a Basecamp URL", spotlightHint) } parsed := urlarg.Parse(arg) if parsed == nil || parsed.IsCollection || !slices.Contains(spotlightKinds, parsed.Type) { - return 0, spotlightUsage(cmd, "the URL names something else") + return 0, spotlightUsage(cmd, "the URL names something else", spotlightHint) } - idStr = parsed.RecordingID + // The URL's ID is the recurring event's, so acting on it would spotlight + // the whole series. The occurrence is a recording of its own. + if parsed.OccurrenceDate != "" { + return 0, spotlightUsage(cmd, "the URL names one occurrence of a recurring event", + "Pass the occurrence's own ID, which basecamp schedule show <url> prints, or the event's ID to act on the whole series") + } + idStr, urlAccountID = parsed.RecordingID, parsed.AccountID } recordingID, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return 0, output.ErrUsage("Invalid ID") } + if urlAccountID != "" { + switch { + case app.Config.AccountID == "": + app.Config.AccountID = urlAccountID + case urlAccountID != app.Config.AccountID: + return 0, output.ErrUsage(fmt.Sprintf("URL account %s does not match the configured account %s", urlAccountID, app.Config.AccountID)) + } + } if err := ensureAccount(cmd, app); err != nil { return 0, err } return recordingID, nil } -func spotlightUsage(cmd *cobra.Command, reason string) error { - return output.ErrUsageHint(cmd.Name()+" takes a single item: "+reason, - "Pass its ID or its own URL (…/messages/<id>, …/todos/<id>, …); basecamp spotlight --help lists the types") +const spotlightHint = "Pass its ID or its own URL (…/messages/<id>, …/todos/<id>, …); basecamp spotlight --help lists the types" + +func spotlightUsage(cmd *cobra.Command, reason, hint string) error { + return output.ErrUsageHint(cmd.Name()+" takes a single item: "+reason, hint) } // spotlightError says why bc3 refused a spotlight. Its 422 carries no reason diff --git a/internal/commands/spotlight_test.go b/internal/commands/spotlight_test.go index fe6c83166..80c3e728e 100644 --- a/internal/commands/spotlight_test.go +++ b/internal/commands/spotlight_test.go @@ -17,11 +17,13 @@ import ( const spotlightPath = "/99999/recordings/456/spotlight.json" -// Each argument names recording 456: a bare ID, the item's URL, and a comment's -// URL on it, which must reach the item because comments cannot be spotlighted. +// Each argument names recording 456: a bare ID, the item's URL, its generic +// recording URL, and a comment's URL on it, which must reach the item because +// comments cannot be spotlighted. var spotlightArgs = []string{ "456", "https://app.basecamp.com/99999/buckets/123/messages/456", + "https://app.basecamp.com/99999/buckets/123/recordings/456", "https://app.basecamp.com/99999/buckets/123/messages/456#__recording_789", } @@ -77,8 +79,10 @@ func TestUnspotlightDeletesTheSpotlight(t *testing.T) { } } -// None of these names a single item, so no ID from them may go out as a -// recording's: a project, a look-alike host, a to-do list's to-dos, a person. +// No ID from these may go out as a recording's: a project, a look-alike host, +// a to-do list's to-dos, a person, one occurrence of a recurring event, whose +// URL carries the whole series' ID, and an item in another account than the +// configured 99999. func TestSpotlightRefusesANonRecordingArgument(t *testing.T) { cmds := map[string]func() *cobra.Command{ "spotlight": NewSpotlightCmd, @@ -91,6 +95,8 @@ func TestSpotlightRefusesANonRecordingArgument(t *testing.T) { "https://evil.example/99999/buckets/123/messages/456", "https://app.basecamp.com/99999/buckets/123/todolists/456/todos", "https://app.basecamp.com/99999/people/456", + "https://app.basecamp.com/99999/buckets/123/schedule_entries/456/occurrences/20261022", + "https://app.basecamp.com/88888/buckets/123/messages/456", } { t.Run(name+" "+arg, func(t *testing.T) { app, transport := setupRecordingTestApp(t) @@ -106,6 +112,48 @@ func TestSpotlightRefusesANonRecordingArgument(t *testing.T) { } } +// With no account configured, the URL's account is the one acted on, and the +// follow-ups carry it so they run in a fresh process. +func TestSpotlightAdoptsTheURLAccount(t *testing.T) { + cmds := map[string]struct { + newCmd func() *cobra.Command + route stubRoute + followUp string + }{ + "spotlight": { + newCmd: NewSpotlightCmd, + route: stubRoute{method: http.MethodPost, path: spotlightPath, status: http.StatusCreated, body: `{"id":456}`}, + followUp: "basecamp unspotlight 456 --account 99999", + }, + "unspotlight": { + newCmd: NewUnspotlightCmd, + route: stubRoute{method: http.MethodDelete, path: spotlightPath, status: http.StatusNoContent}, + followUp: "basecamp spotlight 456 --account 99999", + }, + } + for name, c := range cmds { + t.Run(name, func(t *testing.T) { + app, transport := setupRecordingTestApp(t, c.route) + app.Config.AccountID = "" + buf := captureSpotlightOutput(app) + + require.NoError(t, executeRecordingCommand(c.newCmd(), app, + "https://app.basecamp.com/99999/buckets/123/messages/456")) + + calls := transport.recorded() + require.Len(t, calls, 1) + assert.Equal(t, c.route.method, calls[0].Method) + assert.Equal(t, spotlightPath, calls[0].Path) + + var followUps []string + for _, b := range decodeSpotlightEnvelope(t, buf).Breadcrumbs { + followUps = append(followUps, b.Cmd) + } + assert.Contains(t, followUps, c.followUp) + }) + } +} + func TestSpotlightSurfacesARefusal(t *testing.T) { app, _ := setupRecordingTestApp(t, stubRoute{ method: http.MethodPost, path: spotlightPath, status: http.StatusUnprocessableEntity,