From e8db9f8697e46eb0b97729904d2446012af797cf Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 13 Apr 2026 15:41:55 -0400 Subject: [PATCH 1/5] Add ergonomic inline attachment flags --- README.md | 20 ++ SURFACE.txt | 4 + e2e/cli_tests/attachment_ergonomics_test.go | 181 +++++++++++++++++++ internal/commands/card.go | 51 +++--- internal/commands/card_test.go | 113 ++++++++++++ internal/commands/comment.go | 56 +++--- internal/commands/comment_test.go | 110 ++++++++++- internal/commands/inline_attachments.go | 114 ++++++++++++ internal/commands/inline_attachments_test.go | 124 +++++++++++++ internal/commands/mock_client_test.go | 8 + internal/commands/upload.go | 4 +- skills/fizzy/SKILL.md | 30 ++- 12 files changed, 748 insertions(+), 67 deletions(-) create mode 100644 e2e/cli_tests/attachment_ergonomics_test.go create mode 100644 internal/commands/inline_attachments.go create mode 100644 internal/commands/inline_attachments_test.go diff --git a/README.md b/README.md index 8b26c40..2a7a64a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,26 @@ fizzy search "authentication" # Search across cards fizzy comment create --card 42 --body "Looks good!" # Add comment ``` +### Attachments + +Simple mode uses repeatable `--attach` and appends inline attachments to the end of card descriptions or comment bodies: + +```bash +fizzy card create --board ID --title "Bug report" --description "See attached" --attach screenshot.png +fizzy comment create --card 42 --attach logs.txt +fizzy comment create --card 42 --body_file comment.md --attach screenshot.png --attach trace.txt +``` + +Advanced mode still works when exact placement matters: + +```bash +SGID=$(fizzy upload file screenshot.png --jq '.data.attachable_sgid') +fizzy card create --board ID --title "Bug report" \ + --description "

See screenshot

