From 996062f8129e45c2613b2c4000cd1f9e18710921 Mon Sep 17 00:00:00 2001 From: Artur Sabirov Date: Sun, 29 Mar 2026 22:01:25 +0200 Subject: [PATCH 1/4] fix: use int type for RoutineFolder ID and Routine FolderID fields The Hevy API returns routine folder IDs as JSON numbers, but the Go structs declared them as strings, causing json.Unmarshal to fail with "cannot unmarshal number into Go struct field RoutineFolder.id of type string". Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/folder/create.go | 2 +- cmd/folder/delete.go | 15 ++++++++++----- cmd/folder/get.go | 17 +++++++++++------ cmd/folder/list.go | 2 +- cmd/folder/update.go | 15 ++++++++++----- cmd/routine/get.go | 2 +- cmd/routine/list.go | 9 +++++++-- internal/api/client.go | 12 ++++++------ internal/api/types.go | 4 ++-- 9 files changed, 49 insertions(+), 29 deletions(-) diff --git a/cmd/folder/create.go b/cmd/folder/create.go index 044eb82..96652d0 100644 --- a/cmd/folder/create.go +++ b/cmd/folder/create.go @@ -94,7 +94,7 @@ func runFolderCreate(cmd *cobra.Command, args []string) error { fmt.Println(out) } else { fmt.Println("Folder created successfully!") - fmt.Printf("ID: %s\n", folder.ID) + fmt.Printf("ID: %d\n", folder.ID) fmt.Printf("Title: %s\n", folder.Title) } diff --git a/cmd/folder/delete.go b/cmd/folder/delete.go index a864e7e..0e61926 100644 --- a/cmd/folder/delete.go +++ b/cmd/folder/delete.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "strconv" "strings" "github.com/spf13/cobra" @@ -54,9 +55,13 @@ func runFolderDelete(cmd *cobra.Command, args []string) error { client := api.NewClient(apiKey) - var folderID string + var folderID int if len(args) > 0 { - folderID = args[0] + var err error + folderID, err = strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid folder ID: %s", args[0]) + } } else { // Interactive mode - let user select from folders selected, err := prompt.SearchSelect(prompt.SearchSelectConfig{ @@ -71,7 +76,7 @@ func runFolderDelete(cmd *cobra.Command, args []string) error { options := make([]prompt.SelectOption, len(folders.RoutineFolders)) for i, f := range folders.RoutineFolders { options[i] = prompt.SelectOption{ - ID: f.ID, + ID: fmt.Sprintf("%d", f.ID), Title: f.Title, Description: fmt.Sprintf("Index: %d", f.Index), } @@ -82,7 +87,7 @@ func runFolderDelete(cmd *cobra.Command, args []string) error { if err != nil { return err } - folderID = selected.ID + folderID, _ = strconv.Atoi(selected.ID) } // Get folder details first to show what we're deleting @@ -93,7 +98,7 @@ func runFolderDelete(cmd *cobra.Command, args []string) error { // Confirm deletion unless --force is used if !deleteForce { - fmt.Printf("Are you sure you want to delete folder '%s' (%s)?\n", folder.Title, folder.ID) + fmt.Printf("Are you sure you want to delete folder '%s' (%d)?\n", folder.Title, folder.ID) fmt.Printf("This action cannot be undone. Routines inside will become unorganized.\n") fmt.Print("Type 'yes' to confirm: ") diff --git a/cmd/folder/get.go b/cmd/folder/get.go index 84f67c3..8cf9288 100644 --- a/cmd/folder/get.go +++ b/cmd/folder/get.go @@ -3,6 +3,7 @@ package folder import ( "fmt" "os" + "strconv" "github.com/spf13/cobra" @@ -38,9 +39,13 @@ func runGet(cmd *cobra.Command, args []string) error { client := api.NewClient(apiKey) - var folderID string + var folderID int if len(args) > 0 { - folderID = args[0] + var err error + folderID, err = strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid folder ID: %s", args[0]) + } } else { // Interactive mode - let user select from folders selected, err := prompt.SearchSelect(prompt.SearchSelectConfig{ @@ -55,7 +60,7 @@ func runGet(cmd *cobra.Command, args []string) error { options := make([]prompt.SelectOption, len(folders.RoutineFolders)) for i, f := range folders.RoutineFolders { options[i] = prompt.SelectOption{ - ID: f.ID, + ID: fmt.Sprintf("%d", f.ID), Title: f.Title, Description: fmt.Sprintf("Index: %d", f.Index), } @@ -66,7 +71,7 @@ func runGet(cmd *cobra.Command, args []string) error { if err != nil { return err } - folderID = selected.ID + folderID, _ = strconv.Atoi(selected.ID) } // Search for the folder in the list @@ -93,7 +98,7 @@ func runGet(cmd *cobra.Command, args []string) error { } if folder == nil { - return fmt.Errorf("folder not found: %s", folderID) + return fmt.Errorf("folder not found: %d", folderID) } // Determine output format @@ -123,7 +128,7 @@ func runGet(cmd *cobra.Command, args []string) error { func printFolderDetails(f *api.RoutineFolder, cfg *config.Config) { fmt.Printf("Folder: %s\n", f.Title) - fmt.Printf("ID: %s\n", f.ID) + fmt.Printf("ID: %d\n", f.ID) fmt.Printf("Index: %d\n", f.Index) fmt.Printf("Created: %s\n", f.CreatedAt.Format(cfg.Display.DateFormat)) fmt.Printf("Updated: %s\n", f.UpdatedAt.Format(cfg.Display.DateFormat)) diff --git a/cmd/folder/list.go b/cmd/folder/list.go index b4d7784..f838c47 100644 --- a/cmd/folder/list.go +++ b/cmd/folder/list.go @@ -110,7 +110,7 @@ func runList(cmd *cobra.Command, args []string) error { for _, f := range allFolders { updated := f.UpdatedAt.Format(cfg.Display.DateFormat) table.AddRow( - f.ID, + fmt.Sprintf("%d", f.ID), f.Title, fmt.Sprintf("%d", f.Index), updated, diff --git a/cmd/folder/update.go b/cmd/folder/update.go index 124811d..8054269 100644 --- a/cmd/folder/update.go +++ b/cmd/folder/update.go @@ -3,6 +3,7 @@ package folder import ( "fmt" "os" + "strconv" "github.com/spf13/cobra" @@ -48,9 +49,13 @@ func runFolderUpdate(cmd *cobra.Command, args []string) error { client := api.NewClient(apiKey) - var folderID string + var folderID int if len(args) > 0 { - folderID = args[0] + var err error + folderID, err = strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid folder ID: %s", args[0]) + } } else { // Interactive mode - let user select from folders selected, err := prompt.SearchSelect(prompt.SearchSelectConfig{ @@ -65,7 +70,7 @@ func runFolderUpdate(cmd *cobra.Command, args []string) error { options := make([]prompt.SelectOption, len(folders.RoutineFolders)) for i, f := range folders.RoutineFolders { options[i] = prompt.SelectOption{ - ID: f.ID, + ID: fmt.Sprintf("%d", f.ID), Title: f.Title, Description: fmt.Sprintf("Index: %d", f.Index), } @@ -76,7 +81,7 @@ func runFolderUpdate(cmd *cobra.Command, args []string) error { if err != nil { return err } - folderID = selected.ID + folderID, _ = strconv.Atoi(selected.ID) } // Determine output format @@ -112,7 +117,7 @@ func runFolderUpdate(cmd *cobra.Command, args []string) error { fmt.Println(out) } else { fmt.Println("Folder updated successfully!") - fmt.Printf("ID: %s\n", folder.ID) + fmt.Printf("ID: %d\n", folder.ID) fmt.Printf("Title: %s\n", folder.Title) } diff --git a/cmd/routine/get.go b/cmd/routine/get.go index 2fb283c..4931513 100644 --- a/cmd/routine/get.go +++ b/cmd/routine/get.go @@ -105,7 +105,7 @@ func printRoutineDetails(r *api.Routine, cfg *config.Config, formatter output.Fo fmt.Printf("ID: %s\n", r.ID) if r.FolderID != nil { - fmt.Printf("Folder: %s\n", *r.FolderID) + fmt.Printf("Folder: %d\n", *r.FolderID) } fmt.Printf("Created: %s\n", r.CreatedAt.Format(cfg.Display.DateFormat)) diff --git a/cmd/routine/list.go b/cmd/routine/list.go index ea2fd5f..727a7cb 100644 --- a/cmd/routine/list.go +++ b/cmd/routine/list.go @@ -3,6 +3,7 @@ package routine import ( "fmt" "os" + "strconv" "github.com/spf13/cobra" @@ -94,9 +95,13 @@ func runList(cmd *cobra.Command, args []string) error { // Filter by folder if specified if listFolder != "" { + folderID, err := strconv.Atoi(listFolder) + if err != nil { + return fmt.Errorf("invalid folder ID: %s", listFolder) + } var filtered []api.Routine for _, r := range allRoutines { - if r.FolderID != nil && *r.FolderID == listFolder { + if r.FolderID != nil && *r.FolderID == folderID { filtered = append(filtered, r) } } @@ -120,7 +125,7 @@ func runList(cmd *cobra.Command, args []string) error { for _, r := range allRoutines { folder := "-" if r.FolderID != nil { - folder = *r.FolderID + folder = fmt.Sprintf("%d", *r.FolderID) } updated := r.UpdatedAt.Format(cfg.Display.DateFormat) diff --git a/internal/api/client.go b/internal/api/client.go index 425dd4c..57007a8 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -403,11 +403,11 @@ func (c *Client) CreateRoutineFolder(req *CreateRoutineFolderRequest) (*RoutineF } // GetRoutineFolder fetches a single routine folder by ID -func (c *Client) GetRoutineFolder(id string) (*RoutineFolder, error) { +func (c *Client) GetRoutineFolder(id int) (*RoutineFolder, error) { var result RoutineFolderResponse resp, err := c.httpClient.R(). SetResult(&result). - Get("/routine_folders/" + id) + Get(fmt.Sprintf("/routine_folders/%d", id)) if err != nil { return nil, &APIError{ @@ -532,12 +532,12 @@ func (c *Client) DeleteRoutine(id string) error { } // UpdateRoutineFolder updates an existing routine folder -func (c *Client) UpdateRoutineFolder(id string, req *UpdateRoutineFolderRequest) (*RoutineFolder, error) { +func (c *Client) UpdateRoutineFolder(id int, req *UpdateRoutineFolderRequest) (*RoutineFolder, error) { var result RoutineFolderResponse resp, err := c.httpClient.R(). SetBody(req). SetResult(&result). - Put("/routine_folders/" + id) + Put(fmt.Sprintf("/routine_folders/%d", id)) if err != nil { return nil, &APIError{ @@ -554,9 +554,9 @@ func (c *Client) UpdateRoutineFolder(id string, req *UpdateRoutineFolderRequest) } // DeleteRoutineFolder deletes a routine folder by ID -func (c *Client) DeleteRoutineFolder(id string) error { +func (c *Client) DeleteRoutineFolder(id int) error { resp, err := c.httpClient.R(). - Delete("/routine_folders/" + id) + Delete(fmt.Sprintf("/routine_folders/%d", id)) if err != nil { return &APIError{ diff --git a/internal/api/types.go b/internal/api/types.go index 463102f..20eaf8a 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -68,7 +68,7 @@ const ( type Routine struct { ID string `json:"id"` Title string `json:"title"` - FolderID *string `json:"folder_id,omitempty"` + FolderID *int `json:"folder_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Exercises []Exercise `json:"exercises"` @@ -76,7 +76,7 @@ type Routine struct { // RoutineFolder represents a folder for organizing routines type RoutineFolder struct { - ID string `json:"id"` + ID int `json:"id"` Title string `json:"title"` Index int `json:"index"` CreatedAt time.Time `json:"created_at"` From 13af5c473fae995a45074bd28cd4c420655d8e35 Mon Sep 17 00:00:00 2001 From: Artur Sabirov Date: Sun, 29 Mar 2026 22:12:36 +0200 Subject: [PATCH 2/4] chore: update goreleaser config for fork release --- .goreleaser.yaml | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d47df70..898b073 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -62,39 +62,9 @@ changelog: - Merge pull request - Merge branch -homebrew_casks: - - name: hevycli - repository: - owner: obay - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" - directory: Casks - homepage: "https://github.com/obay/hevycli" - description: "CLI for the Hevy fitness tracking platform" - binaries: - - hevycli - commit_author: - name: goreleaserbot - email: bot@goreleaser.com - commit_msg_template: "Brew cask update for {{ .ProjectName }} version {{ .Tag }}" - -scoops: - - name: hevycli - repository: - owner: obay - name: scoop-bucket - token: "{{ .Env.SCOOP_BUCKET_TOKEN }}" - homepage: "https://github.com/obay/hevycli" - description: "CLI for the Hevy fitness tracking platform" - license: MIT - commit_author: - name: goreleaserbot - email: bot@goreleaser.com - commit_msg_template: "Scoop manifest update for {{ .ProjectName }} version {{ .Tag }}" - release: github: - owner: obay + owner: asabirov name: hevycli draft: false prerelease: auto From 6bb3d6c198bdeebf5f5f933a09c2d205c036b2d3 Mon Sep 17 00:00:00 2001 From: Artur Sabirov Date: Sun, 29 Mar 2026 22:21:53 +0200 Subject: [PATCH 3/4] fix: handle array response from routine create/update API endpoints The Hevy API returns the routine field as an array in POST/PUT responses, but the client expected a single object, causing unmarshal errors when creating or updating routines. Fixes obay/hevycli#2 --- internal/api/client.go | 18 ++++++++++++++++-- internal/api/types.go | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 57007a8..e654965 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -355,7 +355,14 @@ func (c *Client) CreateRoutine(req *CreateRoutineRequest) (*Routine, error) { return nil, err } - return &result.Routine, nil + if len(result.Routines) == 0 { + return nil, &APIError{ + ErrorCode: "EMPTY_RESPONSE", + ErrorMessage: "API returned no routines in response", + } + } + + return &result.Routines[0], nil } // UpdateRoutine updates an existing routine @@ -377,7 +384,14 @@ func (c *Client) UpdateRoutine(id string, req *UpdateRoutineRequest) (*Routine, return nil, err } - return &result.Routine, nil + if len(result.Routines) == 0 { + return nil, &APIError{ + ErrorCode: "EMPTY_RESPONSE", + ErrorMessage: "API returned no routines in response", + } + } + + return &result.Routines[0], nil } // CreateRoutineFolder creates a new routine folder diff --git a/internal/api/types.go b/internal/api/types.go index 20eaf8a..25f77cc 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -280,7 +280,7 @@ type WorkoutResponse struct { // RoutineResponse represents the response from POST/PUT /routines type RoutineResponse struct { - Routine Routine `json:"routine"` + Routines []Routine `json:"routine"` } // RoutineFolderResponse represents the response from POST /routine_folders From cad7b83e7476cd459573dad029c11034a8402c8b Mon Sep 17 00:00:00 2001 From: Artur Sabirov Date: Mon, 30 Mar 2026 12:02:56 +0200 Subject: [PATCH 4/4] fix: address code review feedback from PR #3 - Revert goreleaser owner to obay to match go.mod module path - Update folder get examples to use integer IDs instead of UUIDs - Handle strconv.Atoi errors in folder get/delete/update interactive mode --- .goreleaser.yaml | 2 +- cmd/folder/delete.go | 5 ++++- cmd/folder/get.go | 9 ++++++--- cmd/folder/update.go | 5 ++++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 898b073..c2e267d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -64,7 +64,7 @@ changelog: release: github: - owner: asabirov + owner: obay name: hevycli draft: false prerelease: auto diff --git a/cmd/folder/delete.go b/cmd/folder/delete.go index 0e61926..4f8e4ce 100644 --- a/cmd/folder/delete.go +++ b/cmd/folder/delete.go @@ -87,7 +87,10 @@ func runFolderDelete(cmd *cobra.Command, args []string) error { if err != nil { return err } - folderID, _ = strconv.Atoi(selected.ID) + folderID, err = strconv.Atoi(selected.ID) + if err != nil { + return fmt.Errorf("invalid folder ID selected: %s", selected.ID) + } } // Get folder details first to show what we're deleting diff --git a/cmd/folder/get.go b/cmd/folder/get.go index 8cf9288..0d7d251 100644 --- a/cmd/folder/get.go +++ b/cmd/folder/get.go @@ -20,8 +20,8 @@ var getCmd = &cobra.Command{ Long: `Get detailed information about a specific routine folder. Examples: - hevycli folder get abc123-def456 # Get folder by ID - hevycli folder get abc123 -o json # Output as JSON`, + hevycli folder get 123 # Get folder by ID + hevycli folder get 123 -o json # Output as JSON`, Args: cmdutil.RequireArgs(1, ""), RunE: runGet, } @@ -71,7 +71,10 @@ func runGet(cmd *cobra.Command, args []string) error { if err != nil { return err } - folderID, _ = strconv.Atoi(selected.ID) + folderID, err = strconv.Atoi(selected.ID) + if err != nil { + return fmt.Errorf("invalid folder ID selected: %s", selected.ID) + } } // Search for the folder in the list diff --git a/cmd/folder/update.go b/cmd/folder/update.go index 8054269..d663b98 100644 --- a/cmd/folder/update.go +++ b/cmd/folder/update.go @@ -81,7 +81,10 @@ func runFolderUpdate(cmd *cobra.Command, args []string) error { if err != nil { return err } - folderID, _ = strconv.Atoi(selected.ID) + folderID, err = strconv.Atoi(selected.ID) + if err != nil { + return fmt.Errorf("invalid folder ID selected: %s", selected.ID) + } } // Determine output format