diff --git a/internal/commands/attachment.go b/internal/commands/attachment.go
index 50e39076..97f1111b 100644
--- a/internal/commands/attachment.go
+++ b/internal/commands/attachment.go
@@ -1,9 +1,11 @@
package commands
import (
+ "fmt"
"path/filepath"
"regexp"
"strconv"
+ "strings"
"github.com/robzolkos/fizzy-cli/internal/errors"
"github.com/spf13/cobra"
@@ -27,11 +29,16 @@ var attachmentsCmd = &cobra.Command{
Long: "Commands for viewing and downloading card attachments.",
}
+// Attachment show flags
+var attachmentsShowIncludeComments bool
+
var attachmentsShowCmd = &cobra.Command{
Use: "show CARD_NUMBER",
Short: "List attachments on a card",
- Long: "Lists all attachments embedded in a card's description.",
- Args: cobra.ExactArgs(1),
+ Long: `Lists all attachments embedded in a card's description.
+
+Use --include-comments to also include attachments from comments on the card.`,
+ Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := requireAuthAndAccount(); err != nil {
exitWithError(err)
@@ -51,12 +58,27 @@ var attachmentsShowCmd = &cobra.Command{
descriptionHTML, _ := cardData["description_html"].(string)
attachments := parseAttachments(descriptionHTML)
+ if attachmentsShowIncludeComments {
+ commentsResp, err := client.GetWithPagination("/cards/"+args[0]+"/comments.json", true)
+ if err == nil {
+ if comments, ok := commentsResp.Data.([]interface{}); ok {
+ commentAttachments := extractCommentAttachments(comments)
+ // Re-index and append
+ for _, ca := range commentAttachments {
+ ca.Attachment.Index = len(attachments) + 1
+ attachments = append(attachments, ca.Attachment)
+ }
+ }
+ }
+ }
+
printSuccess(attachments)
},
}
// Attachment download flags
var attachmentDownloadOutput string
+var attachmentsDownloadIncludeComments bool
var attachmentsDownloadCmd = &cobra.Command{
Use: "download CARD_NUMBER [ATTACHMENT_INDEX]",
@@ -66,6 +88,8 @@ var attachmentsDownloadCmd = &cobra.Command{
If ATTACHMENT_INDEX is provided, downloads only that attachment (1-based index).
If ATTACHMENT_INDEX is omitted, downloads all attachments.
+Use --include-comments to also download attachments from comments on the card.
+
Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and their indices.`,
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
@@ -89,6 +113,19 @@ Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and t
descriptionHTML, _ := cardData["description_html"].(string)
attachments := parseAttachments(descriptionHTML)
+ if attachmentsDownloadIncludeComments {
+ commentsResp, err := client.GetWithPagination("/cards/"+cardNumber+"/comments.json", true)
+ if err == nil {
+ if comments, ok := commentsResp.Data.([]interface{}); ok {
+ commentAttachments := extractCommentAttachments(comments)
+ for _, ca := range commentAttachments {
+ ca.Attachment.Index = len(attachments) + 1
+ attachments = append(attachments, ca.Attachment)
+ }
+ }
+ }
+ }
+
if len(attachments) == 0 {
exitWithError(errors.NewNotFoundError("No attachments found on this card"))
}
@@ -112,13 +149,8 @@ Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and t
// Download the files
var results []map[string]interface{}
- for _, attachment := range toDownload {
- // Sanitize filename to prevent path traversal attacks
- outputPath := filepath.Base(attachment.Filename)
- // If downloading single file with custom output name
- if len(toDownload) == 1 && attachmentDownloadOutput != "" {
- outputPath = attachmentDownloadOutput
- }
+ for i, attachment := range toDownload {
+ outputPath := buildOutputPath(attachmentDownloadOutput, attachment.Filename, i+1, len(toDownload))
if err := client.DownloadFile(attachment.DownloadURL, outputPath); err != nil {
exitWithError(err)
@@ -204,7 +236,21 @@ func parseAttachments(html string) []Attachment {
attachments = append(attachments, attachment)
}
- return attachments
+ // Filter out non-downloadable entries (e.g. mentions) that have no filename or download URL
+ filtered := attachments[:0]
+ for _, a := range attachments {
+ if a.Filename == "" && a.DownloadURL == "" {
+ continue
+ }
+ filtered = append(filtered, a)
+ }
+
+ // Re-index after filtering
+ for i := range filtered {
+ filtered[i].Index = i + 1
+ }
+
+ return filtered
}
// extractAttr extracts an attribute value from an HTML attribute string
@@ -217,11 +263,31 @@ func extractAttr(attrs, name string) string {
return ""
}
+// buildOutputPath determines the output filename for a download.
+// For a single file, outputFlag is used as the exact filename.
+// For multiple files, outputFlag is used as a prefix: prefix_1.ext, prefix_2.ext, etc.
+// If outputFlag is empty, the original filename is used (sanitized).
+func buildOutputPath(outputFlag, originalFilename string, index, total int) string {
+ safeName := filepath.Base(originalFilename)
+ if outputFlag == "" {
+ return safeName
+ }
+ if total == 1 {
+ return outputFlag
+ }
+ // Use as prefix: prefix_1.ext
+ ext := filepath.Ext(safeName)
+ prefix := strings.TrimSuffix(outputFlag, filepath.Ext(outputFlag))
+ return fmt.Sprintf("%s_%d%s", prefix, index, ext)
+}
+
func init() {
cardCmd.AddCommand(attachmentsCmd)
+ attachmentsShowCmd.Flags().BoolVar(&attachmentsShowIncludeComments, "include-comments", false, "Also include attachments from comments")
attachmentsCmd.AddCommand(attachmentsShowCmd)
- attachmentsDownloadCmd.Flags().StringVarP(&attachmentDownloadOutput, "output", "o", "", "Output filename (default: original filename, only applies when downloading single attachment)")
+ attachmentsDownloadCmd.Flags().StringVarP(&attachmentDownloadOutput, "output", "o", "", "Output filename (single file) or prefix (multiple files, e.g. -o test produces test_1.png)")
+ attachmentsDownloadCmd.Flags().BoolVar(&attachmentsDownloadIncludeComments, "include-comments", false, "Also include attachments from comments")
attachmentsCmd.AddCommand(attachmentsDownloadCmd)
}
diff --git a/internal/commands/attachment_test.go b/internal/commands/attachment_test.go
index d2023fa2..824fa914 100644
--- a/internal/commands/attachment_test.go
+++ b/internal/commands/attachment_test.go
@@ -73,6 +73,27 @@ func TestParseAttachments(t *testing.T) {
html: "",
expected: []Attachment{},
},
+ {
+ name: "filters out mentions (no filename or download URL)",
+ html: `
`,
+ expected: []Attachment{
+ {
+ Index: 1,
+ Filename: "real.png",
+ ContentType: "image/png",
+ Filesize: 500,
+ SGID: "real-sgid",
+ DownloadURL: "/blobs/blob1/real.png?disposition=attachment",
+ },
+ },
+ },
}
for _, tt := range tests {
@@ -188,6 +209,75 @@ func TestCardAttachmentsCommand(t *testing.T) {
}
}
+func TestBuildOutputPath(t *testing.T) {
+ tests := []struct {
+ name string
+ flag string
+ filename string
+ index int
+ total int
+ expected string
+ }{
+ {
+ name: "no flag uses original filename",
+ flag: "",
+ filename: "screenshot.png",
+ index: 1,
+ total: 1,
+ expected: "screenshot.png",
+ },
+ {
+ name: "single file uses flag as exact name",
+ flag: "output.png",
+ filename: "screenshot.png",
+ index: 1,
+ total: 1,
+ expected: "output.png",
+ },
+ {
+ name: "multiple files uses flag as prefix",
+ flag: "test.png",
+ filename: "screenshot.png",
+ index: 1,
+ total: 3,
+ expected: "test_1.png",
+ },
+ {
+ name: "prefix with second file keeps original extension",
+ flag: "test.png",
+ filename: "document.pdf",
+ index: 2,
+ total: 3,
+ expected: "test_2.pdf",
+ },
+ {
+ name: "prefix without extension",
+ flag: "backup",
+ filename: "image.jpg",
+ index: 1,
+ total: 2,
+ expected: "backup_1.jpg",
+ },
+ {
+ name: "sanitizes path traversal in original filename",
+ flag: "",
+ filename: "../../../etc/passwd",
+ index: 1,
+ total: 1,
+ expected: "passwd",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := buildOutputPath(tt.flag, tt.filename, tt.index, tt.total)
+ if result != tt.expected {
+ t.Errorf("expected %q, got %q", tt.expected, result)
+ }
+ })
+ }
+}
+
func TestExtractAttr(t *testing.T) {
tests := []struct {
attrs string
diff --git a/internal/commands/comment_attachment.go b/internal/commands/comment_attachment.go
new file mode 100644
index 00000000..50cf1391
--- /dev/null
+++ b/internal/commands/comment_attachment.go
@@ -0,0 +1,198 @@
+package commands
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/robzolkos/fizzy-cli/internal/errors"
+ "github.com/robzolkos/fizzy-cli/internal/response"
+ "github.com/spf13/cobra"
+)
+
+// CommentAttachment extends Attachment with comment context
+type CommentAttachment struct {
+ Attachment
+ CommentID string `json:"comment_id"`
+}
+
+var commentAttachmentsCmd = &cobra.Command{
+ Use: "attachments",
+ Short: "Manage comment attachments",
+ Long: "Commands for viewing and downloading attachments embedded in comments.",
+}
+
+// Comment attachments show flags
+var commentAttachmentsShowCard string
+
+var commentAttachmentsShowCmd = &cobra.Command{
+ Use: "show",
+ Short: "List attachments in comments",
+ Long: "Lists all attachments embedded in comment bodies for a card.",
+ Run: func(cmd *cobra.Command, args []string) {
+ if err := requireAuthAndAccount(); err != nil {
+ exitWithError(err)
+ }
+
+ if commentAttachmentsShowCard == "" {
+ exitWithError(newRequiredFlagError("card"))
+ }
+
+ client := getClient()
+ path := "/cards/" + commentAttachmentsShowCard + "/comments.json"
+ resp, err := client.GetWithPagination(path, true)
+ if err != nil {
+ exitWithError(err)
+ }
+
+ comments, ok := resp.Data.([]interface{})
+ if !ok {
+ exitWithError(errors.NewError("Invalid comments response"))
+ }
+
+ attachments := extractCommentAttachments(comments)
+
+ summary := fmt.Sprintf("%d attachments across %d comments on card #%s", len(attachments), len(comments), commentAttachmentsShowCard)
+
+ breadcrumbs := []response.Breadcrumb{
+ breadcrumb("download", fmt.Sprintf("fizzy comment attachments download --card %s", commentAttachmentsShowCard), "Download attachments"),
+ breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", commentAttachmentsShowCard), "List comments"),
+ breadcrumb("card-attachments", fmt.Sprintf("fizzy card attachments show %s", commentAttachmentsShowCard), "Card attachments"),
+ }
+
+ printSuccessWithBreadcrumbs(attachments, summary, breadcrumbs)
+ },
+}
+
+// Comment attachments download flags
+var commentAttachmentsDownloadCard string
+var commentAttachmentsDownloadOutput string
+
+var commentAttachmentsDownloadCmd = &cobra.Command{
+ Use: "download [ATTACHMENT_INDEX]",
+ Short: "Download attachments from comments",
+ Long: `Downloads attachments embedded in comment bodies for a card.
+
+If ATTACHMENT_INDEX is provided, downloads only that attachment (1-based index).
+If ATTACHMENT_INDEX is omitted, downloads all comment attachments.
+
+When downloading a single attachment, -o sets the exact output filename.
+When downloading multiple attachments, -o sets a prefix (e.g. -o test produces test_1.png, test_2.png).
+
+Use 'fizzy comment attachments show --card CARD_NUMBER' to see available attachments and their indices.`,
+ Args: cobra.MaximumNArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ if err := requireAuthAndAccount(); err != nil {
+ exitWithError(err)
+ }
+
+ if commentAttachmentsDownloadCard == "" {
+ exitWithError(newRequiredFlagError("card"))
+ }
+
+ client := getClient()
+ path := "/cards/" + commentAttachmentsDownloadCard + "/comments.json"
+ resp, err := client.GetWithPagination(path, true)
+ if err != nil {
+ exitWithError(err)
+ }
+
+ comments, ok := resp.Data.([]interface{})
+ if !ok {
+ exitWithError(errors.NewError("Invalid comments response"))
+ }
+
+ attachments := extractCommentAttachments(comments)
+
+ if len(attachments) == 0 {
+ exitWithError(errors.NewNotFoundError("No attachments found in comments on this card"))
+ }
+
+ // Determine which attachments to download
+ var toDownload []CommentAttachment
+ if len(args) == 1 {
+ attachmentIndex, err := strconv.Atoi(args[0])
+ if err != nil {
+ exitWithError(errors.NewInvalidArgsError("attachment index must be a number"))
+ }
+ if attachmentIndex < 1 || attachmentIndex > len(attachments) {
+ exitWithError(errors.NewInvalidArgsError("attachment index must be between 1 and " + strconv.Itoa(len(attachments))))
+ }
+ toDownload = []CommentAttachment{attachments[attachmentIndex-1]}
+ } else {
+ toDownload = attachments
+ }
+
+ // Download the files
+ var results []map[string]interface{}
+ for i, attachment := range toDownload {
+ outputPath := buildOutputPath(commentAttachmentsDownloadOutput, attachment.Filename, i+1, len(toDownload))
+
+ if err := client.DownloadFile(attachment.DownloadURL, outputPath); err != nil {
+ exitWithError(err)
+ }
+
+ results = append(results, map[string]interface{}{
+ "filename": attachment.Filename,
+ "saved_to": outputPath,
+ "filesize": attachment.Filesize,
+ "comment_id": attachment.CommentID,
+ })
+ }
+
+ printSuccess(map[string]interface{}{
+ "downloaded": len(results),
+ "files": results,
+ })
+ },
+}
+
+// extractCommentAttachments parses all comments and returns attachments with comment context
+func extractCommentAttachments(comments []interface{}) []CommentAttachment {
+ var allAttachments []CommentAttachment
+ globalIndex := 1
+
+ for _, c := range comments {
+ comment, ok := c.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ commentID, _ := comment["id"].(string)
+
+ // Comment body is an object with html and plain_text fields
+ bodyObj, ok := comment["body"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ bodyHTML, _ := bodyObj["html"].(string)
+ if bodyHTML == "" {
+ continue
+ }
+
+ attachments := parseAttachments(bodyHTML)
+ for _, a := range attachments {
+ a.Index = globalIndex
+ globalIndex++
+ allAttachments = append(allAttachments, CommentAttachment{
+ Attachment: a,
+ CommentID: commentID,
+ })
+ }
+ }
+
+ return allAttachments
+}
+
+func init() {
+ commentCmd.AddCommand(commentAttachmentsCmd)
+
+ // Show
+ commentAttachmentsShowCmd.Flags().StringVar(&commentAttachmentsShowCard, "card", "", "Card number (required)")
+ commentAttachmentsCmd.AddCommand(commentAttachmentsShowCmd)
+
+ // Download
+ commentAttachmentsDownloadCmd.Flags().StringVar(&commentAttachmentsDownloadCard, "card", "", "Card number (required)")
+ commentAttachmentsDownloadCmd.Flags().StringVarP(&commentAttachmentsDownloadOutput, "output", "o", "", "Output filename (single file) or prefix (multiple files, e.g. -o test produces test_1.png)")
+ commentAttachmentsCmd.AddCommand(commentAttachmentsDownloadCmd)
+}
diff --git a/internal/commands/comment_attachment_test.go b/internal/commands/comment_attachment_test.go
new file mode 100644
index 00000000..4d7a42f7
--- /dev/null
+++ b/internal/commands/comment_attachment_test.go
@@ -0,0 +1,364 @@
+package commands
+
+import (
+ "testing"
+
+ "github.com/robzolkos/fizzy-cli/internal/client"
+ "github.com/robzolkos/fizzy-cli/internal/errors"
+)
+
+func TestExtractCommentAttachments(t *testing.T) {
+ tests := []struct {
+ name string
+ comments []interface{}
+ expectedCount int
+ expectedFirst *CommentAttachment
+ }{
+ {
+ name: "comment with attachment",
+ comments: []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": `
+ Download
+ `,
+ "plain_text": "screenshot.png",
+ },
+ },
+ },
+ expectedCount: 1,
+ expectedFirst: &CommentAttachment{
+ Attachment: Attachment{
+ Index: 1,
+ Filename: "screenshot.png",
+ ContentType: "image/png",
+ Filesize: 5000,
+ Width: 800,
+ Height: 600,
+ SGID: "sgid1",
+ DownloadURL: "/blobs/blob1/screenshot.png?disposition=attachment",
+ },
+ CommentID: "comment-1",
+ },
+ },
+ {
+ name: "multiple comments with attachments",
+ comments: []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": `
+ Download
+ `,
+ },
+ },
+ map[string]interface{}{
+ "id": "comment-2",
+ "body": map[string]interface{}{
+ "html": `
+ Download
+ `,
+ },
+ },
+ },
+ expectedCount: 2,
+ },
+ {
+ name: "comment without attachments",
+ comments: []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": "Just text
",
+ "plain_text": "Just text",
+ },
+ },
+ },
+ expectedCount: 0,
+ },
+ {
+ name: "empty comments",
+ comments: []interface{}{},
+ expectedCount: 0,
+ },
+ {
+ name: "mixed comments with and without attachments",
+ comments: []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": "No attachment here
",
+ },
+ },
+ map[string]interface{}{
+ "id": "comment-2",
+ "body": map[string]interface{}{
+ "html": `
+ Download
+ `,
+ },
+ },
+ },
+ expectedCount: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := extractCommentAttachments(tt.comments)
+
+ if len(result) != tt.expectedCount {
+ t.Errorf("expected %d attachments, got %d", tt.expectedCount, len(result))
+ return
+ }
+
+ if tt.expectedFirst != nil && len(result) > 0 {
+ actual := result[0]
+ if actual.Index != tt.expectedFirst.Index {
+ t.Errorf("expected index %d, got %d", tt.expectedFirst.Index, actual.Index)
+ }
+ if actual.Filename != tt.expectedFirst.Filename {
+ t.Errorf("expected filename %q, got %q", tt.expectedFirst.Filename, actual.Filename)
+ }
+ if actual.CommentID != tt.expectedFirst.CommentID {
+ t.Errorf("expected comment_id %q, got %q", tt.expectedFirst.CommentID, actual.CommentID)
+ }
+ if actual.DownloadURL != tt.expectedFirst.DownloadURL {
+ t.Errorf("expected download_url %q, got %q", tt.expectedFirst.DownloadURL, actual.DownloadURL)
+ }
+ }
+
+ // Verify global indexing is sequential
+ for i, a := range result {
+ if a.Index != i+1 {
+ t.Errorf("attachment %d: expected index %d, got %d", i, i+1, a.Index)
+ }
+ }
+ })
+ }
+}
+
+func TestCommentAttachmentsShowCommand(t *testing.T) {
+ t.Run("shows attachments from comments", func(t *testing.T) {
+ mock := NewMockClient()
+ mock.GetWithPaginationResponse = &client.APIResponse{
+ StatusCode: 200,
+ Data: []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": `
+ Download
+ `,
+ },
+ },
+ },
+ }
+
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsShowCard = "172"
+ RunTestCommand(func() {
+ commentAttachmentsShowCmd.Run(commentAttachmentsShowCmd, []string{})
+ })
+ commentAttachmentsShowCard = ""
+
+ if result.ExitCode != 0 {
+ t.Errorf("expected exit code 0, got %d", result.ExitCode)
+ }
+ if !result.Response.Success {
+ t.Errorf("expected success, got error")
+ }
+ if mock.GetWithPaginationCalls[0].Path != "/cards/172/comments.json" {
+ t.Errorf("expected path '/cards/172/comments.json', got '%s'", mock.GetWithPaginationCalls[0].Path)
+ }
+ })
+
+ t.Run("requires card flag", func(t *testing.T) {
+ mock := NewMockClient()
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsShowCard = ""
+ RunTestCommand(func() {
+ commentAttachmentsShowCmd.Run(commentAttachmentsShowCmd, []string{})
+ })
+
+ if result.ExitCode != errors.ExitInvalidArgs {
+ t.Errorf("expected exit code %d, got %d", errors.ExitInvalidArgs, result.ExitCode)
+ }
+ })
+}
+
+func TestCommentAttachmentsDownloadCommand(t *testing.T) {
+ commentsWithAttachment := []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": `
+ Download
+ `,
+ },
+ },
+ }
+
+ commentsWithMultipleAttachments := []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": `
+ Download
+
+
+ Download
+ `,
+ },
+ },
+ }
+
+ t.Run("downloads all comment attachments", func(t *testing.T) {
+ mock := NewMockClient()
+ mock.GetWithPaginationResponse = &client.APIResponse{
+ StatusCode: 200,
+ Data: commentsWithMultipleAttachments,
+ }
+
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsDownloadCard = "172"
+ RunTestCommand(func() {
+ commentAttachmentsDownloadCmd.Run(commentAttachmentsDownloadCmd, []string{})
+ })
+ commentAttachmentsDownloadCard = ""
+
+ if !result.Response.Success {
+ t.Errorf("expected success, got error: %v", result.Response)
+ }
+ if len(mock.DownloadFileCalls) != 2 {
+ t.Errorf("expected 2 downloads, got %d", len(mock.DownloadFileCalls))
+ }
+ })
+
+ t.Run("downloads single attachment by index", func(t *testing.T) {
+ mock := NewMockClient()
+ mock.GetWithPaginationResponse = &client.APIResponse{
+ StatusCode: 200,
+ Data: commentsWithAttachment,
+ }
+
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsDownloadCard = "172"
+ RunTestCommand(func() {
+ commentAttachmentsDownloadCmd.Run(commentAttachmentsDownloadCmd, []string{"1"})
+ })
+ commentAttachmentsDownloadCard = ""
+
+ if !result.Response.Success {
+ t.Errorf("expected success, got error: %v", result.Response)
+ }
+ if len(mock.DownloadFileCalls) != 1 {
+ t.Errorf("expected 1 download, got %d", len(mock.DownloadFileCalls))
+ }
+ if mock.DownloadFileCalls[0].URLPath != "/blobs/blob1/test.png?disposition=attachment" {
+ t.Errorf("expected download URL '/blobs/blob1/test.png?disposition=attachment', got '%s'", mock.DownloadFileCalls[0].URLPath)
+ }
+ })
+
+ t.Run("errors on no attachments", func(t *testing.T) {
+ mock := NewMockClient()
+ mock.GetWithPaginationResponse = &client.APIResponse{
+ StatusCode: 200,
+ Data: []interface{}{
+ map[string]interface{}{
+ "id": "comment-1",
+ "body": map[string]interface{}{
+ "html": "No images here
",
+ },
+ },
+ },
+ }
+
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsDownloadCard = "172"
+ RunTestCommand(func() {
+ commentAttachmentsDownloadCmd.Run(commentAttachmentsDownloadCmd, []string{})
+ })
+ commentAttachmentsDownloadCard = ""
+
+ if result.Response.Success {
+ t.Error("expected error, got success")
+ }
+ })
+
+ t.Run("errors on invalid index", func(t *testing.T) {
+ mock := NewMockClient()
+ mock.GetWithPaginationResponse = &client.APIResponse{
+ StatusCode: 200,
+ Data: commentsWithAttachment,
+ }
+
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsDownloadCard = "172"
+ RunTestCommand(func() {
+ commentAttachmentsDownloadCmd.Run(commentAttachmentsDownloadCmd, []string{"abc"})
+ })
+ commentAttachmentsDownloadCard = ""
+
+ if result.Response.Success {
+ t.Error("expected error for non-numeric index")
+ }
+ })
+
+ t.Run("errors on out of range index", func(t *testing.T) {
+ mock := NewMockClient()
+ mock.GetWithPaginationResponse = &client.APIResponse{
+ StatusCode: 200,
+ Data: commentsWithAttachment,
+ }
+
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsDownloadCard = "172"
+ RunTestCommand(func() {
+ commentAttachmentsDownloadCmd.Run(commentAttachmentsDownloadCmd, []string{"5"})
+ })
+ commentAttachmentsDownloadCard = ""
+
+ if result.Response.Success {
+ t.Error("expected error for out of range index")
+ }
+ })
+
+ t.Run("requires card flag", func(t *testing.T) {
+ mock := NewMockClient()
+ result := SetTestMode(mock)
+ SetTestConfig("token", "account", "https://api.example.com")
+ defer ResetTestMode()
+
+ commentAttachmentsDownloadCard = ""
+ RunTestCommand(func() {
+ commentAttachmentsDownloadCmd.Run(commentAttachmentsDownloadCmd, []string{})
+ })
+
+ if result.ExitCode != errors.ExitInvalidArgs {
+ t.Errorf("expected exit code %d, got %d", errors.ExitInvalidArgs, result.ExitCode)
+ }
+ })
+}
diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md
index c2f89be3..37728ec3 100644
--- a/skills/fizzy/SKILL.md
+++ b/skills/fizzy/SKILL.md
@@ -15,7 +15,7 @@ Manage Fizzy boards, cards, steps, comments, reactions, and pins.
| card | `card list` | `card show NUMBER` | `card create` | `card update NUMBER` | `card delete NUMBER` | `card move NUMBER` |
| search | `search QUERY` | - | - | - | - | - |
| column | `column list --board ID` | `column show ID --board ID` | `column create` | `column update ID` | `column delete ID` | - |
-| comment | `comment list --card NUMBER` | `comment show ID --card NUMBER` | `comment create` | `comment update ID` | `comment delete ID` | - |
+| comment | `comment list --card NUMBER` | `comment show ID --card NUMBER` | `comment create` | `comment update ID` | `comment delete ID` | `comment attachments show --card NUMBER` |
| step | - | `step show ID --card NUMBER` | `step create` | `step update ID` | `step delete ID` | - |
| reaction | `reaction list` | - | `reaction create` | - | `reaction delete ID` | - |
| tag | `tag list` | - | - | - | - | - |
@@ -546,9 +546,9 @@ fizzy card image-remove CARD_NUMBER # Remove header image
#### Attachments
```bash
-fizzy card attachments show CARD_NUMBER # List attachments
-fizzy card attachments download CARD_NUMBER [INDEX] # Download (1-based index)
- -o, --output FILENAME # Output filename (single file)
+fizzy card attachments show CARD_NUMBER [--include-comments] # List attachments
+fizzy card attachments download CARD_NUMBER [INDEX] [--include-comments] # Download (1-based index)
+ -o, --output FILENAME # Exact name (single) or prefix (multiple: test_1.png, test_2.png)
```
### Columns
@@ -573,6 +573,14 @@ fizzy comment update COMMENT_ID --card NUMBER [--body "HTML"] [--body_file PATH]
fizzy comment delete COMMENT_ID --card NUMBER
```
+#### Comment Attachments
+
+```bash
+fizzy comment attachments show --card NUMBER # List attachments in comments
+fizzy comment attachments download --card NUMBER [INDEX] # Download (1-based index)
+ -o, --output FILENAME # Exact name (single) or prefix (multiple: test_1.png, test_2.png)
+```
+
### Steps (To-Do Items)
Steps are returned in `card show` response. No separate list command.