From a0dd5095015660a53a9d9a289807ba4e76577433 Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Mon, 4 May 2026 18:16:42 +0800 Subject: [PATCH] feat(chat): add chat update command for editing campfire messages Adds 'basecamp chat update [content]' for editing existing campfire lines in place via PUT /chats/{c}/lines/{l}. Mirrors 'chat post' shape: positional content or --content flag, --content-type for HTML, @mention resolution with auto-promotion to text/html. Backed by basecamp-sdk CampfiresService.UpdateLine. While the SDK addition is in flight, go.mod has a temporary replace pinning to a fork branch carrying just that operation; the replace is dropped once an SDK release with UpdateLine ships. Wires up surface snapshot, smoke + e2e coverage, and the basecamp skill documentation. --- .surface | 56 ++++++++ API-COVERAGE.md | 4 +- e2e/chat.bats | 9 ++ e2e/smoke/smoke_campfire.bats | 22 ++++ go.mod | 2 + go.sum | 4 +- internal/commands/chat.go | 190 +++++++++++++++++++++++++++ internal/commands/chat_test.go | 155 ++++++++++++++++++++++ internal/commands/commands.go | 2 +- internal/version/sdk-provenance.json | 6 +- skills/basecamp/SKILL.md | 1 + 11 files changed, 443 insertions(+), 8 deletions(-) diff --git a/.surface b/.surface index 372852d3..e1dd0fb7 100644 --- a/.surface +++ b/.surface @@ -30,6 +30,8 @@ ARG basecamp campfire delete 00 ARG basecamp campfire line 00 ARG basecamp campfire post 00 ARG basecamp campfire show 00 +ARG basecamp campfire update 00 +ARG basecamp campfire update 01 [content] ARG basecamp campfire upload 00 ARG basecamp card 00 ARG basecamp card 01 [body] @@ -66,6 +68,8 @@ ARG basecamp chat delete 00 <id|url> ARG basecamp chat line 00 <id|url> ARG basecamp chat post 00 <message> ARG basecamp chat show 00 <id|url> +ARG basecamp chat update 00 <id|url> +ARG basecamp chat update 01 [content] ARG basecamp chat upload 00 <file> ARG basecamp checkin answer 00 <id|url> ARG basecamp checkin answer create 00 <question-id> @@ -479,6 +483,7 @@ CMD basecamp campfire list CMD basecamp campfire messages CMD basecamp campfire post CMD basecamp campfire show +CMD basecamp campfire update CMD basecamp campfire upload CMD basecamp card CMD basecamp card move @@ -520,6 +525,7 @@ CMD basecamp chat list CMD basecamp chat messages CMD basecamp chat post CMD basecamp chat show +CMD basecamp chat update CMD basecamp chat upload CMD basecamp checkin CMD basecamp checkin answer @@ -2363,6 +2369,30 @@ FLAG basecamp campfire show --stats type=bool FLAG basecamp campfire show --styled type=bool FLAG basecamp campfire show --todolist type=string FLAG basecamp campfire show --verbose type=count +FLAG basecamp campfire update --account type=string +FLAG basecamp campfire update --agent type=bool +FLAG basecamp campfire update --cache-dir type=string +FLAG basecamp campfire update --content type=string +FLAG basecamp campfire update --content-type type=string +FLAG basecamp campfire update --count type=bool +FLAG basecamp campfire update --help type=bool +FLAG basecamp campfire update --hints type=bool +FLAG basecamp campfire update --ids-only type=bool +FLAG basecamp campfire update --in type=string +FLAG basecamp campfire update --jq type=string +FLAG basecamp campfire update --json type=bool +FLAG basecamp campfire update --markdown type=bool +FLAG basecamp campfire update --md type=bool +FLAG basecamp campfire update --no-hints type=bool +FLAG basecamp campfire update --no-stats type=bool +FLAG basecamp campfire update --profile type=string +FLAG basecamp campfire update --project type=string +FLAG basecamp campfire update --quiet type=bool +FLAG basecamp campfire update --room type=string +FLAG basecamp campfire update --stats type=bool +FLAG basecamp campfire update --styled type=bool +FLAG basecamp campfire update --todolist type=string +FLAG basecamp campfire update --verbose type=count FLAG basecamp campfire upload --account type=string FLAG basecamp campfire upload --agent type=bool FLAG basecamp campfire upload --cache-dir type=string @@ -3336,6 +3366,30 @@ FLAG basecamp chat show --stats type=bool FLAG basecamp chat show --styled type=bool FLAG basecamp chat show --todolist type=string FLAG basecamp chat show --verbose type=count +FLAG basecamp chat update --account type=string +FLAG basecamp chat update --agent type=bool +FLAG basecamp chat update --cache-dir type=string +FLAG basecamp chat update --content type=string +FLAG basecamp chat update --content-type type=string +FLAG basecamp chat update --count type=bool +FLAG basecamp chat update --help type=bool +FLAG basecamp chat update --hints type=bool +FLAG basecamp chat update --ids-only type=bool +FLAG basecamp chat update --in type=string +FLAG basecamp chat update --jq type=string +FLAG basecamp chat update --json type=bool +FLAG basecamp chat update --markdown type=bool +FLAG basecamp chat update --md type=bool +FLAG basecamp chat update --no-hints type=bool +FLAG basecamp chat update --no-stats type=bool +FLAG basecamp chat update --profile type=string +FLAG basecamp chat update --project type=string +FLAG basecamp chat update --quiet type=bool +FLAG basecamp chat update --room type=string +FLAG basecamp chat update --stats type=bool +FLAG basecamp chat update --styled type=bool +FLAG basecamp chat update --todolist type=string +FLAG basecamp chat update --verbose type=count FLAG basecamp chat upload --account type=string FLAG basecamp chat upload --agent type=bool FLAG basecamp chat upload --cache-dir type=string @@ -16266,6 +16320,7 @@ SUB basecamp campfire list SUB basecamp campfire messages SUB basecamp campfire post SUB basecamp campfire show +SUB basecamp campfire update SUB basecamp campfire upload SUB basecamp card SUB basecamp card move @@ -16307,6 +16362,7 @@ SUB basecamp chat list SUB basecamp chat messages SUB basecamp chat post SUB basecamp chat show +SUB basecamp chat update SUB basecamp chat upload SUB basecamp checkin SUB basecamp checkin answer diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 82f0d5a0..00bf0f54 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -16,7 +16,7 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif > Note: the per-row `Endpoints` column in the Coverage by Section table sums higher than the Summary totals above. The discrepancy predates the BC5 baseline; the row count (46 sections) is authoritative for the `Since` column. Reconciling endpoint counts is pre-existing maintenance, tracked separately. -**SDK version:** v0.7.3 — maintenance bump: API date advanced to 2026-03-23, transitive dependency updates. No new services or methods. +**SDK version:** v0.7.3 + pending UpdateCampfireLine (basecamp/basecamp-sdk#295). On merge of the SDK PR + next tagged release, this header will move to that version and the temporary fork pin in `go.mod` will be dropped. ## Coverage by Section @@ -37,7 +37,7 @@ The **Since** column tags each row with the Basecamp version that introduced its | messages | 10 | `messages`, `message` | ✅ | BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active | | message_boards | 3 | `messageboards` | ✅ | BC4 | - | Container, accessed via project dock | | message_types | 5 | `messagetypes` | ✅ | BC4 | - | list, show, create, update, delete | -| campfires | 14 | `chat` | ✅ | BC4 | - | list, messages, post, line show/delete. @mentions in content | +| campfires | 14 | `chat` | ✅ | BC4 | - | list, messages, post, line show/update/delete. @mentions in content | | comments | 8 | `comment`, `comments` | ✅ | BC4 | - | list, show, create, update. @mentions in content | | boosts | 6 | `boost`, `react` | ✅ | BC4 | - | list (recording + event), show, create (recording + event), delete | | notifications | 2 | `notifications` | ✅ | BC4 | - | list, mark as read | diff --git a/e2e/chat.bats b/e2e/chat.bats index ace7a0b0..9fa272d7 100644 --- a/e2e/chat.bats +++ b/e2e/chat.bats @@ -95,6 +95,15 @@ load test_helper assert_output_contains "ID required" } +@test "chat update without args shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp chat update + assert_failure + assert_output_contains "required" +} + # Help flag diff --git a/e2e/smoke/smoke_campfire.bats b/e2e/smoke/smoke_campfire.bats index 50891387..db0a24a5 100644 --- a/e2e/smoke/smoke_campfire.bats +++ b/e2e/smoke/smoke_campfire.bats @@ -44,6 +44,28 @@ setup_file() { assert_json_not_null '.data.id' } +@test "campfire update edits a message" { + local id_file="$BATS_FILE_TMPDIR/campfire_line_id" + [[ -f "$id_file" ]] || mark_unverifiable "No campfire line created in prior test" + local line_id new_content + line_id=$(<"$id_file") + new_content="Edited smoke test $(date +%s)" + + run_smoke basecamp campfire update "$line_id" "$new_content" \ + --room "$QA_CAMPFIRE" -p "$QA_PROJECT" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_not_null '.data.id' + + # Re-fetch the line and verify its content actually changed (guards against + # a no-op update silently passing). + run_smoke basecamp campfire line "$line_id" \ + --room "$QA_CAMPFIRE" -p "$QA_PROJECT" --json + assert_success + echo "$output" | jq -e --arg expected "$new_content" '.data.content | contains($expected)' >/dev/null \ + || fail "expected updated line content to contain '$new_content', got: $(echo "$output" | jq -r '.data.content')" +} + @test "campfire delete deletes a message" { local id_file="$BATS_FILE_TMPDIR/campfire_line_id" [[ -f "$id_file" ]] || mark_unverifiable "No campfire line created in prior test" diff --git a/go.mod b/go.mod index 7dcd8bf2..07c26806 100644 --- a/go.mod +++ b/go.mod @@ -76,3 +76,5 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/term v0.38.0 // indirect ) + +replace github.com/basecamp/basecamp-sdk/go => github.com/nnemirovsky/basecamp-sdk/go v0.5.1-0.20260504111141-62d09023dc73 diff --git a/go.sum b/go.sum index 18f0082c..396d1251 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,6 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260423230153-f54589f0924a h1:TPVDkxRbdon4oxEYycnTV1Aslz2ZyMqgPiPUutNc+cg= -github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260423230153-f54589f0924a/go.mod h1:g53B/9z0VNYo217NrAf4zuEDc2yNolFBa09C3vSHbUI= github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ= github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -129,6 +127,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nnemirovsky/basecamp-sdk/go v0.5.1-0.20260504111141-62d09023dc73 h1:uNGFi10azpdkC0/Vdhj0gWJPwd0xlTyX81yuXQo/Dxg= +github.com/nnemirovsky/basecamp-sdk/go v0.5.1-0.20260504111141-62d09023dc73/go.mod h1:g53B/9z0VNYo217NrAf4zuEDc2yNolFBa09C3vSHbUI= github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/commands/chat.go b/internal/commands/chat.go index 206536e2..12ee99be 100644 --- a/internal/commands/chat.go +++ b/internal/commands/chat.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "slices" "strconv" "strings" @@ -15,8 +16,29 @@ import ( "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/richtext" "github.com/basecamp/basecamp-cli/internal/tui" + "github.com/basecamp/basecamp-cli/internal/urlarg" ) +// chatLineURLRe captures the chat (campfire) ID and line ID from a Basecamp +// chat-line URL. The host is locked to the public Basecamp 3 domains so a +// look-alike URL on another host can't slip through. +// +// https://3.basecamp.com/{account}/buckets/{bucket}/chats/{chatID}/lines/{lineID} +// https://3.basecamp.com/{account}/buckets/{bucket}/chats/{chatID}@{lineID} +// (also matches the `3.basecampapi.com` variant returned by the API) +var chatLineURLRe = regexp.MustCompile(`^https?://3\.basecamp(?:api)?\.com/\d+/buckets/\d+/chats/(\d+)(?:/lines/|@)(\d+)`) + +// extractChatLineFromURL pulls the chat (campfire) ID from a chat-line URL +// when present. Returns ("", "") if arg is not a chat-line URL — callers fall +// back to --room and the project's default chat in that case. +func extractChatLineFromURL(arg string) (chatID, lineID string) { + m := chatLineURLRe.FindStringSubmatch(arg) + if m == nil { + return "", "" + } + return m[1], m[2] +} + // NewChatCmd creates the chat command for real-time chat. func NewChatCmd() *cobra.Command { var project string @@ -44,6 +66,7 @@ Use 'basecamp chat post "message"' to post a message.`, newChatPostCmd(&project, &chatID, &contentType), newChatUploadCmd(&project, &chatID), newChatLineShowCmd(&project, &chatID), + newChatLineUpdateCmd(&project, &chatID, &contentType), newChatLineDeleteCmd(&project, &chatID), ) @@ -733,6 +756,173 @@ You can pass either a line ID or a Basecamp line URL: return cmd } +func newChatLineUpdateCmd(project, chatID, contentType *string) *cobra.Command { + var content string + + cmd := &cobra.Command{ + Use: "update <id|url> [content]", + Short: "Update an existing message", + Long: `Update the content of an existing chat message. + +You can pass either a line ID or a Basecamp line URL: + basecamp chat update 789 "edited message" --in my-project + basecamp chat update https://3.basecamp.com/123/buckets/456/chats/789/lines/111 --content "edited" + +By default, content is sent as plain text. Use --content-type text/html +for rich text. @mentions resolve like 'chat post' and promote to text/html +when present.`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + messageContent := content + if len(args) > 1 { + messageContent = args[1] + } + + if strings.TrimSpace(messageContent) == "" { + return missingArg(cmd, "<content>") + } + + if err := ensureAccount(cmd, app); err != nil { + return err + } + + // Reject non-chat-line URLs up front. We accept either bare numeric IDs + // or chat-line URLs in either /chats/{c}/lines/{l} or /chats/{c}@{l} form. + // A pasted card/todo/message URL would otherwise be silently misinterpreted + // as a numeric line ID via extractWithProject. + urlChatID, urlLineID := extractChatLineFromURL(args[0]) + lineID := args[0] + urlProjectID := "" + if urlChatID != "" { + lineID = urlLineID + _, urlProjectID = extractWithProject(args[0]) + } else if urlarg.IsURL(args[0]) { + return output.ErrUsage("expected a chat-line ID or URL of the form /chats/{c}/lines/{l} or /chats/{c}@{l}") + } + + // URL-derived bucket wins over --in/--project: the URL is unambiguous + // about which project owns the line, while the flag may be stale from a + // previous command in the same shell. + projectID := urlProjectID + if projectID == "" { + projectID = *project + } + if projectID == "" { + projectID = app.Flags.Project + } + if projectID == "" { + projectID = app.Config.ProjectID + } + if projectID == "" { + if err := ensureProject(cmd, app); err != nil { + return err + } + projectID = app.Config.ProjectID + } + + resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID) + if err != nil { + return err + } + + // URL-derived chat ID wins over --room for the same reason: the URL is + // unambiguous about which campfire owns the line, while --room is a + // project-wide hint that may not match. + effectiveChatID := urlChatID + if effectiveChatID == "" { + effectiveChatID = *chatID + } + if effectiveChatID == "" { + effectiveChatID, err = getChatID(cmd, app, resolvedProjectID) + if err != nil { + return err + } + } + + chatIDInt, err := strconv.ParseInt(effectiveChatID, 10, 64) + if err != nil { + return output.ErrUsage("Invalid chat room ID") + } + lineIDInt, err := strconv.ParseInt(lineID, 10, 64) + if err != nil { + return output.ErrUsage("Invalid line ID") + } + + // Resolve @mentions — same flow as chat post. + ct := *contentType + var mentionNotice string + if ct == "" || ct == "text/html" { + mentionInput := messageContent + if ct == "" { + mentionInput = richtext.MarkdownToHTML(messageContent) + } + result, resolveErr := resolveMentions(cmd.Context(), app.Names, mentionInput) + if resolveErr != nil { + return resolveErr + } + if result.HTML != mentionInput || len(result.Unresolved) > 0 { + messageContent = result.HTML + if ct == "" { + ct = "text/html" + } + } + mentionNotice = unresolvedMentionWarning(result.Unresolved) + } + + var opts *basecamp.UpdateLineOptions + if ct != "" { + opts = &basecamp.UpdateLineOptions{ContentType: ct} + } + if err := app.Account().Campfires().UpdateLine(cmd.Context(), chatIDInt, lineIDInt, messageContent, opts); err != nil { + return convertSDKError(err) + } + + // SDK PUT returns 204; re-fetch so the response carries the canonical + // post-update line. A failure here doesn't roll back the update — we + // surface it as a diagnostic rather than the command's exit code. + line, fetchErr := app.Account().Campfires().GetLine(cmd.Context(), chatIDInt, lineIDInt) + + respOpts := []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("Updated line #%s", lineID)), + output.WithEntity("chat_line"), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp chat line %s --room %s --in %s", lineID, effectiveChatID, resolvedProjectID), + Description: "View line", + }, + output.Breadcrumb{ + Action: "messages", + Cmd: fmt.Sprintf("basecamp chat messages --room %s --in %s", effectiveChatID, resolvedProjectID), + Description: "Back to messages", + }, + ), + } + if line != nil { + respOpts = append(respOpts, output.WithDisplayData(chatLineDisplayData(line))) + } + if mentionNotice != "" { + respOpts = append(respOpts, output.WithDiagnostic(mentionNotice)) + } + if fetchErr != nil { + respOpts = append(respOpts, output.WithDiagnostic(fmt.Sprintf("update succeeded; refetch failed: %v", fetchErr))) + } + + if line == nil { + return app.OK(map[string]any{"updated": true, "id": lineID}, respOpts...) + } + return app.OK(line, respOpts...) + }, + } + + cmd.Flags().StringVar(&content, "content", "", "New message content") + cmd.Flags().StringVar(contentType, "content-type", "", "Content type (text/html for rich text)") + + return cmd +} + func newChatLineDeleteCmd(project, chatID *string) *cobra.Command { var force bool diff --git a/internal/commands/chat_test.go b/internal/commands/chat_test.go index b0ab955d..72ab8148 100644 --- a/internal/commands/chat_test.go +++ b/internal/commands/chat_test.go @@ -1093,6 +1093,161 @@ func TestChatPostAgentModeWarningOnStderr(t *testing.T) { } // TestChatDeleteReturnsDeletedPayload verifies that delete returns {"deleted": true, "id": "..."}. +// mockChatUpdateTransport handles resolver GETs, the PUT update, and the +// follow-up GET that UpdateLine performs to re-fetch the line. It also serves +// pingable people for mention-resolution tests. +type mockChatUpdateTransport struct { + capturedMethod string + capturedPath string + capturedBody []byte +} + +func (t *mockChatUpdateTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + + if req.Method == "GET" { + var body string + switch { + case strings.Contains(req.URL.Path, "/projects.json"): + body = `[{"id": 123, "name": "Test Project"}]` + case strings.Contains(req.URL.Path, "/projects/"): + body = `{"id": 123, "dock": [{"name": "chat", "id": 789, "enabled": true}]}` + case strings.Contains(req.URL.Path, "/circles/people.json") || strings.Contains(req.URL.Path, "/people/pingable.json"): + body = `[{"id": 42000, "name": "Jane Smith", "email_address": "jane@example.com", "attachable_sgid": "sgid-jane"}]` + case strings.Contains(req.URL.Path, "/lines/"): + body = `{"id": 111, "content": "Edited!", "type": "Chat::Lines::Text", "creator": {"id": 1, "name": "Tester"}}` + default: + body = `{}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil + } + + if req.Method == "PUT" { + t.capturedMethod = req.Method + t.capturedPath = req.URL.Path + if req.Body != nil { + t.capturedBody, _ = io.ReadAll(req.Body) + req.Body.Close() + } + return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Header: header}, nil + } + + return nil, errors.New("unexpected request") +} + +func TestChatUpdateSendsPutAndReturnsLine(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockChatUpdateTransport{} + app, buf := newChatDeleteTestApp(transport) + + cmd := NewChatCmd() + err := executeChatCommand(cmd, app, "update", "111", "Edited!") + require.NoError(t, err) + + assert.Equal(t, "PUT", transport.capturedMethod) + assert.Contains(t, transport.capturedPath, "/lines/111") + + var requestBody map[string]any + require.NoError(t, json.Unmarshal(transport.capturedBody, &requestBody)) + assert.Equal(t, "Edited!", requestBody["content"]) + _, hasContentType := requestBody["content_type"] + assert.False(t, hasContentType, "content_type should be absent without --content-type") + + var envelope map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + data, ok := envelope["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(111), data["id"]) +} + +// TestChatUpdateMentionPromotesToHTML verifies that an @mention in content +// auto-promotes to text/html and resolves to a bc-attachment tag, mirroring +// chat post behavior. +func TestChatUpdateMentionPromotesToHTML(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockChatUpdateTransport{} + app, _ := newChatDeleteTestApp(transport) + + cmd := NewChatCmd() + err := executeChatCommand(cmd, app, "update", "111", "Hey @Jane.Smith, see this") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBody) + + var requestBody map[string]any + require.NoError(t, json.Unmarshal(transport.capturedBody, &requestBody)) + + assert.Equal(t, "text/html", requestBody["content_type"], + "content_type should be promoted to text/html when mentions resolve") + content, ok := requestBody["content"].(string) + require.True(t, ok) + assert.Contains(t, content, "bc-attachment", + "content should contain bc-attachment mention tag") +} + +// TestChatUpdatePlainTextOptOut verifies that --content-type text/plain +// bypasses mention resolution and sends content as-is. +func TestChatUpdatePlainTextOptOut(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockChatUpdateTransport{} + app, _ := newChatDeleteTestApp(transport) + + cmd := NewChatCmd() + err := executeChatCommand(cmd, app, "update", "111", "Hey @Jane.Smith", "--content-type", "text/plain") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBody) + + var requestBody map[string]any + require.NoError(t, json.Unmarshal(transport.capturedBody, &requestBody)) + + assert.Equal(t, "text/plain", requestBody["content_type"], + "content_type should remain text/plain when explicitly set") + content, ok := requestBody["content"].(string) + require.True(t, ok) + assert.NotContains(t, content, "bc-attachment", + "content should not contain bc-attachment when content-type is text/plain") + assert.Contains(t, content, "@Jane.Smith", + "@mention should be left as literal text") +} + +// TestChatUpdateExtractsChatIDFromURL verifies that pasting a chat-line URL +// targets the chat referenced by the URL rather than falling back to --room or +// the project's default chat — important for projects with multiple campfires. +func TestChatUpdateExtractsChatIDFromURL(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockChatUpdateTransport{} + app, _ := newChatDeleteTestApp(transport) + + cmd := NewChatCmd() + err := executeChatCommand(cmd, app, + "update", + "https://3.basecamp.com/99999/buckets/123/chats/456@111", + "Edited via URL") + require.NoError(t, err) + + // PUT should target /chats/456/lines/111 — the chat ID came from the URL, + // not from --room or the dock default (789). + assert.Equal(t, "PUT", transport.capturedMethod) + assert.Contains(t, transport.capturedPath, "/chats/456/lines/111") +} + +func TestChatUpdateRejectsEmptyContent(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockChatUpdateTransport{} + app, _ := newChatDeleteTestApp(transport) + app.Flags.Agent = true // forces structured ErrUsageHint instead of help text + + cmd := NewChatCmd() + err := executeChatCommand(cmd, app, "update", "111", "") + require.Error(t, err) + assert.Empty(t, transport.capturedMethod, "no PUT should be issued when content is empty") +} + func TestChatDeleteReturnsDeletedPayload(t *testing.T) { t.Setenv("BASECAMP_NO_KEYRING", "1") diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 242b3131..e9808bde 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -42,7 +42,7 @@ func CommandCategories() []CommandCategory { {Name: "gauges", Category: "core", Description: "Manage gauges", Actions: []string{"list", "needles", "needle", "create", "update", "delete", "enable", "disable"}}, {Name: "todolistgroups", Category: "core", Description: "Manage to-do list groups", Actions: []string{"list", "show", "create", "update", "position"}}, {Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "trash", "archive", "restore"}}, - {Name: "chat", Category: "core", Description: "Chat in real-time", Actions: []string{"list", "messages", "post", "upload", "line", "delete"}}, + {Name: "chat", Category: "core", Description: "Chat in real-time", Actions: []string{"list", "messages", "post", "upload", "line", "update", "delete"}}, {Name: "cards", Category: "core", Description: "Manage Kanban cards", Actions: []string{"list", "show", "create", "update", "move", "columns", "steps", "trash", "archive", "restore"}}, {Name: "files", Category: "core", Description: "Manage files, documents, and folders", Actions: []string{"list", "show", "download", "update", "trash", "archive", "restore"}}, {Name: "checkins", Category: "core", Description: "View automatic check-ins", Actions: []string{"questions", "question", "answers", "answer"}}, diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 736a603c..dcb4e26c 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.7.4-0.20260423230153-f54589f0924a", - "revision": "f54589f0924a", - "updated_at": "2026-04-23T23:01:53Z" + "version": "v0.5.1-0.20260504111141-62d09023dc73", + "revision": "62d09023dc73", + "updated_at": "2026-05-04T11:11:41Z" }, "api": { "repo": "basecamp/bc3", diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 3d2304eb..3d087ddf 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -729,6 +729,7 @@ basecamp chat messages --in <project> --json # List messages basecamp chat post "Hello!" --in <project> basecamp chat post "@Jane.Smith, check this" --in <project> # With @mention (auto text/html) basecamp chat line <line_id> --in <project> # Show line +basecamp chat update <line_id> "edited content" --in <project> # Edit existing message in place basecamp chat delete <line_id> --in <project> --force # Delete line (permanent, not trashable) ```