From 8c13bf2eeb71073858eda114c5f2fc063fc79320 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Tue, 5 May 2026 08:01:35 -0700 Subject: [PATCH 1/3] themes commands --- cmd/themes.go | 193 ++++++++++++++++++++++++++++++++++++++++ cmd/themes_get_test.go | 54 +++++++++++ cmd/themes_list_test.go | 35 ++++++++ 3 files changed, 282 insertions(+) create mode 100644 cmd/themes.go create mode 100644 cmd/themes_get_test.go create mode 100644 cmd/themes_list_test.go diff --git a/cmd/themes.go b/cmd/themes.go new file mode 100644 index 0000000..f21d031 --- /dev/null +++ b/cmd/themes.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/loops-so/cli/internal/config" + "github.com/loops-so/loops-go" + "github.com/spf13/cobra" +) + +func runThemesGet(cfg *config.Config, id string) (*loops.Theme, error) { + return newAPIClient(cfg).GetTheme(id) +} + +func runThemesList(cfg *config.Config, params loops.PaginationParams) ([]loops.Theme, error) { + client := newAPIClient(cfg) + if params.Cursor != "" { + themes, _, err := client.ListThemes(params) + return themes, err + } + return loops.Paginate(func(cursor string) ([]loops.Theme, *loops.Pagination, error) { + return client.ListThemes(loops.PaginationParams{ + PerPage: params.PerPage, + Cursor: cursor, + }) + }) +} + +var themesCmd = &cobra.Command{ + Use: "themes", + Short: "Manage themes", +} + +var themesListCmd = &cobra.Command{ + Use: "list", + Short: "List themes", + RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + themes, err := runThemesList(cfg, paginationParams(cmd)) + if err != nil { + return err + } + + if isJSONOutput() { + if themes == nil { + themes = []loops.Theme{} + } + return printJSON(cmd.OutOrStdout(), themes) + } + + if len(themes) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No themes found.") + return nil + } + + headers := []string{"ID", "NAME", "DEFAULT", "UPDATED"} + rows := make([][]string, 0, len(themes)) + for _, th := range themes { + rows = append(rows, []string{ + th.ThemeID, + th.Name, + strconv.FormatBool(th.IsDefault), + th.UpdatedAt, + }) + } + + if isPicking(cmd) { + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy id", "theme ID", rows, 0, cmd.OutOrStdout()), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) + } + return t.Render() + }, +} + +var themesGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a theme", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + th, err := runThemesGet(cfg, args[0]) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), th) + } + + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("themeId", th.ThemeID) + t.Row("name", th.Name) + t.Row("isDefault", strconv.FormatBool(th.IsDefault)) + t.Row("createdAt", th.CreatedAt) + t.Row("updatedAt", th.UpdatedAt) + if err := t.Render(); err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout()) + return printThemeStyles(cmd, th.Styles) + }, +} + +func printThemeStyles(cmd *cobra.Command, s loops.ThemeStyles) error { + t := newStyledTable(cmd.OutOrStdout(), "STYLE", "VALUE") + for _, row := range themeStyleRows(s) { + t.Row(row[0], dashIfEmpty(row[1])) + } + return t.Render() +} + +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} + +func themeStyleRows(s loops.ThemeStyles) [][2]string { + return [][2]string{ + {"backgroundColor", s.BackgroundColor}, + {"backgroundXPadding", formatFloat(s.BackgroundXPadding)}, + {"backgroundYPadding", formatFloat(s.BackgroundYPadding)}, + {"bodyColor", s.BodyColor}, + {"bodyXPadding", formatFloat(s.BodyXPadding)}, + {"bodyYPadding", formatFloat(s.BodyYPadding)}, + {"bodyFontFamily", s.BodyFontFamily}, + {"bodyFontCategory", s.BodyFontCategory}, + {"borderColor", s.BorderColor}, + {"borderWidth", formatFloat(s.BorderWidth)}, + {"borderRadius", formatFloat(s.BorderRadius)}, + {"buttonBodyColor", s.ButtonBodyColor}, + {"buttonBodyXPadding", formatFloat(s.ButtonBodyXPadding)}, + {"buttonBodyYPadding", formatFloat(s.ButtonBodyYPadding)}, + {"buttonBorderColor", s.ButtonBorderColor}, + {"buttonBorderWidth", formatFloat(s.ButtonBorderWidth)}, + {"buttonBorderRadius", formatFloat(s.ButtonBorderRadius)}, + {"buttonTextColor", s.ButtonTextColor}, + {"buttonTextFormat", formatFloat(s.ButtonTextFormat)}, + {"buttonTextFontSize", formatFloat(s.ButtonTextFontSize)}, + {"dividerColor", s.DividerColor}, + {"dividerBorderWidth", formatFloat(s.DividerBorderWidth)}, + {"textBaseColor", s.TextBaseColor}, + {"textBaseFontSize", formatFloat(s.TextBaseFontSize)}, + {"textBaseLineHeight", formatFloat(s.TextBaseLineHeight)}, + {"textBaseLetterSpacing", formatFloat(s.TextBaseLetterSpacing)}, + {"textLinkColor", s.TextLinkColor}, + {"heading1Color", s.Heading1Color}, + {"heading1FontSize", formatFloat(s.Heading1FontSize)}, + {"heading1LineHeight", formatFloat(s.Heading1LineHeight)}, + {"heading1LetterSpacing", formatFloat(s.Heading1LetterSpacing)}, + {"heading2Color", s.Heading2Color}, + {"heading2FontSize", formatFloat(s.Heading2FontSize)}, + {"heading2LineHeight", formatFloat(s.Heading2LineHeight)}, + {"heading2LetterSpacing", formatFloat(s.Heading2LetterSpacing)}, + {"heading3Color", s.Heading3Color}, + {"heading3FontSize", formatFloat(s.Heading3FontSize)}, + {"heading3LineHeight", formatFloat(s.Heading3LineHeight)}, + {"heading3LetterSpacing", formatFloat(s.Heading3LetterSpacing)}, + } +} + +func formatFloat(f float64) string { + return strconv.FormatFloat(f, 'f', -1, 64) +} + +func init() { + addPaginationFlags(themesListCmd) + addPickFlag(themesListCmd) + themesCmd.AddCommand(themesListCmd) + themesCmd.AddCommand(themesGetCmd) + rootCmd.AddCommand(themesCmd) +} diff --git a/cmd/themes_get_test.go b/cmd/themes_get_test.go new file mode 100644 index 0000000..3d6695f --- /dev/null +++ b/cmd/themes_get_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "net/http" + "testing" +) + +func TestRunThemesGet(t *testing.T) { + body := `{ + "success": true, + "themeId": "thm_abc123", + "name": "Default", + "isDefault": true, + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-02T10:00:00Z", + "styles": { + "backgroundColor": "#ffffff", + "bodyColor": "#000000", + "textBaseFontSize": 16, + "heading1FontSize": 32 + } + }` + + t.Run("returns the theme", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + th, err := runThemesGet(cfg(t), "thm_abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if th.ThemeID != "thm_abc123" { + t.Errorf("ThemeID = %q, want thm_abc123", th.ThemeID) + } + if th.Name != "Default" { + t.Errorf("Name = %q, want Default", th.Name) + } + if !th.IsDefault { + t.Error("IsDefault = false, want true") + } + if th.Styles.BackgroundColor != "#ffffff" { + t.Errorf("Styles.BackgroundColor = %q, want #ffffff", th.Styles.BackgroundColor) + } + if th.Styles.TextBaseFontSize != 16 { + t.Errorf("Styles.TextBaseFontSize = %v, want 16", th.Styles.TextBaseFontSize) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Theme not found"}`) + _, err := runThemesGet(cfg(t), "thm_missing") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/themes_list_test.go b/cmd/themes_list_test.go new file mode 100644 index 0000000..ff28a9e --- /dev/null +++ b/cmd/themes_list_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunThemesList(t *testing.T) { + t.Run("returns themes", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"themeId":"thm_1","name":"Default","isDefault":true,"createdAt":"2026-04-01","updatedAt":"2026-04-02","styles":{}}]}`) + themes, err := runThemesList(cfg(t), loops.PaginationParams{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(themes) != 1 { + t.Fatalf("expected 1 theme, got %d", len(themes)) + } + if themes[0].ThemeID != "thm_1" { + t.Errorf("ThemeID = %q, want thm_1", themes[0].ThemeID) + } + if !themes[0].IsDefault { + t.Error("IsDefault = false, want true") + } + }) + + t.Run("returns error on api failure", func(t *testing.T) { + serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`) + _, err := runThemesList(cfg(t), loops.PaginationParams{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} From 1a698ecff0a92e7f9bb0f8a0dcfd297af490e7f5 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Tue, 5 May 2026 08:02:57 -0700 Subject: [PATCH 2/3] hide themes commands --- cmd/themes.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/themes.go b/cmd/themes.go index f21d031..ff6be51 100644 --- a/cmd/themes.go +++ b/cmd/themes.go @@ -28,8 +28,9 @@ func runThemesList(cfg *config.Config, params loops.PaginationParams) ([]loops.T } var themesCmd = &cobra.Command{ - Use: "themes", - Short: "Manage themes", + Use: "themes", + Short: "Manage themes", + Hidden: true, } var themesListCmd = &cobra.Command{ From 4d5b20c760a0f3e4baacb45312471d4fd7fedbd3 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Tue, 5 May 2026 08:21:08 -0700 Subject: [PATCH 3/3] loops-go v0.1.3 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a3b9c88..68805a6 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 - github.com/loops-so/loops-go v0.1.2 + github.com/loops-so/loops-go v0.1.3 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.6 golang.org/x/term v0.41.0 diff --git a/go.sum b/go.sum index ae96475..89272db 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/loops-so/loops-go v0.1.2 h1:ROaDb9LEQvRlZupa1w+OqBDfNKo6agIZhoDYv7hjkes= -github.com/loops-so/loops-go v0.1.2/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= +github.com/loops-so/loops-go v0.1.3 h1:JZqbHBE6T3e6UoJNVydP/I6Ie4mW94IW5uVbwTC+rXM= +github.com/loops-so/loops-go v0.1.3/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=