From 7835453d6237b0cc7bd0af85e65eeaf9e6114d57 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 20 Oct 2026 11:27:19 -0400 Subject: [PATCH 01/14] Embed a URL that stands alone on its line in rich text An agent writing a card, comment, message, document, to-do or schedule entry had no way to embed a video or a Dash chart: a URL stayed a link, where a person pasting it in the editor gets an embed. Now, in Markdown content, a URL alone on its line becomes an embed when Basecamp can embed it. A titled link, a URL inside a sentence, list, quote, table or code, and anything sent with --format html are left as written. The CLI creates every embed first, then waits for them together, polling as the editor does, and inserts only the ones that are ready. A URL Basecamp can't embed stays a link quietly; a refusal, a rate limit, or a provider with nothing to embed or too slow to answer stays a link with a warning. Nothing fails the post. embeds create and embeds show cover the new SDK methods, and are how to embed with --format html. The MCP catalog serves them in the files domain. The skill, --format help, API coverage, surface snapshot and a smoke test say so. --- .surface | 71 ++++ API-COVERAGE.md | 9 +- e2e/smoke/smoke_embeds.bats | 32 ++ go.mod | 2 +- go.sum | 4 +- internal/cli/root.go | 1 + internal/commands/commands.go | 1 + internal/commands/commands_test.go | 1 + internal/commands/embeds.go | 214 +++++++++++ internal/commands/embeds_test.go | 340 ++++++++++++++++++ internal/commands/richtext_format.go | 4 +- internal/mcpserver/catalog_test.go | 2 +- internal/mcpserver/domains.go | 4 +- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/behavior-model.json | 24 ++ internal/mcpserver/model/openapi.json | 276 ++++++++++++++ .../mcpserver/testdata/catalog_snapshot.txt | 4 +- internal/richtext/embeds.go | 205 +++++++++++ internal/richtext/embeds_test.go | 132 +++++++ internal/richtext/richtext.go | 16 +- internal/version/sdk-provenance.json | 6 +- skill-evals/cases/create-card-embed.yml | 10 + skills/basecamp/SKILL.md | 17 + 23 files changed, 1348 insertions(+), 31 deletions(-) create mode 100644 e2e/smoke/smoke_embeds.bats create mode 100644 internal/commands/embeds.go create mode 100644 internal/commands/embeds_test.go create mode 100644 internal/richtext/embeds.go create mode 100644 internal/richtext/embeds_test.go create mode 100644 skill-evals/cases/create-card-embed.yml diff --git a/.surface b/.surface index 8092d5922..308ea452a 100644 --- a/.surface +++ b/.surface @@ -182,6 +182,8 @@ ARG basecamp documents uploads create 00 ARG basecamp documents vault create 00 ARG basecamp documents vaults create 00 ARG basecamp documents versions 00 +ARG basecamp embeds create 00 +ARG basecamp embeds show 00 ARG basecamp events 00 [id|url] ARG basecamp file archive 00 ARG basecamp file doc create 00 @@ -788,6 +790,9 @@ CMD basecamp documents vaults list CMD basecamp documents versions CMD basecamp drafts CMD basecamp drafts list +CMD basecamp embeds +CMD basecamp embeds create +CMD basecamp embeds show CMD basecamp events CMD basecamp events poll CMD basecamp events ticket @@ -7532,6 +7537,69 @@ FLAG basecamp drafts list --stats type=bool FLAG basecamp drafts list --styled type=bool FLAG basecamp drafts list --todolist type=string FLAG basecamp drafts list --verbose type=count +FLAG basecamp embeds --account type=string +FLAG basecamp embeds --agent type=bool +FLAG basecamp embeds --cache-dir type=string +FLAG basecamp embeds --count type=bool +FLAG basecamp embeds --help type=bool +FLAG basecamp embeds --hints type=bool +FLAG basecamp embeds --ids-only type=bool +FLAG basecamp embeds --in type=string +FLAG basecamp embeds --jq type=string +FLAG basecamp embeds --json type=bool +FLAG basecamp embeds --markdown type=bool +FLAG basecamp embeds --md type=bool +FLAG basecamp embeds --no-hints type=bool +FLAG basecamp embeds --no-stats type=bool +FLAG basecamp embeds --profile type=string +FLAG basecamp embeds --project type=string +FLAG basecamp embeds --quiet type=bool +FLAG basecamp embeds --stats type=bool +FLAG basecamp embeds --styled type=bool +FLAG basecamp embeds --todolist type=string +FLAG basecamp embeds --verbose type=count +FLAG basecamp embeds create --account type=string +FLAG basecamp embeds create --agent type=bool +FLAG basecamp embeds create --cache-dir type=string +FLAG basecamp embeds create --count type=bool +FLAG basecamp embeds create --help type=bool +FLAG basecamp embeds create --hints type=bool +FLAG basecamp embeds create --ids-only type=bool +FLAG basecamp embeds create --in type=string +FLAG basecamp embeds create --jq type=string +FLAG basecamp embeds create --json type=bool +FLAG basecamp embeds create --markdown type=bool +FLAG basecamp embeds create --md type=bool +FLAG basecamp embeds create --no-hints type=bool +FLAG basecamp embeds create --no-stats type=bool +FLAG basecamp embeds create --profile type=string +FLAG basecamp embeds create --project type=string +FLAG basecamp embeds create --quiet type=bool +FLAG basecamp embeds create --stats type=bool +FLAG basecamp embeds create --styled type=bool +FLAG basecamp embeds create --todolist type=string +FLAG basecamp embeds create --verbose type=count +FLAG basecamp embeds show --account type=string +FLAG basecamp embeds show --agent type=bool +FLAG basecamp embeds show --cache-dir type=string +FLAG basecamp embeds show --count type=bool +FLAG basecamp embeds show --help type=bool +FLAG basecamp embeds show --hints type=bool +FLAG basecamp embeds show --ids-only type=bool +FLAG basecamp embeds show --in type=string +FLAG basecamp embeds show --jq type=string +FLAG basecamp embeds show --json type=bool +FLAG basecamp embeds show --markdown type=bool +FLAG basecamp embeds show --md type=bool +FLAG basecamp embeds show --no-hints type=bool +FLAG basecamp embeds show --no-stats type=bool +FLAG basecamp embeds show --profile type=string +FLAG basecamp embeds show --project type=string +FLAG basecamp embeds show --quiet type=bool +FLAG basecamp embeds show --stats type=bool +FLAG basecamp embeds show --styled type=bool +FLAG basecamp embeds show --todolist type=string +FLAG basecamp embeds show --verbose type=count FLAG basecamp events --account type=string FLAG basecamp events --agent type=bool FLAG basecamp events --all type=bool @@ -19520,6 +19588,9 @@ SUB basecamp documents vaults list SUB basecamp documents versions SUB basecamp drafts SUB basecamp drafts list +SUB basecamp embeds +SUB basecamp embeds create +SUB basecamp embeds show SUB basecamp events SUB basecamp events poll SUB basecamp events ticket diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 4d50b0574..58733df26 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 | 53 | 207 | +| ✅ Implemented | 54 | 209 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **57** | **219** | +| **Total tracked** | **58** | **221** | -**207 of 207 tracked in-scope endpoints.** The three Pings endpoints bc3#13943 +**209 of 209 tracked in-scope endpoints.** 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 /circles.json` — land as `pings`. Moving a document, upload or folder between folders (`POST /recordings/:id/filing.json`, documented by bc3#13644 and @@ -238,6 +240,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | vaults (move) | 1 | `files move` | ✅ | BC5 | - | `POST /recordings/:id/filing.json`: moves a document, upload or folder into another folder in the same project, in place (same id and comments). `--to` takes a folder ID or URL; `--position` is 1-indexed. Flat route, so no `--in` | | documents | 8 | `files`, `docs` | ✅ | BC4 | - | list, show, create, update. Create supports `--subscribe`/`--no-subscribe`, `--visible-to-clients` (root vault only) | | attachments | 1 | `uploads`, `attachments` | ✅ | BC4 | - | Upload via `attach`; list embedded attachments via `attachments list` (parses `<bc-attachment>` from content) | +| embeds | 2 | `embeds` | ✅ | BC5 | - | create, show. Rich-text commands also embed a URL that stands alone on its line in Markdown content, through the same create | | **Schedule** | | calendars | 2 | `calendars` | ✅ | BC5 | - | show, update (color only). No index endpoint, so there is no `calendars list` — address one by id or pasted URL. The eleven colors are validated client-side for an immediate answer that names the alternatives; since v0.13.0 the SDK also carries the server's field-keyed 422 message | | schedules | 2 | `schedule` | ✅ | BC4 | - | Schedule container + settings | diff --git a/e2e/smoke/smoke_embeds.bats b/e2e/smoke/smoke_embeds.bats new file mode 100644 index 000000000..991086277 --- /dev/null +++ b/e2e/smoke/smoke_embeds.bats @@ -0,0 +1,32 @@ +#!/usr/bin/env bats +# smoke_embeds.bats - Level 1: Embeds for rich text + +load smoke_helper + +setup_file() { + ensure_token || return 1 +} + +@test "embeds without subcommand shows help" { + run basecamp embeds + assert_success + assert_output_contains "create" +} + +@test "embeds create returns an embed with its sgid" { + run_smoke basecamp embeds create "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --json + if [[ "$status" -ne 0 ]] && [[ "$output" == *"not found"* || "$output" == *"404"* ]]; then + mark_unverifiable "This Basecamp does not serve POST /embeds.json to API clients yet" + return + fi + assert_success + assert_json_not_null '.data.sgid' + echo "$output" | jq -r '.data.sgid' > "$BATS_FILE_TMPDIR/embed_sgid" +} + +@test "embeds show returns the embed" { + [[ -s "$BATS_FILE_TMPDIR/embed_sgid" ]] || mark_unverifiable "No embed was created to show" + run_smoke basecamp embeds show "$(cat "$BATS_FILE_TMPDIR/embed_sgid")" --json + assert_success + assert_json_value '.data.url' 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' +} diff --git a/go.mod b/go.mod index ac592f6f2..b9f476276 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.0 + github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020150141-b0c6c2c9a84f 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 f28371adc..8dc5e76b3 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.0 h1:ENIK+0Q9WWRCzcJjz0RLLpvAMSfKoQOl8lHRumaEeOg= -github.com/basecamp/basecamp-sdk/go v0.26.0/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020150141-b0c6c2c9a84f h1:GV6HeQXyxZ119ZdvZ6mwNo6sVisyPM9G7WxGFvguWZc= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020150141-b0c6c2c9a84f/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= 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/cli/root.go b/internal/cli/root.go index 9c34bfd5e..1069c8a3b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -362,6 +362,7 @@ func Execute() { cmd.AddCommand(commands.NewProfileCmd()) cmd.AddCommand(commands.NewSkillCmd()) cmd.AddCommand(commands.NewAttachmentsCmd()) + cmd.AddCommand(commands.NewEmbedsCmd()) cmd.AddCommand(commands.NewAttachCmd()) cmd.AddCommand(commands.NewUploadCmd()) cmd.AddCommand(commands.NewGaugesCmd()) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 604595a68..84f57d6f2 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -104,6 +104,7 @@ func CommandCategories() []CommandCategory { {Name: "forwards", Category: "communication", Description: "Manage email forwards (inbox)", Actions: []string{"list", "show", "inbox", "replies", "reply"}}, {Name: "subscriptions", Category: "communication", Description: "Manage notification subscriptions", Actions: []string{"show", "subscribe", "unsubscribe", "add", "remove"}}, {Name: "attachments", Category: "communication", Description: "List and download attachments", Actions: []string{"list", "download"}}, + {Name: "embeds", Category: "communication", Description: "Embed videos, charts and other links in rich text", Actions: []string{"create", "show"}}, {Name: "comments", Category: "communication", Description: "Manage comments", Actions: []string{"create", "list", "show", "thread", "update", "trash", "archive", "restore"}}, {Name: "boost", Category: "communication", Description: "Manage boosts (reactions)", Actions: []string{"list", "show", "create", "update", "delete"}}, {Name: "notifications", Category: "communication", Description: "View and manage notifications", Actions: []string{"list", "read", "bubbleups"}}, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 1710ef1eb..25de382b7 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -111,6 +111,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewUpgradeCmd()) root.AddCommand(commands.NewMigrateCmd()) root.AddCommand(commands.NewAttachmentsCmd()) + root.AddCommand(commands.NewEmbedsCmd()) root.AddCommand(commands.NewAttachCmd()) root.AddCommand(commands.NewUploadCmd()) root.AddCommand(commands.NewSkillCmd()) diff --git a/internal/commands/embeds.go b/internal/commands/embeds.go new file mode 100644 index 000000000..2bb37e32e --- /dev/null +++ b/internal/commands/embeds.go @@ -0,0 +1,214 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/richtext" +) + +type embedClient interface { + Create(ctx context.Context, url string) (*basecamp.Embed, error) + Get(ctx context.Context, sgid string) (*basecamp.Embed, error) +} + +var ( + embedPollInterval = time.Second + embedWait = 10 * time.Second +) + +type EmbedResult struct { + *basecamp.Embed + Attachment string `json:"attachment,omitempty"` +} + +func NewEmbedsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "embeds", + Short: "Embed videos, charts and other links in rich text", + Long: `Embed content from another site, such as a YouTube video or a Dash chart, +in a card, comment, message, document or to-do. + +With Markdown content (the default), you don't need this command: put the URL +on a line by itself and Basecamp embeds it, as the editor does when you paste +a link. Write [title](url) to keep a link instead. + +Use 'create' when you write HTML (--format html): it prints the +<bc-attachment> tag to put in your content.`, + Annotations: map[string]string{"agent_notes": "A URL alone on its line in Markdown content becomes an embed automatically\nWrite [title](url) to keep it a link\nURLs Basecamp can't embed stay links, with no warning\nWith --format html, use embeds create and paste its attachment tag"}, + } + + cmd.AddCommand( + newEmbedsCreateCmd(), + newEmbedsShowCmd(), + ) + + return cmd +} + +func newEmbedsCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "create <url>", + Short: "Create an embed for a URL", + Long: `Create an embed for a URL and wait until Basecamp has it from the provider. + +Prints the embed, with the <bc-attachment> tag to put in HTML rich text. +A URL no provider embeds is refused; link to it instead.`, + Example: ` basecamp embeds create https://www.youtube.com/watch?v=dQw4w9WgXcQ + basecamp embeds create https://dash.37signals.com/widgets/abc --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return missingArg(cmd, "<url>") + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + embed, err := createEmbed(cmd.Context(), app.Account().Embeds(), args[0]) + if err != nil { + return err + } + return app.OK(embedResult(embed), output.WithSummary(embedSummary(embed))) + }, + } +} + +func newEmbedsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show <sgid>", + Short: "Show an embed", + Long: "Show an embed by the sgid that 'embeds create' printed.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return missingArg(cmd, "<sgid>") + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + embed, err := app.Account().Embeds().Get(cmd.Context(), args[0]) + if err != nil { + return convertSDKError(err) + } + return app.OK(embedResult(embed), output.WithSummary(embedSummary(embed))) + }, + } +} + +func createEmbed(ctx context.Context, client embedClient, url string) (*basecamp.Embed, error) { + embed, err := client.Create(ctx, url) + if err != nil { + if httpStatus(err) == http.StatusUnprocessableEntity { + return nil, output.ErrUsageHint("Basecamp can't embed "+url, "Link to it instead") + } + return nil, convertSDKError(err) + } + return waitForEmbed(ctx, client, embed, time.Now().Add(embedWait)) +} + +func waitForEmbed(ctx context.Context, client embedClient, embed *basecamp.Embed, deadline time.Time) (*basecamp.Embed, error) { + for embed.Status == "pending" && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(embedPollInterval): + } + + refreshed, err := client.Get(ctx, embed.SGID) + if err != nil { + return nil, convertSDKError(err) + } + embed = refreshed + } + return embed, nil +} + +func embedResult(embed *basecamp.Embed) EmbedResult { + result := EmbedResult{Embed: embed} + if embed.Status == "active" { + result.Attachment = richtext.EmbedToHTML(richtext.EmbedRef{SGID: embed.SGID, ContentType: embed.ContentType}) + } + return result +} + +func embedSummary(embed *basecamp.Embed) string { + switch embed.Status { + case "active": + return "Embed ready for " + embed.URL + case "pending": + return "Embed still pending for " + embed.URL + "; check again with basecamp embeds show" + default: + return "Basecamp found nothing to embed at " + embed.URL + "; link to it instead" + } +} + +func embedLinks(cmd *cobra.Command) richtext.EmbedFunc { + app := appctx.FromContext(cmd.Context()) + if app == nil || app.SDK == nil || app.RequireAccount() != nil { + return nil + } + return func(urls []string) map[string]richtext.EmbedRef { + return resolveEmbeds(cmd.Context(), app.Account().Embeds(), urls, cmd.ErrOrStderr()) + } +} + +func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warnings io.Writer) map[string]richtext.EmbedRef { + created := map[string]*basecamp.Embed{} + for _, url := range urls { + embed, err := client.Create(ctx, url) + if err != nil { + if status := httpStatus(err); status != http.StatusUnprocessableEntity && status != http.StatusNotFound { + fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, err) + } + continue + } + created[url] = embed + } + + refs := map[string]richtext.EmbedRef{} + deadline := time.Now().Add(embedWait) + for _, url := range urls { + embed, ok := created[url] + if !ok { + continue + } + + embed, err := waitForEmbed(ctx, client, embed, deadline) + switch { + case err != nil: + fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, err) + case embed.Status == "active": + refs[url] = richtext.EmbedRef{SGID: embed.SGID, ContentType: embed.ContentType} + case embed.Status == "pending": + fmt.Fprintf(warnings, "Warning: %s took too long to answer, so it stays a link.\n", url) + default: + fmt.Fprintf(warnings, "Warning: Basecamp found nothing to embed at %s, so it stays a link.\n", url) + } + } + return refs +} + +func httpStatus(err error) int { + var apiErr *basecamp.Error + if errors.As(err, &apiErr) { + return apiErr.HTTPStatus + } + return 0 +} diff --git a/internal/commands/embeds_test.go b/internal/commands/embeds_test.go new file mode 100644 index 000000000..ee87c224d --- /dev/null +++ b/internal/commands/embeds_test.go @@ -0,0 +1,340 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "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 ( + embedVideoURL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + embedChartURL = "https://dash.37signals.com/widgets/abc" + embedCardURL = "https://3.basecamp.com/99999/buckets/123/card_tables/cards/789" +) + +type embedReply struct { + status int + body string +} + +type embedTransport struct { + mu sync.Mutex + creates map[string]embedReply + gets map[string][]embedReply + calls []string + posted []byte +} + +func (t *embedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.Lock() + defer t.mu.Unlock() + + respond := func(reply embedReply) (*http.Response, error) { + return &http.Response{ + StatusCode: reply.status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(reply.body)), + Request: req, + }, nil + } + + var body []byte + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + req.Body.Close() + } + + switch { + case req.Method == http.MethodPost && req.URL.Path == "/99999/embeds.json": + var params struct { + URL string `json:"url"` + } + _ = json.Unmarshal(body, ¶ms) + t.calls = append(t.calls, "create "+params.URL) + if reply, ok := t.creates[params.URL]; ok { + return respond(reply) + } + return respond(embedReply{http.StatusUnprocessableEntity, `{"error":"Basecamp can't embed this URL. Link to it instead."}`}) + case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/99999/embeds/"): + sgid := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/99999/embeds/"), ".json") + t.calls = append(t.calls, "get "+sgid) + replies := t.gets[sgid] + if len(replies) == 0 { + return respond(embedReply{http.StatusNotFound, `{"error":"not found"}`}) + } + reply := replies[0] + if len(replies) > 1 { + t.gets[sgid] = replies[1:] + } + return respond(reply) + case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/comments.json"): + t.calls = append(t.calls, "comment") + t.posted = body + return respond(embedReply{http.StatusCreated, `{"id": 999, "content": "ok", "created_at": "2024-01-01T00:00:00Z"}`}) + default: + return respond(embedReply{http.StatusNotFound, `{"error":"not found"}`}) + } +} + +func (t *embedTransport) postedContent(tb testing.TB) string { + tb.Helper() + t.mu.Lock() + defer t.mu.Unlock() + require.NotEmpty(tb, t.posted, "nothing was posted") + var body map[string]any + require.NoError(tb, json.Unmarshal(t.posted, &body)) + content, _ := body["content"].(string) + return content +} + +func (t *embedTransport) recordedCalls() []string { + t.mu.Lock() + defer t.mu.Unlock() + return append([]string(nil), t.calls...) +} + +func embedJSON(url, status, sgid string) string { + embed := map[string]any{"url": url, "status": status, "sgid": sgid} + if status == "active" { + embed["type"] = "video" + embed["content_type"] = "embed/youtube-video" + } + b, _ := json.Marshal(embed) + return string(b) +} + +func fastEmbedPolling(t *testing.T, wait time.Duration) { + t.Helper() + interval, deadline := embedPollInterval, embedWait + embedPollInterval, embedWait = time.Millisecond, wait + t.Cleanup(func() { embedPollInterval, embedWait = interval, deadline }) +} + +func runEmbedCommand(cmd *cobra.Command, app *appctx.App, args ...string) (string, error) { + cmd.SetArgs(args) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + err := cmd.Execute() + return stderr.String(), err +} + +func TestCommentsCreateEmbedsAURLAloneOnItsLine(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + gets: map[string][]embedReply{"sgid-video": { + {http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}, + {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}, + }}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, "Watch this:\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>Watch this:</p>\n"+`<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, + transport.postedContent(t)) + assert.Equal(t, []string{"create " + embedVideoURL, "get sgid-video", "get sgid-video", "comment"}, transport.recordedCalls()) + assert.Empty(t, stderr) +} + +func TestCommentsCreateEmbedsEveryURLBeforeWaitingOnAny(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusCreated, embedJSON(embedChartURL, "pending", "sgid-chart")}, + embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}, + }, + gets: map[string][]embedReply{"sgid-chart": {{http.StatusOK, embedJSON(embedChartURL, "active", "sgid-chart")}}}, + } + app, _ := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL+"\n\n"+embedChartURL) + require.NoError(t, err) + + assert.Equal(t, []string{"create " + embedChartURL, "create " + embedVideoURL, "get sgid-chart", "comment"}, transport.recordedCalls()) + assert.Equal(t, 2, strings.Count(transport.postedContent(t), `sgid="sgid-chart"`)) + assert.Contains(t, transport.postedContent(t), `sgid="sgid-video"`) +} + +func TestCommentsCreateLeavesAURLBasecampCanNotEmbedAsALinkQuietly(t *testing.T) { + transport := &embedTransport{} + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, "https://github.com/basecamp/bc3/pull/1") + require.NoError(t, err) + + assert.Equal(t, "<p>https://github.com/basecamp/bc3/pull/1</p>", transport.postedContent(t)) + assert.Empty(t, stderr) +} + +func TestCommentsCreateLeavesALinkWhenTheEmbedFails(t *testing.T) { + for name, tc := range map[string]struct { + create embedReply + gets []embedReply + warning string + }{ + "rate limited": { + create: embedReply{http.StatusTooManyRequests, `{"error":"Too many new embeds. Try again later."}`}, + warning: "couldn't embed " + embedVideoURL + ", so it stays a link", + }, + "refused to agents": { + create: embedReply{http.StatusForbidden, `{"error":"Basecamp doesn't let agents do this.","reason":"agent_not_permitted"}`}, + warning: "couldn't embed " + embedVideoURL + ", so it stays a link", + }, + "the provider has nothing": { + create: embedReply{http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}, + gets: []embedReply{{http.StatusOK, embedJSON(embedVideoURL, "not_found", "sgid-video")}}, + warning: "Basecamp found nothing to embed at " + embedVideoURL + ", so it stays a link.", + }, + "the provider is slow": { + create: embedReply{http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}, + gets: []embedReply{{http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + warning: embedVideoURL + " took too long to answer, so it stays a link.", + }, + "the embed vanishes": { + create: embedReply{http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}, + gets: []embedReply{{http.StatusNotFound, `{"error":"not found"}`}}, + warning: "couldn't embed " + embedVideoURL + ", so it stays a link", + }, + } { + t.Run(name, func(t *testing.T) { + fastEmbedPolling(t, 20*time.Millisecond) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: tc.create}, + gets: map[string][]embedReply{"sgid-video": tc.gets}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Contains(t, stderr, tc.warning) + }) + } +} + +func TestCommentsCreateWithFormatHTMLEmbedsNothing(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}, + } + app, _ := newTestAppWithTransport(t, transport) + + html := "<p>" + embedVideoURL + "</p>\n" + embedVideoURL + _, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, html, "--format", "html") + require.NoError(t, err) + + assert.Equal(t, html, transport.postedContent(t)) + assert.Equal(t, []string{"comment"}, transport.recordedCalls()) +} + +func TestCommentsCreateKeepsATitledLink(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}, + } + app, _ := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, "[The video]("+embedVideoURL+")") + require.NoError(t, err) + + assert.Equal(t, `<p><a href="`+embedVideoURL+`">The video</a></p>`, transport.postedContent(t)) + assert.Equal(t, []string{"comment"}, transport.recordedCalls()) +} + +func TestEmbedsCreateWaitsAndPrintsTheAttachment(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + gets: map[string][]embedReply{"sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}}, + } + app, out := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "create", embedVideoURL) + require.NoError(t, err) + + var envelope struct { + Data struct { + URL string `json:"url"` + Status string `json:"status"` + SGID string `json:"sgid"` + Attachment string `json:"attachment"` + } `json:"data"` + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + assert.Equal(t, "active", envelope.Data.Status) + assert.Equal(t, "sgid-video", envelope.Data.SGID) + assert.Equal(t, `<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, envelope.Data.Attachment) + assert.Equal(t, "Embed ready for "+embedVideoURL, envelope.Summary) +} + +func TestEmbedsCreateRefusesAURLBasecampCanNotEmbed(t *testing.T) { + transport := &embedTransport{} + app, _ := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "create", "https://example.com") + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, "Basecamp can't embed https://example.com", outErr.Message) + assert.Equal(t, "Link to it instead", outErr.Hint) +} + +func TestEmbedsCreateWithoutAURLAsksForNothing(t *testing.T) { + transport := &embedTransport{} + app, _ := newTestAppWithTransport(t, transport) + + _, _ = runEmbedCommand(NewEmbedsCmd(), app, "create", " ") + + assert.Empty(t, transport.recordedCalls()) +} + +func TestEmbedsCreateLeavesNoAttachmentForAnEmbedThatIsNotReady(t *testing.T) { + fastEmbedPolling(t, 5*time.Millisecond) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + gets: map[string][]embedReply{"sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}}}, + } + app, out := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "create", embedVideoURL) + require.NoError(t, err) + + assert.NotContains(t, out.String(), `"attachment"`) + assert.Contains(t, out.String(), "basecamp embeds show") +} + +func TestEmbedsShow(t *testing.T) { + transport := &embedTransport{ + gets: map[string][]embedReply{"sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}}, + } + app, out := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "show", "sgid-video") + require.NoError(t, err) + + var envelope struct { + Data struct { + Attachment string `json:"attachment"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + assert.Equal(t, `<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, envelope.Data.Attachment) + assert.Equal(t, []string{"get sgid-video"}, transport.recordedCalls()) +} diff --git a/internal/commands/richtext_format.go b/internal/commands/richtext_format.go index 75c683f44..ea84cdbf4 100644 --- a/internal/commands/richtext_format.go +++ b/internal/commands/richtext_format.go @@ -42,7 +42,7 @@ func (f *richTextFormat) Type() string { return "string" } // addRichTextFormatFlag gives cmd the --format flag richTextToHTML reads. func addRichTextFormatFlag(cmd *cobra.Command) { cmd.Flags().Var(new(richTextFormat), "format", - "Rich-text input format: markdown (converted to HTML) or html (sent without conversion)") + "Rich-text input format: markdown (converted to HTML; a URL alone on its line becomes an embed) or html (sent without conversion)") _ = cmd.RegisterFlagCompletionFunc("format", cobra.FixedCompletions([]string{string(richTextMarkdown), string(richTextHTML)}, cobra.ShellCompDirectiveNoFileComp)) } @@ -68,5 +68,5 @@ func richTextToHTML(cmd *cobra.Command, content string) string { "Supported tags are kept and the Markdown around them is converted. "+ "To send HTML without Markdown conversion, pass --format html.") } - return richtext.MarkdownToHTML(content) + return richtext.MarkdownToHTMLWithEmbeds(content, embedLinks(cmd)) } diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index d13c68d5c..e92884a0e 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -71,7 +71,7 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 279, model, "served model operation count") + assert.Equal(t, 281, model, "served model operation count") assert.Equal(t, 1, composite, "served composite operation count") } diff --git a/internal/mcpserver/domains.go b/internal/mcpserver/domains.go index 339c22e92..5efa8f855 100644 --- a/internal/mcpserver/domains.go +++ b/internal/mcpserver/domains.go @@ -56,8 +56,8 @@ var DomainSpecs = []catalog.DomainSpec{ }, { Key: "files", - Tags: []string{"Files", "Folders"}, - Blurb: "Docs & Files: vaults (folders), documents, uploads, attachments, cloud file links, Google documents, and home-screen project folders.", + Tags: []string{"Files", "Folders", "Embeds"}, + Blurb: "Docs & Files: vaults (folders), documents, uploads, attachments, embeds for rich text (videos, charts), cloud file links, Google documents, and home-screen project folders.", }, { Key: "people", diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 987474f70..32a099617 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "dfe90054e69065000bd3410d6f02435186fe4b1b", - "ref": "v0.26.0", + "commit": "b0c6c2c9a84f36988c6a4fbeccac4f35964a85be", + "ref": "go/v0.26.0-1-gb0c6c2c9", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index 98e2f2258..d6f1fa369 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -174,6 +174,18 @@ ] } }, + "CreateEmbed": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateEventBoost": { "retry": { "max": 2, @@ -1050,6 +1062,18 @@ ] } }, + "GetEmbed": { + "readonly": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "GetEverythingCheckins": { "readonly": true, "pagination": { diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 21f6cc92f..e230b214e 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -9014,6 +9014,225 @@ } } }, + "/{accountId}/embeds.json": { + "post": { + "description": "Create an embed for a URL, or return the one Basecamp already has for it.\n\nAnswers `201 Created` with a new embed, whose status is `pending`, and\n`200 OK` with an existing embed in any status. Get the embed about once a\nsecond until its status is `active` or `not_found`.\n\nAnswers `422` (`{\"error\": \"Basecamp can't embed this URL. Link to it\ninstead.\"}`) for a URL no provider embeds, and `429` when the caller has\ncreated too many new embeds in the last hour.", + "operationId": "CreateEmbed", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEmbedRequestContent" + } + } + }, + "required": true + }, + "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": { + "201": { + "description": "CreateEmbed 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEmbedResponseContent" + } + } + } + }, + "400": { + "description": "BadRequestError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorResponseContent" + } + } + } + }, + "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" + } + } + } + }, + "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": [ + "Embeds" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, + "/{accountId}/embeds/{sgid}": { + "get": { + "description": "Get an embed by its sgid.\n\nAnswers `404 Not Found` for an sgid that does not name an embed.", + "operationId": "GetEmbed", + "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": "sgid", + "in": "path", + "description": "An embed's signed global id, an opaque string.", + "schema": { + "type": "string", + "description": "An embed's signed global id, an opaque string." + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetEmbed 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEmbedResponseContent" + } + } + } + }, + "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": [ + "Embeds" + ], + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/events.json": { "get": { "description": "Poll the account event feed for events after a position (oldest first, strict event-id order, up to 100 per page).\n\n**Entry.** With neither `since` nor `position` the feed begins at the present\n(equivalent to `since=now`). `since=<event id>` starts after that id and\n`since=0` replays all served history back to the feed's epoch; `since` is a\nsigned 64-bit integer written in decimal, or the literal `now`. `position`\nresumes from a token a previous page issued \u2014 signed, opaque, bound to the\naccount and the filter set.\n\n**Pagination**: cursor style \u2014 the body envelope, not the Link header.\n`position` is the durable cursor (persist it only after processing the\npage's events); `next` is an absolute continuation URL present only while\nthis walk has more to serve. One call answers one page; no generator emits\na walk for the cursor style \u2014 see the section note.\n\n**Errors.** 400 (FeedRequestError) for a malformed position (resume with\n`since=`) or a malformed filter (fix the filters; a position reset will not\nhelp), told apart by its optional `reason` and undifferentiated when `reason`\nis absent. 409 (FeedFilterMismatchError) when the position was minted for a\ndifferent filter set. 410 (FeedPositionGoneError) when the position predates\nthe feed's epoch; its `resume` re-enters at the epoch.", @@ -31809,6 +32028,21 @@ "CreateDocumentResponseContent": { "$ref": "#/components/schemas/Document" }, + "CreateEmbedRequestContent": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL of the content to embed." + } + }, + "required": [ + "url" + ] + }, + "CreateEmbedResponseContent": { + "$ref": "#/components/schemas/Embed" + }, "CreateEventBoostRequestContent": { "type": "object", "properties": { @@ -32904,6 +33138,45 @@ "title" ] }, + "Embed": { + "type": "object", + "description": "Content from another site, such as a YouTube video, that rich text shows\ninline. Put it in rich text as `<bc-attachment sgid=\"SGID\"></bc-attachment>`\nonce its status is `active`; an embed that is not active shows as a link to\nits URL.", + "properties": { + "url": { + "type": "string" + }, + "status": { + "type": "string", + "description": "`pending` while Basecamp asks the provider about the URL, then `active`,\nor `not_found` when the provider has nothing to embed." + }, + "sgid": { + "type": "string", + "description": "An embed's signed global id, an opaque string." + }, + "type": { + "type": "string", + "description": "The provider's oEmbed type, such as `video`, `rich` or `photo`. Present\nonly when the embed is active." + }, + "content_type": { + "type": "string", + "description": "Such as `embed/youtube-video`. Present only when the embed is active." + }, + "image_url": { + "type": "string", + "description": "Present only when the embed is active, and null when the provider gave no\nimage.", + "nullable": true + }, + "content": { + "type": "string", + "description": "The provider's HTML, sanitized. Present only when the embed is active and\nthe provider gave HTML; may be empty." + } + }, + "required": [ + "sgid", + "status", + "url" + ] + }, "EnableCardColumnOnHoldResponseContent": { "$ref": "#/components/schemas/CardColumn" }, @@ -34007,6 +34280,9 @@ "GetDocumentResponseContent": { "$ref": "#/components/schemas/Document" }, + "GetEmbedResponseContent": { + "$ref": "#/components/schemas/Embed" + }, "GetEverythingCheckinsResponseContent": { "type": "array", "items": { diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index c0d833a68..509ee8e44 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -169,7 +169,7 @@ ACTIONS (RO = read-only): - update_timesheet_entry: Update a timesheet entry == basecamp_files -Docs & Files: vaults (folders), documents, uploads, attachments, cloud file links, Google documents, and home-screen project folders. +Docs & Files: vaults (folders), documents, uploads, attachments, embeds for rich text (videos, charts), cloud file links, Google documents, and home-screen project folders. Gateway tool: call with {"action": "...", "params": {...}}. Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. @@ -177,6 +177,7 @@ Call {"action": "describe", "params": {"action": "NAME"}} for an action's full p ACTIONS (RO = read-only): - create_cloud_file: Create a new cloud file in a vault - create_document: Create a new document in a vault +- create_embed: Create an embed for a URL, or return the one Basecamp already has for it - create_folder: Create a folder for the authenticated user and file the given projects into it - create_google_document: Create a new Google document in a vault - create_upload: Create a new upload in a vault @@ -185,6 +186,7 @@ ACTIONS (RO = read-only): - delete_folder: Delete a folder and unpin its projects from the home screen (returns 204 No Content) - get_cloud_file (RO): Get a single cloud file by id - get_document (RO): Get a single document by id +- get_embed (RO): Get an embed by its sgid - get_folder (RO): Get one folder, with the projects grouped inside it expanded under `projects` - get_google_document (RO): Get a single Google document by id - get_upload (RO): Get a single upload by id diff --git a/internal/richtext/embeds.go b/internal/richtext/embeds.go new file mode 100644 index 000000000..818388c6f --- /dev/null +++ b/internal/richtext/embeds.go @@ -0,0 +1,205 @@ +package richtext + +import ( + "bytes" + "html" + "regexp" + "slices" + "strings" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type EmbedRef struct { + SGID string + ContentType string +} + +type EmbedFunc func(urls []string) map[string]EmbedRef + +type EmbedAttachment struct { + ast.BaseBlock + Ref EmbedRef +} + +var KindEmbedAttachment = ast.NewNodeKind("EmbedAttachment") + +func (n *EmbedAttachment) Kind() ast.NodeKind { return KindEmbedAttachment } +func (n *EmbedAttachment) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +func EmbedToHTML(ref EmbedRef) string { + tag := `<bc-attachment sgid="` + escapeAttr(ref.SGID) + `"` + if ref.ContentType != "" { + tag += ` content-type="` + escapeAttr(ref.ContentType) + `"` + } + return tag + `></bc-attachment>` +} + +func MarkdownToHTMLWithEmbeds(md string, embed EmbedFunc) string { + if md == "" { + return "" + } + + md = strings.ReplaceAll(md, "\r\n", "\n") + md = strings.ReplaceAll(md, "\r", "\n") + source := []byte(md) + + doc := mdConverter.Parser().Parse(text.NewReader(source)) + if embed != nil { + embedStandaloneURLs(doc, source, embed) + } + + var buf bytes.Buffer + if err := mdConverter.Renderer().Render(&buf, source, doc); err != nil { + return "<p>" + html.EscapeString(md) + "</p>" + } + return strings.TrimSpace(buf.String()) +} + +var reStandaloneURL = regexp.MustCompile(`^https?://[^\s<>"'\\]+$`) + +func embedStandaloneURLs(doc ast.Node, source []byte, embed EmbedFunc) { + paragraphs := topLevelParagraphs(doc) + + var urls []string + for _, p := range paragraphs { + for _, line := range paragraphLines(p) { + if url := standaloneURL(line, source); url != "" && !slices.Contains(urls, url) { + urls = append(urls, url) + } + } + } + if len(urls) == 0 { + return + } + + refs := embed(urls) + if len(refs) == 0 { + return + } + + for _, p := range paragraphs { + splitParagraphAtEmbeds(p, source, refs) + } +} + +func topLevelParagraphs(doc ast.Node) []*ast.Paragraph { + var paragraphs []*ast.Paragraph + for child := doc.FirstChild(); child != nil; child = child.NextSibling() { + if p, ok := child.(*ast.Paragraph); ok { + paragraphs = append(paragraphs, p) + } + } + return paragraphs +} + +func paragraphLines(p *ast.Paragraph) [][]ast.Node { + var lines [][]ast.Node + var line []ast.Node + for child := p.FirstChild(); child != nil; child = child.NextSibling() { + line = append(line, child) + if t, ok := child.(*ast.Text); ok && (t.SoftLineBreak() || t.HardLineBreak()) { + lines = append(lines, line) + line = nil + } + } + if len(line) > 0 { + lines = append(lines, line) + } + return lines +} + +func standaloneURL(line []ast.Node, source []byte) string { + var content []ast.Node + for _, n := range line { + if t, ok := n.(*ast.Text); ok && len(bytes.TrimSpace(t.Segment.Value(source))) == 0 { + continue + } + content = append(content, n) + } + + if len(content) == 1 { + if link, ok := content[0].(*ast.AutoLink); ok && link.AutoLinkType == ast.AutoLinkURL { + url := string(link.URL(source)) + if reStandaloneURL.MatchString(url) { + return url + } + return "" + } + } + + var raw []byte + for _, n := range content { + t, ok := n.(*ast.Text) + if !ok || t.IsRaw() { + return "" + } + raw = append(raw, t.Segment.Value(source)...) + } + url := string(bytes.TrimSpace(raw)) + if reStandaloneURL.MatchString(url) { + return url + } + return "" +} + +func splitParagraphAtEmbeds(p *ast.Paragraph, source []byte, refs map[string]EmbedRef) { + lines := paragraphLines(p) + embeds := make([]*EmbedRef, len(lines)) + embedded := false + for i, line := range lines { + if ref, ok := refs[standaloneURL(line, source)]; ok { + embeds[i] = &ref + embedded = true + } + } + if !embedded { + return + } + + parent := p.Parent() + var pending *ast.Paragraph + flush := func() { + if pending != nil { + trimTrailingBreak(pending) + parent.InsertBefore(parent, p, pending) + pending = nil + } + } + for i, line := range lines { + if embeds[i] != nil { + flush() + parent.InsertBefore(parent, p, &EmbedAttachment{Ref: *embeds[i]}) + continue + } + if pending == nil { + pending = ast.NewParagraph() + } + for _, n := range line { + pending.AppendChild(pending, n) + } + } + flush() + parent.RemoveChild(parent, p) +} + +func trimTrailingBreak(p *ast.Paragraph) { + if t, ok := p.LastChild().(*ast.Text); ok { + t.SetSoftLineBreak(false) + t.SetHardLineBreak(false) + } +} + +func (r *trixRenderer) renderEmbedAttachment(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if n, ok := node.(*EmbedAttachment); ok { + _, _ = w.WriteString(EmbedToHTML(n.Ref) + "\n") + } + return ast.WalkContinue, nil +} diff --git a/internal/richtext/embeds_test.go b/internal/richtext/embeds_test.go new file mode 100644 index 000000000..d3f5b9bc0 --- /dev/null +++ b/internal/richtext/embeds_test.go @@ -0,0 +1,132 @@ +package richtext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + videoURL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + chartURL = "https://dash.37signals.com/widgets/abc" +) + +func embedAll(seen *[]string) EmbedFunc { + return func(urls []string) map[string]EmbedRef { + *seen = append(*seen, urls...) + refs := map[string]EmbedRef{} + for _, url := range urls { + refs[url] = EmbedRef{SGID: "sgid-" + url[len(url)-3:], ContentType: "embed/test"} + } + return refs + } +} + +func TestMarkdownToHTMLWithEmbedsEmbedsAURLAloneOnItsLine(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds(videoURL, embedAll(&seen)) + + assert.Equal(t, []string{videoURL}, seen) + assert.Equal(t, `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`, got) +} + +func TestMarkdownToHTMLWithEmbedsSplitsAParagraphAroundEmbeddedLines(t *testing.T) { + var seen []string + md := "Charts for the incident:\n" + chartURL + "\n" + videoURL + "\nThat's all." + got := MarkdownToHTMLWithEmbeds(md, embedAll(&seen)) + + assert.Equal(t, []string{chartURL, videoURL}, seen) + assert.Equal(t, "<p>Charts for the incident:</p>\n"+ + `<bc-attachment sgid="sgid-abc" content-type="embed/test"></bc-attachment>`+"\n"+ + `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n"+ + "<p>That's all.</p>", got) +} + +func TestMarkdownToHTMLWithEmbedsKeepsBlankLineSpacing(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds("Intro\n\n"+videoURL+"\n\nOutro", embedAll(&seen)) + + assert.Equal(t, "<p>Intro</p>\n<p><br></p>\n"+ + `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n"+ + "<p><br></p>\n<p>Outro</p>", got) +} + +func TestMarkdownToHTMLWithEmbedsEmbedsAnAutolinkAloneOnItsLine(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds("<"+videoURL+">\nnext", embedAll(&seen)) + + assert.Equal(t, []string{videoURL}, seen) + assert.Equal(t, `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n<p>next</p>", got) +} + +func TestMarkdownToHTMLWithEmbedsAsksOncePerURL(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds(videoURL+"\n\n"+videoURL, embedAll(&seen)) + + assert.Equal(t, []string{videoURL}, seen) + assert.Equal(t, `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n<p><br></p>\n"+ + `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`, got) +} + +func TestMarkdownToHTMLWithEmbedsLeavesURLsItCanNotEmbed(t *testing.T) { + refuse := func(urls []string) map[string]EmbedRef { return nil } + md := "Intro\n" + videoURL + "\nOutro" + + assert.Equal(t, MarkdownToHTML(md), MarkdownToHTMLWithEmbeds(md, refuse)) +} + +func TestMarkdownToHTMLWithEmbedsEmbedsOnlyTheURLsItWasGiven(t *testing.T) { + onlyChart := func(urls []string) map[string]EmbedRef { + return map[string]EmbedRef{chartURL: {SGID: "chart"}} + } + got := MarkdownToHTMLWithEmbeds(videoURL+"\n"+chartURL, onlyChart) + + assert.Equal(t, "<p>"+videoURL+"</p>\n"+`<bc-attachment sgid="chart"></bc-attachment>`, got) +} + +func TestMarkdownToHTMLWithEmbedsLeavesEverythingButAURLAloneOnALine(t *testing.T) { + for name, md := range map[string]string{ + "in a sentence": "Watch " + videoURL + " today", + "a titled link": "[The video](" + videoURL + ")", + "in a list": "- " + videoURL, + "in a numbered list": "1. " + videoURL, + "in a quote": "> " + videoURL, + "in a code span": "`" + videoURL + "`", + "in a code block": "```\n" + videoURL + "\n```", + "in an indented block": " " + videoURL, + "in an HTML block": "<p>\n" + videoURL + "\n</p>", + "in a heading": "# " + videoURL, + "emphasized": "*" + videoURL + "*", + "with an escape": videoURL + `\_x`, + "another scheme": "ftp://example.com/video", + "javascript": "javascript:alert(1)", + "a mail autolink": "<someone@example.com>", + "with markup in it": "https://www.youtube.com/watch?v=<b>x</b>", + "two URLs on one line": videoURL + " " + chartURL, + "in a table": "| a |\n|---|\n| " + videoURL + " |", + "followed by text": videoURL + " is the one", + "after a bc-attachment": `<bc-attachment sgid="x"></bc-attachment> ` + videoURL, + } { + t.Run(name, func(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds(md, embedAll(&seen)) + + assert.Empty(t, seen) + assert.Equal(t, MarkdownToHTML(md), got) + }) + } +} + +func TestMarkdownToHTMLWithEmbedsWithoutAnEmbedFuncIsMarkdownToHTML(t *testing.T) { + md := "Intro\n" + videoURL + "\n\n- item" + + assert.Equal(t, MarkdownToHTML(md), MarkdownToHTMLWithEmbeds(md, nil)) + assert.Equal(t, "<p>Intro\n"+videoURL+"</p>\n<p><br></p>\n<ul>\n<li>item</li>\n</ul>", MarkdownToHTML(md)) +} + +func TestEmbedToHTMLEscapesItsAttributes(t *testing.T) { + assert.Equal(t, `<bc-attachment sgid="a"><script>"></bc-attachment>`, + EmbedToHTML(EmbedRef{SGID: `a"><script>`})) + assert.Equal(t, `<bc-attachment sgid="s" content-type="embed/"x"></bc-attachment>`, + EmbedToHTML(EmbedRef{SGID: "s", ContentType: `embed/"x`})) +} diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index 8d5432d1b..bd62cda99 100644 --- a/internal/richtext/richtext.go +++ b/internal/richtext/richtext.go @@ -3,7 +3,6 @@ package richtext import ( - "bytes" "errors" "fmt" "html" @@ -350,6 +349,7 @@ func (r *trixRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) reg.Register(KindTrixBreak, r.renderTrixBreak) reg.Register(KindEscapedAt, r.renderEscapedAt) + reg.Register(KindEmbedAttachment, r.renderEmbedAttachment) } func (r *trixRenderer) renderBlockquote(w util.BufWriter, _ []byte, _ ast.Node, entering bool) (ast.WalkStatus, error) { @@ -479,19 +479,7 @@ func (r *trixRenderer) renderEscapedAt(w util.BufWriter, _ []byte, _ ast.Node, e // is not converted, as CommonMark specifies; a blank line ends the block. // Callers that want HTML sent exactly as written skip this function. func MarkdownToHTML(md string) string { - if md == "" { - return "" - } - - md = strings.ReplaceAll(md, "\r\n", "\n") - md = strings.ReplaceAll(md, "\r", "\n") - - var buf bytes.Buffer - if err := mdConverter.Convert([]byte(md), &buf); err != nil { - return "<p>" + html.EscapeString(md) + "</p>" - } - - return strings.TrimSpace(buf.String()) + return MarkdownToHTMLWithEmbeds(md, nil) } // HasRichTextHTML reports whether MarkdownToHTML would keep any raw HTML from diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index fd3395d2b..50ae1d97d 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.26.0", - "revision": "dfe90054e690", - "updated_at": "2026-10-20T00:38:27Z" + "version": "v0.26.1-0.20261020150141-b0c6c2c9a84f", + "revision": "b0c6c2c9a84f", + "updated_at": "2026-10-20T15:01:41Z" }, "api": { "repo": "basecamp/bc3", diff --git a/skill-evals/cases/create-card-embed.yml b/skill-evals/cases/create-card-embed.yml new file mode 100644 index 000000000..d6c704202 --- /dev/null +++ b/skill-evals/cases/create-card-embed.yml @@ -0,0 +1,10 @@ +task: Create a card titled "Checkout latency" in project 55555 that shows the Dash chart https://dash.37signals.com/widgets/abc in the card itself, not as a link +tags: [domain, create] +accept: + - 'cards? create .*Checkout latency' + - '--(in|project) 55555' + - '(?m)^https://dash\.37signals\.com/widgets/abc\s*$' +reject: + - '\[[^\]]*\]\(https://dash\.37signals\.com/widgets/abc\)' + - '--format html' + - 'basecamp api' diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 2f651c138..8b6ac48af 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -253,6 +253,23 @@ flowchart LR EOF ```` +To embed a video, a Dash chart, or anything else Basecamp's editor embeds when +a link is pasted, put the URL on a line by itself in Markdown content. The CLI +asks Basecamp for the embed and waits for it. A URL Basecamp can't embed stays +a link, and so does a URL inside a sentence, a list, a quote, or code. To keep +a link to something Basecamp could embed, write `[title](url)`. + +```bash +basecamp cards create "Checkout latency" - --in "<project>" --json <<'EOF' +p99 since the deploy: + +https://dash.37signals.com/widgets/abc +EOF +``` + +With `--format html` nothing is embedded for you: run +`basecamp embeds create <url> --json` and put its `attachment` tag in the HTML. + For deterministic mentions, prefer the machine-provided mention syntax from people or comment results, for example `[@Name](mention:SGID)`. Mention support varies by field; leaf help is authoritative. From 0e171c3fa434117146082cc9436346adc2fea88d Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 11:39:05 -0400 Subject: [PATCH 02/14] Bound the embed step, poll embeds together, and leave HTML and references alone One ten-second budget now covers every embed request, not just the waiting, so a hung request can't hold the post. Pending embeds are polled together, so one slow provider no longer stops the others being seen. The first failure that isn't a refusal ends the embed step, leaving the rest as links, so a struggling server costs the post one request, not one per URL, and can't trip the circuit breaker the post goes through. A paragraph holding inline HTML is left as written, since splitting it would break the HTML, and so is a URL with a character reference, which would reach Basecamp undecoded. --- internal/commands/embeds.go | 89 +++++++++++++++++++++++--------- internal/commands/embeds_test.go | 59 +++++++++++++++++++++ internal/richtext/embeds.go | 29 +++++++---- internal/richtext/embeds_test.go | 12 +++++ 4 files changed, 156 insertions(+), 33 deletions(-) diff --git a/internal/commands/embeds.go b/internal/commands/embeds.go index 2bb37e32e..f106199c3 100644 --- a/internal/commands/embeds.go +++ b/internal/commands/embeds.go @@ -120,19 +120,19 @@ func createEmbed(ctx context.Context, client embedClient, url string) (*basecamp } return nil, convertSDKError(err) } - return waitForEmbed(ctx, client, embed, time.Now().Add(embedWait)) + return waitForEmbed(ctx, client, embed) } -func waitForEmbed(ctx context.Context, client embedClient, embed *basecamp.Embed, deadline time.Time) (*basecamp.Embed, error) { - for embed.Status == "pending" && time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(embedPollInterval): - } +func waitForEmbed(ctx context.Context, client embedClient, embed *basecamp.Embed) (*basecamp.Embed, error) { + ctx, cancel := context.WithTimeout(ctx, embedWait) + defer cancel() + for embed.Status == "pending" && pauseForEmbed(ctx) { refreshed, err := client.Get(ctx, embed.SGID) if err != nil { + if ctx.Err() != nil { + break + } return nil, convertSDKError(err) } embed = refreshed @@ -140,6 +140,15 @@ func waitForEmbed(ctx context.Context, client embedClient, embed *basecamp.Embed return embed, nil } +func pauseForEmbed(ctx context.Context) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(embedPollInterval): + return true + } +} + func embedResult(embed *basecamp.Embed) EmbedResult { result := EmbedResult{Embed: embed} if embed.Status == "active" { @@ -170,32 +179,55 @@ func embedLinks(cmd *cobra.Command) richtext.EmbedFunc { } func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warnings io.Writer) map[string]richtext.EmbedRef { - created := map[string]*basecamp.Embed{} - for _, url := range urls { + ctx, cancel := context.WithTimeout(ctx, embedWait) + defer cancel() + + var created []string + embeds := map[string]*basecamp.Embed{} + for i, url := range urls { embed, err := client.Create(ctx, url) if err != nil { - if status := httpStatus(err); status != http.StatusUnprocessableEntity && status != http.StatusNotFound { - fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, err) + if status := httpStatus(err); status == http.StatusUnprocessableEntity || status == http.StatusNotFound { + continue + } + fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, err) + switch rest := len(urls) - i - 1; rest { + case 0: + case 1: + fmt.Fprintln(warnings, "Warning: the URL after it stays a link too.") + default: + fmt.Fprintf(warnings, "Warning: the %d URLs after it stay links too.\n", rest) } - continue + break } - created[url] = embed + created = append(created, url) + embeds[url] = embed } - refs := map[string]richtext.EmbedRef{} - deadline := time.Now().Add(embedWait) - for _, url := range urls { - embed, ok := created[url] - if !ok { - continue + var pollErr error + for pollErr == nil && anyPendingEmbed(created, embeds) && pauseForEmbed(ctx) { + for _, url := range created { + if embeds[url].Status != "pending" { + continue + } + refreshed, err := client.Get(ctx, embeds[url].SGID) + if err != nil { + if ctx.Err() == nil { + pollErr = err + } + break + } + embeds[url] = refreshed } + } - embed, err := waitForEmbed(ctx, client, embed, deadline) - switch { - case err != nil: - fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, err) + refs := map[string]richtext.EmbedRef{} + for _, url := range created { + switch embed := embeds[url]; { case embed.Status == "active": refs[url] = richtext.EmbedRef{SGID: embed.SGID, ContentType: embed.ContentType} + case embed.Status == "pending" && pollErr != nil: + fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, pollErr) case embed.Status == "pending": fmt.Fprintf(warnings, "Warning: %s took too long to answer, so it stays a link.\n", url) default: @@ -205,6 +237,15 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni return refs } +func anyPendingEmbed(urls []string, embeds map[string]*basecamp.Embed) bool { + for _, url := range urls { + if embeds[url].Status == "pending" { + return true + } + } + return false +} + func httpStatus(err error) int { var apiErr *basecamp.Error if errors.As(err, &apiErr) { diff --git a/internal/commands/embeds_test.go b/internal/commands/embeds_test.go index ee87c224d..bd4e19b0a 100644 --- a/internal/commands/embeds_test.go +++ b/internal/commands/embeds_test.go @@ -32,6 +32,7 @@ type embedReply struct { type embedTransport struct { mu sync.Mutex + hang bool creates map[string]embedReply gets map[string][]embedReply calls []string @@ -58,6 +59,12 @@ func (t *embedTransport) RoundTrip(req *http.Request) (*http.Response, error) { } switch { + case req.Method == http.MethodPost && req.URL.Path == "/99999/embeds.json" && t.hang: + t.calls = append(t.calls, "create (hung)") + t.mu.Unlock() + <-req.Context().Done() + t.mu.Lock() + return nil, req.Context().Err() case req.Method == http.MethodPost && req.URL.Path == "/99999/embeds.json": var params struct { URL string `json:"url"` @@ -230,6 +237,58 @@ func TestCommentsCreateLeavesALinkWhenTheEmbedFails(t *testing.T) { } } +func TestCommentsCreateKeepsPollingTheOthersWhileOneProviderIsSlow(t *testing.T) { + fastEmbedPolling(t, 50*time.Millisecond) + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusOK, embedJSON(embedChartURL, "pending", "sgid-chart")}, + embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}, + }, + gets: map[string][]embedReply{ + "sgid-chart": {{http.StatusOK, embedJSON(embedChartURL, "pending", "sgid-chart")}}, + "sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>"+embedChartURL+"</p>\n"+`<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, transport.postedContent(t)) + assert.Contains(t, stderr, embedChartURL+" took too long to answer") +} + +func TestCommentsCreateStopsAskingForEmbedsAfterAFailure(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusInternalServerError, `{"error":"boom"}`}, + embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>"+embedChartURL+"\n"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Equal(t, 1, strings.Count(strings.Join(transport.recordedCalls(), ","), "create ")) + assert.Contains(t, stderr, "the URL after it stays a link too") +} + +func TestCommentsCreatePostsWhenAnEmbedRequestHangs(t *testing.T) { + fastEmbedPolling(t, 50*time.Millisecond) + transport := &embedTransport{hang: true} + app, _ := newTestAppWithTransport(t, transport) + + started := time.Now() + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedVideoURL) + require.NoError(t, err) + + assert.Less(t, time.Since(started), 5*time.Second) + assert.Equal(t, "<p>"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Contains(t, stderr, "couldn't embed "+embedVideoURL) +} + func TestCommentsCreateWithFormatHTMLEmbedsNothing(t *testing.T) { transport := &embedTransport{ creates: map[string]embedReply{embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}, diff --git a/internal/richtext/embeds.go b/internal/richtext/embeds.go index 818388c6f..762b179c9 100644 --- a/internal/richtext/embeds.go +++ b/internal/richtext/embeds.go @@ -60,7 +60,10 @@ func MarkdownToHTMLWithEmbeds(md string, embed EmbedFunc) string { return strings.TrimSpace(buf.String()) } -var reStandaloneURL = regexp.MustCompile(`^https?://[^\s<>"'\\]+$`) +var ( + reStandaloneURL = regexp.MustCompile(`^https?://[^\s<>"'\\]+$`) + reCharacterReference = regexp.MustCompile(`&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);`) +) func embedStandaloneURLs(doc ast.Node, source []byte, embed EmbedFunc) { paragraphs := topLevelParagraphs(doc) @@ -90,13 +93,22 @@ func embedStandaloneURLs(doc ast.Node, source []byte, embed EmbedFunc) { func topLevelParagraphs(doc ast.Node) []*ast.Paragraph { var paragraphs []*ast.Paragraph for child := doc.FirstChild(); child != nil; child = child.NextSibling() { - if p, ok := child.(*ast.Paragraph); ok { + if p, ok := child.(*ast.Paragraph); ok && !holdsInlineHTML(p) { paragraphs = append(paragraphs, p) } } return paragraphs } +func holdsInlineHTML(p *ast.Paragraph) bool { + for child := p.FirstChild(); child != nil; child = child.NextSibling() { + if _, ok := child.(*ast.RawHTML); ok { + return true + } + } + return false +} + func paragraphLines(p *ast.Paragraph) [][]ast.Node { var lines [][]ast.Node var line []ast.Node @@ -124,11 +136,7 @@ func standaloneURL(line []ast.Node, source []byte) string { if len(content) == 1 { if link, ok := content[0].(*ast.AutoLink); ok && link.AutoLinkType == ast.AutoLinkURL { - url := string(link.URL(source)) - if reStandaloneURL.MatchString(url) { - return url - } - return "" + return embeddableURL(string(link.URL(source))) } } @@ -140,8 +148,11 @@ func standaloneURL(line []ast.Node, source []byte) string { } raw = append(raw, t.Segment.Value(source)...) } - url := string(bytes.TrimSpace(raw)) - if reStandaloneURL.MatchString(url) { + return embeddableURL(string(bytes.TrimSpace(raw))) +} + +func embeddableURL(url string) string { + if reStandaloneURL.MatchString(url) && !reCharacterReference.MatchString(url) { return url } return "" diff --git a/internal/richtext/embeds_test.go b/internal/richtext/embeds_test.go index d3f5b9bc0..c63867193 100644 --- a/internal/richtext/embeds_test.go +++ b/internal/richtext/embeds_test.go @@ -106,6 +106,11 @@ func TestMarkdownToHTMLWithEmbedsLeavesEverythingButAURLAloneOnALine(t *testing. "in a table": "| a |\n|---|\n| " + videoURL + " |", "followed by text": videoURL + " is the one", "after a bc-attachment": `<bc-attachment sgid="x"></bc-attachment> ` + videoURL, + "inside inline HTML": "Intro <span>\n" + videoURL + "\n</span>", + "under inline HTML": "<b>Watch</b>\n" + videoURL, + "with an entity": "https://www.youtube.com/watch?v=x&t=1", + "with a number entity": "https://www.youtube.com/watch?v=x&t=1", + "autolinked entity": "<https://www.youtube.com/watch?v=x&t=1>", } { t.Run(name, func(t *testing.T) { var seen []string @@ -117,6 +122,13 @@ func TestMarkdownToHTMLWithEmbedsLeavesEverythingButAURLAloneOnALine(t *testing. } } +func TestMarkdownToHTMLWithEmbedsKeepsAQueryWithABareAmpersand(t *testing.T) { + var seen []string + MarkdownToHTMLWithEmbeds("https://www.youtube.com/watch?v=x&t=1", embedAll(&seen)) + + assert.Equal(t, []string{"https://www.youtube.com/watch?v=x&t=1"}, seen) +} + func TestMarkdownToHTMLWithEmbedsWithoutAnEmbedFuncIsMarkdownToHTML(t *testing.T) { md := "Intro\n" + videoURL + "\n\n- item" From d5f9b1fe2f0fd07665c8984c22e28f9a336493ae Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 11:49:11 -0400 Subject: [PATCH 03/14] End the embed step at a create failure too, and skip HTML nested in a paragraph A create failure stopped further creates but still polled the embeds created before it; now it ends the step. Inline HTML inside emphasis or a link also keeps its paragraph as written. --- internal/commands/embeds.go | 11 ++++++----- internal/commands/embeds_test.go | 19 +++++++++++++++++++ internal/richtext/embeds.go | 13 ++++++++----- internal/richtext/embeds_test.go | 1 + 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/internal/commands/embeds.go b/internal/commands/embeds.go index f106199c3..f9b67b62f 100644 --- a/internal/commands/embeds.go +++ b/internal/commands/embeds.go @@ -183,6 +183,7 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni defer cancel() var created []string + var failure error embeds := map[string]*basecamp.Embed{} for i, url := range urls { embed, err := client.Create(ctx, url) @@ -190,6 +191,7 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni if status := httpStatus(err); status == http.StatusUnprocessableEntity || status == http.StatusNotFound { continue } + failure = err fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, err) switch rest := len(urls) - i - 1; rest { case 0: @@ -204,8 +206,7 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni embeds[url] = embed } - var pollErr error - for pollErr == nil && anyPendingEmbed(created, embeds) && pauseForEmbed(ctx) { + for failure == nil && anyPendingEmbed(created, embeds) && pauseForEmbed(ctx) { for _, url := range created { if embeds[url].Status != "pending" { continue @@ -213,7 +214,7 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni refreshed, err := client.Get(ctx, embeds[url].SGID) if err != nil { if ctx.Err() == nil { - pollErr = err + failure = err } break } @@ -226,8 +227,8 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni switch embed := embeds[url]; { case embed.Status == "active": refs[url] = richtext.EmbedRef{SGID: embed.SGID, ContentType: embed.ContentType} - case embed.Status == "pending" && pollErr != nil: - fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, pollErr) + case embed.Status == "pending" && failure != nil: + fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, failure) case embed.Status == "pending": fmt.Fprintf(warnings, "Warning: %s took too long to answer, so it stays a link.\n", url) default: diff --git a/internal/commands/embeds_test.go b/internal/commands/embeds_test.go index bd4e19b0a..59861ccfa 100644 --- a/internal/commands/embeds_test.go +++ b/internal/commands/embeds_test.go @@ -275,6 +275,25 @@ func TestCommentsCreateStopsAskingForEmbedsAfterAFailure(t *testing.T) { assert.Contains(t, stderr, "the URL after it stays a link too") } +func TestCommentsCreateStopsPollingAfterAFailure(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusOK, embedJSON(embedChartURL, "pending", "sgid-chart")}, + embedVideoURL: {http.StatusInternalServerError, `{"error":"boom"}`}, + }, + gets: map[string][]embedReply{"sgid-chart": {{http.StatusOK, embedJSON(embedChartURL, "active", "sgid-chart")}}}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, []string{"create " + embedChartURL, "create " + embedVideoURL, "comment"}, transport.recordedCalls()) + assert.Equal(t, "<p>"+embedChartURL+"\n"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Contains(t, stderr, "couldn't embed "+embedChartURL) +} + func TestCommentsCreatePostsWhenAnEmbedRequestHangs(t *testing.T) { fastEmbedPolling(t, 50*time.Millisecond) transport := &embedTransport{hang: true} diff --git a/internal/richtext/embeds.go b/internal/richtext/embeds.go index 762b179c9..e76ce6610 100644 --- a/internal/richtext/embeds.go +++ b/internal/richtext/embeds.go @@ -101,12 +101,15 @@ func topLevelParagraphs(doc ast.Node) []*ast.Paragraph { } func holdsInlineHTML(p *ast.Paragraph) bool { - for child := p.FirstChild(); child != nil; child = child.NextSibling() { - if _, ok := child.(*ast.RawHTML); ok { - return true + found := false + _ = ast.Walk(p, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if _, ok := n.(*ast.RawHTML); ok && entering { + found = true + return ast.WalkStop, nil } - } - return false + return ast.WalkContinue, nil + }) + return found } func paragraphLines(p *ast.Paragraph) [][]ast.Node { diff --git a/internal/richtext/embeds_test.go b/internal/richtext/embeds_test.go index c63867193..f327effd8 100644 --- a/internal/richtext/embeds_test.go +++ b/internal/richtext/embeds_test.go @@ -108,6 +108,7 @@ func TestMarkdownToHTMLWithEmbedsLeavesEverythingButAURLAloneOnALine(t *testing. "after a bc-attachment": `<bc-attachment sgid="x"></bc-attachment> ` + videoURL, "inside inline HTML": "Intro <span>\n" + videoURL + "\n</span>", "under inline HTML": "<b>Watch</b>\n" + videoURL, + "under nested HTML": "**<span>Intro</span>**\n" + videoURL, "with an entity": "https://www.youtube.com/watch?v=x&t=1", "with a number entity": "https://www.youtube.com/watch?v=x&t=1", "autolinked entity": "<https://www.youtube.com/watch?v=x&t=1>", From c0f77877ae9c773d8edbf2d792bc5a0640a6f8c5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 11:53:05 -0400 Subject: [PATCH 04/14] Read a standalone URL's scheme in any case --- internal/richtext/embeds.go | 2 +- internal/richtext/embeds_test.go | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/richtext/embeds.go b/internal/richtext/embeds.go index e76ce6610..3c3a98411 100644 --- a/internal/richtext/embeds.go +++ b/internal/richtext/embeds.go @@ -61,7 +61,7 @@ func MarkdownToHTMLWithEmbeds(md string, embed EmbedFunc) string { } var ( - reStandaloneURL = regexp.MustCompile(`^https?://[^\s<>"'\\]+$`) + reStandaloneURL = regexp.MustCompile(`^(?i:https?)://[^\s<>"'\\]+$`) reCharacterReference = regexp.MustCompile(`&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);`) ) diff --git a/internal/richtext/embeds_test.go b/internal/richtext/embeds_test.go index f327effd8..bd81de70f 100644 --- a/internal/richtext/embeds_test.go +++ b/internal/richtext/embeds_test.go @@ -130,6 +130,13 @@ func TestMarkdownToHTMLWithEmbedsKeepsAQueryWithABareAmpersand(t *testing.T) { assert.Equal(t, []string{"https://www.youtube.com/watch?v=x&t=1"}, seen) } +func TestMarkdownToHTMLWithEmbedsReadsTheSchemeInAnyCase(t *testing.T) { + var seen []string + MarkdownToHTMLWithEmbeds("HTTPS://www.youtube.com/watch?v=x", embedAll(&seen)) + + assert.Equal(t, []string{"HTTPS://www.youtube.com/watch?v=x"}, seen) +} + func TestMarkdownToHTMLWithEmbedsWithoutAnEmbedFuncIsMarkdownToHTML(t *testing.T) { md := "Intro\n" + videoURL + "\n\n- item" From 7f1abc97294056d1aa13a90e16a5fdb5fa56f04b Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 12:16:22 -0400 Subject: [PATCH 05/14] Bump the SDK to treat the embed limit's 429 as a verdict CreateEmbed no longer retries on 429 and its hooks name the operation CreateEmbed, so a headerless 429 from the hourly new-embed limit sets no CLI-wide block that would hold back the post it was for. --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/mcpserver/model/behavior-model.json | 1 - internal/mcpserver/model/openapi.json | 9 ++++----- internal/resilience/hooks_test.go | 1 + internal/version/sdk-provenance.json | 6 +++--- 7 files changed, 13 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index b9f476276..b2c4997cc 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020150141-b0c6c2c9a84f + github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020160613-2dfe9bbb9cc7 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 8dc5e76b3..cfa17706f 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020150141-b0c6c2c9a84f h1:GV6HeQXyxZ119ZdvZ6mwNo6sVisyPM9G7WxGFvguWZc= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020150141-b0c6c2c9a84f/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020160613-2dfe9bbb9cc7 h1:gp+OAWHFXX+vw4iOAmWoIbEl+kI0o53e6RwEF+RJiaA= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020160613-2dfe9bbb9cc7/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= 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/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 32a099617..a2cde47f2 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "b0c6c2c9a84f36988c6a4fbeccac4f35964a85be", - "ref": "go/v0.26.0-1-gb0c6c2c9", + "commit": "2dfe9bbb9cc77a05a402fb03eb963e9a2b624c78", + "ref": "go/v0.26.0-2-g2dfe9bbb", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index d6f1fa369..a8bc4d3b9 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -181,7 +181,6 @@ "base_delay_ms": 1000, "backoff": "exponential", "retry_on": [ - 429, 503 ] } diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index e230b214e..6169b4a06 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -9016,7 +9016,7 @@ }, "/{accountId}/embeds.json": { "post": { - "description": "Create an embed for a URL, or return the one Basecamp already has for it.\n\nAnswers `201 Created` with a new embed, whose status is `pending`, and\n`200 OK` with an existing embed in any status. Get the embed about once a\nsecond until its status is `active` or `not_found`.\n\nAnswers `422` (`{\"error\": \"Basecamp can't embed this URL. Link to it\ninstead.\"}`) for a URL no provider embeds, and `429` when the caller has\ncreated too many new embeds in the last hour.", + "description": "Create an embed for a URL, or return the one Basecamp already has for it.\n\nAnswers `200 OK` with the embed either way. A new embed's status is\n`pending`; get it about once a second until its status is `active` or\n`not_found`.\n\nAnswers `422` (`{\"error\": \"Basecamp can't embed this URL. Link to it\ninstead.\"}`) for a URL no provider embeds. Past 100 new embeds in an hour,\ncreating another answers `429` (`{\"error\": \"Too many new embeds. Try again\nlater.\"}`) with no Retry-After; asking for an embed Basecamp already has\nstill works. That 429 is a verdict for the rest of the hour, not\nthrottling, so this operation declares `retryOn: [503]` and the 429\nsurfaces on the first attempt.", "operationId": "CreateEmbed", "requestBody": { "content": { @@ -9042,8 +9042,8 @@ } ], "responses": { - "201": { - "description": "CreateEmbed 201 response", + "200": { + "description": "CreateEmbed 200 response", "content": { "application/json": { "schema": { @@ -9124,7 +9124,6 @@ "baseDelayMs": 1000, "backoff": "exponential", "retryOn": [ - 429, 503 ] } @@ -15516,7 +15515,7 @@ }, "/{accountId}/projects/{projectId}/people/client_users.json": { "put": { - "description": "Update project client access (grant/revoke/create client users)\n\nThe client-side counterpart to UpdateProjectAccess: `grant` adds existing\nclient users by id, `revoke` removes client users, and `create` invites\nbrand-new clients by email (`name` optional, defaulting to the address).\nOnly client users are eligible \u2014 a `grant` id belonging to a team member is\nrejected (omitted from `granted`) rather than cross-graded, and `revoke` never removes a team\nmember. The response mirrors UpdateProjectAccess: `granted` and `revoked`\npeople, each with `client: true`.\n\nRequires clients to be enabled on the project (EnableProjectClients);\notherwise 403. Invitations are all-or-nothing: an invalid `create` row\n(including one with no email address) answers 422 with the rejected\naddresses and nobody is invited; new addresses that would exceed the\naccount's user limit answer 429 and nobody is invited. Addresses already on\nthe account take no seat and a repeated address counts once.\n\nThe seat-limit 429 is a verdict, not throttling: it carries no Retry-After\nand re-asking cannot change the answer, so this operation declares\n`retryOn: [503]` and a 429 surfaces on the first attempt (status-mapped to\n`rate_limit`, since the wire status is the only signal). Every other\noperation retries on 429.", + "description": "Update project client access (grant/revoke/create client users)\n\nThe client-side counterpart to UpdateProjectAccess: `grant` adds existing\nclient users by id, `revoke` removes client users, and `create` invites\nbrand-new clients by email (`name` optional, defaulting to the address).\nOnly client users are eligible \u2014 a `grant` id belonging to a team member is\nrejected (omitted from `granted`) rather than cross-graded, and `revoke` never removes a team\nmember. The response mirrors UpdateProjectAccess: `granted` and `revoked`\npeople, each with `client: true`.\n\nRequires clients to be enabled on the project (EnableProjectClients);\notherwise 403. Invitations are all-or-nothing: an invalid `create` row\n(including one with no email address) answers 422 with the rejected\naddresses and nobody is invited; new addresses that would exceed the\naccount's user limit answer 429 and nobody is invited. Addresses already on\nthe account take no seat and a repeated address counts once.\n\nThe seat-limit 429 is a verdict, not throttling: it carries no Retry-After\nand re-asking cannot change the answer, so this operation declares\n`retryOn: [503]` and a 429 surfaces on the first attempt (status-mapped to\n`rate_limit`, since the wire status is the only signal). Every other\noperation but CreateEmbed retries on 429.", "operationId": "UpdateProjectClientAccess", "requestBody": { "content": { diff --git a/internal/resilience/hooks_test.go b/internal/resilience/hooks_test.go index 5255c1f1d..105633a89 100644 --- a/internal/resilience/hooks_test.go +++ b/internal/resilience/hooks_test.go @@ -278,6 +278,7 @@ func TestGatingHooksHeaderless429BlocksUnlessTheOperationTreatsItAsAVerdict(t *t assert.Greater(t, block("UpdateProjectAccess"), 50*time.Second, "throttling 429 keeps the default block") assert.Greater(t, block(""), 50*time.Second, "an unnamed operation keeps the default block") assert.Zero(t, block("UpdateProjectClientAccess"), "a seat-limit verdict sets no block") + assert.Zero(t, block("CreateEmbed"), "the hourly new-embed limit sets no block") } // A 429 that names a Retry-After is honored regardless of the operation. diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 50ae1d97d..888ca2e7b 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.26.1-0.20261020150141-b0c6c2c9a84f", - "revision": "b0c6c2c9a84f", - "updated_at": "2026-10-20T15:01:41Z" + "version": "v0.26.1-0.20261020160613-2dfe9bbb9cc7", + "revision": "2dfe9bbb9cc7", + "updated_at": "2026-10-20T16:06:13Z" }, "api": { "repo": "basecamp/bc3", From 77f2a096fcbaeb7c5c5f48d82896598448d11e80 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 12:40:17 -0400 Subject: [PATCH 06/14] Update the Nix vendorHash for the SDK bump --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index b47f10d95..74e291a3b 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-l/kddGGXlnJXenhyAj5xRpEy0bJA+gTFFNreq+KY4kA="; + vendorHash = "sha256-jXAw3axxg8msaQOgL6THmBvvQ2KTg0SGZitF71CBXPA="; subPackages = [ "cmd/basecamp" ]; From 5343f4b45dbf6cc9136c32320313f361291cb42b Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 12:51:29 -0400 Subject: [PATCH 07/14] Pin the SDK at its Embeds pull request's head --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/version/sdk-provenance.json | 6 +++--- nix/package.nix | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index b2c4997cc..3e5eec82d 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020160613-2dfe9bbb9cc7 + github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020164227-17895880165c 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 cfa17706f..8ed7e68e4 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020160613-2dfe9bbb9cc7 h1:gp+OAWHFXX+vw4iOAmWoIbEl+kI0o53e6RwEF+RJiaA= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020160613-2dfe9bbb9cc7/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020164227-17895880165c h1:R/216e6O4QbeUBJ7yZLdCDXR6+1dFT1I0kXJBUN4Bko= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020164227-17895880165c/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= 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/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index a2cde47f2..322d82855 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "2dfe9bbb9cc77a05a402fb03eb963e9a2b624c78", - "ref": "go/v0.26.0-2-g2dfe9bbb", + "commit": "17895880165ce3f2aa6cbeeb898299fd322f3bd2", + "ref": "go/v0.26.0-3-g17895880", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 888ca2e7b..dd0ffcbbf 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.26.1-0.20261020160613-2dfe9bbb9cc7", - "revision": "2dfe9bbb9cc7", - "updated_at": "2026-10-20T16:06:13Z" + "version": "v0.26.1-0.20261020164227-17895880165c", + "revision": "17895880165c", + "updated_at": "2026-10-20T16:42:27Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 74e291a3b..e3e6de45f 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-jXAw3axxg8msaQOgL6THmBvvQ2KTg0SGZitF71CBXPA="; + vendorHash = "sha256-A0LiHAupH4dZRZtZAMTMO/p6leKky7kqtMbegW1f3VA="; subPackages = [ "cmd/basecamp" ]; From 310d98454803928cbbae613ae142151bc3804776 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 13:01:23 -0400 Subject: [PATCH 08/14] Pin the SDK at its Embeds pull request's new head --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/version/sdk-provenance.json | 6 +++--- nix/package.nix | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 3e5eec82d..4a0fbc2b1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020164227-17895880165c + github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020165412-ac3217c1b6cf 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 8ed7e68e4..20d64aac0 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020164227-17895880165c h1:R/216e6O4QbeUBJ7yZLdCDXR6+1dFT1I0kXJBUN4Bko= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020164227-17895880165c/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020165412-ac3217c1b6cf h1:pf8z4SVv1GfwTRus6gNgKh/h2fg74ONx2HmOcn3T8XA= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020165412-ac3217c1b6cf/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= 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/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 322d82855..a11c23275 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "17895880165ce3f2aa6cbeeb898299fd322f3bd2", - "ref": "go/v0.26.0-3-g17895880", + "commit": "ac3217c1b6cf96a9843726cfe175de4b3879557e", + "ref": "go/v0.26.0-4-gac3217c1", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index dd0ffcbbf..b9887909d 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.26.1-0.20261020164227-17895880165c", - "revision": "17895880165c", - "updated_at": "2026-10-20T16:42:27Z" + "version": "v0.26.1-0.20261020165412-ac3217c1b6cf", + "revision": "ac3217c1b6cf", + "updated_at": "2026-10-20T16:54:12Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index e3e6de45f..aea40d6d0 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-A0LiHAupH4dZRZtZAMTMO/p6leKky7kqtMbegW1f3VA="; + vendorHash = "sha256-fn0qImOinKzSmY/YX/BBqx0WtJvSOnvB8cDiGjri7TY="; subPackages = [ "cmd/basecamp" ]; From 892fe00e6dd8ed701155ecc0172e35005948d48e Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 13:03:04 -0400 Subject: [PATCH 09/14] Sanitize embed warnings, and say the attachment tag is in --json URLs and server error text in the embed warnings go through SanitizeSingleLine, so a control character or a newline can't restyle the terminal or forge a line. The styled renderer turns any HTML into a placeholder, so the help says the attachment tag comes in --json's attachment field. --- internal/commands/embeds.go | 25 +++++++++++++++---------- internal/commands/embeds_test.go | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/internal/commands/embeds.go b/internal/commands/embeds.go index f9b67b62f..40b0d88bd 100644 --- a/internal/commands/embeds.go +++ b/internal/commands/embeds.go @@ -44,9 +44,9 @@ With Markdown content (the default), you don't need this command: put the URL on a line by itself and Basecamp embeds it, as the editor does when you paste a link. Write [title](url) to keep a link instead. -Use 'create' when you write HTML (--format html): it prints the -<bc-attachment> tag to put in your content.`, - Annotations: map[string]string{"agent_notes": "A URL alone on its line in Markdown content becomes an embed automatically\nWrite [title](url) to keep it a link\nURLs Basecamp can't embed stay links, with no warning\nWith --format html, use embeds create and paste its attachment tag"}, +Use 'create --json' when you write HTML (--format html): its attachment +field is the <bc-attachment> tag to put in your content.`, + Annotations: map[string]string{"agent_notes": "A URL alone on its line in Markdown content becomes an embed automatically\nWrite [title](url) to keep it a link\nURLs Basecamp can't embed stay links, with no warning\nWith --format html, use embeds create --json and paste its attachment field"}, } cmd.AddCommand( @@ -63,7 +63,8 @@ func newEmbedsCreateCmd() *cobra.Command { Short: "Create an embed for a URL", Long: `Create an embed for a URL and wait until Basecamp has it from the provider. -Prints the embed, with the <bc-attachment> tag to put in HTML rich text. +With --json, the embed's attachment field is the <bc-attachment> tag to put +in HTML rich text. A URL no provider embeds is refused; link to it instead.`, Example: ` basecamp embeds create https://www.youtube.com/watch?v=dQw4w9WgXcQ basecamp embeds create https://dash.37signals.com/widgets/abc --json`, @@ -192,13 +193,13 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni continue } failure = err - fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, err) + warnEmbed(warnings, "Warning: couldn't embed %s, so it stays a link: %v", url, err) switch rest := len(urls) - i - 1; rest { case 0: case 1: - fmt.Fprintln(warnings, "Warning: the URL after it stays a link too.") + warnEmbed(warnings, "Warning: the URL after it stays a link too.") default: - fmt.Fprintf(warnings, "Warning: the %d URLs after it stay links too.\n", rest) + warnEmbed(warnings, "Warning: the %d URLs after it stay links too.", rest) } break } @@ -228,16 +229,20 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni case embed.Status == "active": refs[url] = richtext.EmbedRef{SGID: embed.SGID, ContentType: embed.ContentType} case embed.Status == "pending" && failure != nil: - fmt.Fprintf(warnings, "Warning: couldn't embed %s, so it stays a link: %v\n", url, failure) + warnEmbed(warnings, "Warning: couldn't embed %s, so it stays a link: %v", url, failure) case embed.Status == "pending": - fmt.Fprintf(warnings, "Warning: %s took too long to answer, so it stays a link.\n", url) + warnEmbed(warnings, "Warning: %s took too long to answer, so it stays a link.", url) default: - fmt.Fprintf(warnings, "Warning: Basecamp found nothing to embed at %s, so it stays a link.\n", url) + warnEmbed(warnings, "Warning: Basecamp found nothing to embed at %s, so it stays a link.", url) } } return refs } +func warnEmbed(warnings io.Writer, format string, args ...any) { + fmt.Fprintln(warnings, richtext.SanitizeSingleLine(fmt.Sprintf(format, args...))) +} + func anyPendingEmbed(urls []string, embeds map[string]*basecamp.Embed) bool { for _, url := range urls { if embeds[url].Status == "pending" { diff --git a/internal/commands/embeds_test.go b/internal/commands/embeds_test.go index 59861ccfa..7ebd5a605 100644 --- a/internal/commands/embeds_test.go +++ b/internal/commands/embeds_test.go @@ -294,6 +294,20 @@ func TestCommentsCreateStopsPollingAfterAFailure(t *testing.T) { assert.Contains(t, stderr, "couldn't embed "+embedChartURL) } +func TestCommentsCreateWarnsOnOneCleanLine(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusInternalServerError, `{"error":"boom\u001b[31m\nWarning: forged"}`}}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedVideoURL) + require.NoError(t, err) + + assert.NotContains(t, stderr, "\x1b") + assert.Equal(t, 1, strings.Count(stderr, "\n"), stderr) + assert.Contains(t, stderr, "couldn't embed "+embedVideoURL) +} + func TestCommentsCreatePostsWhenAnEmbedRequestHangs(t *testing.T) { fastEmbedPolling(t, 50*time.Millisecond) transport := &embedTransport{hang: true} From 56b6a0cb42025f80846d3ab2e2f6b4d84ae531e9 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 13:35:44 -0400 Subject: [PATCH 10/14] Pin the SDK at its Embeds pull request's converged head --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/version/sdk-provenance.json | 6 +++--- nix/package.nix | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 4a0fbc2b1..c09c836d4 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020165412-ac3217c1b6cf + github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020171155-8281ed5ea3bb 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 20d64aac0..8413673ee 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020165412-ac3217c1b6cf h1:pf8z4SVv1GfwTRus6gNgKh/h2fg74ONx2HmOcn3T8XA= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020165412-ac3217c1b6cf/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020171155-8281ed5ea3bb h1:VBYP1NFfRDMgMgiMtf+MFlRlCDk7pHsDRQau7zIVjRw= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020171155-8281ed5ea3bb/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= 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/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index a11c23275..55fd0c259 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "ac3217c1b6cf96a9843726cfe175de4b3879557e", - "ref": "go/v0.26.0-4-gac3217c1", + "commit": "8281ed5ea3bb27106a0702cfd3cec24c4a577381", + "ref": "go/v0.26.0-5-g8281ed5e", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index b9887909d..36a18ccde 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.26.1-0.20261020165412-ac3217c1b6cf", - "revision": "ac3217c1b6cf", - "updated_at": "2026-10-20T16:54:12Z" + "version": "v0.26.1-0.20261020171155-8281ed5ea3bb", + "revision": "8281ed5ea3bb", + "updated_at": "2026-10-20T17:11:55Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index aea40d6d0..3675012cb 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-fn0qImOinKzSmY/YX/BBqx0WtJvSOnvB8cDiGjri7TY="; + vendorHash = "sha256-4cmrE7XV9ya7PqYGSOVgxDbPuPPn+Bz0gOTjMmdTwZA="; subPackages = [ "cmd/basecamp" ]; From 5829b5c17e8fb57a3714c02b91a1933b471dcea9 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 13:48:17 -0400 Subject: [PATCH 11/14] Stop asking for embeds quietly where Basecamp doesn't serve them A 404 from create means the endpoint isn't there, as before the server change deploys, so asking again for each URL only spends the budget. --- internal/commands/embeds.go | 8 ++++++-- internal/commands/embeds_test.go | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/commands/embeds.go b/internal/commands/embeds.go index 40b0d88bd..628598c1c 100644 --- a/internal/commands/embeds.go +++ b/internal/commands/embeds.go @@ -186,11 +186,15 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni var created []string var failure error embeds := map[string]*basecamp.Embed{} +creating: for i, url := range urls { embed, err := client.Create(ctx, url) if err != nil { - if status := httpStatus(err); status == http.StatusUnprocessableEntity || status == http.StatusNotFound { + switch httpStatus(err) { + case http.StatusUnprocessableEntity: continue + case http.StatusNotFound: + break creating } failure = err warnEmbed(warnings, "Warning: couldn't embed %s, so it stays a link: %v", url, err) @@ -201,7 +205,7 @@ func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warni default: warnEmbed(warnings, "Warning: the %d URLs after it stay links too.", rest) } - break + break creating } created = append(created, url) embeds[url] = embed diff --git a/internal/commands/embeds_test.go b/internal/commands/embeds_test.go index 7ebd5a605..a4f21c1b4 100644 --- a/internal/commands/embeds_test.go +++ b/internal/commands/embeds_test.go @@ -190,6 +190,23 @@ func TestCommentsCreateLeavesAURLBasecampCanNotEmbedAsALinkQuietly(t *testing.T) assert.Empty(t, stderr) } +func TestCommentsCreateStopsQuietlyWhereBasecampHasNoEmbeds(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusNotFound, `{"error":"not found"}`}, + embedVideoURL: {http.StatusNotFound, `{"error":"not found"}`}, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, []string{"create " + embedChartURL, "comment"}, transport.recordedCalls()) + assert.Equal(t, "<p>"+embedChartURL+"\n"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Empty(t, stderr) +} + func TestCommentsCreateLeavesALinkWhenTheEmbedFails(t *testing.T) { for name, tc := range map[string]struct { create embedReply From 65a87c879e8879941b109dfea6870e0b6dc6252a Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 20 Oct 2026 13:59:19 -0400 Subject: [PATCH 12/14] Run the embeds smoke test, and accept a URL-only body in its eval make smoke lists its files, and smoke_embeds.bats wasn't among them. The eval also takes the chart URL as the whole positional body, which the CLI embeds the same way. --- e2e/smoke/run_smoke.sh | 1 + skill-evals/cases/create-card-embed.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/smoke/run_smoke.sh b/e2e/smoke/run_smoke.sh index ab624bcac..99aff3a23 100755 --- a/e2e/smoke/run_smoke.sh +++ b/e2e/smoke/run_smoke.sh @@ -101,6 +101,7 @@ level1=( "$SMOKE_DIR"/smoke_assign.bats "$SMOKE_DIR"/smoke_lineup.bats "$SMOKE_DIR"/smoke_communication_write.bats + "$SMOKE_DIR"/smoke_embeds.bats "$SMOKE_DIR"/smoke_misc_write.bats "$SMOKE_DIR"/smoke_tools.bats "$SMOKE_DIR"/smoke_cards_column_write.bats diff --git a/skill-evals/cases/create-card-embed.yml b/skill-evals/cases/create-card-embed.yml index d6c704202..f967f2aa4 100644 --- a/skill-evals/cases/create-card-embed.yml +++ b/skill-evals/cases/create-card-embed.yml @@ -3,7 +3,7 @@ tags: [domain, create] accept: - 'cards? create .*Checkout latency' - '--(in|project) 55555' - - '(?m)^https://dash\.37signals\.com/widgets/abc\s*$' + - '(?m)(^|["''])https://dash\.37signals\.com/widgets/abc\s*(["'']|$)' reject: - '\[[^\]]*\]\(https://dash\.37signals\.com/widgets/abc\)' - '--format html' From 93b2e72348745e8ee060314f87eab101ceb9017e Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Wed, 21 Oct 2026 11:37:29 -0400 Subject: [PATCH 13/14] Pin the SDK at its Embeds pull request's rebased head --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/version/sdk-provenance.json | 6 +++--- nix/package.nix | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c09c836d4..8ff312e33 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020171155-8281ed5ea3bb + github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261021152245-f951b8276143 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 8413673ee..8b33b3111 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020171155-8281ed5ea3bb h1:VBYP1NFfRDMgMgiMtf+MFlRlCDk7pHsDRQau7zIVjRw= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261020171155-8281ed5ea3bb/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261021152245-f951b8276143 h1:qcLBKOsKTe/isLfSJvzoH5zEl9trR+qV0OY/pkvpB38= +github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261021152245-f951b8276143/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= 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/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 55fd0c259..c25697172 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "8281ed5ea3bb27106a0702cfd3cec24c4a577381", - "ref": "go/v0.26.0-5-g8281ed5e", + "commit": "f951b827614364325baee8ac841255d0067f6894", + "ref": "go/v0.26.0-6-gf951b827", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 36a18ccde..8bea108e9 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.26.1-0.20261020171155-8281ed5ea3bb", - "revision": "8281ed5ea3bb", - "updated_at": "2026-10-20T17:11:55Z" + "version": "v0.26.1-0.20261021152245-f951b8276143", + "revision": "f951b8276143", + "updated_at": "2026-10-21T15:22:45Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 3675012cb..fd2aeb844 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-4cmrE7XV9ya7PqYGSOVgxDbPuPPn+Bz0gOTjMmdTwZA="; + vendorHash = "sha256-KjsHYUHEYs/NSd31fjrjcjEt1uo8GereCWNyKHKSHSA="; subPackages = [ "cmd/basecamp" ]; From a2411c5df72007127d47c8c58c535149c77ee891 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Wed, 21 Oct 2026 12:57:44 -0400 Subject: [PATCH 14/14] Pin the SDK at its v0.27.0 release --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/version/sdk-provenance.json | 6 +++--- nix/package.nix | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 8ff312e33..97018b094 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261021152245-f951b8276143 + github.com/basecamp/basecamp-sdk/go v0.27.0 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 8b33b3111..62ea780c7 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261021152245-f951b8276143 h1:qcLBKOsKTe/isLfSJvzoH5zEl9trR+qV0OY/pkvpB38= -github.com/basecamp/basecamp-sdk/go v0.26.1-0.20261021152245-f951b8276143/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.27.0 h1:Shq2lyAMBDvvBtmVLZZes42UyKkFMIvpR1Pa8xyWOVo= +github.com/basecamp/basecamp-sdk/go v0.27.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/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/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index c25697172..03c32f4a5 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "f951b827614364325baee8ac841255d0067f6894", - "ref": "go/v0.26.0-6-gf951b827", + "commit": "5e6961286780c6d0d8b2335fa1364d8e16d1b585", + "ref": "v0.27.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 8bea108e9..10525c72d 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.26.1-0.20261021152245-f951b8276143", - "revision": "f951b8276143", - "updated_at": "2026-10-21T15:22:45Z" + "version": "v0.27.0", + "revision": "5e6961286780", + "updated_at": "2026-10-21T16:35:15Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index fd2aeb844..d143cbff0 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-KjsHYUHEYs/NSd31fjrjcjEt1uo8GereCWNyKHKSHSA="; + vendorHash = "sha256-IeUWPtO52gtx8YwSwMXUWWyMMGC1NwoRurcNafM2ZSo="; subPackages = [ "cmd/basecamp" ];