diff --git a/README.md b/README.md index ef9f997..e8dd770 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,30 @@ fizzy search "bug" | jq -r '.summary' The summary adapts to pagination flags (`--page N` or `--all`) and includes contextual details like unread counts for notifications. +### Breadcrumbs + +Command responses include a `breadcrumbs` array with suggested next actions. This is designed for AI agents (and humans) to discover contextual workflows without needing to know the full CLI. + +```bash +fizzy card show 42 | jq '.breadcrumbs' +``` + +```json +[ + {"action": "comment", "cmd": "fizzy comment create --card 42 --body \"text\"", "description": "Add comment"}, + {"action": "triage", "cmd": "fizzy card column 42 --column ", "description": "Move to column"}, + {"action": "close", "cmd": "fizzy card close 42", "description": "Close card"}, + {"action": "assign", "cmd": "fizzy card assign 42 --user ", "description": "Assign user"} +] +``` + +Each breadcrumb contains: +- `action`: A short identifier for the action type +- `cmd`: The complete CLI command to execute +- `description`: Human-readable description + +Breadcrumbs are included by default in all responses. They are contextual - after creating a card you'll see suggestions to view, triage, or comment on it; after listing cards you'll see suggestions to show a specific card, create a new one, or search. + When creating resources, the CLI automatically follows the `Location` header to fetch the complete resource data: ```json diff --git a/internal/commands/auth.go b/internal/commands/auth.go index c53cf5e..988a1b9 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -2,6 +2,7 @@ package commands import ( "github.com/robzolkos/fizzy-cli/internal/config" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -27,10 +28,17 @@ var authLoginCmd = &cobra.Command{ exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("status", "fizzy auth status", "Check auth status"), + breadcrumb("identity", "fizzy identity show", "View identity"), + breadcrumb("boards", "fizzy board list", "List boards"), + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "authenticated": true, "message": "Token saved to config file", - }) + }, "", breadcrumbs) }, } @@ -43,10 +51,15 @@ var authLogoutCmd = &cobra.Command{ exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("login", "fizzy auth login ", "Log in again"), + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "authenticated": false, "message": "Logged out successfully", - }) + }, "", breadcrumbs) }, } @@ -74,7 +87,13 @@ var authStatusCmd = &cobra.Command{ } } - printSuccess(status) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("identity", "fizzy identity show", "View identity"), + breadcrumb("logout", "fizzy auth logout", "Log out"), + } + + printSuccessWithBreadcrumbs(status, "", breadcrumbs) }, } diff --git a/internal/commands/board.go b/internal/commands/board.go index a5670e6..fd5a7f3 100644 --- a/internal/commands/board.go +++ b/internal/commands/board.go @@ -2,8 +2,10 @@ package commands import ( "fmt" + "os" "github.com/robzolkos/fizzy-cli/internal/errors" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -49,8 +51,23 @@ var boardListCmd = &cobra.Command{ summary += fmt.Sprintf(" (page %d)", boardListPage) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", "fizzy board show ", "View board details"), + breadcrumb("cards", "fizzy card list --board ", "List cards on board"), + breadcrumb("columns", "fizzy column list --board ", "List board columns"), + } + hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndSummary(resp.Data, hasNext, resp.LinkNext, summary) + if hasNext { + nextPage := boardListPage + 1 + if nextPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy board list --page %d", nextPage), "Next page")) + } + + printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) }, } @@ -64,8 +81,10 @@ var boardShowCmd = &cobra.Command{ exitWithError(err) } + boardID := args[0] + client := getClient() - resp, err := client.Get("/boards/" + args[0] + ".json") + resp, err := client.Get("/boards/" + boardID + ".json") if err != nil { exitWithError(err) } @@ -78,7 +97,14 @@ var boardShowCmd = &cobra.Command{ } } - printSuccessWithSummary(resp.Data, summary) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("cards", fmt.Sprintf("fizzy card list --board %s", boardID), "List cards"), + breadcrumb("columns", fmt.Sprintf("fizzy column list --board %s", boardID), "List columns"), + breadcrumb("create-card", fmt.Sprintf("fizzy card create --board %s --title \"title\"", boardID), "Create card"), + } + + printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) }, } @@ -125,7 +151,33 @@ var boardCreateCmd = &cobra.Command{ if resp.Location != "" { followResp, err := client.FollowLocation(resp.Location) if err == nil && followResp != nil { - printSuccessWithLocation(followResp.Data, resp.Location) + // Extract board ID from response + boardID := "" + if board, ok := followResp.Data.(map[string]interface{}); ok { + if id, ok := board["id"].(string); ok { + boardID = id + } + } + + // Build breadcrumbs + var breadcrumbs []response.Breadcrumb + if boardID != "" { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy board show %s", boardID), "View board details"), + breadcrumb("cards", fmt.Sprintf("fizzy card list --board %s", boardID), "List cards"), + breadcrumb("columns", fmt.Sprintf("fizzy column list --board %s", boardID), "List columns"), + } + } + + respObj := response.SuccessWithBreadcrumbs(followResp.Data, "", breadcrumbs) + respObj.Location = resp.Location + if lastResult != nil { + lastResult.Response = respObj + lastResult.ExitCode = 0 + panic(testExitSignal{}) + } + respObj.Print() + os.Exit(0) return } // If follow fails, just return success with location @@ -152,6 +204,8 @@ var boardUpdateCmd = &cobra.Command{ exitWithError(err) } + boardID := args[0] + boardParams := make(map[string]interface{}) if boardUpdateName != "" { @@ -169,12 +223,18 @@ var boardUpdateCmd = &cobra.Command{ } client := getClient() - resp, err := client.Patch("/boards/"+args[0]+".json", body) + resp, err := client.Patch("/boards/"+boardID+".json", body) if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy board show %s", boardID), "View board"), + breadcrumb("cards", fmt.Sprintf("fizzy card list --board %s", boardID), "List cards"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -194,9 +254,15 @@ var boardDeleteCmd = &cobra.Command{ exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("boards", "fizzy board list", "List boards"), + breadcrumb("create", "fizzy board create --name \"name\"", "Create new board"), + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "deleted": true, - }) + }, "", breadcrumbs) }, } diff --git a/internal/commands/card.go b/internal/commands/card.go index 728c56f..1cd3555 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/robzolkos/fizzy-cli/internal/errors" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -186,8 +187,23 @@ var cardListCmd = &cobra.Command{ summary += fmt.Sprintf(" (page %d)", cardListPage) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", "fizzy card show ", "View card details"), + breadcrumb("create", "fizzy card create --board --title \"title\"", "Create new card"), + breadcrumb("search", "fizzy search \"query\"", "Search cards"), + } + hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndSummary(resp.Data, hasNext, resp.LinkNext, summary) + if hasNext { + nextPage := cardListPage + 1 + if nextPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy card list --page %d", nextPage), "Next page")) + } + + printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) }, } @@ -207,15 +223,25 @@ var cardShowCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + // Build summary - summary := fmt.Sprintf("Card #%s", args[0]) + summary := fmt.Sprintf("Card #%s", cardNumber) if card, ok := resp.Data.(map[string]interface{}); ok { if title, ok := card["title"].(string); ok { - summary = fmt.Sprintf("Card #%s: %s", args[0], title) + summary = fmt.Sprintf("Card #%s: %s", cardNumber, title) } } - printSuccessWithSummary(resp.Data, summary) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("comment", fmt.Sprintf("fizzy comment create --card %s --body \"text\"", cardNumber), "Add comment"), + breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), + breadcrumb("close", fmt.Sprintf("fizzy card close %s", cardNumber), "Close card"), + breadcrumb("assign", fmt.Sprintf("fizzy card assign %s --user ", cardNumber), "Assign user"), + } + + printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) }, } @@ -285,7 +311,33 @@ var cardCreateCmd = &cobra.Command{ if resp.Location != "" { followResp, err := client.FollowLocation(resp.Location) if err == nil && followResp != nil { - printSuccessWithLocation(followResp.Data, resp.Location) + // Extract card number from response + cardNumber := "" + if card, ok := followResp.Data.(map[string]interface{}); ok { + if num, ok := card["number"].(float64); ok { + cardNumber = fmt.Sprintf("%d", int(num)) + } + } + + // Build breadcrumbs + var breadcrumbs []response.Breadcrumb + if cardNumber != "" { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card details"), + breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), + breadcrumb("comment", fmt.Sprintf("fizzy comment create --card %s --body \"text\"", cardNumber), "Add comment"), + } + } + + respObj := response.SuccessWithBreadcrumbs(followResp.Data, "", breadcrumbs) + respObj.Location = resp.Location + if lastResult != nil { + lastResult.Response = respObj + lastResult.ExitCode = 0 + panic(testExitSignal{}) + } + respObj.Print() + os.Exit(0) return } printSuccessWithLocation(nil, resp.Location) @@ -344,7 +396,16 @@ var cardUpdateCmd = &cobra.Command{ exitWithError(err) } - printSuccess(resp.Data) + cardNumber := args[0] + + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card details"), + breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), + breadcrumb("comment", fmt.Sprintf("fizzy comment create --card %s --body \"text\"", cardNumber), "Add comment"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -364,9 +425,15 @@ var cardDeleteCmd = &cobra.Command{ exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("cards", "fizzy card list", "List cards"), + breadcrumb("create", "fizzy card create --board --title \"title\"", "Create new card"), + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "deleted": true, - }) + }, "", breadcrumbs) }, } @@ -380,17 +447,25 @@ var cardCloseCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Post("/cards/"+args[0]+"/closure.json", nil) + resp, err := client.Post("/cards/"+cardNumber+"/closure.json", nil) if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("reopen", fmt.Sprintf("fizzy card reopen %s", cardNumber), "Reopen card"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -404,17 +479,26 @@ var cardReopenCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Delete("/cards/" + args[0] + "/closure.json") + resp, err := client.Delete("/cards/" + cardNumber + "/closure.json") if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("close", fmt.Sprintf("fizzy card close %s", cardNumber), "Close card"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -428,17 +512,25 @@ var cardPostponeCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Post("/cards/"+args[0]+"/not_now.json", nil) + resp, err := client.Post("/cards/"+cardNumber+"/not_now.json", nil) if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} + } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -459,31 +551,39 @@ var cardMoveCmd = &cobra.Command{ exitWithError(newRequiredFlagError("to")) } + cardNumber := args[0] + body := map[string]interface{}{ "board_id": cardMoveBoard, } client := getClient() - _, err := client.Patch("/cards/"+args[0]+"/board.json", body) + _, err := client.Patch("/cards/"+cardNumber+"/board.json", body) if err != nil { exitWithError(err) } // Fetch the updated card to show confirmation with title - resp, err := client.Get("/cards/" + args[0] + ".json") + resp, err := client.Get("/cards/" + cardNumber + ".json") if err != nil { exitWithError(err) } // Build summary with card title if available - summary := fmt.Sprintf("Card #%s moved to board %s", args[0], cardMoveBoard) + summary := fmt.Sprintf("Card #%s moved to board %s", cardNumber, cardMoveBoard) if card, ok := resp.Data.(map[string]interface{}); ok { if title, ok := card["title"].(string); ok { - summary = fmt.Sprintf("Card #%s \"%s\" moved to board %s", args[0], title, cardMoveBoard) + summary = fmt.Sprintf("Card #%s \"%s\" moved to board %s", cardNumber, title, cardMoveBoard) } } - printSuccessWithSummary(resp.Data, summary) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), + } + + printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) }, } @@ -504,41 +604,50 @@ var cardColumnCmd = &cobra.Command{ exitWithError(newRequiredFlagError("column")) } + cardNumber := args[0] + + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("untriage", fmt.Sprintf("fizzy card untriage %s", cardNumber), "Send back to triage"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("close", fmt.Sprintf("fizzy card close %s", cardNumber), "Close card"), + } + client := getClient() if pseudo, ok := parsePseudoColumnID(cardColumnColumn); ok { switch pseudo.Kind { case "triage": - resp, err := client.Delete("/cards/" + args[0] + "/triage.json") + resp, err := client.Delete("/cards/" + cardNumber + "/triage.json") if err != nil { exitWithError(err) } - if resp != nil && resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) return case "not_now": - resp, err := client.Post("/cards/"+args[0]+"/not_now.json", nil) + resp, err := client.Post("/cards/"+cardNumber+"/not_now.json", nil) if err != nil { exitWithError(err) } - if resp != nil && resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) return case "closed": - resp, err := client.Post("/cards/"+args[0]+"/closure.json", nil) + resp, err := client.Post("/cards/"+cardNumber+"/closure.json", nil) if err != nil { exitWithError(err) } - if resp != nil && resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) return } } @@ -547,16 +656,16 @@ var cardColumnCmd = &cobra.Command{ "column_id": cardColumnColumn, } - resp, err := client.Post("/cards/"+args[0]+"/triage.json", body) + resp, err := client.Post("/cards/"+cardNumber+"/triage.json", body) if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -570,19 +679,27 @@ var cardUntriageCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Delete("/cards/" + args[0] + "/triage.json") + resp, err := client.Delete("/cards/" + cardNumber + "/triage.json") if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{ "untriaged": true, - }) + } } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -603,21 +720,29 @@ var cardAssignCmd = &cobra.Command{ exitWithError(newRequiredFlagError("user")) } + cardNumber := args[0] + body := map[string]interface{}{ "assignee_id": cardAssignUser, } client := getClient() - resp, err := client.Post("/cards/"+args[0]+"/assignments.json", body) + resp, err := client.Post("/cards/"+cardNumber+"/assignments.json", body) if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("people", "fizzy user list", "List users"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -638,21 +763,29 @@ var cardTagCmd = &cobra.Command{ exitWithError(newRequiredFlagError("tag")) } + cardNumber := args[0] + body := map[string]interface{}{ "tag_title": cardTagTag, } client := getClient() - resp, err := client.Post("/cards/"+args[0]+"/taggings.json", body) + resp, err := client.Post("/cards/"+cardNumber+"/taggings.json", body) if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("tags", "fizzy tag list", "List tags"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -666,17 +799,25 @@ var cardWatchCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Post("/cards/"+args[0]+"/watch.json", nil) + resp, err := client.Post("/cards/"+cardNumber+"/watch.json", nil) if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("notifications", "fizzy notification list", "View notifications"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -690,17 +831,25 @@ var cardUnwatchCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Delete("/cards/" + args[0] + "/watch.json") + resp, err := client.Delete("/cards/" + cardNumber + "/watch.json") if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("notifications", "fizzy notification list", "View notifications"), } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} + } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -714,17 +863,25 @@ var cardImageRemoveCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Delete("/cards/" + args[0] + "/image.json") + resp, err := client.Delete("/cards/" + cardNumber + "/image.json") if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("update", fmt.Sprintf("fizzy card update %s", cardNumber), "Update card"), } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} + } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -738,17 +895,25 @@ var cardGoldenCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Post("/cards/"+args[0]+"/goldness.json", nil) + resp, err := client.Post("/cards/"+cardNumber+"/goldness.json", nil) if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("golden", "fizzy card list --indexed-by golden", "List golden cards"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -762,17 +927,25 @@ var cardUngoldenCmd = &cobra.Command{ exitWithError(err) } + cardNumber := args[0] + client := getClient() - resp, err := client.Delete("/cards/" + args[0] + "/goldness.json") + resp, err := client.Delete("/cards/" + cardNumber + "/goldness.json") if err != nil { exitWithError(err) } - if resp.Data != nil { - printSuccess(resp.Data) - } else { - printSuccess(map[string]interface{}{}) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("golden", "fizzy card list --indexed-by golden", "List golden cards"), + } + + data := resp.Data + if data == nil { + data = map[string]interface{}{} } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } diff --git a/internal/commands/column.go b/internal/commands/column.go index 3ff8b76..7a082a2 100644 --- a/internal/commands/column.go +++ b/internal/commands/column.go @@ -2,8 +2,10 @@ package commands import ( "fmt" + "os" "github.com/robzolkos/fizzy-cli/internal/errors" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -50,7 +52,13 @@ var columnListCmd = &cobra.Command{ // Build summary summary := fmt.Sprintf("%d columns", len(cols)) - printSuccessWithSummary(cols, summary) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("create", fmt.Sprintf("fizzy column create --board %s --name \"name\"", boardID), "Create column"), + breadcrumb("cards", fmt.Sprintf("fizzy card list --board %s", boardID), "List cards"), + } + + printSuccessWithBreadcrumbs(cols, summary, breadcrumbs) }, } @@ -67,8 +75,14 @@ var columnShowCmd = &cobra.Command{ exitWithError(err) } - if pseudo, ok := parsePseudoColumnID(args[0]); ok { - printSuccess(pseudoColumnObject(pseudo)) + columnID := args[0] + + if pseudo, ok := parsePseudoColumnID(columnID); ok { + // For pseudo columns, we don't have a board ID context + breadcrumbs := []response.Breadcrumb{ + breadcrumb("columns", "fizzy column list --board ", "List columns"), + } + printSuccessWithBreadcrumbs(pseudoColumnObject(pseudo), "", breadcrumbs) return } @@ -78,12 +92,18 @@ var columnShowCmd = &cobra.Command{ } client := getClient() - resp, err := client.Get("/boards/" + boardID + "/columns/" + args[0] + ".json") + resp, err := client.Get("/boards/" + boardID + "/columns/" + columnID + ".json") if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("columns", fmt.Sprintf("fizzy column list --board %s", boardID), "List columns"), + breadcrumb("update", fmt.Sprintf("fizzy column update %s --board %s", columnID, boardID), "Update column"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -130,7 +150,32 @@ var columnCreateCmd = &cobra.Command{ if resp.Location != "" { followResp, err := client.FollowLocation(resp.Location) if err == nil && followResp != nil { - printSuccessWithLocation(followResp.Data, resp.Location) + // Extract column ID from response + columnID := "" + if col, ok := followResp.Data.(map[string]interface{}); ok { + if id, ok := col["id"].(string); ok { + columnID = id + } + } + + // Build breadcrumbs + var breadcrumbs []response.Breadcrumb + if columnID != "" { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("columns", fmt.Sprintf("fizzy column list --board %s", boardID), "List columns"), + breadcrumb("show", fmt.Sprintf("fizzy column show %s --board %s", columnID, boardID), "View column"), + } + } + + respObj := response.SuccessWithBreadcrumbs(followResp.Data, "", breadcrumbs) + respObj.Location = resp.Location + if lastResult != nil { + lastResult.Response = respObj + lastResult.ExitCode = 0 + panic(testExitSignal{}) + } + respObj.Print() + os.Exit(0) return } printSuccessWithLocation(nil, resp.Location) @@ -177,13 +222,21 @@ var columnUpdateCmd = &cobra.Command{ "column": columnParams, } + columnID := args[0] + client := getClient() - resp, err := client.Patch("/boards/"+boardID+"/columns/"+args[0]+".json", body) + resp, err := client.Patch("/boards/"+boardID+"/columns/"+columnID+".json", body) if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("columns", fmt.Sprintf("fizzy column list --board %s", boardID), "List columns"), + breadcrumb("show", fmt.Sprintf("fizzy column show %s --board %s", columnID, boardID), "View column"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -215,9 +268,15 @@ var columnDeleteCmd = &cobra.Command{ exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("columns", fmt.Sprintf("fizzy column list --board %s", boardID), "List columns"), + breadcrumb("create", fmt.Sprintf("fizzy column create --board %s --name \"name\"", boardID), "Create column"), + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "deleted": true, - }) + }, "", breadcrumbs) }, } diff --git a/internal/commands/comment.go b/internal/commands/comment.go index c2ea745..a30c157 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -54,8 +55,15 @@ var commentListCmd = &cobra.Command{ summary += fmt.Sprintf(" (page %d)", commentListPage) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("add", fmt.Sprintf("fizzy comment create --card %s --body \"text\"", commentListCard), "Add comment"), + breadcrumb("react", fmt.Sprintf("fizzy reaction create --card %s --comment --content \"👍\"", commentListCard), "Add reaction"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", commentListCard), "View card"), + } + hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndSummary(resp.Data, hasNext, resp.LinkNext, summary) + printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) }, } @@ -76,13 +84,23 @@ var commentShowCmd = &cobra.Command{ exitWithError(newRequiredFlagError("card")) } + commentID := args[0] + cardNumber := commentShowCard + client := getClient() - resp, err := client.Get("/cards/" + commentShowCard + "/comments/" + args[0] + ".json") + resp, err := client.Get("/cards/" + cardNumber + "/comments/" + commentID + ".json") if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("update", fmt.Sprintf("fizzy comment update %s --card %s", commentID, cardNumber), "Edit comment"), + breadcrumb("react", fmt.Sprintf("fizzy reaction create --card %s --comment %s --content \"👍\"", cardNumber, commentID), "Add reaction"), + breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", cardNumber), "List comments"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -130,24 +148,40 @@ var commentCreateCmd = &cobra.Command{ "comment": commentParams, } + cardNumber := commentCreateCard + client := getClient() - resp, err := client.Post("/cards/"+commentCreateCard+"/comments.json", reqBody) + resp, err := client.Post("/cards/"+cardNumber+"/comments.json", reqBody) if err != nil { exitWithError(err) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", cardNumber), "List comments"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + } + // Create returns location header - follow it to get the created resource if resp.Location != "" { followResp, err := client.FollowLocation(resp.Location) if err == nil && followResp != nil { - printSuccessWithLocation(followResp.Data, resp.Location) + respObj := response.SuccessWithBreadcrumbs(followResp.Data, "", breadcrumbs) + respObj.Location = resp.Location + if lastResult != nil { + lastResult.Response = respObj + lastResult.ExitCode = 0 + panic(testExitSignal{}) + } + respObj.Print() + os.Exit(0) return } printSuccessWithLocation(nil, resp.Location) return } - printSuccess(resp.Data) + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -186,13 +220,22 @@ var commentUpdateCmd = &cobra.Command{ "comment": commentParams, } + commentID := args[0] + cardNumber := commentUpdateCard + client := getClient() - resp, err := client.Patch("/cards/"+commentUpdateCard+"/comments/"+args[0]+".json", reqBody) + resp, err := client.Patch("/cards/"+cardNumber+"/comments/"+commentID+".json", reqBody) if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy comment show %s --card %s", commentID, cardNumber), "View comment"), + breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", cardNumber), "List comments"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -213,15 +256,23 @@ var commentDeleteCmd = &cobra.Command{ exitWithError(newRequiredFlagError("card")) } + cardNumber := commentDeleteCard + client := getClient() - _, err := client.Delete("/cards/" + commentDeleteCard + "/comments/" + args[0] + ".json") + _, err := client.Delete("/cards/" + cardNumber + "/comments/" + args[0] + ".json") if err != nil { exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", cardNumber), "List comments"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "deleted": true, - }) + }, "", breadcrumbs) }, } diff --git a/internal/commands/identity.go b/internal/commands/identity.go index f7dcfd4..395841c 100644 --- a/internal/commands/identity.go +++ b/internal/commands/identity.go @@ -1,6 +1,7 @@ package commands import ( + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -26,7 +27,12 @@ var identityShowCmd = &cobra.Command{ exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("status", "fizzy auth status", "Auth status"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } diff --git a/internal/commands/notification.go b/internal/commands/notification.go index 17a6eb7..0af4b24 100644 --- a/internal/commands/notification.go +++ b/internal/commands/notification.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -57,8 +58,23 @@ var notificationListCmd = &cobra.Command{ summary = fmt.Sprintf("%d notifications (%d unread, page %d)", count, unreadCount, notificationListPage) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("read", "fizzy notification read ", "Mark as read"), + breadcrumb("read-all", "fizzy notification read-all", "Mark all as read"), + breadcrumb("show", "fizzy card show ", "View card"), + } + hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndSummary(resp.Data, hasNext, resp.LinkNext, summary) + if hasNext { + nextPage := notificationListPage + 1 + if nextPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy notification list --page %d", nextPage), "Next page")) + } + + printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) }, } @@ -78,7 +94,12 @@ var notificationReadCmd = &cobra.Command{ exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("notifications", "fizzy notification list", "List notifications"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -98,7 +119,12 @@ var notificationUnreadCmd = &cobra.Command{ exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("notifications", "fizzy notification list", "List notifications"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -117,7 +143,12 @@ var notificationReadAllCmd = &cobra.Command{ exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("notifications", "fizzy notification list", "List notifications"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } diff --git a/internal/commands/reaction.go b/internal/commands/reaction.go index 9f6fd66..63aabe3 100644 --- a/internal/commands/reaction.go +++ b/internal/commands/reaction.go @@ -3,6 +3,7 @@ package commands import ( "fmt" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -55,7 +56,23 @@ var reactionListCmd = &cobra.Command{ summary = fmt.Sprintf("%d reactions on card #%s", count, reactionListCard) } - printSuccessWithSummary(resp.Data, summary) + // Build breadcrumbs + var breadcrumbs []response.Breadcrumb + if reactionListComment != "" { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("react", fmt.Sprintf("fizzy reaction create --card %s --comment %s --content \"👍\"", reactionListCard, reactionListComment), "Add reaction"), + breadcrumb("comment", fmt.Sprintf("fizzy comment show %s --card %s", reactionListComment, reactionListCard), "View comment"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", reactionListCard), "View card"), + } + } else { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("react", fmt.Sprintf("fizzy reaction create --card %s --content \"👍\"", reactionListCard), "Add reaction"), + breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", reactionListCard), "View comments"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", reactionListCard), "View card"), + } + } + + printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) }, } @@ -98,12 +115,26 @@ var reactionCreateCmd = &cobra.Command{ exitWithError(err) } - // Reaction create returns just success, no location or data - if resp.Data != nil { - printSuccess(resp.Data) + // Build breadcrumbs + var breadcrumbs []response.Breadcrumb + if reactionCreateComment != "" { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("reactions", fmt.Sprintf("fizzy reaction list --card %s --comment %s", reactionCreateCard, reactionCreateComment), "List reactions"), + breadcrumb("comment", fmt.Sprintf("fizzy comment show %s --card %s", reactionCreateComment, reactionCreateCard), "View comment"), + } } else { - printSuccess(map[string]interface{}{}) + breadcrumbs = []response.Breadcrumb{ + breadcrumb("reactions", fmt.Sprintf("fizzy reaction list --card %s", reactionCreateCard), "List reactions"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", reactionCreateCard), "View card"), + } } + + // Reaction create returns just success, no location or data + data := resp.Data + if data == nil { + data = map[string]interface{}{} + } + printSuccessWithBreadcrumbs(data, "", breadcrumbs) }, } @@ -139,9 +170,23 @@ var reactionDeleteCmd = &cobra.Command{ exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + var breadcrumbs []response.Breadcrumb + if reactionDeleteComment != "" { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("reactions", fmt.Sprintf("fizzy reaction list --card %s --comment %s", reactionDeleteCard, reactionDeleteComment), "List reactions"), + breadcrumb("comment", fmt.Sprintf("fizzy comment show %s --card %s", reactionDeleteComment, reactionDeleteCard), "View comment"), + } + } else { + breadcrumbs = []response.Breadcrumb{ + breadcrumb("reactions", fmt.Sprintf("fizzy reaction list --card %s", reactionDeleteCard), "List reactions"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", reactionDeleteCard), "View card"), + } + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "deleted": true, - }) + }, "", breadcrumbs) }, } diff --git a/internal/commands/root.go b/internal/commands/root.go index d290580..6ca50fa 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -230,6 +230,35 @@ func printSuccessWithPaginationAndSummary(data interface{}, hasNext bool, nextUR os.Exit(errors.ExitSuccess) } +// breadcrumb creates a single breadcrumb. +func breadcrumb(action, cmd, description string) response.Breadcrumb { + return response.NewBreadcrumb(action, cmd, description) +} + +// printSuccessWithBreadcrumbs prints a success response with breadcrumbs. +func printSuccessWithBreadcrumbs(data interface{}, summary string, breadcrumbs []response.Breadcrumb) { + resp := response.SuccessWithBreadcrumbs(data, summary, breadcrumbs) + if lastResult != nil { + lastResult.Response = resp + lastResult.ExitCode = errors.ExitSuccess + panic(testExitSignal{}) // Signal to stop execution in test mode + } + resp.Print() + os.Exit(errors.ExitSuccess) +} + +// printSuccessWithPaginationAndBreadcrumbs prints a success response with pagination and breadcrumbs. +func printSuccessWithPaginationAndBreadcrumbs(data interface{}, hasNext bool, nextURL string, summary string, breadcrumbs []response.Breadcrumb) { + resp := response.SuccessWithPaginationAndBreadcrumbs(data, hasNext, nextURL, summary, breadcrumbs) + if lastResult != nil { + lastResult.Response = resp + lastResult.ExitCode = errors.ExitSuccess + panic(testExitSignal{}) // Signal to stop execution in test mode + } + resp.Print() + os.Exit(errors.ExitSuccess) +} + // SetTestMode configures the commands package for testing. // It sets a mock client factory and captures results instead of exiting. func SetTestMode(mockClient client.API) *CommandResult { diff --git a/internal/commands/search.go b/internal/commands/search.go index aa58ad8..57888ee 100644 --- a/internal/commands/search.go +++ b/internal/commands/search.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -81,8 +82,14 @@ var searchCmd = &cobra.Command{ summary = fmt.Sprintf("%d results for \"%s\" (page %d)", count, query, searchPage) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", "fizzy card show ", "View card details"), + breadcrumb("narrow", fmt.Sprintf("fizzy search \"%s\" --board ", query), "Filter by board"), + } + hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndSummary(resp.Data, hasNext, resp.LinkNext, summary) + printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) }, } diff --git a/internal/commands/step.go b/internal/commands/step.go index e144654..31e6993 100644 --- a/internal/commands/step.go +++ b/internal/commands/step.go @@ -1,6 +1,10 @@ package commands import ( + "fmt" + "os" + + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -27,13 +31,22 @@ var stepShowCmd = &cobra.Command{ exitWithError(newRequiredFlagError("card")) } + stepID := args[0] + cardNumber := stepShowCard + client := getClient() - resp, err := client.Get("/cards/" + stepShowCard + "/steps/" + args[0] + ".json") + resp, err := client.Get("/cards/" + cardNumber + "/steps/" + stepID + ".json") if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("update", fmt.Sprintf("fizzy step update %s --card %s", stepID, cardNumber), "Update step"), + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -69,24 +82,40 @@ var stepCreateCmd = &cobra.Command{ "step": stepParams, } + cardNumber := stepCreateCard + client := getClient() - resp, err := client.Post("/cards/"+stepCreateCard+"/steps.json", body) + resp, err := client.Post("/cards/"+cardNumber+"/steps.json", body) if err != nil { exitWithError(err) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("step", fmt.Sprintf("fizzy step create --card %s --content \"text\"", cardNumber), "Add another step"), + } + // Create returns location header - follow it to get the created resource if resp.Location != "" { followResp, err := client.FollowLocation(resp.Location) if err == nil && followResp != nil { - printSuccessWithLocation(followResp.Data, resp.Location) + respObj := response.SuccessWithBreadcrumbs(followResp.Data, "", breadcrumbs) + respObj.Location = resp.Location + if lastResult != nil { + lastResult.Response = respObj + lastResult.ExitCode = 0 + panic(testExitSignal{}) + } + respObj.Print() + os.Exit(0) return } printSuccessWithLocation(nil, resp.Location) return } - printSuccess(resp.Data) + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -126,13 +155,22 @@ var stepUpdateCmd = &cobra.Command{ "step": stepParams, } + stepID := args[0] + cardNumber := stepUpdateCard + client := getClient() - resp, err := client.Patch("/cards/"+stepUpdateCard+"/steps/"+args[0]+".json", body) + resp, err := client.Patch("/cards/"+cardNumber+"/steps/"+stepID+".json", body) if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy step show %s --card %s", stepID, cardNumber), "View step"), + breadcrumb("card", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } @@ -153,15 +191,23 @@ var stepDeleteCmd = &cobra.Command{ exitWithError(newRequiredFlagError("card")) } + cardNumber := stepDeleteCard + client := getClient() - _, err := client.Delete("/cards/" + stepDeleteCard + "/steps/" + args[0] + ".json") + _, err := client.Delete("/cards/" + cardNumber + "/steps/" + args[0] + ".json") if err != nil { exitWithError(err) } - printSuccess(map[string]interface{}{ + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), + breadcrumb("step", fmt.Sprintf("fizzy step create --card %s --content \"text\"", cardNumber), "Add step"), + } + + printSuccessWithBreadcrumbs(map[string]interface{}{ "deleted": true, - }) + }, "", breadcrumbs) }, } diff --git a/internal/commands/tag.go b/internal/commands/tag.go index cb1a8a6..bba1f79 100644 --- a/internal/commands/tag.go +++ b/internal/commands/tag.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -49,8 +50,22 @@ var tagListCmd = &cobra.Command{ summary += fmt.Sprintf(" (page %d)", tagListPage) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("tag", "fizzy card tag --tag ", "Tag a card"), + breadcrumb("cards", "fizzy card list --tag ", "List cards with tag"), + } + hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndSummary(resp.Data, hasNext, resp.LinkNext, summary) + if hasNext { + nextPage := tagListPage + 1 + if nextPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy tag list --page %d", nextPage), "Next page")) + } + + printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) }, } diff --git a/internal/commands/user.go b/internal/commands/user.go index c217cb1..ae5228a 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + "github.com/robzolkos/fizzy-cli/internal/response" "github.com/spf13/cobra" ) @@ -49,8 +50,22 @@ var userListCmd = &cobra.Command{ summary += fmt.Sprintf(" (page %d)", userListPage) } + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("show", "fizzy user show ", "View user details"), + breadcrumb("assign", "fizzy card assign --user ", "Assign user to card"), + } + hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndSummary(resp.Data, hasNext, resp.LinkNext, summary) + if hasNext { + nextPage := userListPage + 1 + if nextPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy user list --page %d", nextPage), "Next page")) + } + + printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) }, } @@ -64,13 +79,21 @@ var userShowCmd = &cobra.Command{ exitWithError(err) } + userID := args[0] + client := getClient() - resp, err := client.Get("/users/" + args[0] + ".json") + resp, err := client.Get("/users/" + userID + ".json") if err != nil { exitWithError(err) } - printSuccess(resp.Data) + // Build breadcrumbs + breadcrumbs := []response.Breadcrumb{ + breadcrumb("people", "fizzy user list", "List users"), + breadcrumb("assign", fmt.Sprintf("fizzy card assign --user %s", userID), "Assign to card"), + } + + printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) }, } diff --git a/internal/response/response.go b/internal/response/response.go index 9344564..c827769 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -21,13 +21,14 @@ func SetPrettyPrint(enabled bool) { // Response represents the JSON response envelope. type Response struct { - Success bool `json:"success"` - Data interface{} `json:"data,omitempty"` - Error *ErrorDetail `json:"error,omitempty"` - Pagination *Pagination `json:"pagination,omitempty"` - Location string `json:"location,omitempty"` - Summary string `json:"summary,omitempty"` - Meta map[string]interface{} `json:"meta,omitempty"` + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorDetail `json:"error,omitempty"` + Pagination *Pagination `json:"pagination,omitempty"` + Breadcrumbs []Breadcrumb `json:"breadcrumbs,omitempty"` + Location string `json:"location,omitempty"` + Summary string `json:"summary,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` } // ErrorDetail represents an error in the response. @@ -44,6 +45,22 @@ type Pagination struct { NextURL string `json:"next_url,omitempty"` } +// Breadcrumb represents a suggested next action. +type Breadcrumb struct { + Action string `json:"action"` + Cmd string `json:"cmd"` + Description string `json:"description"` +} + +// NewBreadcrumb creates a new breadcrumb. +func NewBreadcrumb(action, cmd, description string) Breadcrumb { + return Breadcrumb{ + Action: action, + Cmd: cmd, + Description: description, + } +} + // Success creates a successful response with data. func Success(data interface{}) *Response { return &Response{ @@ -106,6 +123,35 @@ func SuccessWithPaginationAndSummary(data interface{}, hasNext bool, nextURL str return resp } +// SuccessWithBreadcrumbs creates a successful response with breadcrumbs. +func SuccessWithBreadcrumbs(data interface{}, summary string, breadcrumbs []Breadcrumb) *Response { + return &Response{ + Success: true, + Data: data, + Summary: summary, + Breadcrumbs: breadcrumbs, + Meta: createMeta(), + } +} + +// SuccessWithPaginationAndBreadcrumbs creates a successful response with pagination, summary, and breadcrumbs. +func SuccessWithPaginationAndBreadcrumbs(data interface{}, hasNext bool, nextURL string, summary string, breadcrumbs []Breadcrumb) *Response { + resp := &Response{ + Success: true, + Data: data, + Summary: summary, + Breadcrumbs: breadcrumbs, + Meta: createMeta(), + } + if hasNext || nextURL != "" { + resp.Pagination = &Pagination{ + HasNext: hasNext, + NextURL: nextURL, + } + } + return resp +} + // Error creates an error response from a CLIError. func Error(err *errors.CLIError) *Response { resp := &Response{ diff --git a/internal/response/response_test.go b/internal/response/response_test.go index 7a09385..3309e0a 100644 --- a/internal/response/response_test.go +++ b/internal/response/response_test.go @@ -327,3 +327,140 @@ func TestPrintDoesNotEscapeHTML(t *testing.T) { t.Errorf("expected HTML tags to be preserved, got: %s", output) } } + +func TestNewBreadcrumb(t *testing.T) { + bc := NewBreadcrumb("show", "fizzy card show 42", "View card details") + + if bc.Action != "show" { + t.Errorf("expected Action 'show', got '%s'", bc.Action) + } + if bc.Cmd != "fizzy card show 42" { + t.Errorf("expected Cmd 'fizzy card show 42', got '%s'", bc.Cmd) + } + if bc.Description != "View card details" { + t.Errorf("expected Description 'View card details', got '%s'", bc.Description) + } +} + +func TestSuccessWithBreadcrumbs(t *testing.T) { + data := map[string]string{"id": "42"} + breadcrumbs := []Breadcrumb{ + NewBreadcrumb("show", "fizzy card show 42", "View card details"), + NewBreadcrumb("comment", "fizzy comment create --card 42 --body \"text\"", "Add comment"), + } + + resp := SuccessWithBreadcrumbs(data, "Card #42 created", breadcrumbs) + + if !resp.Success { + t.Error("expected Success to be true") + } + if resp.Data == nil { + t.Error("expected Data to be set") + } + if resp.Summary != "Card #42 created" { + t.Errorf("expected Summary 'Card #42 created', got '%s'", resp.Summary) + } + if len(resp.Breadcrumbs) != 2 { + t.Errorf("expected 2 breadcrumbs, got %d", len(resp.Breadcrumbs)) + } + if resp.Breadcrumbs[0].Action != "show" { + t.Errorf("expected first breadcrumb action 'show', got '%s'", resp.Breadcrumbs[0].Action) + } + if resp.Breadcrumbs[1].Action != "comment" { + t.Errorf("expected second breadcrumb action 'comment', got '%s'", resp.Breadcrumbs[1].Action) + } + if resp.Meta == nil { + t.Error("expected Meta to be set") + } +} + +func TestSuccessWithPaginationAndBreadcrumbs(t *testing.T) { + data := []string{"item1", "item2"} + breadcrumbs := []Breadcrumb{ + NewBreadcrumb("show", "fizzy card show ", "View card details"), + NewBreadcrumb("next", "fizzy card list --page 2", "Next page"), + } + + t.Run("with pagination and breadcrumbs", func(t *testing.T) { + resp := SuccessWithPaginationAndBreadcrumbs(data, true, "https://example.com/page2", "10 cards", breadcrumbs) + + if !resp.Success { + t.Error("expected Success to be true") + } + if resp.Pagination == nil { + t.Fatal("expected Pagination to be set") + } + if !resp.Pagination.HasNext { + t.Error("expected HasNext to be true") + } + if resp.Summary != "10 cards" { + t.Errorf("expected Summary '10 cards', got '%s'", resp.Summary) + } + if len(resp.Breadcrumbs) != 2 { + t.Errorf("expected 2 breadcrumbs, got %d", len(resp.Breadcrumbs)) + } + }) + + t.Run("without pagination but with breadcrumbs", func(t *testing.T) { + resp := SuccessWithPaginationAndBreadcrumbs(data, false, "", "10 cards", breadcrumbs) + + if resp.Pagination != nil { + t.Error("expected Pagination to be nil when no next page") + } + if len(resp.Breadcrumbs) != 2 { + t.Errorf("expected 2 breadcrumbs, got %d", len(resp.Breadcrumbs)) + } + }) +} + +func TestBreadcrumbsOmittedWhenEmpty(t *testing.T) { + resp := Success(map[string]string{"key": "value"}) + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + jsonStr := string(data) + + // Breadcrumbs should be omitted when nil/empty + if containsKey(jsonStr, "breadcrumbs") { + t.Error("expected 'breadcrumbs' to be omitted from JSON when empty") + } +} + +func TestBreadcrumbsIncludedWhenPresent(t *testing.T) { + breadcrumbs := []Breadcrumb{ + NewBreadcrumb("show", "fizzy card show 42", "View card"), + } + resp := SuccessWithBreadcrumbs(map[string]string{"id": "42"}, "", breadcrumbs) + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + jsonStr := string(data) + + // Breadcrumbs should be present + if !containsKey(jsonStr, "breadcrumbs") { + t.Error("expected 'breadcrumbs' to be present in JSON") + } + + // Verify the structure + var parsed Response + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(parsed.Breadcrumbs) != 1 { + t.Errorf("expected 1 breadcrumb after parsing, got %d", len(parsed.Breadcrumbs)) + } + if parsed.Breadcrumbs[0].Action != "show" { + t.Errorf("expected action 'show', got '%s'", parsed.Breadcrumbs[0].Action) + } + if parsed.Breadcrumbs[0].Cmd != "fizzy card show 42" { + t.Errorf("expected cmd 'fizzy card show 42', got '%s'", parsed.Breadcrumbs[0].Cmd) + } + if parsed.Breadcrumbs[0].Description != "View card" { + t.Errorf("expected description 'View card', got '%s'", parsed.Breadcrumbs[0].Description) + } +}