" +``` + +Use `signed_id` from `fizzy upload file` only for card header images via `--image`. + ### Output Formats ```bash diff --git a/SURFACE.txt b/SURFACE.txt index 2bbacfc..fdd8b25 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -893,6 +893,7 @@ FLAG fizzy card column --token type=string FLAG fizzy card column --verbose type=bool FLAG fizzy card create --agent type=bool FLAG fizzy card create --api-url type=string +FLAG fizzy card create --attach type=stringArray FLAG fizzy card create --board type=string FLAG fizzy card create --count type=bool FLAG fizzy card create --created-at type=string @@ -1237,6 +1238,7 @@ FLAG fizzy card unwatch --token type=string FLAG fizzy card unwatch --verbose type=bool FLAG fizzy card update --agent type=bool FLAG fizzy card update --api-url type=string +FLAG fizzy card update --attach type=stringArray FLAG fizzy card update --count type=bool FLAG fizzy card update --created-at type=string FLAG fizzy card update --description type=string @@ -1580,6 +1582,7 @@ FLAG fizzy comment attachments view --token type=string FLAG fizzy comment attachments view --verbose type=bool FLAG fizzy comment create --agent type=bool FLAG fizzy comment create --api-url type=string +FLAG fizzy comment create --attach type=stringArray FLAG fizzy comment create --body type=string FLAG fizzy comment create --body_file type=string FLAG fizzy comment create --card type=string @@ -1691,6 +1694,7 @@ FLAG fizzy comment show --token type=string FLAG fizzy comment show --verbose type=bool FLAG fizzy comment update --agent type=bool FLAG fizzy comment update --api-url type=string +FLAG fizzy comment update --attach type=stringArray FLAG fizzy comment update --body type=string FLAG fizzy comment update --body_file type=string FLAG fizzy comment update --card type=string diff --git a/e2e/cli_tests/attachment_ergonomics_test.go b/e2e/cli_tests/attachment_ergonomics_test.go new file mode 100644 index 0000000..8f42670 --- /dev/null +++ b/e2e/cli_tests/attachment_ergonomics_test.go @@ -0,0 +1,181 @@ +package clitests + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestCardAttachFlag(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + imagePath := fixtureFile(t, "test_image.png") + docPath := fixtureFile(t, "test_document.txt") + + t.Run("creates card with single attach and downloads it", func(t *testing.T) { + title := fmt.Sprintf("Attach Flag Card %d", time.Now().UnixNano()) + result := h.Run("card", "create", "--board", boardID, "--title", title, "--attach", imagePath) + assertOK(t, result) + + cardNumber := result.GetNumberFromLocation() + if cardNumber == 0 { + cardNumber = result.GetDataInt("number") + } + if cardNumber == 0 { + t.Fatalf("failed to get card number from create (location: %s)", result.GetLocation()) + } + + showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) + assertOK(t, showResult) + arr := showResult.GetDataArray() + if len(arr) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(arr)) + } + attachment := asMap(arr[0]) + if got := mapValueString(attachment, "filename"); got != "test_image.png" { + t.Fatalf("expected filename test_image.png, got %v", got) + } + + outputPath := filepath.Join(t.TempDir(), "test_image.png") + downloadResult := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "1", "-o", outputPath) + assertOK(t, downloadResult) + assertFileExists(t, outputPath) + }) + + t.Run("creates card with multiple attaches in order", func(t *testing.T) { + title := fmt.Sprintf("Attach Flag Multi Card %d", time.Now().UnixNano()) + result := h.Run( + "card", "create", + "--board", boardID, + "--title", title, + "--description", "See attached files", + "--attach", imagePath, + "--attach", docPath, + ) + assertOK(t, result) + + cardNumber := result.GetNumberFromLocation() + if cardNumber == 0 { + cardNumber = result.GetDataInt("number") + } + + showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) + assertOK(t, showResult) + arr := showResult.GetDataArray() + if len(arr) != 2 { + t.Fatalf("expected 2 attachments, got %d", len(arr)) + } + if got := mapValueString(asMap(arr[0]), "filename"); got != "test_image.png" { + t.Fatalf("expected first attachment test_image.png, got %v", got) + } + if got := mapValueString(asMap(arr[1]), "filename"); got != "test_document.txt" { + t.Fatalf("expected second attachment test_document.txt, got %v", got) + } + }) + + t.Run("works with description_file", func(t *testing.T) { + descriptionFile := filepath.Join(t.TempDir(), "description.md") + mustWriteFile(t, descriptionFile, []byte("See file-based content")) + + title := fmt.Sprintf("Attach Flag File Card %d", time.Now().UnixNano()) + result := h.Run( + "card", "create", + "--board", boardID, + "--title", title, + "--description_file", descriptionFile, + "--attach", imagePath, + ) + assertOK(t, result) + + cardNumber := result.GetNumberFromLocation() + if cardNumber == 0 { + cardNumber = result.GetDataInt("number") + } + + showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) + assertOK(t, showResult) + if got := len(showResult.GetDataArray()); got != 1 { + t.Fatalf("expected 1 attachment from description_file flow, got %d", got) + } + }) +} + +func assertFileExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file at %s: %v", path, err) + } +} + +func mustWriteFile(t *testing.T, path string, content []byte) { + t.Helper() + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +func TestCommentAttachFlag(t *testing.T) { + h := newHarness(t) + cardNumber := createCard(t, h, fixture.BoardID) + cardStr := strconv.Itoa(cardNumber) + imagePath := fixtureFile(t, "test_image.png") + docPath := fixtureFile(t, "test_document.txt") + + t.Run("creates attachment-only comment with single attach", func(t *testing.T) { + before := h.Run("comment", "attachments", "show", "--card", cardStr) + assertOK(t, before) + beforeCount := len(before.GetDataArray()) + + result := h.Run("comment", "create", "--card", cardStr, "--attach", imagePath) + assertOK(t, result) + + showResult := h.Run("comment", "attachments", "show", "--card", cardStr) + assertOK(t, showResult) + arr := showResult.GetDataArray() + if len(arr) != beforeCount+1 { + t.Fatalf("expected %d comment attachments, got %d", beforeCount+1, len(arr)) + } + added := asMap(arr[len(arr)-1]) + if got := mapValueString(added, "filename"); got != "test_image.png" { + t.Fatalf("expected filename test_image.png, got %v", got) + } + + outputPath := filepath.Join(t.TempDir(), "test_image.png") + downloadResult := h.Run("comment", "attachments", "download", "--card", cardStr, strconv.Itoa(len(arr)), "-o", outputPath) + assertOK(t, downloadResult) + assertFileExists(t, outputPath) + }) + + t.Run("creates comment with multiple attaches in order", func(t *testing.T) { + before := h.Run("comment", "attachments", "show", "--card", cardStr) + assertOK(t, before) + beforeCount := len(before.GetDataArray()) + + result := h.Run( + "comment", "create", + "--card", cardStr, + "--body", "See attached files", + "--attach", imagePath, + "--attach", docPath, + ) + assertOK(t, result) + + showResult := h.Run("comment", "attachments", "show", "--card", cardStr) + assertOK(t, showResult) + arr := showResult.GetDataArray() + if len(arr) != beforeCount+2 { + t.Fatalf("expected %d comment attachments, got %d", beforeCount+2, len(arr)) + } + + lastTwo := arr[len(arr)-2:] + if got := mapValueString(asMap(lastTwo[0]), "filename"); got != "test_image.png" { + t.Fatalf("expected first new attachment test_image.png, got %v", got) + } + if got := mapValueString(asMap(lastTwo[1]), "filename"); got != "test_document.txt" { + t.Fatalf("expected second new attachment test_document.txt, got %v", got) + } + }) +} diff --git a/internal/commands/card.go b/internal/commands/card.go index b0eff75..22e8e83 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -2,7 +2,6 @@ package commands import ( "fmt" - "os" "strconv" "strings" @@ -266,13 +265,14 @@ var cardCreateBoard string var cardCreateTitle string var cardCreateDescription string var cardCreateDescriptionFile string +var cardCreateAttach []string var cardCreateImage string var cardCreateCreatedAt string var cardCreateCmd = &cobra.Command{ Use: "create", Short: "Create a card", - Long: "Creates a new card in a board.", + Long: "Creates a new card in a board. Use --attach for simple end-appended inline attachments. For precise placement, upload files first and embed tags manually in --description or --description_file.", RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuthAndAccount(); err != nil { return err @@ -286,16 +286,13 @@ var cardCreateCmd = &cobra.Command{ return newRequiredFlagError("title") } - // Resolve description - var description string - if cardCreateDescriptionFile != "" { - descContent, descErr := os.ReadFile(cardCreateDescriptionFile) - if descErr != nil { - return descErr - } - description = markdownToHTML(string(descContent)) - } else if cardCreateDescription != "" { - description = markdownToHTML(cardCreateDescription) + description, err := resolveRichTextContent(cardCreateDescription, cardCreateDescriptionFile) + if err != nil { + return err + } + description, err = appendInlineAttachmentsToContent(description, cardCreateAttach) + if err != nil { + return err } ac := getSDK() @@ -362,13 +359,14 @@ var cardCreateCmd = &cobra.Command{ var cardUpdateTitle string var cardUpdateDescription string var cardUpdateDescriptionFile string +var cardUpdateAttach []string var cardUpdateImage string var cardUpdateCreatedAt string var cardUpdateCmd = &cobra.Command{ Use: "update CARD_NUMBER", Short: "Update a card", - Long: "Updates an existing card.", + Long: "Updates an existing card. Use --attach for simple end-appended inline attachments. For precise placement, upload files first and embed tags manually in --description or --description_file.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuthAndAccount(); err != nil { @@ -377,16 +375,13 @@ var cardUpdateCmd = &cobra.Command{ cardNumber := args[0] - // Resolve description - var description string - if cardUpdateDescriptionFile != "" { - content, err := os.ReadFile(cardUpdateDescriptionFile) - if err != nil { - return err - } - description = markdownToHTML(string(content)) - } else if cardUpdateDescription != "" { - description = markdownToHTML(cardUpdateDescription) + description, err := resolveRichTextContent(cardUpdateDescription, cardUpdateDescriptionFile) + if err != nil { + return err + } + description, err = appendInlineAttachmentsToContent(description, cardUpdateAttach) + if err != nil { + return err } // Build breadcrumbs @@ -1098,16 +1093,18 @@ func init() { // Create cardCreateCmd.Flags().StringVar(&cardCreateBoard, "board", "", "Board ID (required)") cardCreateCmd.Flags().StringVar(&cardCreateTitle, "title", "", "Card title (required)") - cardCreateCmd.Flags().StringVar(&cardCreateDescription, "description", "", "Card description (HTML)") - cardCreateCmd.Flags().StringVar(&cardCreateDescriptionFile, "description_file", "", "Read description from file") + cardCreateCmd.Flags().StringVar(&cardCreateDescription, "description", "", "Card description (markdown or HTML)") + cardCreateCmd.Flags().StringVar(&cardCreateDescriptionFile, "description_file", "", "Read description from file (markdown or HTML)") + cardCreateCmd.Flags().StringArrayVar(&cardCreateAttach, "attach", nil, "Upload and append inline attachment at the end of the description. Repeatable.") cardCreateCmd.Flags().StringVar(&cardCreateImage, "image", "", "Header image signed ID") cardCreateCmd.Flags().StringVar(&cardCreateCreatedAt, "created-at", "", "Custom created_at timestamp") cardCmd.AddCommand(cardCreateCmd) // Update cardUpdateCmd.Flags().StringVar(&cardUpdateTitle, "title", "", "Card title") - cardUpdateCmd.Flags().StringVar(&cardUpdateDescription, "description", "", "Card description (HTML)") - cardUpdateCmd.Flags().StringVar(&cardUpdateDescriptionFile, "description_file", "", "Read description from file") + cardUpdateCmd.Flags().StringVar(&cardUpdateDescription, "description", "", "Card description (markdown or HTML)") + cardUpdateCmd.Flags().StringVar(&cardUpdateDescriptionFile, "description_file", "", "Read description from file (markdown or HTML)") + cardUpdateCmd.Flags().StringArrayVar(&cardUpdateAttach, "attach", nil, "Upload and append inline attachment at the end of the description. Repeatable.") cardUpdateCmd.Flags().StringVar(&cardUpdateImage, "image", "", "Header image signed ID") cardUpdateCmd.Flags().StringVar(&cardUpdateCreatedAt, "created-at", "", "Custom created_at timestamp") cardCmd.AddCommand(cardUpdateCmd) diff --git a/internal/commands/card_test.go b/internal/commands/card_test.go index 8603f62..2e85c9b 100644 --- a/internal/commands/card_test.go +++ b/internal/commands/card_test.go @@ -1,6 +1,7 @@ package commands import ( + "strings" "testing" "github.com/basecamp/fizzy-cli/internal/client" @@ -491,6 +492,89 @@ func TestCardCreate(t *testing.T) { t.Errorf("expected description '

Description

', got '%v'", body["description"]) } }) + + t.Run("uploads and appends single inline attachment", func(t *testing.T) { + tempDir := t.TempDir() + attachPath := writeTestAttachmentFile(t, tempDir, "single.txt", "single") + + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Data: map[string]any{"id": "abc", "number": 42}, + } + mock.UploadFileResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{"attachable_sgid": "sgid-single"}, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardCreateBoard = "123" + cardCreateTitle = "Test" + cardCreateDescription = "See attached" + cardCreateAttach = []string{attachPath} + err := cardCreateCmd.RunE(cardCreateCmd, []string{}) + cardCreateBoard = "" + cardCreateTitle = "" + cardCreateDescription = "" + cardCreateAttach = nil + + assertExitCode(t, err, 0) + + if len(mock.UploadFileCalls) != 1 || mock.UploadFileCalls[0] != attachPath { + t.Fatalf("unexpected upload calls: %#v", mock.UploadFileCalls) + } + + body := mock.PostCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + "See attached", + ``, + }, "\n") + if body["description"] != expected { + t.Errorf("expected description %q, got %v", expected, body["description"]) + } + }) + + t.Run("uploads and appends multiple inline attachments in order", func(t *testing.T) { + tempDir := t.TempDir() + attachPath1 := writeTestAttachmentFile(t, tempDir, "first.txt", "first") + attachPath2 := writeTestAttachmentFile(t, tempDir, "second.txt", "second") + + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Data: map[string]any{"id": "abc", "number": 42}, + } + mock.UploadFileResponses = []*client.APIResponse{ + {StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-1"}}, + {StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-2"}}, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardCreateBoard = "123" + cardCreateTitle = "Test" + cardCreateAttach = []string{attachPath1, attachPath2} + err := cardCreateCmd.RunE(cardCreateCmd, []string{}) + cardCreateBoard = "" + cardCreateTitle = "" + cardCreateAttach = nil + + assertExitCode(t, err, 0) + + body := mock.PostCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + ``, + ``, + }, "\n") + if body["description"] != expected { + t.Errorf("expected description %q, got %v", expected, body["description"]) + } + }) } func TestCardUpdate(t *testing.T) { @@ -517,6 +601,35 @@ func TestCardUpdate(t *testing.T) { t.Errorf("expected path '/cards/42', got '%s'", mock.PatchCalls[0].Path) } }) + + t.Run("uploads and appends inline attachments", func(t *testing.T) { + tempDir := t.TempDir() + attachPath := writeTestAttachmentFile(t, tempDir, "update.txt", "update") + + mock := NewMockClient() + mock.PatchResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"id": "abc"}} + mock.UploadFileResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-update"}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardUpdateDescription = "Updated body" + cardUpdateAttach = []string{attachPath} + err := cardUpdateCmd.RunE(cardUpdateCmd, []string{"42"}) + cardUpdateDescription = "" + cardUpdateAttach = nil + + assertExitCode(t, err, 0) + body := mock.PatchCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + "Updated body", + ``, + }, "\n") + if body["description"] != expected { + t.Errorf("expected description %q, got %v", expected, body["description"]) + } + }) } func TestCardDelete(t *testing.T) { diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 00b385e..8795bcb 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -2,9 +2,9 @@ package commands import ( "fmt" - "os" "strconv" + "github.com/basecamp/fizzy-cli/internal/errors" "github.com/basecamp/fizzy-sdk/go/pkg/generated" "github.com/spf13/cobra" ) @@ -127,12 +127,13 @@ var commentShowCmd = &cobra.Command{ var commentCreateCard string var commentCreateBody string var commentCreateBodyFile string +var commentCreateAttach []string var commentCreateCreatedAt string var commentCreateCmd = &cobra.Command{ Use: "create", Short: "Create a comment", - Long: "Creates a new comment on a card.", + Long: "Creates a new comment on a card. Use --attach for simple end-appended inline attachments. For precise placement, upload files first and embed tags manually in --body or --body_file.", RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuthAndAccount(); err != nil { return err @@ -142,18 +143,16 @@ var commentCreateCmd = &cobra.Command{ return newRequiredFlagError("card") } - // Determine body content - var body string - if commentCreateBodyFile != "" { - content, err := os.ReadFile(commentCreateBodyFile) - if err != nil { - return err - } - body = markdownToHTML(string(content)) - } else if commentCreateBody != "" { - body = markdownToHTML(commentCreateBody) - } else { - return newRequiredFlagError("body or body_file") + body, err := resolveRichTextContent(commentCreateBody, commentCreateBodyFile) + if err != nil { + return err + } + body, err = appendInlineAttachmentsToContent(body, commentCreateAttach) + if err != nil { + return err + } + if body == "" { + return errors.NewInvalidArgsError("required flag --body or --body_file or --attach not provided") } cardNumber := commentCreateCard @@ -188,11 +187,12 @@ var commentCreateCmd = &cobra.Command{ var commentUpdateCard string var commentUpdateBody string var commentUpdateBodyFile string +var commentUpdateAttach []string var commentUpdateCmd = &cobra.Command{ Use: "update COMMENT_ID", Short: "Update a comment", - Long: "Updates an existing comment.", + Long: "Updates an existing comment. Use --attach for simple end-appended inline attachments. For precise placement, upload files first and embed tags manually in --body or --body_file.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuthAndAccount(); err != nil { @@ -203,15 +203,13 @@ var commentUpdateCmd = &cobra.Command{ return newRequiredFlagError("card") } - var body string - if commentUpdateBodyFile != "" { - content, err := os.ReadFile(commentUpdateBodyFile) - if err != nil { - return err - } - body = markdownToHTML(string(content)) - } else if commentUpdateBody != "" { - body = markdownToHTML(commentUpdateBody) + body, err := resolveRichTextContent(commentUpdateBody, commentUpdateBodyFile) + if err != nil { + return err + } + body, err = appendInlineAttachmentsToContent(body, commentUpdateAttach) + if err != nil { + return err } commentID := args[0] @@ -289,15 +287,17 @@ func init() { // Create commentCreateCmd.Flags().StringVar(&commentCreateCard, "card", "", "Card number (required)") - commentCreateCmd.Flags().StringVar(&commentCreateBody, "body", "", "Comment body (HTML)") - commentCreateCmd.Flags().StringVar(&commentCreateBodyFile, "body_file", "", "Read body from file") + commentCreateCmd.Flags().StringVar(&commentCreateBody, "body", "", "Comment body (markdown or HTML)") + commentCreateCmd.Flags().StringVar(&commentCreateBodyFile, "body_file", "", "Read body from file (markdown or HTML)") + commentCreateCmd.Flags().StringArrayVar(&commentCreateAttach, "attach", nil, "Upload and append inline attachment at the end of the body. Repeatable.") commentCreateCmd.Flags().StringVar(&commentCreateCreatedAt, "created-at", "", "Custom created_at timestamp") commentCmd.AddCommand(commentCreateCmd) // Update commentUpdateCmd.Flags().StringVar(&commentUpdateCard, "card", "", "Card number (required)") - commentUpdateCmd.Flags().StringVar(&commentUpdateBody, "body", "", "Comment body (HTML)") - commentUpdateCmd.Flags().StringVar(&commentUpdateBodyFile, "body_file", "", "Read body from file") + commentUpdateCmd.Flags().StringVar(&commentUpdateBody, "body", "", "Comment body (markdown or HTML)") + commentUpdateCmd.Flags().StringVar(&commentUpdateBodyFile, "body_file", "", "Read body from file (markdown or HTML)") + commentUpdateCmd.Flags().StringArrayVar(&commentUpdateAttach, "attach", nil, "Upload and append inline attachment at the end of the body. Repeatable.") commentCmd.AddCommand(commentUpdateCmd) // Delete diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index 8dd4e05..dfdcd9d 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -1,6 +1,7 @@ package commands import ( + "strings" "testing" "github.com/basecamp/fizzy-cli/internal/client" @@ -189,7 +190,7 @@ func TestCommentCreate(t *testing.T) { assertExitCode(t, err, errors.ExitInvalidArgs) }) - t.Run("requires body or body_file", func(t *testing.T) { + t.Run("requires body, body_file, or attach", func(t *testing.T) { mock := NewMockClient() SetTestModeWithSDK(mock) SetTestConfig("token", "account", "https://api.example.com") @@ -198,6 +199,7 @@ func TestCommentCreate(t *testing.T) { commentCreateCard = "42" commentCreateBody = "" commentCreateBodyFile = "" + commentCreateAttach = nil err := commentCreateCmd.RunE(commentCreateCmd, []string{}) commentCreateCard = "" @@ -233,6 +235,81 @@ func TestCommentCreate(t *testing.T) { t.Errorf("expected created_at '2020-01-01T00:00:00Z', got '%v'", body["created_at"]) } }) + + t.Run("uploads and appends single inline attachment", func(t *testing.T) { + tempDir := t.TempDir() + attachPath := writeTestAttachmentFile(t, tempDir, "single.txt", "single") + + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Data: map[string]any{"id": "comment-1", "body": map[string]any{"html": "", "plain_text": ""}}, + } + mock.UploadFileResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{"attachable_sgid": "sgid-single"}, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + commentCreateCard = "42" + commentCreateBody = "See attached" + commentCreateAttach = []string{attachPath} + err := commentCreateCmd.RunE(commentCreateCmd, []string{}) + commentCreateCard = "" + commentCreateBody = "" + commentCreateAttach = nil + + assertExitCode(t, err, 0) + + body := mock.PostCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + "See attached", + ``, + }, "\n") + if body["body"] != expected { + t.Errorf("expected body %q, got %v", expected, body["body"]) + } + }) + + t.Run("allows attachment-only comments and preserves order", func(t *testing.T) { + tempDir := t.TempDir() + attachPath1 := writeTestAttachmentFile(t, tempDir, "first.txt", "first") + attachPath2 := writeTestAttachmentFile(t, tempDir, "second.txt", "second") + + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Data: map[string]any{"id": "comment-1", "body": map[string]any{"html": "", "plain_text": ""}}, + } + mock.UploadFileResponses = []*client.APIResponse{ + {StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-1"}}, + {StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-2"}}, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + commentCreateCard = "42" + commentCreateAttach = []string{attachPath1, attachPath2} + err := commentCreateCmd.RunE(commentCreateCmd, []string{}) + commentCreateCard = "" + commentCreateAttach = nil + + assertExitCode(t, err, 0) + + body := mock.PostCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + ``, + ``, + }, "\n") + if body["body"] != expected { + t.Errorf("expected body %q, got %v", expected, body["body"]) + } + }) } func TestCommentUpdate(t *testing.T) { @@ -266,6 +343,37 @@ func TestCommentUpdate(t *testing.T) { } }) + t.Run("uploads and appends inline attachments", func(t *testing.T) { + tempDir := t.TempDir() + attachPath := writeTestAttachmentFile(t, tempDir, "update.txt", "update") + + mock := NewMockClient() + mock.PatchResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"id": "comment-1"}} + mock.UploadFileResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-update"}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + commentUpdateCard = "42" + commentUpdateBody = "Updated comment" + commentUpdateAttach = []string{attachPath} + err := commentUpdateCmd.RunE(commentUpdateCmd, []string{"comment-1"}) + commentUpdateCard = "" + commentUpdateBody = "" + commentUpdateAttach = nil + + assertExitCode(t, err, 0) + body := mock.PatchCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + "Updated comment", + ``, + }, "\n") + if body["body"] != expected { + t.Errorf("expected body %q, got %v", expected, body["body"]) + } + }) + t.Run("requires card flag", func(t *testing.T) { mock := NewMockClient() SetTestModeWithSDK(mock) diff --git a/internal/commands/inline_attachments.go b/internal/commands/inline_attachments.go new file mode 100644 index 0000000..a546cb0 --- /dev/null +++ b/internal/commands/inline_attachments.go @@ -0,0 +1,114 @@ +package commands + +import ( + "fmt" + "html" + "os" + "strings" + + "github.com/basecamp/fizzy-cli/internal/errors" +) + +func resolveRichTextContent(content string, filePath string) (string, error) { + if filePath != "" { + fileContent, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + return markdownToHTML(string(fileContent)), nil + } + if content == "" { + return "", nil + } + return markdownToHTML(content), nil +} + +func appendInlineAttachmentsToContent(content string, paths []string) (string, error) { + if len(paths) == 0 { + return content, nil + } + + sgids, err := uploadAttachableSGIDs(paths) + if err != nil { + return "", err + } + + return appendAttachmentTags(content, sgids), nil +} + +func uploadAttachableSGIDs(paths []string) ([]string, error) { + client := getClient() + sgids := make([]string, 0, len(paths)) + + for _, path := range paths { + if err := validateAttachmentPath(path); err != nil { + return nil, err + } + + resp, err := client.UploadFile(path) + if err != nil { + return nil, err + } + + data, ok := resp.Data.(map[string]any) + if !ok { + return nil, errors.NewError(fmt.Sprintf("Invalid attachment upload response for %s", path)) + } + + sgid, _ := data["attachable_sgid"].(string) + if sgid == "" { + return nil, errors.NewError(fmt.Sprintf("Upload response missing attachable_sgid for %s", path)) + } + + sgids = append(sgids, sgid) + } + + return sgids, nil +} + +func validateAttachmentPath(path string) error { + if strings.TrimSpace(path) == "" { + return errors.NewInvalidArgsError("attachment path cannot be empty") + } + + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return errors.NewError("File not found: " + path) + } + return errors.NewError(fmt.Sprintf("Failed to stat attachment %s: %v", path, err)) + } + if info.IsDir() { + return errors.NewError("Attachment path is a directory: " + path) + } + + file, err := os.Open(path) + if err != nil { + return errors.NewError(fmt.Sprintf("Failed to open attachment %s: %v", path, err)) + } + return file.Close() +} + +func appendAttachmentTags(content string, sgids []string) string { + if len(sgids) == 0 { + return content + } + + tags := make([]string, 0, len(sgids)) + for _, sgid := range sgids { + tags = append(tags, actionTextAttachmentTag(sgid)) + } + + attachments := strings.Join(tags, "\n") + if content == "" { + return attachments + } + if strings.HasSuffix(content, "\n") { + return content + attachments + } + return content + "\n" + attachments +} + +func actionTextAttachmentTag(sgid string) string { + return `` +} diff --git a/internal/commands/inline_attachments_test.go b/internal/commands/inline_attachments_test.go new file mode 100644 index 0000000..501d050 --- /dev/null +++ b/internal/commands/inline_attachments_test.go @@ -0,0 +1,124 @@ +package commands + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/basecamp/fizzy-cli/internal/client" +) + +func TestAppendAttachmentTags(t *testing.T) { + got := appendAttachmentTags("See attached", []string{"sgid-1", "sgid-2"}) + want := strings.Join([]string{ + "See attached", + ``, + ``, + }, "\n") + + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAppendInlineAttachmentsToContentPreservesOrderAndUploadsEachPath(t *testing.T) { + tempDir := t.TempDir() + pathA := writeTestAttachmentFile(t, tempDir, "a.txt", "a") + pathB := writeTestAttachmentFile(t, tempDir, "b.txt", "b") + + mock := NewMockClient() + mock.UploadFileResponses = []*client.APIResponse{ + {StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-a-1"}}, + {StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-b"}}, + {StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-a-2"}}, + } + SetTestMode(mock) + defer ResetTestMode() + + got, err := appendInlineAttachmentsToContent("Body", []string{pathA, pathB, pathA}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + want := strings.Join([]string{ + "Body", + ``, + ``, + ``, + }, "\n") + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } + + if len(mock.UploadFileCalls) != 3 { + t.Fatalf("expected 3 uploads, got %d", len(mock.UploadFileCalls)) + } + if mock.UploadFileCalls[0] != pathA || mock.UploadFileCalls[1] != pathB || mock.UploadFileCalls[2] != pathA { + t.Fatalf("unexpected upload order: %#v", mock.UploadFileCalls) + } +} + +func TestAppendInlineAttachmentsToContentAllowsAttachmentOnlyBody(t *testing.T) { + tempDir := t.TempDir() + path := writeTestAttachmentFile(t, tempDir, "only.txt", "content") + + mock := NewMockClient() + mock.UploadFileResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-only"}} + SetTestMode(mock) + defer ResetTestMode() + + got, err := appendInlineAttachmentsToContent("", []string{path}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + want := `` + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAppendInlineAttachmentsToContentErrorsForMissingFile(t *testing.T) { + mock := NewMockClient() + SetTestMode(mock) + defer ResetTestMode() + + _, err := appendInlineAttachmentsToContent("Body", []string{filepath.Join(t.TempDir(), "missing.txt")}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "File not found") { + t.Fatalf("expected file not found error, got %v", err) + } + if len(mock.UploadFileCalls) != 0 { + t.Fatalf("expected no upload attempts, got %d", len(mock.UploadFileCalls)) + } +} + +func TestUploadAttachableSGIDsRequiresAttachableSGID(t *testing.T) { + tempDir := t.TempDir() + path := writeTestAttachmentFile(t, tempDir, "missing-sgid.txt", "content") + + mock := NewMockClient() + mock.UploadFileResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"signed_id": "signed-only"}} + SetTestMode(mock) + defer ResetTestMode() + + _, err := uploadAttachableSGIDs([]string{path}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "missing attachable_sgid") { + t.Fatalf("expected missing attachable_sgid error, got %v", err) + } +} + +func writeTestAttachmentFile(t *testing.T, dir string, name string, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write test attachment file: %v", err) + } + return path +} diff --git a/internal/commands/mock_client_test.go b/internal/commands/mock_client_test.go index 42e09ab..4c6a387 100644 --- a/internal/commands/mock_client_test.go +++ b/internal/commands/mock_client_test.go @@ -20,6 +20,7 @@ type MockClient struct { GetWithPaginationResponse *client.APIResponse FollowLocationResponse *client.APIResponse UploadFileResponse *client.APIResponse + UploadFileResponses []*client.APIResponse PatchMultipartResponse *client.APIResponse @@ -202,6 +203,13 @@ func (m *MockClient) UploadFile(filePath string) (*client.APIResponse, error) { if m.UploadFileError != nil { return nil, m.UploadFileError } + if len(m.UploadFileResponses) > 0 { + idx := len(m.UploadFileCalls) - 1 + if idx >= len(m.UploadFileResponses) { + idx = len(m.UploadFileResponses) - 1 + } + return m.UploadFileResponses[idx], nil + } return m.UploadFileResponse, nil } diff --git a/internal/commands/upload.go b/internal/commands/upload.go index 03012b4..630bd2b 100644 --- a/internal/commands/upload.go +++ b/internal/commands/upload.go @@ -10,13 +10,13 @@ import ( var uploadCmd = &cobra.Command{ Use: "upload", Short: "Upload files", - Long: "Commands for uploading files for use in rich text fields.", + Long: "Commands for uploading files for use in rich text fields and card header images.", } var uploadFileCmd = &cobra.Command{ Use: "file PATH", Short: "Upload a file", - Long: "Uploads a file and returns a signed_id for use in rich text fields.", + Long: "Uploads a file and returns both signed_id (for --image) and attachable_sgid (for inline rich text attachments when embedding manually).", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Upload uses legacy client only — skip SDK initialization diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index 1fb05e5..e26c04c 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -65,7 +65,7 @@ Full CLI coverage: boards, cards, columns, comments, steps, reactions, tags, use 3. **Check breadcrumbs** in responses for available next actions with pre-filled values. 4. **Check for board context** via `.fizzy.yaml` or `--board` flag before listing cards. 5. **Use `fizzy doctor` for setup/config/auth issues** before guessing — it is the primary read-only health check and includes remediation hints. -6. **Rich text fields accept HTML** — use `

` tags for paragraphs, `` for inline images. +6. **Rich text fields accept markdown or HTML** — use repeatable `--attach PATH` for simple end-appended inline attachments, or embed `` tags manually when exact placement matters. 7. **Card description is a string**, but comment body is a nested object — `.description` vs `.body.plain_text`. 8. **Display the welcome message for new signups** — When `signup complete --name` returns `is_new_user: true`, you MUST immediately display the `welcome_message` field prominently to the user. This is a one-time personal note from the CEO — if you skip it, the user will never see it. @@ -577,16 +577,18 @@ fizzy card show CARD_NUMBER # Show card details (includes steps) ```bash fizzy card create --board ID --title "Title" [flags] - --description "HTML" # Card description (HTML) - --description_file PATH # Read description from file + --description "TEXT" # Card description (markdown or HTML) + --description_file PATH # Read description from file (markdown or HTML) + --attach PATH # Upload and append inline attachment at end (repeatable) --image SIGNED_ID # Header image (use signed_id from upload) --tag-ids "id1,id2" # Comma-separated tag IDs --created-at TIMESTAMP # Custom created_at fizzy card update CARD_NUMBER [flags] --title "Title" - --description "HTML" + --description "TEXT" --description_file PATH + --attach PATH --image SIGNED_ID --created-at TIMESTAMP @@ -654,8 +656,8 @@ fizzy column move-right COLUMN_ID # Move column one position right ```bash fizzy comment list --card NUMBER [--page N] [--all] fizzy comment show COMMENT_ID --card NUMBER -fizzy comment create --card NUMBER --body "HTML" [--body_file PATH] [--created-at TIMESTAMP] -fizzy comment update COMMENT_ID --card NUMBER [--body "HTML"] [--body_file PATH] +fizzy comment create --card NUMBER [--body "TEXT"] [--body_file PATH] [--attach PATH] [--created-at TIMESTAMP] +fizzy comment update COMMENT_ID --card NUMBER [--body "TEXT"] [--body_file PATH] [--attach PATH] fizzy comment delete COMMENT_ID --card NUMBER ``` @@ -795,7 +797,9 @@ fizzy upload file PATH | ID | Use For | |---|---| | `signed_id` | Card header/background images (`--image` flag) | -| `attachable_sgid` | Inline images in rich text (descriptions, comments) | +| `attachable_sgid` | Manual inline rich text embedding with `` | + +**Simple inline attachment mode:** prefer `--attach PATH` on `card create`, `card update`, `comment create`, and `comment update` when appending attachments at the end is fine. --- @@ -824,8 +828,14 @@ fizzy comment create --card 42 --body "

Commit $(git rev-parse --short HEAD): fizzy card close 42 ``` -### Create Card with Inline Image +### Create Card with Inline Attachment +Simple mode: +```bash +fizzy card create --board BOARD_ID --title "Bug Report" --description "See the screenshot below" --attach screenshot.png +``` + +Advanced/manual placement mode: ```bash # Upload image SGID=$(fizzy upload file screenshot.png --jq '.data.attachable_sgid') @@ -1060,7 +1070,9 @@ Complete field reference for all resources. Use these exact field paths in jq qu ## Rich Text Formatting -Card descriptions and comments support HTML. For multiple paragraphs with spacing: +Card descriptions and comments support markdown or HTML. For simple inline attachments appended at the end, use `--attach PATH`. + +For exact placement, upload first and embed `` tags manually. For multiple paragraphs with spacing: ```html

First paragraph.

From 8299bb918b25520454a087a9ae581ae25ed111aa Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 13 Apr 2026 15:59:31 -0400 Subject: [PATCH 2/5] Address PR review feedback on attach updates --- internal/commands/card.go | 12 +++++++++ internal/commands/card_test.go | 37 ++++++++++++++++++++++++++ internal/commands/comment.go | 20 +++++++++++--- internal/commands/comment_test.go | 42 ++++++++++++++++++++++++++++++ internal/commands/markdown.go | 7 ++++- internal/commands/markdown_test.go | 10 +++++++ 6 files changed, 124 insertions(+), 4 deletions(-) diff --git a/internal/commands/card.go b/internal/commands/card.go index 22e8e83..891731d 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -375,10 +375,22 @@ var cardUpdateCmd = &cobra.Command{ cardNumber := args[0] + hasDescriptionInput := cardUpdateDescription != "" || cardUpdateDescriptionFile != "" description, err := resolveRichTextContent(cardUpdateDescription, cardUpdateDescriptionFile) if err != nil { return err } + if len(cardUpdateAttach) > 0 && !hasDescriptionInput { + currentData, _, err := getSDK().Cards().Get(cmd.Context(), cardNumber) + if err != nil { + return convertSDKError(err) + } + if current, ok := normalizeAny(currentData).(map[string]any); ok { + if currentDescription, ok := current["description_html"].(string); ok { + description = currentDescription + } + } + } description, err = appendInlineAttachmentsToContent(description, cardUpdateAttach) if err != nil { return err diff --git a/internal/commands/card_test.go b/internal/commands/card_test.go index 2e85c9b..2e6cb40 100644 --- a/internal/commands/card_test.go +++ b/internal/commands/card_test.go @@ -630,6 +630,43 @@ func TestCardUpdate(t *testing.T) { t.Errorf("expected description %q, got %v", expected, body["description"]) } }) + + t.Run("preserves existing description when only attach is provided", func(t *testing.T) { + tempDir := t.TempDir() + attachPath := writeTestAttachmentFile(t, tempDir, "update.txt", "update") + + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{ + "id": "abc", + "description_html": "

Existing description

", + }, + } + mock.PatchResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"id": "abc"}} + mock.UploadFileResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-update"}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardUpdateAttach = []string{attachPath} + err := cardUpdateCmd.RunE(cardUpdateCmd, []string{"42"}) + cardUpdateAttach = nil + + assertExitCode(t, err, 0) + if len(mock.GetCalls) == 0 || mock.GetCalls[0].Path != "/cards/42" { + t.Fatalf("expected existing card fetch before update, got %#v", mock.GetCalls) + } + body := mock.PatchCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + "

Existing description

", + ``, + }, "\n") + if body["description"] != expected { + t.Errorf("expected description %q, got %v", expected, body["description"]) + } + }) } func TestCardDelete(t *testing.T) { diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 8795bcb..f3f9eaf 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -203,18 +203,32 @@ var commentUpdateCmd = &cobra.Command{ return newRequiredFlagError("card") } + commentID := args[0] + cardNumber := commentUpdateCard + + hasBodyInput := commentUpdateBody != "" || commentUpdateBodyFile != "" body, err := resolveRichTextContent(commentUpdateBody, commentUpdateBodyFile) if err != nil { return err } + if len(commentUpdateAttach) > 0 && !hasBodyInput { + currentData, _, err := getSDK().Comments().Get(cmd.Context(), cardNumber, commentID) + if err != nil { + return convertSDKError(err) + } + if current, ok := normalizeAny(currentData).(map[string]any); ok { + if currentBody, ok := current["body"].(map[string]any); ok { + if currentHTML, ok := currentBody["html"].(string); ok { + body = currentHTML + } + } + } + } body, err = appendInlineAttachmentsToContent(body, commentUpdateAttach) if err != nil { return err } - commentID := args[0] - cardNumber := commentUpdateCard - req := &generated.UpdateCommentRequest{} if body != "" { req.Body = body diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index dfdcd9d..713bd1c 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -374,6 +374,48 @@ func TestCommentUpdate(t *testing.T) { } }) + t.Run("preserves existing body when only attach is provided", func(t *testing.T) { + tempDir := t.TempDir() + attachPath := writeTestAttachmentFile(t, tempDir, "update.txt", "update") + + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{ + "id": "comment-1", + "body": map[string]any{ + "html": "

Existing comment

", + "plain_text": "Existing comment", + }, + }, + } + mock.PatchResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"id": "comment-1"}} + mock.UploadFileResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{"attachable_sgid": "sgid-update"}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + commentUpdateCard = "42" + commentUpdateAttach = []string{attachPath} + err := commentUpdateCmd.RunE(commentUpdateCmd, []string{"comment-1"}) + commentUpdateCard = "" + commentUpdateAttach = nil + + assertExitCode(t, err, 0) + if len(mock.GetCalls) == 0 || mock.GetCalls[0].Path != "/cards/42/comments/comment-1" { + t.Fatalf("expected existing comment fetch before update, got %#v", mock.GetCalls) + } + body := mock.PatchCalls[0].Body.(map[string]any) + expected := strings.Join([]string{ + "

Existing comment

", + ``, + }, "\n") + if body["body"] != expected { + t.Errorf("expected body %q, got %v", expected, body["body"]) + } + }) + t.Run("requires card flag", func(t *testing.T) { mock := NewMockClient() SetTestModeWithSDK(mock) diff --git a/internal/commands/markdown.go b/internal/commands/markdown.go index 176e9dc..0279832 100644 --- a/internal/commands/markdown.go +++ b/internal/commands/markdown.go @@ -18,11 +18,16 @@ var md = goldmark.New( // backtickAttachmentRegex matches backtick-wrapped action-text-attachment tags // that goldmark didn't convert (because they were inside HTML blocks). var backtickAttachmentRegex = regexp.MustCompile("`(]*>)(
)`") +var blockMarkdownRegex = regexp.MustCompile("(?m)^(#{1,6}\\s|[*+-]\\s|\\d+\\.\\s|>\\s|```|~~~)") +var inlineMarkdownRegex = regexp.MustCompile(`(\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|[[:space:][:punct:]])[*_][^*_\n]+[*_]([[:space:][:punct:]]|$))`) // containsMarkdownOrHTML checks whether content has HTML tags or markdown // syntax that would benefit from conversion. func containsMarkdownOrHTML(content string) bool { - return strings.ContainsAny(content, "<>`") + if strings.ContainsAny(content, "<>`") { + return true + } + return blockMarkdownRegex.MatchString(content) || inlineMarkdownRegex.MatchString(content) } // markdownToHTML converts markdown content to HTML. Raw HTML in the input is diff --git a/internal/commands/markdown_test.go b/internal/commands/markdown_test.go index 4940193..24b5d13 100644 --- a/internal/commands/markdown_test.go +++ b/internal/commands/markdown_test.go @@ -18,6 +18,16 @@ func TestMarkdownToHTML(t *testing.T) { input: "Just a simple comment", exactOutput: "Just a simple comment", }, + { + name: "common markdown emphasis is converted", + input: "This is **bold** and _italic_", + shouldContain: []string{"bold", "italic"}, + }, + { + name: "markdown list is converted", + input: "- first\n- second", + shouldContain: []string{"
    ", "
  • first
  • ", "
  • second
  • "}, + }, { name: "backtick-wrapped attachment tag is escaped", input: "Manually construct the ``", From 81ebc5e9428231ea5a84f6298d671f97ed9795c7 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 13 Apr 2026 16:13:07 -0400 Subject: [PATCH 3/5] Fix lint shadowing in attach update paths --- internal/commands/card.go | 6 +++--- internal/commands/comment.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/commands/card.go b/internal/commands/card.go index 891731d..4380506 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -381,9 +381,9 @@ var cardUpdateCmd = &cobra.Command{ return err } if len(cardUpdateAttach) > 0 && !hasDescriptionInput { - currentData, _, err := getSDK().Cards().Get(cmd.Context(), cardNumber) - if err != nil { - return convertSDKError(err) + currentData, _, getErr := getSDK().Cards().Get(cmd.Context(), cardNumber) + if getErr != nil { + return convertSDKError(getErr) } if current, ok := normalizeAny(currentData).(map[string]any); ok { if currentDescription, ok := current["description_html"].(string); ok { diff --git a/internal/commands/comment.go b/internal/commands/comment.go index f3f9eaf..52d27a1 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -212,9 +212,9 @@ var commentUpdateCmd = &cobra.Command{ return err } if len(commentUpdateAttach) > 0 && !hasBodyInput { - currentData, _, err := getSDK().Comments().Get(cmd.Context(), cardNumber, commentID) - if err != nil { - return convertSDKError(err) + currentData, _, getErr := getSDK().Comments().Get(cmd.Context(), cardNumber, commentID) + if getErr != nil { + return convertSDKError(getErr) } if current, ok := normalizeAny(currentData).(map[string]any); ok { if currentBody, ok := current["body"].(map[string]any); ok { From b51dbc5fe7369f602fdb7117fc74f3e795971169 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 13 Apr 2026 16:22:49 -0400 Subject: [PATCH 4/5] Address remaining PR review comments --- internal/commands/comment.go | 3 +-- internal/commands/inline_attachments.go | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 52d27a1..a5a30ba 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -4,7 +4,6 @@ import ( "fmt" "strconv" - "github.com/basecamp/fizzy-cli/internal/errors" "github.com/basecamp/fizzy-sdk/go/pkg/generated" "github.com/spf13/cobra" ) @@ -152,7 +151,7 @@ var commentCreateCmd = &cobra.Command{ return err } if body == "" { - return errors.NewInvalidArgsError("required flag --body or --body_file or --attach not provided") + return newRequiredFlagError("body, body_file, or attach") } cardNumber := commentCreateCard diff --git a/internal/commands/inline_attachments.go b/internal/commands/inline_attachments.go index a546cb0..b31d534 100644 --- a/internal/commands/inline_attachments.go +++ b/internal/commands/inline_attachments.go @@ -86,7 +86,10 @@ func validateAttachmentPath(path string) error { if err != nil { return errors.NewError(fmt.Sprintf("Failed to open attachment %s: %v", path, err)) } - return file.Close() + if err := file.Close(); err != nil { + return errors.NewError(fmt.Sprintf("Failed to close attachment %s: %v", path, err)) + } + return nil } func appendAttachmentTags(content string, sgids []string) string { From 1d4c4829e11cf3766c33d06101f8e0bbfe20095d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 13 Apr 2026 17:03:50 -0400 Subject: [PATCH 5/5] Update internal/commands/upload.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/commands/upload.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/upload.go b/internal/commands/upload.go index 630bd2b..76360e8 100644 --- a/internal/commands/upload.go +++ b/internal/commands/upload.go @@ -16,7 +16,7 @@ var uploadCmd = &cobra.Command{ var uploadFileCmd = &cobra.Command{ Use: "file PATH", Short: "Upload a file", - Long: "Uploads a file and returns both signed_id (for --image) and attachable_sgid (for inline rich text attachments when embedding manually).", + Long: "Uploads a file and returns signed_id (for --image) and, when available/supported by the server, attachable_sgid (for inline rich text attachments when embedding manually).", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Upload uses legacy client only — skip SDK initialization