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: `
+ + @user + + + Download + +
`, + 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.