From c430c25df109522b14c5498098668222d4cf97be Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 12:44:18 -0500 Subject: [PATCH 1/5] Add `PatchRequest` HTTP helper --- pkg/http/http.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/http/http.go b/pkg/http/http.go index 085d098f..0072547a 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -127,6 +127,25 @@ func PutRequest(url *url.URL, verifyTLS bool, headers map[string]string, body [] return statusCode, respHeaders, body, nil } +// PatchRequest perform HTTP PATCH +func PatchRequest(url *url.URL, verifyTLS bool, headers map[string]string, body []byte) (int, http.Header, []byte, error) { + req, err := http.NewRequest("PATCH", url.String(), bytes.NewReader(body)) + if err != nil { + return 0, nil, nil, err + } + + for key, value := range headers { + req.Header.Set(key, value) + } + + statusCode, respHeaders, body, err := performRequest(req, verifyTLS) + if err != nil { + return statusCode, respHeaders, body, err + } + + return statusCode, respHeaders, body, nil +} + // DeleteRequest perform HTTP DELETE func DeleteRequest(url *url.URL, verifyTLS bool, headers map[string]string, body []byte) (int, http.Header, []byte, error) { req, err := http.NewRequest("DELETE", url.String(), bytes.NewReader(body)) From 6dcd38970a5ae816cb1b182bd0230a6492dc1e71 Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 12:44:18 -0500 Subject: [PATCH 2/5] Add tag model and parser --- pkg/models/api.go | 8 ++++++++ pkg/models/parse.go | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/pkg/models/api.go b/pkg/models/api.go index fe092323..21f780d2 100644 --- a/pkg/models/api.go +++ b/pkg/models/api.go @@ -59,6 +59,14 @@ type WorkplaceSettings struct { BillingEmail string `json:"billing_email"` } +// TagInfo workplace tag info +type TagInfo struct { + Slug string `json:"slug"` + Name string `json:"name"` + Color string `json:"color"` + CreatedAt string `json:"created_at"` +} + // ProjectInfo project info type ProjectInfo struct { ID string `json:"id"` diff --git a/pkg/models/parse.go b/pkg/models/parse.go index 34cd72ad..db682fd4 100644 --- a/pkg/models/parse.go +++ b/pkg/models/parse.go @@ -40,6 +40,26 @@ func ParseWorkplaceSettings(info map[string]interface{}) WorkplaceSettings { return workplaceInfo } +// ParseTagInfo parse workplace tag info +func ParseTagInfo(info map[string]interface{}) TagInfo { + var tagInfo TagInfo + + if info["slug"] != nil { + tagInfo.Slug = info["slug"].(string) + } + if info["name"] != nil { + tagInfo.Name = info["name"].(string) + } + if info["color"] != nil { + tagInfo.Color = info["color"].(string) + } + if info["created_at"] != nil { + tagInfo.CreatedAt = info["created_at"].(string) + } + + return tagInfo +} + // ParseProjectInfo parse project info func ParseProjectInfo(info map[string]interface{}) ProjectInfo { var projectInfo ProjectInfo From 531ed399253e12cf4e41bb82a9b36a37c13068f4 Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 12:44:18 -0500 Subject: [PATCH 3/5] Add tag API client functions --- pkg/http/api.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/pkg/http/api.go b/pkg/http/api.go index 4ce4aaa7..f8e8871e 100644 --- a/pkg/http/api.go +++ b/pkg/http/api.go @@ -19,6 +19,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" "strings" "time" @@ -519,6 +520,149 @@ func SetWorkplaceSettings(host string, verifyTLS bool, apiKey string, values mod return settings, Error{} } +// GetTags get workplace tags +func GetTags(host string, verifyTLS bool, apiKey string) ([]models.TagInfo, Error) { + url, err := generateURL(host, "/v3/workplace/tags", nil) + if err != nil { + return nil, Error{Err: err, Message: "Unable to generate url"} + } + + statusCode, _, response, err := GetRequest(url, verifyTLS, apiKeyHeader(apiKey)) + if err != nil { + return nil, Error{Err: err, Message: "Unable to fetch tags", Code: statusCode} + } + + var result map[string]interface{} + err = json.Unmarshal(response, &result) + if err != nil { + return nil, Error{Err: err, Message: "Unable to parse API response", Code: statusCode} + } + + var info []models.TagInfo + for _, tag := range result["tags"].([]interface{}) { + tag, ok := tag.(map[string]interface{}) + if !ok { + return nil, Error{Err: fmt.Errorf("Unexpected type for tag, expected map[string]interface{}, got %T", tag), Message: "Unable to parse API response", Code: statusCode} + } + info = append(info, models.ParseTagInfo(tag)) + } + return info, Error{} +} + +// GetTag get specified workplace tag +func GetTag(host string, verifyTLS bool, apiKey string, tag string) (models.TagInfo, Error) { + url, err := generateURL(host, fmt.Sprintf("/v3/workplace/tags/tag/%s", url.PathEscape(tag)), nil) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to generate url"} + } + + statusCode, _, response, err := GetRequest(url, verifyTLS, apiKeyHeader(apiKey)) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to fetch tag", Code: statusCode} + } + + var result map[string]interface{} + err = json.Unmarshal(response, &result) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to parse API response", Code: statusCode} + } + + resultTag, ok := result["tag"].(map[string]interface{}) + if !ok { + return models.TagInfo{}, Error{Err: fmt.Errorf("Unexpected type for tag, expected map[string]interface{}, got %T", result["tag"]), Message: "Unable to parse API response", Code: statusCode} + } + return models.ParseTagInfo(resultTag), Error{} +} + +// CreateTag create a workplace tag +func CreateTag(host string, verifyTLS bool, apiKey string, name string, color string, slug string) (models.TagInfo, Error) { + postBody := map[string]string{"name": name} + if color != "" { + postBody["color"] = color + } + if slug != "" { + postBody["slug"] = slug + } + body, err := json.Marshal(postBody) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Invalid tag info"} + } + + url, err := generateURL(host, "/v3/workplace/tags", nil) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to generate url"} + } + + statusCode, _, response, err := PostRequest(url, verifyTLS, apiKeyHeader(apiKey), body) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to create tag", Code: statusCode} + } + + var result map[string]interface{} + err = json.Unmarshal(response, &result) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to parse API response", Code: statusCode} + } + + resultTag, ok := result["tag"].(map[string]interface{}) + if !ok { + return models.TagInfo{}, Error{Err: fmt.Errorf("Unexpected type for tag, expected map[string]interface{}, got %T", result["tag"]), Message: "Unable to parse API response", Code: statusCode} + } + return models.ParseTagInfo(resultTag), Error{} +} + +// UpdateTag update a workplace tag's name and/or color +func UpdateTag(host string, verifyTLS bool, apiKey string, tag string, name string, color string) (models.TagInfo, Error) { + postBody := map[string]string{} + if name != "" { + postBody["name"] = name + } + if color != "" { + postBody["color"] = color + } + body, err := json.Marshal(postBody) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Invalid tag info"} + } + + url, err := generateURL(host, fmt.Sprintf("/v3/workplace/tags/tag/%s", url.PathEscape(tag)), nil) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to generate url"} + } + + statusCode, _, response, err := PatchRequest(url, verifyTLS, apiKeyHeader(apiKey), body) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to update tag", Code: statusCode} + } + + var result map[string]interface{} + err = json.Unmarshal(response, &result) + if err != nil { + return models.TagInfo{}, Error{Err: err, Message: "Unable to parse API response", Code: statusCode} + } + + resultTag, ok := result["tag"].(map[string]interface{}) + if !ok { + return models.TagInfo{}, Error{Err: fmt.Errorf("Unexpected type for tag, expected map[string]interface{}, got %T", result["tag"]), Message: "Unable to parse API response", Code: statusCode} + } + return models.ParseTagInfo(resultTag), Error{} +} + +// DeleteTag delete a workplace tag +func DeleteTag(host string, verifyTLS bool, apiKey string, tag string) Error { + url, err := generateURL(host, fmt.Sprintf("/v3/workplace/tags/tag/%s", url.PathEscape(tag)), nil) + if err != nil { + return Error{Err: err, Message: "Unable to generate url"} + } + + statusCode, _, _, err := DeleteRequest(url, verifyTLS, apiKeyHeader(apiKey), nil) + if err != nil { + return Error{Err: err, Message: "Unable to delete tag", Code: statusCode} + } + + return Error{} +} + // GetProjects get projects func GetProjects(host string, verifyTLS bool, apiKey string, page int, number int) ([]models.ProjectInfo, Error) { var params []queryParam From 60a0e43b1f74ad6b32683b5227dad7fbfb0e13bb Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 12:44:18 -0500 Subject: [PATCH 4/5] Add tag printers --- pkg/printer/enclave.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pkg/printer/enclave.go b/pkg/printer/enclave.go index 38f7aa68..45e219ad 100644 --- a/pkg/printer/enclave.go +++ b/pkg/printer/enclave.go @@ -446,6 +446,31 @@ func Settings(settings models.WorkplaceSettings, jsonFlag bool) { Table([]string{"id", "name", "billing email"}, rows, TableOptions()) } +// TagsInfo print info of multiple workplace tags +func TagsInfo(info []models.TagInfo, jsonFlag bool) { + if jsonFlag { + JSON(info) + return + } + + var rows [][]string + for _, tagInfo := range info { + rows = append(rows, []string{tagInfo.Slug, tagInfo.Name, tagInfo.Color, tagInfo.CreatedAt}) + } + Table([]string{"slug", "name", "color", "created at"}, rows, TableOptions()) +} + +// TagInfo print workplace tag info +func TagInfo(info models.TagInfo, jsonFlag bool) { + if jsonFlag { + JSON(info) + return + } + + rows := [][]string{{info.Slug, info.Name, info.Color, info.CreatedAt}} + Table([]string{"slug", "name", "color", "created at"}, rows, TableOptions()) +} + // ConfigServiceTokensInfo print info of multiple config service tokens func ConfigServiceTokensInfo(tokens []models.ConfigServiceToken, number int, jsonFlag bool) { maxTokens := int(math.Min(float64(len(tokens)), float64(number))) From fe6497b9ea672d483a8199631ba2aea584956bf0 Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 12:44:18 -0500 Subject: [PATCH 5/5] Add `tags` command for managing workplace tags --- pkg/cmd/tags.go | 208 ++++++++++++++++++++++++++++++++++++++++ pkg/controllers/tags.go | 37 +++++++ 2 files changed, 245 insertions(+) create mode 100644 pkg/cmd/tags.go create mode 100644 pkg/controllers/tags.go diff --git a/pkg/cmd/tags.go b/pkg/cmd/tags.go new file mode 100644 index 00000000..1df1922a --- /dev/null +++ b/pkg/cmd/tags.go @@ -0,0 +1,208 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/DopplerHQ/cli/pkg/configuration" + "github.com/DopplerHQ/cli/pkg/controllers" + "github.com/DopplerHQ/cli/pkg/http" + "github.com/DopplerHQ/cli/pkg/printer" + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/spf13/cobra" +) + +var validTagColors = []string{"gray", "purple", "blue", "green", "yellow", "orange", "red", "pink"} +var validTagColorsList = strings.Join(validTagColors, ", ") + +var tagsCmd = &cobra.Command{ + Use: "tags", + Short: "Manage workplace tags", + Args: cobra.NoArgs, + Run: tags, +} + +var tagsGetCmd = &cobra.Command{ + Use: "get [slug]", + Short: "Get info for a tag", + Args: cobra.ExactArgs(1), + ValidArgsFunction: tagSlugsValidArgs, + Run: getTag, +} + +var tagsCreateCmd = &cobra.Command{ + Use: "create [name]", + Short: "Create a tag", + Args: cobra.ExactArgs(1), + Run: createTag, +} + +var tagsUpdateCmd = &cobra.Command{ + Use: "update [slug]", + Short: "Update a tag", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return err + } + + if cmd.Flag("name").Value.String() == "" && cmd.Flag("color").Value.String() == "" { + return errors.New("command needs flag --name or --color") + } + + return nil + }, + ValidArgsFunction: tagSlugsValidArgs, + Run: updateTag, +} + +var tagsDeleteCmd = &cobra.Command{ + Use: "delete [slug]", + Short: "Delete a tag", + Args: cobra.ExactArgs(1), + ValidArgsFunction: tagSlugsValidArgs, + Run: deleteTag, +} + +func tags(cmd *cobra.Command, args []string) { + jsonFlag := utils.OutputJSON + localConfig := configuration.LocalConfig(cmd) + + utils.RequireValue("token", localConfig.Token.Value) + + info, err := http.GetTags(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value) + if !err.IsNil() { + utils.HandleError(err.Unwrap(), err.Message) + } + + printer.TagsInfo(info, jsonFlag) +} + +func getTag(cmd *cobra.Command, args []string) { + jsonFlag := utils.OutputJSON + localConfig := configuration.LocalConfig(cmd) + + slug := args[0] + + utils.RequireValue("token", localConfig.Token.Value) + utils.RequireValue("slug", slug) + + info, err := http.GetTag(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, slug) + if !err.IsNil() { + utils.HandleError(err.Unwrap(), err.Message) + } + + printer.TagInfo(info, jsonFlag) +} + +func createTag(cmd *cobra.Command, args []string) { + jsonFlag := utils.OutputJSON + color := cmd.Flag("color").Value.String() + slug := cmd.Flag("slug").Value.String() + localConfig := configuration.LocalConfig(cmd) + + name := args[0] + + utils.RequireValue("token", localConfig.Token.Value) + utils.RequireValue("name", name) + + info, err := http.CreateTag(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, name, color, slug) + if !err.IsNil() { + utils.HandleError(err.Unwrap(), err.Message) + } + + if !utils.Silent { + printer.TagInfo(info, jsonFlag) + } +} + +func updateTag(cmd *cobra.Command, args []string) { + jsonFlag := utils.OutputJSON + name := cmd.Flag("name").Value.String() + color := cmd.Flag("color").Value.String() + localConfig := configuration.LocalConfig(cmd) + + slug := args[0] + + utils.RequireValue("token", localConfig.Token.Value) + utils.RequireValue("slug", slug) + + info, err := http.UpdateTag(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, slug, name, color) + if !err.IsNil() { + utils.HandleError(err.Unwrap(), err.Message) + } + + if !utils.Silent { + printer.TagInfo(info, jsonFlag) + } +} + +func deleteTag(cmd *cobra.Command, args []string) { + jsonFlag := utils.OutputJSON + yes := utils.GetBoolFlag(cmd, "yes") + localConfig := configuration.LocalConfig(cmd) + + slug := args[0] + + utils.RequireValue("token", localConfig.Token.Value) + utils.RequireValue("slug", slug) + + if yes || utils.ConfirmationPrompt(fmt.Sprintf("Delete tag %s", slug), false) { + err := http.DeleteTag(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, slug) + if !err.IsNil() { + utils.HandleError(err.Unwrap(), err.Message) + } + + if !utils.Silent { + info, err := http.GetTags(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value) + if !err.IsNil() { + utils.HandleError(err.Unwrap(), err.Message) + } + + printer.TagsInfo(info, jsonFlag) + } + } +} + +func tagSlugsValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + persistentValidArgsFunction(cmd) + + localConfig := configuration.LocalConfig(cmd) + slugs, err := controllers.GetTagSlugs(localConfig) + if err.IsNil() { + return slugs, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func init() { + tagsCmd.AddCommand(tagsGetCmd) + + tagsCreateCmd.Flags().String("color", "", fmt.Sprintf("tag color. One of: %s", validTagColorsList)) + tagsCreateCmd.Flags().String("slug", "", "tag slug (generated from the name when omitted)") + tagsCmd.AddCommand(tagsCreateCmd) + + tagsUpdateCmd.Flags().String("name", "", "new name") + tagsUpdateCmd.Flags().String("color", "", fmt.Sprintf("new color. One of: %s", validTagColorsList)) + tagsCmd.AddCommand(tagsUpdateCmd) + + tagsDeleteCmd.Flags().BoolP("yes", "y", false, "proceed without confirmation") + tagsCmd.AddCommand(tagsDeleteCmd) + + rootCmd.AddCommand(tagsCmd) +} diff --git a/pkg/controllers/tags.go b/pkg/controllers/tags.go new file mode 100644 index 00000000..d77cbb8e --- /dev/null +++ b/pkg/controllers/tags.go @@ -0,0 +1,37 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package controllers + +import ( + "github.com/DopplerHQ/cli/pkg/http" + "github.com/DopplerHQ/cli/pkg/models" + "github.com/DopplerHQ/cli/pkg/utils" +) + +func GetTagSlugs(config models.ScopedOptions) ([]string, Error) { + utils.RequireValue("token", config.Token.Value) + + tags, err := http.GetTags(config.APIHost.Value, utils.GetBool(config.VerifyTLS.Value, true), config.Token.Value) + if !err.IsNil() { + return nil, Error{Err: err.Unwrap(), Message: err.Message} + } + + var slugs []string + for _, tag := range tags { + slugs = append(slugs, tag.Slug) + } + return slugs, Error{} +}