From 5a29351aa345381e8ee369ed5b49f3921a49f956 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 29 Jul 2025 18:39:51 +0200 Subject: [PATCH 01/19] Update install instructions for goreleaser --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73ece76..235939b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ 2. Install goreleaser ``` -brew install goreleaser/tap/goreleaser +brew install --cask goreleaser ``` 3. Build the CLI From 75521851816b042b956eece43e069dec1ed77653 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 29 Jul 2025 19:05:00 +0200 Subject: [PATCH 02/19] Fix deprecated format --- .goreleaser.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8cfb152..00ae01e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -30,7 +30,7 @@ universal_binaries: - replace: true archives: - - format: tar.gz + - formats: ["tar.gz"] # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}_ @@ -42,7 +42,7 @@ archives: # use zip for windows archives format_overrides: - goos: windows - format: zip + formats: ["zip"] report_sizes: true From 53d584487f6f90e35548644e5ace54396c566dc5 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Fri, 19 Sep 2025 12:03:06 +0200 Subject: [PATCH 03/19] Update `goreleaser` install command --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 235939b..d7494d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ 2. Install goreleaser ``` -brew install --cask goreleaser +brew install --cask goreleaser/tap/goreleaser ``` 3. Build the CLI From 52ba06cabde49e958da0774f2eecc54bd5d31e4e Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 13:35:13 +0200 Subject: [PATCH 04/19] Standard unit testing utilities for commands --- go.mod | 2 +- internal/pkg/utils/testutils/README.md | 228 ++++++++++++++++++ internal/pkg/utils/testutils/assertions.go | 91 +++++++ .../pkg/utils/testutils/index_validation.go | 62 +++++ internal/pkg/utils/testutils/testutils.go | 114 +++++++++ 5 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 internal/pkg/utils/testutils/README.md create mode 100644 internal/pkg/utils/testutils/assertions.go create mode 100644 internal/pkg/utils/testutils/index_validation.go create mode 100644 internal/pkg/utils/testutils/testutils.go diff --git a/go.mod b/go.mod index d871e2b..31e2e70 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/pinecone-io/go-pinecone/v4 v4.1.4 github.com/rs/zerolog v1.32.0 github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 golang.org/x/oauth2 v0.30.0 golang.org/x/term v0.33.0 @@ -49,7 +50,6 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect diff --git a/internal/pkg/utils/testutils/README.md b/internal/pkg/utils/testutils/README.md new file mode 100644 index 0000000..049ffba --- /dev/null +++ b/internal/pkg/utils/testutils/README.md @@ -0,0 +1,228 @@ +# Test Utilities + +This package provides reusable test utilities for testing CLI commands, particularly for common patterns like the `--json` flag and argument validation. + +## File Organization + +- `testutils.go` - Complex testing utilities (`TestCommandArgsAndFlags`) +- `assertions.go` - Simple assertion utilities (`AssertCommandUsage`, `AssertJSONFlag`) +- `index_validation.go` - Index name validation utilities (`GetIndexNameValidationTests`) + +## Generic Command Testing + +The most powerful utility is `TestCommandArgsAndFlags` which provides a generic way to test any command's argument validation and flag handling: + +```go +func TestMyCommand_ArgsValidation(t *testing.T) { + cmd := NewMyCommand() + + tests := []testutils.CommandTestConfig{ + { + Name: "valid - single argument with flag", + Args: []string{"my-arg"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + ExpectedArgs: []string{"my-arg"}, + ExpectedFlags: map[string]interface{}{ + "json": true, + }, + }, + { + Name: "error - no arguments", + Args: []string{}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide an argument", + }, + } + + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} +``` + +## JSON Flag Testing + +The `--json` flag is used across many commands in the CLI. The JSON utility answers one simple question: **"Does my command have a properly configured `--json` flag?"** + +### JSON Flag Test + +```go +func TestMyCommand_Flags(t *testing.T) { + cmd := NewMyCommand() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} +``` + +This single function verifies that the `--json` flag is: + +- Properly defined +- Boolean type +- Optional (not required) +- Has a description mentioning "json" +- Can be set to true/false + +## Command Usage Testing + +The `AssertCommandUsage` utility tests that a command has proper usage metadata: + +```go +func TestMyCommand_Usage(t *testing.T) { + cmd := NewMyCommand() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "my-command ", "domain") +} +``` + +This function verifies that the command has: + +- Correct usage string format +- Non-empty short description +- Description mentions the expected domain + +## Index Name Validation + +For commands that take an index name as a positional argument (like `describe`, `delete`, etc.), there are specialized utilities: + +### Index Name Validator + +**Basic approach (preset tests only):** + +```go +func TestMyIndexCommand_ArgsValidation(t *testing.T) { + cmd := NewMyIndexCommand() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} +``` + +**Advanced approach (preset + custom tests):** + +```go +func TestMyIndexCommand_ArgsValidation(t *testing.T) { + cmd := NewMyIndexCommand() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this specific command + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - with custom flag", + Args: []string{"my-index"}, + Flags: map[string]string{"custom-flag": "value"}, + ExpectError: false, + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} +``` + +### Testing Flags Separately + +**For commands with --json flag:** + +```go +func TestMyIndexCommand_Flags(t *testing.T) { + cmd := NewMyIndexCommand() + + // Test the --json flag specifically + testutils.AssertJSONFlag(t, cmd) +} +``` + +**For commands with custom flags:** + +```go +func TestMyIndexCommand_Flags(t *testing.T) { + cmd := NewMyIndexCommand() + + // Test custom flags using the generic utility + tests := []testutils.CommandTestConfig{ + { + Name: "valid - with custom flag", + Args: []string{"my-index"}, + Flags: map[string]string{"custom-flag": "value"}, + ExpectError: false, + ExpectedArgs: []string{"my-index"}, + ExpectedFlags: map[string]interface{}{ + "custom-flag": "value", + }, + }, + } + + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} +``` + +### Index Name Validator Function + +```go +func NewMyIndexCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "my-command ", + Short: "Description of my command", + Args: testutils.CreateIndexNameValidator(), // Reusable validator + Run: func(cmd *cobra.Command, args []string) { + // Command logic here + }, + } + return cmd +} +``` + +The index name validator handles: + +- Empty string validation +- Whitespace-only validation +- Multiple argument validation +- No argument validation + +## Available Functions + +### Generic Command Testing + +- `TestCommandArgsAndFlags(t, cmd, tests)` - Generic utility to test any command's argument validation and flag handling +- `CommandTestConfig` - Configuration struct for defining test cases +- `AssertCommandUsage(t, cmd, expectedUsage, expectedDomain)` - Tests that a command has proper usage metadata + +### Index Name Validation + +- `GetIndexNameValidationTests()` - Returns preset test cases for index name validation + +### JSON Flag Specific + +- `AssertJSONFlag(t, cmd)` - Verifies that the command has a properly configured `--json` flag (definition, type, optional, description, functionality) + +## Supported Flag Types + +The generic utility supports all common flag types: + +- `bool` - Boolean flags +- `string` - String flags +- `int`, `int64` - Integer flags +- `float64` - Float flags +- `stringSlice`, `intSlice` - Slice flags + +## Usage in Commands + +Any command that follows the standard cobra pattern can use these utilities. The generic utilities are particularly useful for commands with: + +- Positional arguments +- Multiple flags of different types +- Custom argument validation logic + +## Example + +See `internal/pkg/cli/command/index/describe_test.go` for a complete example of how to use these utilities. diff --git a/internal/pkg/utils/testutils/assertions.go b/internal/pkg/utils/testutils/assertions.go new file mode 100644 index 0000000..104dc98 --- /dev/null +++ b/internal/pkg/utils/testutils/assertions.go @@ -0,0 +1,91 @@ +package testutils + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// AssertCommandUsage tests that a command has proper usage metadata +// This function can be reused by any command to test its usage information +func AssertCommandUsage(t *testing.T, cmd *cobra.Command, expectedUsage string, expectedDomain string) { + t.Helper() + + // Test usage string + if cmd.Use != expectedUsage { + t.Errorf("expected Use to be %q, got %q", expectedUsage, cmd.Use) + } + + // Test short description exists + if cmd.Short == "" { + t.Error("expected command to have a short description") + } + + // Test description mentions domain + if !strings.Contains(strings.ToLower(cmd.Short), expectedDomain) { + t.Errorf("expected short description to mention %q, got %q", expectedDomain, cmd.Short) + } +} + +// AssertJSONFlag tests the common --json flag pattern used across commands +// This function comprehensively tests flag definition, properties, and functionality +// This function can be reused by any command that has a --json flag +func AssertJSONFlag(t *testing.T, cmd *cobra.Command) { + t.Helper() + + // Test that the json flag is properly defined + jsonFlag := cmd.Flags().Lookup("json") + if jsonFlag == nil { + t.Error("expected --json flag to be defined") + return + } + + // Test that it's a boolean flag + if jsonFlag.Value.Type() != "bool" { + t.Errorf("expected --json flag to be bool type, got %s", jsonFlag.Value.Type()) + } + + // Test that the flag is optional (not required) + if jsonFlag.Annotations[cobra.BashCompOneRequiredFlag] != nil { + t.Error("expected --json flag to be optional") + } + + // Test that the flag has a description + if jsonFlag.Usage == "" { + t.Error("expected --json flag to have a usage description") + } + + // Test that the description mentions JSON + if !strings.Contains(strings.ToLower(jsonFlag.Usage), "json") { + t.Errorf("expected --json flag description to mention 'json', got %q", jsonFlag.Usage) + } + + // Test setting json flag to true + err := cmd.Flags().Set("json", "true") + if err != nil { + t.Errorf("failed to set --json flag to true: %v", err) + } + + jsonValue, err := cmd.Flags().GetBool("json") + if err != nil { + t.Errorf("failed to get --json flag value: %v", err) + } + if !jsonValue { + t.Error("expected --json flag to be true after setting it") + } + + // Test setting json flag to false + err = cmd.Flags().Set("json", "false") + if err != nil { + t.Errorf("failed to set --json flag to false: %v", err) + } + + jsonValue, err = cmd.Flags().GetBool("json") + if err != nil { + t.Errorf("failed to get --json flag value: %v", err) + } + if jsonValue { + t.Error("expected --json flag to be false after setting it") + } +} diff --git a/internal/pkg/utils/testutils/index_validation.go b/internal/pkg/utils/testutils/index_validation.go new file mode 100644 index 0000000..038c305 --- /dev/null +++ b/internal/pkg/utils/testutils/index_validation.go @@ -0,0 +1,62 @@ +package testutils + +// GetIndexNameValidationTests returns standard index name validation test cases +// These tests focus ONLY on index name validation, without any flag assumptions +// Flags should be tested separately using the generic TestCommandArgsAndFlags utility +func GetIndexNameValidationTests() []CommandTestConfig { + return []CommandTestConfig{ + { + Name: "valid - single index name", + Args: []string{"my-index"}, + Flags: map[string]string{}, + ExpectError: false, + }, + { + Name: "valid - index name with special characters", + Args: []string{"my-index-123"}, + Flags: map[string]string{}, + ExpectError: false, + }, + { + Name: "valid - index name with underscores", + Args: []string{"my_index_123"}, + Flags: map[string]string{}, + ExpectError: false, + }, + { + Name: "error - no arguments", + Args: []string{}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments", + Args: []string{"index1", "index2"}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + { + Name: "error - three arguments", + Args: []string{"index1", "index2", "index3"}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + { + Name: "error - empty string argument", + Args: []string{""}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "index name cannot be empty", + }, + { + Name: "error - whitespace only argument", + Args: []string{" "}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "index name cannot be empty", + }, + } +} diff --git a/internal/pkg/utils/testutils/testutils.go b/internal/pkg/utils/testutils/testutils.go new file mode 100644 index 0000000..591c089 --- /dev/null +++ b/internal/pkg/utils/testutils/testutils.go @@ -0,0 +1,114 @@ +package testutils + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// CommandTestConfig represents the configuration for testing a command's arguments and flags +type CommandTestConfig struct { + Name string + Args []string + Flags map[string]string + ExpectError bool + ErrorSubstr string + ExpectedArgs []string // Expected positional arguments after processing + ExpectedFlags map[string]interface{} // Expected flag values after processing +} + +// TestCommandArgsAndFlags provides a generic way to test any command's argument validation and flag handling +// This can be used for any command that follows the standard cobra pattern +func TestCommandArgsAndFlags(t *testing.T, cmd *cobra.Command, tests []CommandTestConfig) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + // Create a fresh command instance for each test + cmdCopy := *cmd + cmdCopy.Flags().SortFlags = false + + // Reset all flags to their default values + cmdCopy.Flags().VisitAll(func(flag *pflag.Flag) { + flag.Value.Set(flag.DefValue) + }) + + // Set flags if provided + for flagName, flagValue := range tt.Flags { + err := cmdCopy.Flags().Set(flagName, flagValue) + if err != nil { + t.Fatalf("failed to set flag %s=%s: %v", flagName, flagValue, err) + } + } + + // Test the Args validation function + err := cmdCopy.Args(&cmdCopy, tt.Args) + + if tt.ExpectError { + if err == nil { + t.Errorf("expected error but got nil") + } else if tt.ErrorSubstr != "" && !strings.Contains(err.Error(), tt.ErrorSubstr) { + t.Errorf("expected error to contain %q, got %q", tt.ErrorSubstr, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // If validation passed, test that the command would be configured correctly + if len(tt.Args) > 0 && len(tt.ExpectedArgs) > 0 { + // Verify positional arguments + for i, expectedArg := range tt.ExpectedArgs { + if i < len(tt.Args) && tt.Args[i] != expectedArg { + t.Errorf("expected arg[%d] to be %q, got %q", i, expectedArg, tt.Args[i]) + } + } + } + + // Verify flag values + for flagName, expectedValue := range tt.ExpectedFlags { + flag := cmdCopy.Flags().Lookup(flagName) + if flag == nil { + t.Errorf("expected flag %s to exist", flagName) + continue + } + + // Get the actual flag value based on its type + actualValue, err := getFlagValue(&cmdCopy, flagName, flag.Value.Type()) + if err != nil { + t.Errorf("failed to get flag %s value: %v", flagName, err) + continue + } + + if actualValue != expectedValue { + t.Errorf("expected flag %s to be %v, got %v", flagName, expectedValue, actualValue) + } + } + } + }) + } +} + +// getFlagValue retrieves the value of a flag based on its type +func getFlagValue(cmd *cobra.Command, flagName, flagType string) (interface{}, error) { + switch flagType { + case "bool": + return cmd.Flags().GetBool(flagName) + case "string": + return cmd.Flags().GetString(flagName) + case "int": + return cmd.Flags().GetInt(flagName) + case "int64": + return cmd.Flags().GetInt64(flagName) + case "float64": + return cmd.Flags().GetFloat64(flagName) + case "stringSlice": + return cmd.Flags().GetStringSlice(flagName) + case "intSlice": + return cmd.Flags().GetIntSlice(flagName) + default: + return cmd.Flags().GetString(flagName) // fallback to string + } +} From b97659dc1b27fc363cca1bd7783500907cc96806 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 13:36:10 +0200 Subject: [PATCH 05/19] `index describe`: make index name an argument (not a flag) --- internal/pkg/cli/command/index/describe.go | 21 ++++-- .../pkg/cli/command/index/describe_test.go | 64 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 internal/pkg/cli/command/index/describe_test.go diff --git a/internal/pkg/cli/command/index/describe.go b/internal/pkg/cli/command/index/describe.go index 8278b17..b4fbaca 100644 --- a/internal/pkg/cli/command/index/describe.go +++ b/internal/pkg/cli/command/index/describe.go @@ -1,6 +1,7 @@ package index import ( + "errors" "strings" "github.com/pinecone-io/cli/internal/pkg/utils/exit" @@ -22,9 +23,23 @@ func NewDescribeCmd() *cobra.Command { options := DescribeCmdOptions{} cmd := &cobra.Command{ - Use: "describe", + Use: "describe ", Short: "Get configuration and status information for an index", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + // TODO: start interactive mode. For now just return an error. + return errors.New("please provide an index name") + } + if len(args) > 1 { + return errors.New("please provide only one index name") + } + if strings.TrimSpace(args[0]) == "" { + return errors.New("index name cannot be empty") + } + return nil + }, Run: func(cmd *cobra.Command, args []string) { + options.name = args[0] pc := sdk.NewPineconeClient() idx, err := pc.DescribeIndex(cmd.Context(), options.name) @@ -46,10 +61,6 @@ func NewDescribeCmd() *cobra.Command { }, } - // required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "name of index to describe") - _ = cmd.MarkFlagRequired("name") - // optional flags cmd.Flags().BoolVar(&options.json, "json", false, "output as JSON") diff --git a/internal/pkg/cli/command/index/describe_test.go b/internal/pkg/cli/command/index/describe_test.go new file mode 100644 index 0000000..1388ab0 --- /dev/null +++ b/internal/pkg/cli/command/index/describe_test.go @@ -0,0 +1,64 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestDescribeCmd_ArgsValidation(t *testing.T) { + cmd := NewDescribeCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this command (e.g., with --json flag) + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - positional arg with --json flag", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --json=false", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "false"}, + ExpectError: false, + }, + { + Name: "error - no arguments but with --json flag", + Args: []string{}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments with --json flag", + Args: []string{"index1", "index2"}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} + +func TestDescribeCmd_Flags(t *testing.T) { + cmd := NewDescribeCmd() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} + +func TestDescribeCmd_Usage(t *testing.T) { + cmd := NewDescribeCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "describe ", "index") +} From ea51f0eb70b5a4fb536e432bdecbb5eabaf9978b Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 15:35:10 +0200 Subject: [PATCH 06/19] `index delete`: make index name an argument (not a flag) --- internal/pkg/cli/command/index/delete.go | 21 ++++++++++++---- internal/pkg/cli/command/index/delete_test.go | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 internal/pkg/cli/command/index/delete_test.go diff --git a/internal/pkg/cli/command/index/delete.go b/internal/pkg/cli/command/index/delete.go index 65910fe..8f5907f 100644 --- a/internal/pkg/cli/command/index/delete.go +++ b/internal/pkg/cli/command/index/delete.go @@ -2,6 +2,7 @@ package index import ( "context" + "errors" "strings" "github.com/pinecone-io/cli/internal/pkg/utils/exit" @@ -19,9 +20,23 @@ func NewDeleteCmd() *cobra.Command { options := DeleteCmdOptions{} cmd := &cobra.Command{ - Use: "delete", + Use: "delete ", Short: "Delete an index", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + // TODO: start interactive mode. For now just return an error. + return errors.New("please provide an index name") + } + if len(args) > 1 { + return errors.New("please provide only one index name") + } + if strings.TrimSpace(args[0]) == "" { + return errors.New("index name cannot be empty") + } + return nil + }, Run: func(cmd *cobra.Command, args []string) { + options.name = args[0] ctx := context.Background() pc := sdk.NewPineconeClient() @@ -39,9 +54,5 @@ func NewDeleteCmd() *cobra.Command { }, } - // required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "name of index to delete") - _ = cmd.MarkFlagRequired("name") - return cmd } diff --git a/internal/pkg/cli/command/index/delete_test.go b/internal/pkg/cli/command/index/delete_test.go new file mode 100644 index 0000000..9eedecb --- /dev/null +++ b/internal/pkg/cli/command/index/delete_test.go @@ -0,0 +1,24 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestDeleteCmd_ArgsValidation(t *testing.T) { + cmd := NewDeleteCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} + +func TestDeleteCmd_Usage(t *testing.T) { + cmd := NewDeleteCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "delete ", "index") +} From 8d98364db3725b57dd1ae8e58a898cc2ffa583c7 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 17:06:49 +0200 Subject: [PATCH 07/19] `index configure`: make index name an argument (not a flag) --- internal/pkg/cli/command/index/configure.go | 28 +++++-- .../pkg/cli/command/index/configure_test.go | 82 +++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 internal/pkg/cli/command/index/configure_test.go diff --git a/internal/pkg/cli/command/index/configure.go b/internal/pkg/cli/command/index/configure.go index d148486..2abe48d 100644 --- a/internal/pkg/cli/command/index/configure.go +++ b/internal/pkg/cli/command/index/configure.go @@ -2,6 +2,8 @@ package index import ( "context" + "errors" + "strings" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/msg" @@ -19,29 +21,40 @@ type configureIndexOptions struct { podType string replicas int32 deletionProtection string - - json bool + json bool } func NewConfigureIndexCmd() *cobra.Command { options := configureIndexOptions{} cmd := &cobra.Command{ - Use: "configure", + Use: "configure ", Short: "Configure an existing index with the specified configuration", Example: "", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + // TODO: start interactive mode. For now just return an error. + return errors.New("please provide an index name") + } + if len(args) > 1 { + return errors.New("please provide only one index name") + } + if strings.TrimSpace(args[0]) == "" { + return errors.New("index name cannot be empty") + } + return nil + }, Run: func(cmd *cobra.Command, args []string) { + options.name = args[0] runConfigureIndexCmd(options) }, } - // Required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "name of index to configure") - // Optional flags cmd.Flags().StringVarP(&options.podType, "pod_type", "t", "", "type of pod to use, can only upgrade when configuring") cmd.Flags().Int32VarP(&options.replicas, "replicas", "r", 0, "replicas of the index to configure") cmd.Flags().StringVarP(&options.deletionProtection, "deletion_protection", "p", "", "enable or disable deletion protection for the index") + cmd.Flags().BoolVar(&options.json, "json", false, "output as JSON") return cmd } @@ -59,13 +72,14 @@ func runConfigureIndexCmd(options configureIndexOptions) { msg.FailMsg("Failed to configure index %s: %+v\n", style.Emphasis(options.name), err) exit.Error(err) } + if options.json { json := text.IndentJSON(idx) pcio.Println(json) return } - describeCommand := pcio.Sprintf("pc index describe --name %s", idx.Name) + describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) msg.SuccessMsg("Index %s configured successfully. Run %s to check status. \n\n", style.Emphasis(idx.Name), style.Code(describeCommand)) presenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/configure_test.go b/internal/pkg/cli/command/index/configure_test.go new file mode 100644 index 0000000..31e05f4 --- /dev/null +++ b/internal/pkg/cli/command/index/configure_test.go @@ -0,0 +1,82 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestConfigureCmd_ArgsValidation(t *testing.T) { + cmd := NewConfigureIndexCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this command (configure-specific flags) + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - positional arg with --json flag", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --json=false", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "false"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --pod_type flag", + Args: []string{"my-index"}, + Flags: map[string]string{"pod_type": "p1.x1"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --replicas flag", + Args: []string{"my-index"}, + Flags: map[string]string{"replicas": "2"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --deletion_protection flag", + Args: []string{"my-index"}, + Flags: map[string]string{"deletion_protection": "enabled"}, + ExpectError: false, + }, + { + Name: "error - no arguments but with --json flag", + Args: []string{}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments with --json flag", + Args: []string{"index1", "index2"}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} + +func TestConfigureCmd_Flags(t *testing.T) { + cmd := NewConfigureIndexCmd() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} + +func TestConfigureCmd_Usage(t *testing.T) { + cmd := NewConfigureIndexCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "configure ", "index") +} From cf04241d944b566bfa9482f901a9c8ee6fe860ae Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 17:16:40 +0200 Subject: [PATCH 08/19] `index create`: make index name an argument (not a flag) --- internal/pkg/cli/command/index/create.go | 29 +++-- internal/pkg/cli/command/index/create_test.go | 100 ++++++++++++++++++ 2 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 internal/pkg/cli/command/index/create_test.go diff --git a/internal/pkg/cli/command/index/create.go b/internal/pkg/cli/command/index/create.go index 1f4d67b..39928f0 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -2,6 +2,8 @@ package index import ( "context" + "errors" + "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/docslinks" @@ -65,7 +67,7 @@ func NewCreateIndexCmd() *cobra.Command { options := createIndexOptions{} cmd := &cobra.Command{ - Use: "create", + Use: "create ", Short: "Create a new index with the specified configuration", Long: heredoc.Docf(` The %s command creates a new index with the specified configuration. There are several different types of indexes @@ -80,23 +82,32 @@ func NewCreateIndexCmd() *cobra.Command { `, style.Code("pc index create"), style.URL(docslinks.DocsIndexCreate)), Example: heredoc.Doc(` # create a serverless index - $ pc index create --name my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 + $ pc index create my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 # create a pod index - $ pc index create --name my-index --dimension 1536 --metric cosine --environment us-east-1-aws --pod-type p1.x1 --shards 2 --replicas 2 + $ pc index create my-index --dimension 1536 --metric cosine --environment us-east-1-aws --pod-type p1.x1 --shards 2 --replicas 2 # create an integrated index - $ pc index create --name my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 --model multilingual-e5-large --field_map text=chunk_text + $ pc index create my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 --model multilingual-e5-large --field_map text=chunk_text `), + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New("please provide an index name") + } + if len(args) > 1 { + return errors.New("please provide only one index name") + } + if strings.TrimSpace(args[0]) == "" { + return errors.New("index name cannot be empty") + } + return nil + }, Run: func(cmd *cobra.Command, args []string) { + options.name = args[0] runCreateIndexCmd(options) }, } - // Required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "Name of index to create") - _ = cmd.MarkFlagRequired("name") - // Serverless & Pods cmd.Flags().StringVar(&options.sourceCollection, "source_collection", "", "When creating an index from a collection") @@ -243,7 +254,7 @@ func renderSuccessOutput(idx *pinecone.Index, options createIndexOptions) { return } - describeCommand := pcio.Sprintf("pc index describe --name %s", idx.Name) + describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) msg.SuccessMsg("Index %s created successfully. Run %s to check status. \n\n", style.Emphasis(idx.Name), style.Code(describeCommand)) presenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/create_test.go b/internal/pkg/cli/command/index/create_test.go new file mode 100644 index 0000000..7b45a14 --- /dev/null +++ b/internal/pkg/cli/command/index/create_test.go @@ -0,0 +1,100 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestCreateCmd_ArgsValidation(t *testing.T) { + cmd := NewCreateIndexCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this command (create-specific flags) + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - positional arg with --json flag", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --json=false", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "false"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --dimension flag", + Args: []string{"my-index"}, + Flags: map[string]string{"dimension": "1536"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --metric flag", + Args: []string{"my-index"}, + Flags: map[string]string{"metric": "cosine"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --cloud and --region flags", + Args: []string{"my-index"}, + Flags: map[string]string{"cloud": "aws", "region": "us-east-1"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --environment flag", + Args: []string{"my-index"}, + Flags: map[string]string{"environment": "us-east-1-aws"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --pod_type flag", + Args: []string{"my-index"}, + Flags: map[string]string{"pod_type": "p1.x1"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --model flag", + Args: []string{"my-index"}, + Flags: map[string]string{"model": "multilingual-e5-large"}, + ExpectError: false, + }, + { + Name: "error - no arguments but with --json flag", + Args: []string{}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments with --json flag", + Args: []string{"index1", "index2"}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} + +func TestCreateCmd_Flags(t *testing.T) { + cmd := NewCreateIndexCmd() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} + +func TestCreateCmd_Usage(t *testing.T) { + cmd := NewCreateIndexCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "create ", "index") +} From 86c779cf3940d6630c79c551475010bdc4ebb829 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 17:32:39 +0200 Subject: [PATCH 09/19] Extract `ValidateIndexNameArgs` util function --- internal/pkg/cli/command/index/configure.go | 17 ++------------- internal/pkg/cli/command/index/create.go | 16 ++------------ internal/pkg/cli/command/index/delete.go | 16 ++------------ internal/pkg/cli/command/index/describe.go | 16 ++------------ internal/pkg/utils/index/validation.go | 23 +++++++++++++++++++++ 5 files changed, 31 insertions(+), 57 deletions(-) create mode 100644 internal/pkg/utils/index/validation.go diff --git a/internal/pkg/cli/command/index/configure.go b/internal/pkg/cli/command/index/configure.go index 2abe48d..c06df3c 100644 --- a/internal/pkg/cli/command/index/configure.go +++ b/internal/pkg/cli/command/index/configure.go @@ -2,10 +2,9 @@ package index import ( "context" - "errors" - "strings" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" @@ -31,19 +30,7 @@ func NewConfigureIndexCmd() *cobra.Command { Use: "configure ", Short: "Configure an existing index with the specified configuration", Example: "", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - // TODO: start interactive mode. For now just return an error. - return errors.New("please provide an index name") - } - if len(args) > 1 { - return errors.New("please provide only one index name") - } - if strings.TrimSpace(args[0]) == "" { - return errors.New("index name cannot be empty") - } - return nil - }, + Args: index.ValidateIndexNameArgs, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] runConfigureIndexCmd(options) diff --git a/internal/pkg/cli/command/index/create.go b/internal/pkg/cli/command/index/create.go index 39928f0..600c45b 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -2,12 +2,11 @@ package index import ( "context" - "errors" - "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/docslinks" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/log" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" @@ -90,18 +89,7 @@ func NewCreateIndexCmd() *cobra.Command { # create an integrated index $ pc index create my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 --model multilingual-e5-large --field_map text=chunk_text `), - Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return errors.New("please provide an index name") - } - if len(args) > 1 { - return errors.New("please provide only one index name") - } - if strings.TrimSpace(args[0]) == "" { - return errors.New("index name cannot be empty") - } - return nil - }, + Args: index.ValidateIndexNameArgs, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] runCreateIndexCmd(options) diff --git a/internal/pkg/cli/command/index/delete.go b/internal/pkg/cli/command/index/delete.go index 8f5907f..243ce85 100644 --- a/internal/pkg/cli/command/index/delete.go +++ b/internal/pkg/cli/command/index/delete.go @@ -2,10 +2,10 @@ package index import ( "context" - "errors" "strings" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" @@ -22,19 +22,7 @@ func NewDeleteCmd() *cobra.Command { cmd := &cobra.Command{ Use: "delete ", Short: "Delete an index", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - // TODO: start interactive mode. For now just return an error. - return errors.New("please provide an index name") - } - if len(args) > 1 { - return errors.New("please provide only one index name") - } - if strings.TrimSpace(args[0]) == "" { - return errors.New("index name cannot be empty") - } - return nil - }, + Args: index.ValidateIndexNameArgs, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] ctx := context.Background() diff --git a/internal/pkg/cli/command/index/describe.go b/internal/pkg/cli/command/index/describe.go index b4fbaca..1765f66 100644 --- a/internal/pkg/cli/command/index/describe.go +++ b/internal/pkg/cli/command/index/describe.go @@ -1,10 +1,10 @@ package index import ( - "errors" "strings" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" @@ -25,19 +25,7 @@ func NewDescribeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "describe ", Short: "Get configuration and status information for an index", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - // TODO: start interactive mode. For now just return an error. - return errors.New("please provide an index name") - } - if len(args) > 1 { - return errors.New("please provide only one index name") - } - if strings.TrimSpace(args[0]) == "" { - return errors.New("index name cannot be empty") - } - return nil - }, + Args: index.ValidateIndexNameArgs, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] pc := sdk.NewPineconeClient() diff --git a/internal/pkg/utils/index/validation.go b/internal/pkg/utils/index/validation.go new file mode 100644 index 0000000..7ac7399 --- /dev/null +++ b/internal/pkg/utils/index/validation.go @@ -0,0 +1,23 @@ +package index + +import ( + "errors" + "strings" + + "github.com/spf13/cobra" +) + +// ValidateIndexNameArgs validates that exactly one non-empty index name is provided as a positional argument. +// This is the standard validation used across all index commands (create, describe, delete, configure). +func ValidateIndexNameArgs(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New("please provide an index name") + } + if len(args) > 1 { + return errors.New("please provide only one index name") + } + if strings.TrimSpace(args[0]) == "" { + return errors.New("index name cannot be empty") + } + return nil +} From fb61afefe772a89b862b29c839236aaccd3d80e9 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 22:36:57 +0200 Subject: [PATCH 10/19] Improve error message styling --- internal/pkg/cli/command/root/root.go | 6 ++- internal/pkg/utils/index/validation.go | 7 ++-- internal/pkg/utils/style/typography.go | 58 ++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index 44c3677..cd0422c 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -23,7 +23,8 @@ import ( var rootCmd *cobra.Command type GlobalOptions struct { - quiet bool + quiet bool + verbose bool } func Execute() { @@ -54,6 +55,8 @@ Get started by logging in with `, style.CodeWithPrompt("pc login")), } + rootCmd.SetErrPrefix("\r") + rootCmd.SetUsageTemplate(help.HelpTemplate) // Auth group @@ -87,4 +90,5 @@ Get started by logging in with // Global flags rootCmd.PersistentFlags().BoolVarP(&globalOptions.quiet, "quiet", "q", false, "suppress output") + rootCmd.PersistentFlags().BoolVarP(&globalOptions.verbose, "verbose", "V", false, "show detailed error information") } diff --git a/internal/pkg/utils/index/validation.go b/internal/pkg/utils/index/validation.go index 7ac7399..b3e2cfa 100644 --- a/internal/pkg/utils/index/validation.go +++ b/internal/pkg/utils/index/validation.go @@ -4,6 +4,7 @@ import ( "errors" "strings" + "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/spf13/cobra" ) @@ -11,13 +12,13 @@ import ( // This is the standard validation used across all index commands (create, describe, delete, configure). func ValidateIndexNameArgs(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return errors.New("please provide an index name") + return errors.New("\b" + style.FailMsg("please provide an index name")) } if len(args) > 1 { - return errors.New("please provide only one index name") + return errors.New("\b" + style.FailMsg("please provide only one index name")) } if strings.TrimSpace(args[0]) == "" { - return errors.New("index name cannot be empty") + return errors.New("\b" + style.FailMsg("index name cannot be empty")) } return nil } diff --git a/internal/pkg/utils/style/typography.go b/internal/pkg/utils/style/typography.go index 632d10d..1f4fbd0 100644 --- a/internal/pkg/utils/style/typography.go +++ b/internal/pkg/utils/style/typography.go @@ -3,10 +3,39 @@ package style import ( "fmt" + "github.com/charmbracelet/lipgloss" "github.com/fatih/color" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" ) +// Lipgloss styles for cli-alerts style messages +var ( + // Alert type boxes (solid colored backgrounds) - using standard CLI colors + successBoxStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#28a745")). // Standard green + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 1) + + errorBoxStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#dc3545")). // Standard red (softer) + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 1) + + warningBoxStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#ffc107")). // Standard amber/yellow + Foreground(lipgloss.Color("#000000")). + Bold(true). + Padding(0, 1) + + infoBoxStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#17a2b8")). // Standard info blue + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 1) +) + func Emphasis(s string) string { return applyStyle(s, color.FgCyan) } @@ -32,19 +61,40 @@ func CodeHint(templateString string, codeString string) string { } func SuccessMsg(s string) string { - return applyStyle("[SUCCESS] ", color.FgGreen) + s + if color.NoColor { + return fmt.Sprintf("✔ [SUCCESS] %s", s) + } + icon := "✔" + box := successBoxStyle.Render(icon + " SUCCESS") + return fmt.Sprintf("%s %s", box, s) } func WarnMsg(s string) string { - return applyStyle("[WARN] ", color.FgYellow) + s + if color.NoColor { + return fmt.Sprintf("⚠ [WARNING] %s", s) + } + icon := "⚠" + box := warningBoxStyle.Render(icon + " WARNING") + return fmt.Sprintf("%s %s", box, s) } func InfoMsg(s string) string { - return applyStyle("[INFO] ", color.FgHiWhite) + s + if color.NoColor { + return fmt.Sprintf("ℹ [INFO] %s", s) + } + icon := "ℹ" + box := infoBoxStyle.Render(icon + " INFO") + return fmt.Sprintf("%s %s", box, s) } func FailMsg(s string, a ...any) string { - return applyStyle("[ERROR] ", color.FgRed) + fmt.Sprintf(s, a...) + message := fmt.Sprintf(s, a...) + if color.NoColor { + return fmt.Sprintf("✘ [ERROR] %s", message) + } + icon := "✘" + box := errorBoxStyle.Render(icon + " ERROR") + return fmt.Sprintf("%s %s", box, message) } func Code(s string) string { From eace9b03a0ed11432c8477269f3d56f12719b8c5 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 2 Sep 2025 22:37:42 +0200 Subject: [PATCH 11/19] User-friendly error messages and `verbose` flag for details --- internal/pkg/cli/command/index/configure.go | 16 ++-- internal/pkg/cli/command/index/create.go | 28 ++++--- internal/pkg/cli/command/index/delete.go | 15 ++-- internal/pkg/cli/command/index/describe.go | 18 ++--- internal/pkg/cli/command/index/list.go | 4 +- internal/pkg/utils/error/error.go | 85 +++++++++++++++++++++ internal/pkg/utils/error/error_test.go | 62 +++++++++++++++ 7 files changed, 186 insertions(+), 42 deletions(-) create mode 100644 internal/pkg/utils/error/error.go create mode 100644 internal/pkg/utils/error/error_test.go diff --git a/internal/pkg/cli/command/index/configure.go b/internal/pkg/cli/command/index/configure.go index c06df3c..7355fb7 100644 --- a/internal/pkg/cli/command/index/configure.go +++ b/internal/pkg/cli/command/index/configure.go @@ -3,6 +3,7 @@ package index import ( "context" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/msg" @@ -27,13 +28,14 @@ func NewConfigureIndexCmd() *cobra.Command { options := configureIndexOptions{} cmd := &cobra.Command{ - Use: "configure ", - Short: "Configure an existing index with the specified configuration", - Example: "", - Args: index.ValidateIndexNameArgs, + Use: "configure ", + Short: "Configure an existing index with the specified configuration", + Example: "", + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] - runConfigureIndexCmd(options) + runConfigureIndexCmd(options, cmd, args) }, } @@ -46,7 +48,7 @@ func NewConfigureIndexCmd() *cobra.Command { return cmd } -func runConfigureIndexCmd(options configureIndexOptions) { +func runConfigureIndexCmd(options configureIndexOptions, cmd *cobra.Command, args []string) { ctx := context.Background() pc := sdk.NewPineconeClient() @@ -56,7 +58,7 @@ func runConfigureIndexCmd(options configureIndexOptions) { DeletionProtection: pinecone.DeletionProtection(options.deletionProtection), }) if err != nil { - msg.FailMsg("Failed to configure index %s: %+v\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } diff --git a/internal/pkg/cli/command/index/create.go b/internal/pkg/cli/command/index/create.go index 600c45b..a011668 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -5,6 +5,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/docslinks" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/log" @@ -89,10 +90,11 @@ func NewCreateIndexCmd() *cobra.Command { # create an integrated index $ pc index create my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 --model multilingual-e5-large --field_map text=chunk_text `), - Args: index.ValidateIndexNameArgs, + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] - runCreateIndexCmd(options) + runCreateIndexCmd(options, cmd, args) }, } @@ -130,18 +132,20 @@ func NewCreateIndexCmd() *cobra.Command { return cmd } -func runCreateIndexCmd(options createIndexOptions) { +func runCreateIndexCmd(options createIndexOptions, cmd *cobra.Command, args []string) { ctx := context.Background() pc := sdk.NewPineconeClient() // validate and derive index type from arguments err := options.validate() if err != nil { + msg.FailMsg("%s\n", err.Error()) exit.Error(err) return } idxType, err := options.deriveIndexType() if err != nil { + msg.FailMsg("%s\n", err.Error()) exit.Error(err) return } @@ -159,7 +163,7 @@ func runCreateIndexCmd(options createIndexOptions) { switch idxType { case indexTypeServerless: // create serverless index - args := pinecone.CreateServerlessIndexRequest{ + req := pinecone.CreateServerlessIndexRequest{ Name: options.name, Cloud: pinecone.Cloud(options.cloud), Region: options.region, @@ -171,9 +175,9 @@ func runCreateIndexCmd(options createIndexOptions) { SourceCollection: pointerOrNil(options.sourceCollection), } - idx, err = pc.CreateServerlessIndex(ctx, &args) + idx, err = pc.CreateServerlessIndex(ctx, &req) if err != nil { - msg.FailMsg("Failed to create serverless index %s: %s\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } case indexTypePod: @@ -184,7 +188,7 @@ func runCreateIndexCmd(options createIndexOptions) { Indexed: &options.metadataConfig, } } - args := pinecone.CreatePodIndexRequest{ + req := pinecone.CreatePodIndexRequest{ Name: options.name, Dimension: options.dimension, Environment: options.environment, @@ -198,9 +202,9 @@ func runCreateIndexCmd(options createIndexOptions) { MetadataConfig: metadataConfig, } - idx, err = pc.CreatePodIndex(ctx, &args) + idx, err = pc.CreatePodIndex(ctx, &req) if err != nil { - msg.FailMsg("Failed to create pod index %s: %s\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } case indexTypeIntegrated: @@ -208,7 +212,7 @@ func runCreateIndexCmd(options createIndexOptions) { readParams := toInterfaceMap(options.readParameters) writeParams := toInterfaceMap(options.writeParameters) - args := pinecone.CreateIndexForModelRequest{ + req := pinecone.CreateIndexForModelRequest{ Name: options.name, Cloud: pinecone.Cloud(options.cloud), Region: options.region, @@ -221,9 +225,9 @@ func runCreateIndexCmd(options createIndexOptions) { }, } - idx, err = pc.CreateIndexForModel(ctx, &args) + idx, err = pc.CreateIndexForModel(ctx, &req) if err != nil { - msg.FailMsg("Failed to create integrated index %s: %s\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } default: diff --git a/internal/pkg/cli/command/index/delete.go b/internal/pkg/cli/command/index/delete.go index 243ce85..fe6a33b 100644 --- a/internal/pkg/cli/command/index/delete.go +++ b/internal/pkg/cli/command/index/delete.go @@ -2,8 +2,8 @@ package index import ( "context" - "strings" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/msg" @@ -20,9 +20,10 @@ func NewDeleteCmd() *cobra.Command { options := DeleteCmdOptions{} cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete an index", - Args: index.ValidateIndexNameArgs, + Use: "delete ", + Short: "Delete an index", + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] ctx := context.Background() @@ -30,11 +31,7 @@ func NewDeleteCmd() *cobra.Command { err := pc.DeleteIndex(ctx, options.name) if err != nil { - if strings.Contains(err.Error(), "not found") { - msg.FailMsg("The index %s does not exist\n", style.Emphasis(options.name)) - } else { - msg.FailMsg("Failed to delete index %s: %s\n", style.Emphasis(options.name), err) - } + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } diff --git a/internal/pkg/cli/command/index/describe.go b/internal/pkg/cli/command/index/describe.go index 1765f66..f0acbd1 100644 --- a/internal/pkg/cli/command/index/describe.go +++ b/internal/pkg/cli/command/index/describe.go @@ -1,15 +1,12 @@ package index import ( - "strings" - + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/index" - "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" - "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/spf13/cobra" ) @@ -23,20 +20,17 @@ func NewDescribeCmd() *cobra.Command { options := DescribeCmdOptions{} cmd := &cobra.Command{ - Use: "describe ", - Short: "Get configuration and status information for an index", - Args: index.ValidateIndexNameArgs, + Use: "describe ", + Short: "Get configuration and status information for an index", + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] pc := sdk.NewPineconeClient() idx, err := pc.DescribeIndex(cmd.Context(), options.name) if err != nil { - if strings.Contains(err.Error(), "not found") { - msg.FailMsg("The index %s does not exist\n", style.Emphasis(options.name)) - } else { - msg.FailMsg("Failed to describe index %s: %s\n", style.Emphasis(options.name), err) - } + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } diff --git a/internal/pkg/cli/command/index/list.go b/internal/pkg/cli/command/index/list.go index 8c84a07..5521f64 100644 --- a/internal/pkg/cli/command/index/list.go +++ b/internal/pkg/cli/command/index/list.go @@ -7,8 +7,8 @@ import ( "strings" "text/tabwriter" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" - "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -33,7 +33,7 @@ func NewListCmd() *cobra.Command { idxs, err := pc.ListIndexes(ctx) if err != nil { - msg.FailMsg("Failed to list indexes: %s\n", err) + errorutil.HandleIndexAPIError(err, cmd, []string{}) exit.Error(err) } diff --git a/internal/pkg/utils/error/error.go b/internal/pkg/utils/error/error.go new file mode 100644 index 0000000..1d15d06 --- /dev/null +++ b/internal/pkg/utils/error/error.go @@ -0,0 +1,85 @@ +package error + +import ( + "encoding/json" + "strings" + + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/spf13/cobra" +) + +// APIError represents a structured API error response +type APIError struct { + StatusCode int `json:"status_code"` + Body string `json:"body"` + ErrorCode string `json:"error_code"` + Message string `json:"message"` +} + +// HandleIndexAPIError is a convenience function specifically for index operations +// It extracts the operation from the command context and uses the first argument as index name +func HandleIndexAPIError(err error, cmd *cobra.Command, args []string) { + if err == nil { + return + } + + verbose, _ := cmd.Flags().GetBool("verbose") + + // Try to extract JSON error from the error message + errorMsg := err.Error() + + // Look for JSON-like content in the error message + var apiErr APIError + if jsonStart := strings.Index(errorMsg, "{"); jsonStart != -1 { + jsonContent := errorMsg[jsonStart:] + if jsonEnd := strings.LastIndex(jsonContent, "}"); jsonEnd != -1 { + jsonContent = jsonContent[:jsonEnd+1] + if json.Unmarshal([]byte(jsonContent), &apiErr) == nil && apiErr.Message != "" { + displayStructuredError(apiErr, verbose) + return + } + } + } + + // If no structured error found, show the raw error message + if verbose { + msg.FailMsg("%s\nFull error: %s\n", + errorMsg, errorMsg) + } else { + msg.FailMsg("%s\n", errorMsg) + } +} + +// displayStructuredError handles structured API error responses +func displayStructuredError(apiErr APIError, verbose bool) { + // Try to get the message from the body field first (actual API response) + userMessage := apiErr.Message // fallback to outer message + + // Parse the body field which contains the actual API response + if apiErr.Body != "" { + var bodyResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + Status int `json:"status"` + } + + if json.Unmarshal([]byte(apiErr.Body), &bodyResponse) == nil && bodyResponse.Error.Message != "" { + userMessage = bodyResponse.Error.Message + } + } + + if userMessage == "" { + userMessage = "Unknown error occurred" + } + + if verbose { + // Show full JSON error in verbose mode - nicely formatted + jsonBytes, _ := json.MarshalIndent(apiErr, "", " ") + msg.FailMsg("%s\n\nFull error response:\n%s\n", + userMessage, string(jsonBytes)) + } else { + msg.FailMsg("%s\n", userMessage) + } +} diff --git a/internal/pkg/utils/error/error_test.go b/internal/pkg/utils/error/error_test.go new file mode 100644 index 0000000..e806d73 --- /dev/null +++ b/internal/pkg/utils/error/error_test.go @@ -0,0 +1,62 @@ +package error + +import ( + "fmt" + "testing" + + "github.com/spf13/cobra" +) + +func TestHandleIndexAPIErrorWithCommand(t *testing.T) { + tests := []struct { + name string + err error + indexName string + commandName string + verbose bool + expectedOutput string + }{ + { + name: "JSON error with message field", + err: &mockError{message: `{"message": "Index not found", "code": 404}`}, + indexName: "test-index", + commandName: "describe ", + verbose: false, + expectedOutput: "Index not found", + }, + { + name: "Verbose mode shows full JSON", + err: &mockError{message: `{"message": "Rate limit exceeded", "code": 429}`}, + indexName: "my-index", + commandName: "create ", + verbose: true, + expectedOutput: "Rate limit exceeded", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock command with verbose flag and set the command name + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", false, "verbose output") + cmd.Use = tt.commandName + + // Set the verbose flag on the command + cmd.Flags().Set("verbose", fmt.Sprintf("%t", tt.verbose)) + + // This is a basic test to ensure the function doesn't panic + // In a real test environment, we would capture stdout/stderr + // and verify the exact output + HandleIndexAPIError(tt.err, cmd, []string{tt.indexName}) + }) + } +} + +// mockError is a simple error implementation for testing +type mockError struct { + message string +} + +func (e *mockError) Error() string { + return e.message +} From 69a72ae25d7b27ce5a3a5d599b536358fd452682 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Tue, 5 Aug 2025 16:33:13 +0200 Subject: [PATCH 12/19] Add preview of what index will be created # Conflicts: # internal/pkg/cli/command/index/create.go --- internal/pkg/cli/command/index/create.go | 77 ++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/internal/pkg/cli/command/index/create.go b/internal/pkg/cli/command/index/create.go index a011668..5be15c6 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -2,6 +2,7 @@ package index import ( "context" + "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/docslinks" @@ -150,6 +151,9 @@ func runCreateIndexCmd(options createIndexOptions, cmd *cobra.Command, args []st return } + // Print preview of what will be created + printCreatePreview(name, options, idxType) + // index tags var indexTags *pinecone.IndexTags if len(options.tags) > 0 { @@ -239,6 +243,79 @@ func runCreateIndexCmd(options createIndexOptions, cmd *cobra.Command, args []st renderSuccessOutput(idx, options) } +// printCreatePreview prints a preview of the index configuration that will be created +func printCreatePreview(name string, options createIndexOptions, idxType indexType) { + pcio.Println() + pcio.Printf("Creating %s index '%s' with the following configuration:\n\n", style.Emphasis(string(idxType)), style.Emphasis(name)) + + writer := presenters.NewTabWriter() + log.Debug().Str("name", name).Msg("Printing index creation preview") + + columns := []string{"ATTRIBUTE", "VALUE"} + header := strings.Join(columns, "\t") + "\n" + pcio.Fprint(writer, header) + + pcio.Fprintf(writer, "Name\t%s\n", name) + pcio.Fprintf(writer, "Type\t%s\n", string(idxType)) + + if options.dimension > 0 { + pcio.Fprintf(writer, "Dimension\t%d\n", options.dimension) + } + + pcio.Fprintf(writer, "Metric\t%s\n", options.metric) + + if options.deletionProtection != "" { + pcio.Fprintf(writer, "Deletion Protection\t%s\n", options.deletionProtection) + } + + if options.vectorType != "" { + pcio.Fprintf(writer, "Vector Type\t%s\n", options.vectorType) + } + + pcio.Fprintf(writer, "\t\n") + + switch idxType { + case indexTypeServerless: + pcio.Fprintf(writer, "Cloud\t%s\n", options.cloud) + pcio.Fprintf(writer, "Region\t%s\n", options.region) + if options.sourceCollection != "" { + pcio.Fprintf(writer, "Source Collection\t%s\n", options.sourceCollection) + } + case indexTypePod: + pcio.Fprintf(writer, "Environment\t%s\n", options.environment) + pcio.Fprintf(writer, "Pod Type\t%s\n", options.podType) + pcio.Fprintf(writer, "Replicas\t%d\n", options.replicas) + pcio.Fprintf(writer, "Shards\t%d\n", options.shards) + if len(options.metadataConfig) > 0 { + pcio.Fprintf(writer, "Metadata Config\t%s\n", text.InlineJSON(options.metadataConfig)) + } + if options.sourceCollection != "" { + pcio.Fprintf(writer, "Source Collection\t%s\n", options.sourceCollection) + } + case indexTypeIntegrated: + pcio.Fprintf(writer, "Cloud\t%s\n", options.cloud) + pcio.Fprintf(writer, "Region\t%s\n", options.region) + pcio.Fprintf(writer, "Model\t%s\n", options.model) + if len(options.fieldMap) > 0 { + pcio.Fprintf(writer, "Field Map\t%s\n", text.InlineJSON(options.fieldMap)) + } + if len(options.readParameters) > 0 { + pcio.Fprintf(writer, "Read Parameters\t%s\n", text.InlineJSON(options.readParameters)) + } + if len(options.writeParameters) > 0 { + pcio.Fprintf(writer, "Write Parameters\t%s\n", text.InlineJSON(options.writeParameters)) + } + } + + if len(options.tags) > 0 { + pcio.Fprintf(writer, "\t\n") + pcio.Fprintf(writer, "Tags\t%s\n", text.InlineJSON(options.tags)) + } + + writer.Flush() + pcio.Println() +} + func renderSuccessOutput(idx *pinecone.Index, options createIndexOptions) { if options.json { json := text.IndentJSON(idx) From 1f581e5b09fc8492dacf9a9a98fc43bcdec8788f Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Wed, 3 Sep 2025 15:57:43 +0200 Subject: [PATCH 13/19] Extract a color/typography scheme for messages --- go.mod | 1 - go.sum | 2 - internal/pkg/cli/command/config/cmd.go | 2 + .../cli/command/config/set_color_scheme.go | 66 ++++++ .../cli/command/config/show_color_scheme.go | 93 ++++++++ .../pkg/utils/configuration/config/config.go | 6 + .../presenters/collection_description.go | 4 +- .../pkg/utils/presenters/index_description.go | 10 +- .../pkg/utils/presenters/target_context.go | 2 +- internal/pkg/utils/presenters/text.go | 4 +- internal/pkg/utils/style/color.go | 34 --- internal/pkg/utils/style/colors.go | 148 ++++++++++++ internal/pkg/utils/style/functions.go | 98 ++++++++ internal/pkg/utils/style/spinner.go | 55 ----- internal/pkg/utils/style/styles.go | 221 ++++++++++++++++++ internal/pkg/utils/style/typography.go | 111 --------- 16 files changed, 644 insertions(+), 213 deletions(-) create mode 100644 internal/pkg/cli/command/config/set_color_scheme.go create mode 100644 internal/pkg/cli/command/config/show_color_scheme.go delete mode 100644 internal/pkg/utils/style/color.go create mode 100644 internal/pkg/utils/style/colors.go create mode 100644 internal/pkg/utils/style/functions.go delete mode 100644 internal/pkg/utils/style/spinner.go create mode 100644 internal/pkg/utils/style/styles.go delete mode 100644 internal/pkg/utils/style/typography.go diff --git a/go.mod b/go.mod index 31e2e70..8069eac 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.23.0 require ( github.com/MakeNowJust/heredoc v1.0.0 - github.com/briandowns/spinner v1.23.0 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.10.0 diff --git a/go.sum b/go.sum index d4706db..9459458 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= -github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= diff --git a/internal/pkg/cli/command/config/cmd.go b/internal/pkg/cli/command/config/cmd.go index b435531..029058d 100644 --- a/internal/pkg/cli/command/config/cmd.go +++ b/internal/pkg/cli/command/config/cmd.go @@ -18,9 +18,11 @@ func NewConfigCmd() *cobra.Command { } cmd.AddCommand(NewSetColorCmd()) + cmd.AddCommand(NewSetColorSchemeCmd()) cmd.AddCommand(NewSetApiKeyCmd()) cmd.AddCommand(NewGetApiKeyCmd()) cmd.AddCommand(NewSetEnvCmd()) + cmd.AddCommand(NewShowColorSchemeCmd()) return cmd } diff --git a/internal/pkg/cli/command/config/set_color_scheme.go b/internal/pkg/cli/command/config/set_color_scheme.go new file mode 100644 index 0000000..ed4136d --- /dev/null +++ b/internal/pkg/cli/command/config/set_color_scheme.go @@ -0,0 +1,66 @@ +package config + +import ( + "strings" + + conf "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" + "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/spf13/cobra" +) + +func NewSetColorSchemeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-color-scheme", + Short: "Configure the color scheme for the Pinecone CLI", + Long: `Set the color scheme used by the Pinecone CLI. + +Available color schemes: + pc-default-dark - Dark theme optimized for dark terminal backgrounds + pc-default-light - Light theme optimized for light terminal backgrounds + +The color scheme affects all colored output in the CLI, including tables, messages, and the color scheme display.`, + Example: help.Examples([]string{ + "pc config set-color-scheme pc-default-dark", + "pc config set-color-scheme pc-default-light", + }), + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + msg.FailMsg("Please provide a color scheme name") + msg.InfoMsg("Available color schemes: %s", strings.Join(getAvailableColorSchemes(), ", ")) + exit.ErrorMsg("No color scheme provided") + } + + schemeName := args[0] + + // Validate the color scheme + if !isValidColorScheme(schemeName) { + msg.FailMsg("Invalid color scheme: %s", schemeName) + msg.InfoMsg("Available color schemes: %s", strings.Join(getAvailableColorSchemes(), ", ")) + exit.ErrorMsg("Invalid color scheme") + } + + conf.ColorScheme.Set(schemeName) + msg.SuccessMsg("Color scheme updated to %s", style.Emphasis(schemeName)) + }, + } + + return cmd +} + +// getAvailableColorSchemes returns a list of available color scheme names +func getAvailableColorSchemes() []string { + schemes := make([]string, 0, len(style.AvailableColorSchemes)) + for name := range style.AvailableColorSchemes { + schemes = append(schemes, name) + } + return schemes +} + +// isValidColorScheme checks if the given scheme name is valid +func isValidColorScheme(schemeName string) bool { + _, exists := style.AvailableColorSchemes[schemeName] + return exists +} diff --git a/internal/pkg/cli/command/config/show_color_scheme.go b/internal/pkg/cli/command/config/show_color_scheme.go new file mode 100644 index 0000000..2991ae7 --- /dev/null +++ b/internal/pkg/cli/command/config/show_color_scheme.go @@ -0,0 +1,93 @@ +package config + +import ( + "fmt" + + conf "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/spf13/cobra" +) + +func NewShowColorSchemeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show-color-scheme", + Short: "Display the Pinecone CLI color scheme for development reference", + Long: `Display all available colors in the Pinecone CLI color scheme. +This command is useful for developers to see available colors and choose appropriate ones for their components. + +Use 'pc config set-color-scheme' to change the color scheme.`, + Example: help.Examples([]string{ + "pc config show-color-scheme", + }), + Run: func(cmd *cobra.Command, args []string) { + showSimpleColorScheme() + }, + } + + return cmd +} + +// showSimpleColorScheme displays colors in a simple text format +func showSimpleColorScheme() { + colorsEnabled := conf.Color.Get() + + fmt.Println("🎨 Pinecone CLI Color Scheme") + fmt.Println("============================") + fmt.Printf("Colors Enabled: %t\n", colorsEnabled) + + // Show which color scheme is being used + currentScheme := conf.ColorScheme.Get() + fmt.Printf("Color Scheme: %s\n", currentScheme) + fmt.Println() + + if colorsEnabled { + // Primary colors + fmt.Println("Primary Colors:") + fmt.Printf(" Primary Blue: %s\n", style.PrimaryStyle().Render("This is primary blue text")) + fmt.Printf(" Success Green: %s\n", style.SuccessStyle().Render("This is success green text")) + fmt.Printf(" Warning Yellow: %s\n", style.WarningStyle().Render("This is warning yellow text")) + fmt.Printf(" Error Red: %s\n", style.ErrorStyle().Render("This is error red text")) + fmt.Printf(" Info Blue: %s\n", style.InfoStyle().Render("This is info blue text")) + fmt.Println() + + // Text colors + fmt.Println("Text Colors:") + fmt.Printf(" Primary Text: %s\n", style.PrimaryTextStyle().Render("This is primary text")) + fmt.Printf(" Secondary Text: %s\n", style.SecondaryTextStyle().Render("This is secondary text")) + fmt.Printf(" Muted Text: %s\n", style.MutedTextStyle().Render("This is muted text")) + fmt.Println() + + // Background colors + fmt.Println("Background Colors:") + fmt.Printf(" Background: %s\n", style.BackgroundStyle().Render("This is background color")) + fmt.Printf(" Surface: %s\n", style.SurfaceStyle().Render("This is surface color")) + fmt.Println() + + // Border colors + fmt.Println("Border Colors:") + fmt.Printf(" Border: %s\n", style.BorderStyle().Render("This is border color")) + fmt.Printf(" Border Muted: %s\n", style.BorderMutedStyle().Render("This is border muted color")) + fmt.Println() + + // Usage examples with actual CLI function calls + fmt.Println("Status Messages Examples:") + fmt.Printf(" %s\n", style.SuccessMsg("Operation completed successfully")) + fmt.Printf(" %s\n", style.FailMsg("Operation failed")) + fmt.Printf(" %s\n", style.WarnMsg("This is a warning message")) + fmt.Printf(" %s\n", style.InfoMsg("This is an info message")) + fmt.Println() + + // Typography examples + fmt.Println("Typography Examples:") + fmt.Printf(" %s\n", style.Emphasis("This text is emphasized")) + fmt.Printf(" %s\n", style.HeavyEmphasis("This text is heavily emphasized")) + fmt.Printf(" %s\n", style.Heading("This is a heading")) + fmt.Printf(" %s\n", style.Underline("This text is underlined")) + fmt.Printf(" %s\n", style.Hint("This is a hint message")) + fmt.Printf(" This is code/command: %s\n", style.Code("pc login")) + fmt.Printf(" This is URL: %s\n", style.URL("https://pinecone.io")) + } else { + fmt.Println("Colors are disabled. Enable colors to see the color scheme.") + } +} diff --git a/internal/pkg/utils/configuration/config/config.go b/internal/pkg/utils/configuration/config/config.go index 9629f33..7d82877 100644 --- a/internal/pkg/utils/configuration/config/config.go +++ b/internal/pkg/utils/configuration/config/config.go @@ -22,10 +22,16 @@ var ( ViperStore: ConfigViper, DefaultValue: "production", } + ColorScheme = configuration.ConfigProperty[string]{ + KeyName: "color_scheme", + ViperStore: ConfigViper, + DefaultValue: "pc-default-dark", + } ) var properties = []configuration.Property{ Color, Environment, + ColorScheme, } var configFile = configuration.ConfigFile{ diff --git a/internal/pkg/utils/presenters/collection_description.go b/internal/pkg/utils/presenters/collection_description.go index 06ce679..4a35294 100644 --- a/internal/pkg/utils/presenters/collection_description.go +++ b/internal/pkg/utils/presenters/collection_description.go @@ -30,9 +30,9 @@ func PrintDescribeCollectionTable(coll *pinecone.Collection) { func ColorizeCollectionStatus(state pinecone.CollectionStatus) string { switch state { case pinecone.CollectionStatusReady: - return style.StatusGreen(string(state)) + return style.SuccessStyle().Render(string(state)) case pinecone.CollectionStatusInitializing, pinecone.CollectionStatusTerminating: - return style.StatusYellow(string(state)) + return style.WarningStyle().Render(string(state)) } return string(state) diff --git a/internal/pkg/utils/presenters/index_description.go b/internal/pkg/utils/presenters/index_description.go index 3364c15..993c78f 100644 --- a/internal/pkg/utils/presenters/index_description.go +++ b/internal/pkg/utils/presenters/index_description.go @@ -13,11 +13,11 @@ import ( func ColorizeState(state pinecone.IndexStatusState) string { switch state { case pinecone.Ready: - return style.StatusGreen(string(state)) + return style.SuccessStyle().Render(string(state)) case pinecone.Initializing, pinecone.Terminating, pinecone.ScalingDown, pinecone.ScalingDownPodSize, pinecone.ScalingUp, pinecone.ScalingUpPodSize: - return style.StatusYellow(string(state)) + return style.WarningStyle().Render(string(state)) case pinecone.InitializationFailed: - return style.StatusRed(string(state)) + return style.ErrorStyle().Render(string(state)) default: return string(state) } @@ -25,9 +25,9 @@ func ColorizeState(state pinecone.IndexStatusState) string { func ColorizeDeletionProtection(deletionProtection pinecone.DeletionProtection) string { if deletionProtection == pinecone.DeletionProtectionEnabled { - return style.StatusGreen("enabled") + return style.SuccessStyle().Render("enabled") } - return style.StatusRed("disabled") + return style.ErrorStyle().Render("disabled") } func PrintDescribeIndexTable(idx *pinecone.Index) { diff --git a/internal/pkg/utils/presenters/target_context.go b/internal/pkg/utils/presenters/target_context.go index ae26635..3f929de 100644 --- a/internal/pkg/utils/presenters/target_context.go +++ b/internal/pkg/utils/presenters/target_context.go @@ -11,7 +11,7 @@ import ( func labelUnsetIfEmpty(value string) string { if value == "" { - return style.StatusRed("UNSET") + return style.ErrorStyle().Render("UNSET") } return value } diff --git a/internal/pkg/utils/presenters/text.go b/internal/pkg/utils/presenters/text.go index 6e6a867..7da7312 100644 --- a/internal/pkg/utils/presenters/text.go +++ b/internal/pkg/utils/presenters/text.go @@ -8,9 +8,9 @@ import ( func ColorizeBool(b bool) string { if b { - return style.StatusGreen("true") + return style.SuccessStyle().Render("true") } - return style.StatusRed("false") + return style.ErrorStyle().Render("false") } func DisplayOrNone(val any) any { diff --git a/internal/pkg/utils/style/color.go b/internal/pkg/utils/style/color.go deleted file mode 100644 index 9f5486d..0000000 --- a/internal/pkg/utils/style/color.go +++ /dev/null @@ -1,34 +0,0 @@ -package style - -import ( - "github.com/fatih/color" - "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" -) - -func applyColor(s string, c *color.Color) string { - color.NoColor = !config.Color.Get() - colored := c.SprintFunc() - return colored(s) -} - -func applyStyle(s string, c color.Attribute) string { - color.NoColor = !config.Color.Get() - colored := color.New(c).SprintFunc() - return colored(s) -} - -func CodeWithPrompt(s string) string { - return (applyStyle("$ ", color.Faint) + applyColor(s, color.New(color.FgMagenta, color.Bold))) -} - -func StatusGreen(s string) string { - return applyStyle(s, color.FgGreen) -} - -func StatusYellow(s string) string { - return applyStyle(s, color.FgYellow) -} - -func StatusRed(s string) string { - return applyStyle(s, color.FgRed) -} diff --git a/internal/pkg/utils/style/colors.go b/internal/pkg/utils/style/colors.go new file mode 100644 index 0000000..0aff2df --- /dev/null +++ b/internal/pkg/utils/style/colors.go @@ -0,0 +1,148 @@ +package style + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" +) + +// ColorScheme defines the centralized color palette for the Pinecone CLI +// Based on Pinecone's official website CSS variables for consistent branding +type ColorScheme struct { + // Primary brand colors + PrimaryBlue string // Pinecone blue - main brand color + SuccessGreen string // Success states + WarningYellow string // Warning states + ErrorRed string // Error states + InfoBlue string // Info states + + // Text colors + PrimaryText string // Main text color + SecondaryText string // Secondary/muted text + MutedText string // Very muted text + + // Background colors + Background string // Main background + Surface string // Surface/card backgrounds + + // Border colors + Border string // Default borders + BorderMuted string // Muted borders +} + +// Available color schemes +var AvailableColorSchemes = map[string]ColorScheme{ + "pc-default-dark": DarkColorScheme(), + "pc-default-light": LightColorScheme(), +} + +// LightColorScheme returns colors optimized for light terminal backgrounds +// Uses Pinecone's official colors for text/backgrounds, but vibrant colors for status messages +func LightColorScheme() ColorScheme { + return ColorScheme{ + // Primary brand colors (using vibrant colors that work well in both themes) + PrimaryBlue: "#002bff", // --primary-main (Pinecone brand) + SuccessGreen: "#28a745", // More vibrant green for better visibility + WarningYellow: "#ffc107", // More vibrant amber for better visibility + ErrorRed: "#dc3545", // More vibrant red for better visibility + InfoBlue: "#17a2b8", // More vibrant info blue for better visibility + + // Text colors (from Pinecone's light theme) + PrimaryText: "#1c1917", // --text-primary + SecondaryText: "#57534e", // --text-secondary + MutedText: "#a8a29e", // --text-tertiary + + // Background colors (from Pinecone's light theme) + Background: "#fbfbfc", // --background + Surface: "#f2f3f6", // --surface + + // Border colors (from Pinecone's light theme) + Border: "#e7e5e4", // --border + BorderMuted: "#d8dddf", // --divider + } +} + +// DarkColorScheme returns colors optimized for dark terminal backgrounds +// Uses Pinecone's official colors for text/backgrounds, but more vibrant colors for status messages +func DarkColorScheme() ColorScheme { + return ColorScheme{ + // Primary brand colors (optimized for dark terminals) + PrimaryBlue: "#1e86ee", // --primary-main + SuccessGreen: "#28a745", // More vibrant green for dark terminals + WarningYellow: "#ffc107", // More vibrant amber for dark terminals + ErrorRed: "#dc3545", // More vibrant red for dark terminals + InfoBlue: "#17a2b8", // More vibrant info blue for dark terminals + + // Text colors (from Pinecone's dark theme) + PrimaryText: "#fff", // --text-primary + SecondaryText: "#a3a3a3", // --text-secondary + MutedText: "#525252", // --text-tertiary + + // Background colors (from Pinecone's dark theme) + Background: "#171717", // --background + Surface: "#252525", // --surface + + // Border colors (from Pinecone's dark theme) + Border: "#404040", // --border + BorderMuted: "#2a2a2a", // --divider + } +} + +// DefaultColorScheme returns the configured color scheme +func DefaultColorScheme() ColorScheme { + schemeName := config.ColorScheme.Get() + if scheme, exists := AvailableColorSchemes[schemeName]; exists { + return scheme + } + // Fallback to dark theme if configured scheme doesn't exist + return DarkColorScheme() +} + +// GetColorScheme returns the current color scheme +// This can be extended in the future to support themes +func GetColorScheme() ColorScheme { + return DefaultColorScheme() +} + +// LipglossColorScheme provides lipgloss-compatible color styles +type LipglossColorScheme struct { + PrimaryBlue lipgloss.Color + SuccessGreen lipgloss.Color + WarningYellow lipgloss.Color + ErrorRed lipgloss.Color + InfoBlue lipgloss.Color + PrimaryText lipgloss.Color + SecondaryText lipgloss.Color + MutedText lipgloss.Color + Background lipgloss.Color + Surface lipgloss.Color + Border lipgloss.Color + BorderMuted lipgloss.Color +} + +// GetLipglossColorScheme returns lipgloss-compatible colors +func GetLipglossColorScheme() LipglossColorScheme { + scheme := GetColorScheme() + return LipglossColorScheme{ + PrimaryBlue: lipgloss.Color(scheme.PrimaryBlue), + SuccessGreen: lipgloss.Color(scheme.SuccessGreen), + WarningYellow: lipgloss.Color(scheme.WarningYellow), + ErrorRed: lipgloss.Color(scheme.ErrorRed), + InfoBlue: lipgloss.Color(scheme.InfoBlue), + PrimaryText: lipgloss.Color(scheme.PrimaryText), + SecondaryText: lipgloss.Color(scheme.SecondaryText), + MutedText: lipgloss.Color(scheme.MutedText), + Background: lipgloss.Color(scheme.Background), + Surface: lipgloss.Color(scheme.Surface), + Border: lipgloss.Color(scheme.Border), + BorderMuted: lipgloss.Color(scheme.BorderMuted), + } +} + +// GetAvailableColorSchemeNames returns a list of available color scheme names +func GetAvailableColorSchemeNames() []string { + names := make([]string, 0, len(AvailableColorSchemes)) + for name := range AvailableColorSchemes { + names = append(names, name) + } + return names +} diff --git a/internal/pkg/utils/style/functions.go b/internal/pkg/utils/style/functions.go new file mode 100644 index 0000000..61aa93c --- /dev/null +++ b/internal/pkg/utils/style/functions.go @@ -0,0 +1,98 @@ +package style + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/fatih/color" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" +) + +// Typography functions using predefined styles + +func Emphasis(s string) string { + return EmphasisStyle().Render(s) +} + +func HeavyEmphasis(s string) string { + return HeavyEmphasisStyle().Render(s) +} + +func Heading(s string) string { + return HeadingStyle().Render(s) +} + +func Underline(s string) string { + return UnderlineStyle().Render(s) +} + +func Hint(s string) string { + return HintStyle().Render("Hint: ") + s +} + +func CodeHint(templateString string, codeString string) string { + return HintStyle().Render("Hint: ") + pcio.Sprintf(templateString, Code(codeString)) +} + +func Code(s string) string { + if color.NoColor { + // Add backticks for code formatting if color is disabled + return "`" + s + "`" + } + return CodeStyle().Render(s) +} + +func URL(s string) string { + return URLStyle().Render(s) +} + +// Message functions using predefined box styles + +func SuccessMsg(s string) string { + if color.NoColor { + return fmt.Sprintf("🟩 [SUCCESS] %s", s) + } + icon := "\r🟩" + box := SuccessBoxStyle().Render(icon + " SUCCESS") + return fmt.Sprintf("%s %s", box, s) +} + +func WarnMsg(s string) string { + if color.NoColor { + return fmt.Sprintf("🟧 [WARNING] %s", s) + } + icon := "\r🟧" + box := WarningBoxStyle().Render(icon + " WARNING") + return fmt.Sprintf("%s %s", box, s) +} + +func InfoMsg(s string) string { + if color.NoColor { + return fmt.Sprintf("🟦 [INFO] %s", s) + } + icon := "\r🟦" + box := InfoBoxStyle().Render(icon + " INFO") + return fmt.Sprintf("%s %s", box, s) +} + +func FailMsg(s string, a ...any) string { + message := fmt.Sprintf(s, a...) + if color.NoColor { + return fmt.Sprintf("🟥 [ERROR] %s", message) + } + icon := "\r🟥" + box := ErrorBoxStyle().Render(icon + " ERROR") + return fmt.Sprintf("%s %s", box, message) +} + +// Legacy functions using fatih/color (kept for backward compatibility) + +func CodeWithPrompt(s string) string { + if color.NoColor { + return "$ " + s + } + colors := GetLipglossColorScheme() + promptStyle := lipgloss.NewStyle().Foreground(colors.SecondaryText) + commandStyle := lipgloss.NewStyle().Foreground(colors.InfoBlue).Bold(true) + return promptStyle.Render("$ ") + commandStyle.Render(s) +} diff --git a/internal/pkg/utils/style/spinner.go b/internal/pkg/utils/style/spinner.go deleted file mode 100644 index f2aa527..0000000 --- a/internal/pkg/utils/style/spinner.go +++ /dev/null @@ -1,55 +0,0 @@ -package style - -import ( - "time" - - "github.com/briandowns/spinner" - - "github.com/pinecone-io/cli/internal/pkg/utils/exit" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" -) - -var ( - spinnerTextEllipsis = "..." - spinnerTextDone = StatusGreen("done") - spinnerTextFailed = StatusRed("failed") - - spinnerColor = "blue" -) - -func Waiting(fn func() error) error { - return loading("", "", "", fn) -} - -func Spinner(text string, fn func() error) error { - initialMsg := text + "... " - doneMsg := initialMsg + spinnerTextDone + "\n" - failMsg := initialMsg + spinnerTextFailed + "\n" - - return loading(initialMsg, doneMsg, failMsg, fn) -} - -func loading(initialMsg, doneMsg, failMsg string, fn func() error) error { - s := spinner.New(spinner.CharSets[11], 100*time.Millisecond) - s.Prefix = initialMsg - s.FinalMSG = doneMsg - s.HideCursor = true - s.Writer = pcio.Messages - - if err := s.Color(spinnerColor); err != nil { - exit.Error(err) - } - - s.Start() - err := fn() - if err != nil { - s.FinalMSG = failMsg - } - s.Stop() - - if err != nil { - return err - } - - return nil -} diff --git a/internal/pkg/utils/style/styles.go b/internal/pkg/utils/style/styles.go new file mode 100644 index 0000000..dd7ac6f --- /dev/null +++ b/internal/pkg/utils/style/styles.go @@ -0,0 +1,221 @@ +package style + +import ( + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" + "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" +) + +// Predefined styles for common use cases +var ( + // Status styles + SuccessStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.SuccessGreen) + } + + WarningStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.WarningYellow) + } + + ErrorStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.ErrorRed) + } + + InfoStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.InfoBlue) + } + + PrimaryStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryBlue) + } + + // Text styles + PrimaryTextStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryText) + } + + SecondaryTextStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.SecondaryText) + } + + MutedTextStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.MutedText) + } + + // Background styles + BackgroundStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Background(colors.Background).Foreground(colors.PrimaryText) + } + + SurfaceStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Background(colors.Surface).Foreground(colors.PrimaryText) + } + + // Border styles + BorderStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.Border) + } + + BorderMutedStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.BorderMuted) + } + + // Typography styles + EmphasisStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryBlue) + } + + HeavyEmphasisStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryBlue).Bold(true) + } + + HeadingStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryText).Bold(true) + } + + UnderlineStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryText).Underline(true) + } + + HintStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.SecondaryText) + } + + CodeStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.InfoBlue).Bold(true) + } + + URLStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.InfoBlue).Italic(true) + } + + // Message box styles with icon|label layout + SuccessBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.SuccessGreen). + Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast + Bold(true). + Padding(0, 1). + Width(14) // Fixed width for consistent alignment + } + + WarningBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.WarningYellow). + Foreground(lipgloss.Color("#000000")). // Always black text for good contrast on yellow + Bold(true). + Padding(0, 1). + Width(14) // Fixed width for consistent alignment + } + + ErrorBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.ErrorRed). + Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast + Bold(true). + Padding(0, 1). + Width(14) // Fixed width for consistent alignment + } + + InfoBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.InfoBlue). + Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast + Bold(true). + Padding(0, 1). + Width(14) // Fixed width for consistent alignment + } +) + +// GetBrandedTableStyles returns table styles using the centralized color scheme +func GetBrandedTableStyles() (table.Styles, bool) { + colors := GetLipglossColorScheme() + colorsEnabled := config.Color.Get() + + s := table.DefaultStyles() + + if colorsEnabled { + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colors.PrimaryBlue). + Foreground(colors.PrimaryBlue). + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + // Ensure selected row style doesn't interfere + s.Selected = s.Selected. + Foreground(colors.PrimaryText). + Background(colors.Background). + Bold(false) + } else { + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + s.Selected = s.Selected. + Foreground(lipgloss.Color("")). + Background(lipgloss.Color("")). + Bold(false) + } + + return s, colorsEnabled +} + +// GetBrandedConfirmationStyles returns confirmation dialog styles using the centralized color scheme +func GetBrandedConfirmationStyles() (lipgloss.Style, lipgloss.Style, lipgloss.Style, bool) { + colors := GetLipglossColorScheme() + colorsEnabled := config.Color.Get() + + var questionStyle, promptStyle, keyStyle lipgloss.Style + + if colorsEnabled { + questionStyle = lipgloss.NewStyle(). + Foreground(colors.PrimaryBlue). + Bold(true). + MarginBottom(1) + + promptStyle = lipgloss.NewStyle(). + Foreground(colors.SecondaryText). + MarginBottom(1) + + keyStyle = lipgloss.NewStyle(). + Foreground(colors.SuccessGreen). + Bold(true) + } else { + questionStyle = lipgloss.NewStyle(). + Bold(true). + MarginBottom(1) + + promptStyle = lipgloss.NewStyle(). + MarginBottom(1) + + keyStyle = lipgloss.NewStyle(). + Bold(true) + } + + return questionStyle, promptStyle, keyStyle, colorsEnabled +} diff --git a/internal/pkg/utils/style/typography.go b/internal/pkg/utils/style/typography.go deleted file mode 100644 index 1f4fbd0..0000000 --- a/internal/pkg/utils/style/typography.go +++ /dev/null @@ -1,111 +0,0 @@ -package style - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" - "github.com/fatih/color" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" -) - -// Lipgloss styles for cli-alerts style messages -var ( - // Alert type boxes (solid colored backgrounds) - using standard CLI colors - successBoxStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#28a745")). // Standard green - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Padding(0, 1) - - errorBoxStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#dc3545")). // Standard red (softer) - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Padding(0, 1) - - warningBoxStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#ffc107")). // Standard amber/yellow - Foreground(lipgloss.Color("#000000")). - Bold(true). - Padding(0, 1) - - infoBoxStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#17a2b8")). // Standard info blue - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Padding(0, 1) -) - -func Emphasis(s string) string { - return applyStyle(s, color.FgCyan) -} - -func HeavyEmphasis(s string) string { - return applyColor(s, color.New(color.FgCyan, color.Bold)) -} - -func Heading(s string) string { - return applyStyle(s, color.Bold) -} - -func Underline(s string) string { - return applyStyle(s, color.Underline) -} - -func Hint(s string) string { - return applyStyle("Hint: ", color.Faint) + s -} - -func CodeHint(templateString string, codeString string) string { - return applyStyle("Hint: ", color.Faint) + pcio.Sprintf(templateString, Code(codeString)) -} - -func SuccessMsg(s string) string { - if color.NoColor { - return fmt.Sprintf("✔ [SUCCESS] %s", s) - } - icon := "✔" - box := successBoxStyle.Render(icon + " SUCCESS") - return fmt.Sprintf("%s %s", box, s) -} - -func WarnMsg(s string) string { - if color.NoColor { - return fmt.Sprintf("⚠ [WARNING] %s", s) - } - icon := "⚠" - box := warningBoxStyle.Render(icon + " WARNING") - return fmt.Sprintf("%s %s", box, s) -} - -func InfoMsg(s string) string { - if color.NoColor { - return fmt.Sprintf("ℹ [INFO] %s", s) - } - icon := "ℹ" - box := infoBoxStyle.Render(icon + " INFO") - return fmt.Sprintf("%s %s", box, s) -} - -func FailMsg(s string, a ...any) string { - message := fmt.Sprintf(s, a...) - if color.NoColor { - return fmt.Sprintf("✘ [ERROR] %s", message) - } - icon := "✘" - box := errorBoxStyle.Render(icon + " ERROR") - return fmt.Sprintf("%s %s", box, message) -} - -func Code(s string) string { - formatted := applyColor(s, color.New(color.FgMagenta, color.Bold)) - if color.NoColor { - // Add backticks for code formatting if color is disabled - return "`" + formatted + "`" - } - return formatted -} - -func URL(s string) string { - return applyStyle(applyStyle(s, color.FgBlue), color.Italic) -} From 9774f435333ac3cdd0f77731f2233becaf808c26 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Wed, 3 Sep 2025 21:28:54 +0200 Subject: [PATCH 14/19] Confirmation component --- internal/pkg/cli/command/apiKey/delete.go | 25 +--- internal/pkg/cli/command/index/delete.go | 11 ++ .../pkg/cli/command/organization/delete.go | 24 +--- internal/pkg/cli/command/project/delete.go | 25 +--- .../pkg/utils/interactive/confirmation.go | 119 ++++++++++++++++++ internal/pkg/utils/style/styles.go | 2 +- 6 files changed, 143 insertions(+), 63 deletions(-) create mode 100644 internal/pkg/utils/interactive/confirmation.go diff --git a/internal/pkg/cli/command/apiKey/delete.go b/internal/pkg/cli/command/apiKey/delete.go index 7ccf29d..6ac4ffc 100644 --- a/internal/pkg/cli/command/apiKey/delete.go +++ b/internal/pkg/cli/command/apiKey/delete.go @@ -1,15 +1,13 @@ package apiKey import ( - "bufio" "fmt" - "os" - "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" @@ -69,25 +67,10 @@ func confirmDeleteApiKey(apiKeyName string) { msg.WarnMsg("Any integrations you have that auth with this API Key will stop working.") msg.WarnMsg("This action cannot be undone.") - // Prompt the user - fmt.Print("Do you want to continue? (y/N): ") - - // Read the user's input - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - fmt.Println("Error reading input:", err) - return - } - - // Trim any whitespace from the input and convert to lowercase - input = strings.TrimSpace(strings.ToLower(input)) - - // Check if the user entered "y" or "yes" - if input == "y" || input == "yes" { - msg.InfoMsg("You chose to continue delete.") - } else { + question := fmt.Sprintf("Do you want to continue deleting API key '%s'?", apiKeyName) + if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() } + msg.InfoMsg("You chose to continue delete.") } diff --git a/internal/pkg/cli/command/index/delete.go b/internal/pkg/cli/command/index/delete.go index fe6a33b..4accd6d 100644 --- a/internal/pkg/cli/command/index/delete.go +++ b/internal/pkg/cli/command/index/delete.go @@ -2,11 +2,14 @@ package index import ( "context" + "fmt" errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/index" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/spf13/cobra" @@ -26,6 +29,14 @@ func NewDeleteCmd() *cobra.Command { SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { options.name = args[0] + + // Ask for user confirmation + question := fmt.Sprintf("Do you want to delete the index '%s'?", options.name) + if !interactive.GetConfirmation(question) { + pcio.Println(style.InfoMsg("Index deletion cancelled.")) + return + } + ctx := context.Background() pc := sdk.NewPineconeClient() diff --git a/internal/pkg/cli/command/organization/delete.go b/internal/pkg/cli/command/organization/delete.go index 2fed04f..901f4ca 100644 --- a/internal/pkg/cli/command/organization/delete.go +++ b/internal/pkg/cli/command/organization/delete.go @@ -1,17 +1,14 @@ package organization import ( - "bufio" "fmt" - "os" - "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/spf13/cobra" @@ -80,23 +77,10 @@ func confirmDelete(organizationName string, organizationID string) { msg.WarnMsg("This will delete the organization %s (ID: %s).", style.Emphasis(organizationName), style.Emphasis(organizationID)) msg.WarnMsg("This action cannot be undone.") - // Prompt the user - fmt.Print("Do you want to continue? (y/N): ") - - // Read the user's input - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - pcio.Println(fmt.Errorf("Error reading input: %w", err)) - return - } - - input = strings.TrimSpace(strings.ToLower(input)) - - if input == "y" || input == "yes" { - msg.InfoMsg("You chose to continue delete.") - } else { + question := fmt.Sprintf("Do you want to continue deleting organization '%s'?", organizationName) + if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() } + msg.InfoMsg("You chose to continue delete.") } diff --git a/internal/pkg/cli/command/project/delete.go b/internal/pkg/cli/command/project/delete.go index 3687671..3313a97 100644 --- a/internal/pkg/cli/command/project/delete.go +++ b/internal/pkg/cli/command/project/delete.go @@ -1,16 +1,14 @@ package project import ( - "bufio" "context" "fmt" - "os" - "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" @@ -91,27 +89,12 @@ func confirmDelete(projectName string) { msg.WarnMsg("This will delete the project %s in organization %s.", style.Emphasis(projectName), style.Emphasis(state.TargetOrg.Get().Name)) msg.WarnMsg("This action cannot be undone.") - // Prompt the user - fmt.Print("Do you want to continue? (y/N): ") - - // Read the user's input - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - pcio.Println(fmt.Errorf("Error reading input: %w", err)) - return - } - - // Trim any whitespace from the input and convert to lowercase - input = strings.TrimSpace(strings.ToLower(input)) - - // Check if the user entered "y" or "yes" - if input == "y" || input == "yes" { - msg.InfoMsg("You chose to continue delete.") - } else { + question := fmt.Sprintf("Do you want to continue deleting project '%s'?", projectName) + if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() } + msg.InfoMsg("You chose to continue delete.") } func verifyNoIndexes(projectId string, projectName string) { diff --git a/internal/pkg/utils/interactive/confirmation.go b/internal/pkg/utils/interactive/confirmation.go new file mode 100644 index 0000000..f423558 --- /dev/null +++ b/internal/pkg/utils/interactive/confirmation.go @@ -0,0 +1,119 @@ +package interactive + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/pinecone-io/cli/internal/pkg/utils/log" + "github.com/pinecone-io/cli/internal/pkg/utils/style" +) + +// ConfirmationResult represents the result of a confirmation dialog +type ConfirmationResult int + +const ( + ConfirmationYes ConfirmationResult = iota + ConfirmationNo + ConfirmationQuit +) + +// ConfirmationModel handles the user confirmation dialog +type ConfirmationModel struct { + question string + choice ConfirmationResult + quitting bool +} + +// NewConfirmationModel creates a new confirmation dialog model +func NewConfirmationModel(question string) ConfirmationModel { + return ConfirmationModel{ + question: question, + choice: -1, // Invalid state until user makes a choice + } +} + +func (m ConfirmationModel) Init() tea.Cmd { + return nil +} + +func (m ConfirmationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case "y", "Y": + m.choice = ConfirmationYes + return m, tea.Quit + case "n", "N": + m.choice = ConfirmationNo + return m, tea.Quit + } + } + return m, nil +} + +func (m ConfirmationModel) View() string { + if m.quitting { + return "" + } + if m.choice != -1 { + return "" + } + + // Use centralized color scheme + questionStyle, promptStyle, keyStyle, _ := style.GetBrandedConfirmationStyles() + + // Create the confirmation prompt with styled keys + keys := fmt.Sprintf("%s to confirm, %s to cancel", + keyStyle.Render("'y'"), + keyStyle.Render("'n'")) + + return fmt.Sprintf("%s\n%s %s", + questionStyle.Render(m.question), + promptStyle.Render("Press"), + keys) +} + +// GetConfirmation prompts the user to confirm an action +// Returns true if the user confirmed with 'y', false if they declined with 'n' +func GetConfirmation(question string) bool { + m := NewConfirmationModel(question) + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + log.Error().Err(err).Msg("Error running confirmation program") + return false + } + + // Get the final model state + confModel, ok := finalModel.(ConfirmationModel) + if !ok { + log.Error().Msg("Failed to cast final model to ConfirmationModel") + return false + } + + return confModel.choice == ConfirmationYes +} + +// GetConfirmationResult prompts the user to confirm an action and returns the detailed result +// This allows callers to distinguish between "no" and "quit" responses (though both 'n' and 'q' now map to ConfirmationNo) +// Note: Ctrl+C will kill the entire CLI process and is not handled gracefully +func GetConfirmationResult(question string) ConfirmationResult { + m := NewConfirmationModel(question) + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + log.Error().Err(err).Msg("Error running confirmation program") + return ConfirmationNo + } + + // Get the final model state + confModel, ok := finalModel.(ConfirmationModel) + if !ok { + log.Error().Msg("Failed to cast final model to ConfirmationModel") + return ConfirmationNo + } + + return confModel.choice +} diff --git a/internal/pkg/utils/style/styles.go b/internal/pkg/utils/style/styles.go index dd7ac6f..d2158d6 100644 --- a/internal/pkg/utils/style/styles.go +++ b/internal/pkg/utils/style/styles.go @@ -203,7 +203,7 @@ func GetBrandedConfirmationStyles() (lipgloss.Style, lipgloss.Style, lipgloss.St MarginBottom(1) keyStyle = lipgloss.NewStyle(). - Foreground(colors.SuccessGreen). + Foreground(colors.InfoBlue). Bold(true) } else { questionStyle = lipgloss.NewStyle(). From d130b32abf255968bcbfd258057c67d28c048e45 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Thu, 4 Sep 2025 21:06:56 +0200 Subject: [PATCH 15/19] Add multi-line message boxes --- internal/pkg/cli/command/apiKey/delete.go | 13 +- internal/pkg/cli/command/index/delete.go | 7 +- .../pkg/cli/command/organization/delete.go | 11 +- internal/pkg/cli/command/project/delete.go | 9 +- internal/pkg/utils/msg/message.go | 52 ++++++- internal/pkg/utils/style/functions.go | 129 ++++++++++++++++++ internal/pkg/utils/style/styles.go | 10 +- 7 files changed, 206 insertions(+), 25 deletions(-) diff --git a/internal/pkg/cli/command/apiKey/delete.go b/internal/pkg/cli/command/apiKey/delete.go index 6ac4ffc..988c38e 100644 --- a/internal/pkg/cli/command/apiKey/delete.go +++ b/internal/pkg/cli/command/apiKey/delete.go @@ -1,14 +1,13 @@ package apiKey import ( - "fmt" - "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/spf13/cobra" @@ -63,11 +62,13 @@ func NewDeleteKeyCmd() *cobra.Command { } func confirmDeleteApiKey(apiKeyName string) { - msg.WarnMsg("This operation will delete API Key %s from project %s.", style.Emphasis(apiKeyName), style.Emphasis(state.TargetProj.Get().Name)) - msg.WarnMsg("Any integrations you have that auth with this API Key will stop working.") - msg.WarnMsg("This action cannot be undone.") + msg.WarnMsgMultiLine( + pcio.Sprintf("This operation will delete API Key %s from project %s.", style.Emphasis(apiKeyName), style.Emphasis(state.TargetProj.Get().Name)), + "Any integrations you have that auth with this API Key will stop working.", + "This action cannot be undone.", + ) - question := fmt.Sprintf("Do you want to continue deleting API key '%s'?", apiKeyName) + question := "Are you sure you want to proceed with deleting this API key?" if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() diff --git a/internal/pkg/cli/command/index/delete.go b/internal/pkg/cli/command/index/delete.go index 4accd6d..03f475b 100644 --- a/internal/pkg/cli/command/index/delete.go +++ b/internal/pkg/cli/command/index/delete.go @@ -2,7 +2,6 @@ package index import ( "context" - "fmt" errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" @@ -31,7 +30,11 @@ func NewDeleteCmd() *cobra.Command { options.name = args[0] // Ask for user confirmation - question := fmt.Sprintf("Do you want to delete the index '%s'?", options.name) + msg.WarnMsgMultiLine( + pcio.Sprintf("This will delete the index %s and all its data.", style.Emphasis(options.name)), + "This action cannot be undone.", + ) + question := "Are you sure you want to proceed with the deletion?" if !interactive.GetConfirmation(question) { pcio.Println(style.InfoMsg("Index deletion cancelled.")) return diff --git a/internal/pkg/cli/command/organization/delete.go b/internal/pkg/cli/command/organization/delete.go index 901f4ca..1a2aa65 100644 --- a/internal/pkg/cli/command/organization/delete.go +++ b/internal/pkg/cli/command/organization/delete.go @@ -1,14 +1,13 @@ package organization import ( - "fmt" - "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/spf13/cobra" @@ -74,10 +73,12 @@ func NewDeleteOrganizationCmd() *cobra.Command { } func confirmDelete(organizationName string, organizationID string) { - msg.WarnMsg("This will delete the organization %s (ID: %s).", style.Emphasis(organizationName), style.Emphasis(organizationID)) - msg.WarnMsg("This action cannot be undone.") + msg.WarnMsgMultiLine( + pcio.Sprintf("This will delete the organization %s (ID: %s).", style.Emphasis(organizationName), style.Emphasis(organizationID)), + "This action cannot be undone.", + ) - question := fmt.Sprintf("Do you want to continue deleting organization '%s'?", organizationName) + question := "Are you sure you want to proceed with deleting this organization?" if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() diff --git a/internal/pkg/cli/command/project/delete.go b/internal/pkg/cli/command/project/delete.go index 3313a97..c255f37 100644 --- a/internal/pkg/cli/command/project/delete.go +++ b/internal/pkg/cli/command/project/delete.go @@ -2,7 +2,6 @@ package project import ( "context" - "fmt" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" @@ -86,10 +85,12 @@ func NewDeleteProjectCmd() *cobra.Command { } func confirmDelete(projectName string) { - msg.WarnMsg("This will delete the project %s in organization %s.", style.Emphasis(projectName), style.Emphasis(state.TargetOrg.Get().Name)) - msg.WarnMsg("This action cannot be undone.") + msg.WarnMsgMultiLine( + pcio.Sprintf("This will delete the project %s in organization %s.", style.Emphasis(projectName), style.Emphasis(state.TargetOrg.Get().Name)), + "This action cannot be undone.", + ) - question := fmt.Sprintf("Do you want to continue deleting project '%s'?", projectName) + question := "Are you sure you want to proceed with deleting this project?" if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() diff --git a/internal/pkg/utils/msg/message.go b/internal/pkg/utils/msg/message.go index 4e204dc..51ca818 100644 --- a/internal/pkg/utils/msg/message.go +++ b/internal/pkg/utils/msg/message.go @@ -7,25 +7,69 @@ import ( func FailMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.FailMsg(formatted)) + pcio.Println("\n" + style.FailMsg(formatted) + "\n") } func SuccessMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.SuccessMsg(formatted)) + pcio.Println("\n" + style.SuccessMsg(formatted) + "\n") } func WarnMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.WarnMsg(formatted)) + pcio.Println("\n" + style.WarnMsg(formatted) + "\n") } func InfoMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.InfoMsg(formatted)) + pcio.Println("\n" + style.InfoMsg(formatted) + "\n") } func HintMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) pcio.Println(style.Hint(formatted)) } + +// WarnMsgMultiLine displays multiple warning messages in a single message box +func WarnMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line warning box + formatted := style.WarnMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// SuccessMsgMultiLine displays multiple success messages in a single message box +func SuccessMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line success box + formatted := style.SuccessMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// InfoMsgMultiLine displays multiple info messages in a single message box +func InfoMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line info box + formatted := style.InfoMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// FailMsgMultiLine displays multiple error messages in a single message box +func FailMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line error box + formatted := style.FailMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} diff --git a/internal/pkg/utils/style/functions.go b/internal/pkg/utils/style/functions.go index 61aa93c..9268e67 100644 --- a/internal/pkg/utils/style/functions.go +++ b/internal/pkg/utils/style/functions.go @@ -85,6 +85,135 @@ func FailMsg(s string, a ...any) string { return fmt.Sprintf("%s %s", box, message) } +// repeat creates a string by repeating a character n times +func repeat(char string, n int) string { + result := "" + for i := 0; i < n; i++ { + result += char + } + return result +} + +// WarnMsgMultiLine creates a multi-line warning message with proper alignment +func WarnMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟧 [WARNING] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the warning label + icon := "\r🟧" + label := " WARNING" + boxStyle := WarningBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + +// SuccessMsgMultiLine creates a multi-line success message with proper alignment +func SuccessMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟩 [SUCCESS] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the success label + icon := "\r🟩" + label := " SUCCESS" + boxStyle := SuccessBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + +// InfoMsgMultiLine creates a multi-line info message with proper alignment +func InfoMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟦 [INFO] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the info label + icon := "\r🟦" + label := " INFO" + boxStyle := InfoBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + +// FailMsgMultiLine creates a multi-line error message with proper alignment +func FailMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟥 [ERROR] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the error label + icon := "\r🟥" + label := " ERROR" + boxStyle := ErrorBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + // Legacy functions using fatih/color (kept for backward compatibility) func CodeWithPrompt(s string) string { diff --git a/internal/pkg/utils/style/styles.go b/internal/pkg/utils/style/styles.go index d2158d6..ae4e192 100644 --- a/internal/pkg/utils/style/styles.go +++ b/internal/pkg/utils/style/styles.go @@ -6,6 +6,8 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" ) +const MessageBoxFixedWidth = 14 + // Predefined styles for common use cases var ( // Status styles @@ -116,7 +118,7 @@ var ( Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast Bold(true). Padding(0, 1). - Width(14) // Fixed width for consistent alignment + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment } WarningBoxStyle = func() lipgloss.Style { @@ -126,7 +128,7 @@ var ( Foreground(lipgloss.Color("#000000")). // Always black text for good contrast on yellow Bold(true). Padding(0, 1). - Width(14) // Fixed width for consistent alignment + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment } ErrorBoxStyle = func() lipgloss.Style { @@ -136,7 +138,7 @@ var ( Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast Bold(true). Padding(0, 1). - Width(14) // Fixed width for consistent alignment + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment } InfoBoxStyle = func() lipgloss.Style { @@ -146,7 +148,7 @@ var ( Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast Bold(true). Padding(0, 1). - Width(14) // Fixed width for consistent alignment + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment } ) From be29228efa06feef8518322f35486f81619ffe9c Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Thu, 4 Sep 2025 21:08:34 +0200 Subject: [PATCH 16/19] Add table rendering util --- internal/pkg/cli/command/index/create.go | 110 ++-- internal/pkg/cli/command/index/list.go | 37 +- .../pkg/utils/presenters/index_columns.go | 471 ++++++++++++++++++ .../pkg/utils/presenters/index_description.go | 85 ---- internal/pkg/utils/presenters/table.go | 191 +++++++ internal/pkg/utils/style/styles.go | 43 +- 6 files changed, 747 insertions(+), 190 deletions(-) create mode 100644 internal/pkg/utils/presenters/index_columns.go delete mode 100644 internal/pkg/utils/presenters/index_description.go create mode 100644 internal/pkg/utils/presenters/table.go diff --git a/internal/pkg/cli/command/index/create.go b/internal/pkg/cli/command/index/create.go index 5be15c6..3cb2660 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -2,13 +2,13 @@ package index import ( "context" - "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/docslinks" errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/index" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/log" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" @@ -152,7 +152,14 @@ func runCreateIndexCmd(options createIndexOptions, cmd *cobra.Command, args []st } // Print preview of what will be created - printCreatePreview(name, options, idxType) + printCreatePreview(options, idxType) + + // Ask for user confirmation + question := "Is this configuration correct? Do you want to proceed with creating the index?" + if !interactive.GetConfirmation(question) { + pcio.Println(style.InfoMsg("Index creation cancelled.")) + return + } // index tags var indexTags *pinecone.IndexTags @@ -244,76 +251,49 @@ func runCreateIndexCmd(options createIndexOptions, cmd *cobra.Command, args []st } // printCreatePreview prints a preview of the index configuration that will be created -func printCreatePreview(name string, options createIndexOptions, idxType indexType) { - pcio.Println() - pcio.Printf("Creating %s index '%s' with the following configuration:\n\n", style.Emphasis(string(idxType)), style.Emphasis(name)) - - writer := presenters.NewTabWriter() - log.Debug().Str("name", name).Msg("Printing index creation preview") - - columns := []string{"ATTRIBUTE", "VALUE"} - header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) - - pcio.Fprintf(writer, "Name\t%s\n", name) - pcio.Fprintf(writer, "Type\t%s\n", string(idxType)) - - if options.dimension > 0 { - pcio.Fprintf(writer, "Dimension\t%d\n", options.dimension) - } - - pcio.Fprintf(writer, "Metric\t%s\n", options.metric) - - if options.deletionProtection != "" { - pcio.Fprintf(writer, "Deletion Protection\t%s\n", options.deletionProtection) - } - - if options.vectorType != "" { - pcio.Fprintf(writer, "Vector Type\t%s\n", options.vectorType) +func printCreatePreview(options createIndexOptions, idxType indexType) { + log.Debug().Str("name", options.name).Msg("Printing index creation preview") + + // Create a mock pinecone.Index for preview display + mockIndex := &pinecone.Index{ + Name: options.name, + Metric: pinecone.IndexMetric(options.metric), + Dimension: &options.dimension, + DeletionProtection: pinecone.DeletionProtection(options.deletionProtection), + Status: &pinecone.IndexStatus{ + State: "Creating", + }, } - pcio.Fprintf(writer, "\t\n") - - switch idxType { - case indexTypeServerless: - pcio.Fprintf(writer, "Cloud\t%s\n", options.cloud) - pcio.Fprintf(writer, "Region\t%s\n", options.region) - if options.sourceCollection != "" { - pcio.Fprintf(writer, "Source Collection\t%s\n", options.sourceCollection) - } - case indexTypePod: - pcio.Fprintf(writer, "Environment\t%s\n", options.environment) - pcio.Fprintf(writer, "Pod Type\t%s\n", options.podType) - pcio.Fprintf(writer, "Replicas\t%d\n", options.replicas) - pcio.Fprintf(writer, "Shards\t%d\n", options.shards) - if len(options.metadataConfig) > 0 { - pcio.Fprintf(writer, "Metadata Config\t%s\n", text.InlineJSON(options.metadataConfig)) - } - if options.sourceCollection != "" { - pcio.Fprintf(writer, "Source Collection\t%s\n", options.sourceCollection) - } - case indexTypeIntegrated: - pcio.Fprintf(writer, "Cloud\t%s\n", options.cloud) - pcio.Fprintf(writer, "Region\t%s\n", options.region) - pcio.Fprintf(writer, "Model\t%s\n", options.model) - if len(options.fieldMap) > 0 { - pcio.Fprintf(writer, "Field Map\t%s\n", text.InlineJSON(options.fieldMap)) - } - if len(options.readParameters) > 0 { - pcio.Fprintf(writer, "Read Parameters\t%s\n", text.InlineJSON(options.readParameters)) + // Set spec based on index type + if idxType == "serverless" { + mockIndex.Spec = &pinecone.IndexSpec{ + Serverless: &pinecone.ServerlessSpec{ + Cloud: pinecone.Cloud(options.cloud), + Region: options.region, + }, } - if len(options.writeParameters) > 0 { - pcio.Fprintf(writer, "Write Parameters\t%s\n", text.InlineJSON(options.writeParameters)) + mockIndex.VectorType = options.vectorType + } else { + mockIndex.Spec = &pinecone.IndexSpec{ + Pod: &pinecone.PodSpec{ + Environment: options.environment, + PodType: options.podType, + Replicas: options.replicas, + ShardCount: options.shards, + PodCount: 0, //?!?!?!?! + }, } } - if len(options.tags) > 0 { - pcio.Fprintf(writer, "\t\n") - pcio.Fprintf(writer, "Tags\t%s\n", text.InlineJSON(options.tags)) - } - - writer.Flush() + // Print title pcio.Println() + pcio.Printf("%s\n\n", style.Heading(pcio.Sprintf("Creating %s index %s with the following configuration:", + style.Emphasis(string(idxType)), + style.Code(options.name)))) + + // Use the specialized index table without status info (second column set) + presenters.PrintDescribeIndexTable(mockIndex) } func renderSuccessOutput(idx *pinecone.Index, options createIndexOptions) { diff --git a/internal/pkg/cli/command/index/list.go b/internal/pkg/cli/command/index/list.go index 5521f64..6203e2e 100644 --- a/internal/pkg/cli/command/index/list.go +++ b/internal/pkg/cli/command/index/list.go @@ -2,19 +2,15 @@ package index import ( "context" - "os" "sort" - "strings" - "text/tabwriter" errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" + "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/spf13/cobra" - - "github.com/pinecone-io/go-pinecone/v4/pinecone" ) type ListIndexCmdOptions struct { @@ -46,7 +42,11 @@ func NewListCmd() *cobra.Command { json := text.IndentJSON(idxs) pcio.Println(json) } else { - printTable(idxs) + // Show essential and state information + presenters.PrintIndexTableWithIndexAttributesGroups(idxs, []presenters.IndexAttributesGroup{ + presenters.IndexAttributesGroupEssential, + presenters.IndexAttributesGroupState, + }) } }, } @@ -55,28 +55,3 @@ func NewListCmd() *cobra.Command { return cmd } - -func printTable(idxs []*pinecone.Index) { - writer := tabwriter.NewWriter(os.Stdout, 10, 1, 3, ' ', 0) - - columns := []string{"NAME", "STATUS", "HOST", "DIMENSION", "METRIC", "SPEC"} - header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) - - for _, idx := range idxs { - dimension := "nil" - if idx.Dimension != nil { - dimension = pcio.Sprintf("%d", *idx.Dimension) - } - if idx.Spec.Serverless == nil { - // Pod index - values := []string{idx.Name, string(idx.Status.State), idx.Host, dimension, string(idx.Metric), "pod"} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") - } else { - // Serverless index - values := []string{idx.Name, string(idx.Status.State), idx.Host, dimension, string(idx.Metric), "serverless"} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") - } - } - writer.Flush() -} diff --git a/internal/pkg/utils/presenters/index_columns.go b/internal/pkg/utils/presenters/index_columns.go new file mode 100644 index 0000000..773d820 --- /dev/null +++ b/internal/pkg/utils/presenters/index_columns.go @@ -0,0 +1,471 @@ +package presenters + +import ( + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" + "github.com/pinecone-io/go-pinecone/v4/pinecone" +) + +// IndexAttributesGroup represents the available attribute groups for index display +type IndexAttributesGroup string + +const ( + IndexAttributesGroupEssential IndexAttributesGroup = "essential" + IndexAttributesGroupState IndexAttributesGroup = "state" + IndexAttributesGroupPodSpec IndexAttributesGroup = "pod_spec" + IndexAttributesGroupServerlessSpec IndexAttributesGroup = "serverless_spec" + IndexAttributesGroupInference IndexAttributesGroup = "inference" + IndexAttributesGroupOther IndexAttributesGroup = "other" +) + +// AllIndexAttributesGroups returns all available index attribute groups +func AllIndexAttributesGroups() []IndexAttributesGroup { + return []IndexAttributesGroup{ + IndexAttributesGroupEssential, + IndexAttributesGroupState, + IndexAttributesGroupPodSpec, + IndexAttributesGroupServerlessSpec, + IndexAttributesGroupInference, + IndexAttributesGroupOther, + } +} + +// IndexAttributesGroupsToStrings converts a slice of IndexAttributesGroup to strings +func IndexAttributesGroupsToStrings(groups []IndexAttributesGroup) []string { + strings := make([]string, len(groups)) + for i, group := range groups { + strings[i] = string(group) + } + return strings +} + +// StringsToIndexAttributesGroups converts a slice of strings to IndexAttributesGroup (validates input) +func StringsToIndexAttributesGroups(groups []string) []IndexAttributesGroup { + indexGroups := make([]IndexAttributesGroup, 0, len(groups)) + validGroups := map[string]IndexAttributesGroup{ + "essential": IndexAttributesGroupEssential, + "state": IndexAttributesGroupState, + "pod_spec": IndexAttributesGroupPodSpec, + "serverless_spec": IndexAttributesGroupServerlessSpec, + "inference": IndexAttributesGroupInference, + "other": IndexAttributesGroupOther, + } + + for _, group := range groups { + if indexGroup, exists := validGroups[group]; exists { + indexGroups = append(indexGroups, indexGroup) + } + } + return indexGroups +} + +// ColumnGroup represents a group of related columns for index display +type ColumnGroup struct { + Name string + Columns []Column +} + +// ColumnWithNames represents a table column with both short and full names +type ColumnWithNames struct { + ShortTitle string + FullTitle string + Width int +} + +// ColumnGroupWithNames represents a group of columns with both short and full names +type ColumnGroupWithNames struct { + Name string + Columns []ColumnWithNames +} + +// IndexColumnGroups defines the available column groups for index tables +// Each group represents a logical set of related index properties that can be displayed together +var IndexColumnGroups = struct { + Essential ColumnGroup // Basic index information (name, spec, type, metric, dimension) + State ColumnGroup // Runtime state information (status, host, protection) + PodSpec ColumnGroup // Pod-specific configuration (environment, pod type, replicas, etc.) + ServerlessSpec ColumnGroup // Serverless-specific configuration (cloud, region) + Inference ColumnGroup // Inference/embedding model information + Other ColumnGroup // Other information (tags, custom fields, etc.) +}{ + Essential: ColumnGroup{ + Name: "essential", + Columns: []Column{ + {Title: "NAME", Width: 20}, + {Title: "SPEC", Width: 12}, + {Title: "TYPE", Width: 8}, + {Title: "METRIC", Width: 8}, + {Title: "DIM", Width: 8}, + }, + }, + State: ColumnGroup{ + Name: "state", + Columns: []Column{ + {Title: "STATUS", Width: 10}, + {Title: "HOST", Width: 60}, + {Title: "PROT", Width: 8}, + }, + }, + PodSpec: ColumnGroup{ + Name: "pod_spec", + Columns: []Column{ + {Title: "ENV", Width: 12}, + {Title: "POD_TYPE", Width: 12}, + {Title: "REPLICAS", Width: 8}, + {Title: "SHARDS", Width: 8}, + {Title: "PODS", Width: 8}, + }, + }, + ServerlessSpec: ColumnGroup{ + Name: "serverless_spec", + Columns: []Column{ + {Title: "CLOUD", Width: 12}, + {Title: "REGION", Width: 15}, + }, + }, + Inference: ColumnGroup{ + Name: "inference", + Columns: []Column{ + {Title: "MODEL", Width: 25}, + {Title: "EMBED_DIM", Width: 10}, + }, + }, + Other: ColumnGroup{ + Name: "other", + Columns: []Column{ + {Title: "TAGS", Width: 30}, + }, + }, +} + +// IndexColumnGroupsWithNames defines the available column groups with both short and full names +var IndexColumnGroupsWithNames = struct { + Essential ColumnGroupWithNames // Basic index information (name, spec, type, metric, dimension) + State ColumnGroupWithNames // Runtime state information (status, host, protection) + PodSpec ColumnGroupWithNames // Pod-specific configuration (environment, pod type, replicas, etc.) + ServerlessSpec ColumnGroupWithNames // Serverless-specific configuration (cloud, region) + Inference ColumnGroupWithNames // Inference/embedding model information + Other ColumnGroupWithNames // Other information (tags, custom fields, etc.) +}{ + Essential: ColumnGroupWithNames{ + Name: "essential", + Columns: []ColumnWithNames{ + {ShortTitle: "NAME", FullTitle: "Name", Width: 20}, + {ShortTitle: "SPEC", FullTitle: "Specification", Width: 12}, + {ShortTitle: "TYPE", FullTitle: "Vector Type", Width: 8}, + {ShortTitle: "METRIC", FullTitle: "Metric", Width: 8}, + {ShortTitle: "DIM", FullTitle: "Dimension", Width: 8}, + }, + }, + State: ColumnGroupWithNames{ + Name: "state", + Columns: []ColumnWithNames{ + {ShortTitle: "STATUS", FullTitle: "Status", Width: 10}, + {ShortTitle: "HOST", FullTitle: "Host URL", Width: 60}, + {ShortTitle: "PROT", FullTitle: "Deletion Protection", Width: 8}, + }, + }, + PodSpec: ColumnGroupWithNames{ + Name: "pod_spec", + Columns: []ColumnWithNames{ + {ShortTitle: "ENV", FullTitle: "Environment", Width: 12}, + {ShortTitle: "POD_TYPE", FullTitle: "Pod Type", Width: 12}, + {ShortTitle: "REPLICAS", FullTitle: "Replicas", Width: 8}, + {ShortTitle: "SHARDS", FullTitle: "Shard Count", Width: 8}, + {ShortTitle: "PODS", FullTitle: "Pod Count", Width: 8}, + }, + }, + ServerlessSpec: ColumnGroupWithNames{ + Name: "serverless_spec", + Columns: []ColumnWithNames{ + {ShortTitle: "CLOUD", FullTitle: "Cloud Provider", Width: 12}, + {ShortTitle: "REGION", FullTitle: "Region", Width: 15}, + }, + }, + Inference: ColumnGroupWithNames{ + Name: "inference", + Columns: []ColumnWithNames{ + {ShortTitle: "MODEL", FullTitle: "Model", Width: 25}, + {ShortTitle: "EMBED_DIM", FullTitle: "Embedding Dimension", Width: 10}, + }, + }, + Other: ColumnGroupWithNames{ + Name: "other", + Columns: []ColumnWithNames{ + {ShortTitle: "TAGS", FullTitle: "Tags", Width: 30}, + }, + }, +} + +// GetColumnsForIndexAttributesGroups returns columns for the specified index attribute groups (using short names for horizontal tables) +func GetColumnsForIndexAttributesGroups(groups []IndexAttributesGroup) []Column { + var columns []Column + for _, group := range groups { + switch group { + case IndexAttributesGroupEssential: + columns = append(columns, IndexColumnGroups.Essential.Columns...) + case IndexAttributesGroupState: + columns = append(columns, IndexColumnGroups.State.Columns...) + case IndexAttributesGroupPodSpec: + columns = append(columns, IndexColumnGroups.PodSpec.Columns...) + case IndexAttributesGroupServerlessSpec: + columns = append(columns, IndexColumnGroups.ServerlessSpec.Columns...) + case IndexAttributesGroupInference: + columns = append(columns, IndexColumnGroups.Inference.Columns...) + case IndexAttributesGroupOther: + columns = append(columns, IndexColumnGroups.Other.Columns...) + } + } + return columns +} + +// GetColumnsForIndexAttributesGroupsWithNames returns columns for the specified index attribute groups with both short and full names +func GetColumnsForIndexAttributesGroupsWithNames(groups []IndexAttributesGroup) []ColumnWithNames { + var columns []ColumnWithNames + for _, group := range groups { + switch group { + case IndexAttributesGroupEssential: + columns = append(columns, IndexColumnGroupsWithNames.Essential.Columns...) + case IndexAttributesGroupState: + columns = append(columns, IndexColumnGroupsWithNames.State.Columns...) + case IndexAttributesGroupPodSpec: + columns = append(columns, IndexColumnGroupsWithNames.PodSpec.Columns...) + case IndexAttributesGroupServerlessSpec: + columns = append(columns, IndexColumnGroupsWithNames.ServerlessSpec.Columns...) + case IndexAttributesGroupInference: + columns = append(columns, IndexColumnGroupsWithNames.Inference.Columns...) + case IndexAttributesGroupOther: + columns = append(columns, IndexColumnGroupsWithNames.Other.Columns...) + } + } + return columns +} + +// ExtractEssentialValues extracts essential values from an index +func ExtractEssentialValues(idx *pinecone.Index) []string { + // Determine spec + var spec string + if idx.Spec.Serverless == nil { + spec = "pod" + } else { + spec = "serverless" + } + + // Determine type (for serverless indexes) + var indexType string + if idx.VectorType != "" { + indexType = string(idx.VectorType) + } else { + indexType = "dense" // Default for pod indexes + } + + // Get dimension + dimension := "nil" + if idx.Dimension != nil && *idx.Dimension > 0 { + dimension = pcio.Sprintf("%d", *idx.Dimension) + } + + return []string{ + idx.Name, + spec, + indexType, + string(idx.Metric), + dimension, + } +} + +// ExtractStateValues extracts state-related values from an index +func ExtractStateValues(idx *pinecone.Index) []string { + // Check if protected + protected := "no" + if idx.DeletionProtection == pinecone.DeletionProtectionEnabled { + protected = "yes" + } + + return []string{ + string(idx.Status.State), + idx.Host, + protected, + } +} + +// ExtractPodSpecValues extracts pod specification values from an index +func ExtractPodSpecValues(idx *pinecone.Index) []string { + if idx.Spec.Pod == nil { + return []string{"", "", "", "", ""} + } + + return []string{ + idx.Spec.Pod.Environment, + idx.Spec.Pod.PodType, + pcio.Sprintf("%d", idx.Spec.Pod.Replicas), + pcio.Sprintf("%d", idx.Spec.Pod.ShardCount), + pcio.Sprintf("%d", idx.Spec.Pod.PodCount), + } +} + +// ExtractServerlessSpecValues extracts serverless specification values from an index +func ExtractServerlessSpecValues(idx *pinecone.Index) []string { + if idx.Spec.Serverless == nil { + return []string{"", ""} + } + + return []string{ + string(idx.Spec.Serverless.Cloud), + idx.Spec.Serverless.Region, + } +} + +// ExtractInferenceValues extracts inference-related values from an index +func ExtractInferenceValues(idx *pinecone.Index) []string { + if idx.Embed == nil { + return []string{"", ""} + } + + embedDim := "nil" + if idx.Embed.Dimension != nil && *idx.Embed.Dimension > 0 { + embedDim = pcio.Sprintf("%d", *idx.Embed.Dimension) + } + + return []string{ + idx.Embed.Model, + embedDim, + } +} + +// ExtractOtherValues extracts other values from an index (tags, custom fields, etc.) +func ExtractOtherValues(idx *pinecone.Index) []string { + if idx.Tags == nil || len(*idx.Tags) == 0 { + return []string{""} + } + + // Convert tags to a simple string representation + // For now, just show the count, could be enhanced to show key-value pairs + return []string{pcio.Sprintf("%d tags", len(*idx.Tags))} +} + +// ExtractValuesForIndexAttributesGroups extracts values for the specified index attribute groups from an index +func ExtractValuesForIndexAttributesGroups(idx *pinecone.Index, groups []IndexAttributesGroup) []string { + var values []string + for _, group := range groups { + switch group { + case IndexAttributesGroupEssential: + values = append(values, ExtractEssentialValues(idx)...) + case IndexAttributesGroupState: + values = append(values, ExtractStateValues(idx)...) + case IndexAttributesGroupPodSpec: + values = append(values, ExtractPodSpecValues(idx)...) + case IndexAttributesGroupServerlessSpec: + values = append(values, ExtractServerlessSpecValues(idx)...) + case IndexAttributesGroupInference: + values = append(values, ExtractInferenceValues(idx)...) + case IndexAttributesGroupOther: + values = append(values, ExtractOtherValues(idx)...) + } + } + return values +} + +// GetGroupDescription returns a description of what each group contains +func GetGroupDescription(group IndexAttributesGroup) string { + switch group { + case IndexAttributesGroupEssential: + return "Basic index information (name, spec type, vector type, metric, dimension)" + case IndexAttributesGroupState: + return "Runtime state information (status, host URL, deletion protection)" + case IndexAttributesGroupPodSpec: + return "Pod-specific configuration (environment, pod type, replicas, shards, pod count)" + case IndexAttributesGroupServerlessSpec: + return "Serverless-specific configuration (cloud provider, region)" + case IndexAttributesGroupInference: + return "Inference/embedding model information (model name, embedding dimension)" + case IndexAttributesGroupOther: + return "Other information (tags, custom fields, etc.)" + default: + return "" + } +} + +// getColumnsWithNamesForIndexAttributesGroup returns columns with both short and full names for a specific index attribute group +func getColumnsWithNamesForIndexAttributesGroup(group IndexAttributesGroup) []ColumnWithNames { + switch group { + case IndexAttributesGroupEssential: + return IndexColumnGroupsWithNames.Essential.Columns + case IndexAttributesGroupState: + return IndexColumnGroupsWithNames.State.Columns + case IndexAttributesGroupPodSpec: + return IndexColumnGroupsWithNames.PodSpec.Columns + case IndexAttributesGroupServerlessSpec: + return IndexColumnGroupsWithNames.ServerlessSpec.Columns + case IndexAttributesGroupInference: + return IndexColumnGroupsWithNames.Inference.Columns + case IndexAttributesGroupOther: + return IndexColumnGroupsWithNames.Other.Columns + default: + return []ColumnWithNames{} + } +} + +// getValuesForIndexAttributesGroup returns values for a specific index attribute group +func getValuesForIndexAttributesGroup(idx *pinecone.Index, group IndexAttributesGroup) []string { + switch group { + case IndexAttributesGroupEssential: + return ExtractEssentialValues(idx) + case IndexAttributesGroupState: + return ExtractStateValues(idx) + case IndexAttributesGroupPodSpec: + return ExtractPodSpecValues(idx) + case IndexAttributesGroupServerlessSpec: + return ExtractServerlessSpecValues(idx) + case IndexAttributesGroupInference: + return ExtractInferenceValues(idx) + case IndexAttributesGroupOther: + return ExtractOtherValues(idx) + default: + return []string{} + } +} + +// hasNonEmptyValues checks if a group has any meaningful (non-empty) values +func hasNonEmptyValues(values []string) bool { + for _, value := range values { + if value != "" && value != "nil" { + return true + } + } + return false +} + +// filterNonEmptyIndexAttributesGroups filters out index attribute groups that have no meaningful data across all indexes +func filterNonEmptyIndexAttributesGroups(indexes []*pinecone.Index, groups []IndexAttributesGroup) []IndexAttributesGroup { + var nonEmptyGroups []IndexAttributesGroup + + for _, group := range groups { + hasData := false + for _, idx := range indexes { + values := getValuesForIndexAttributesGroup(idx, group) + if hasNonEmptyValues(values) { + hasData = true + break + } + } + if hasData { + nonEmptyGroups = append(nonEmptyGroups, group) + } + } + + return nonEmptyGroups +} + +// filterNonEmptyIndexAttributesGroupsForIndex filters out index attribute groups that have no meaningful data for a specific index +func filterNonEmptyIndexAttributesGroupsForIndex(idx *pinecone.Index, groups []IndexAttributesGroup) []IndexAttributesGroup { + var nonEmptyGroups []IndexAttributesGroup + + for _, group := range groups { + values := getValuesForIndexAttributesGroup(idx, group) + if hasNonEmptyValues(values) { + nonEmptyGroups = append(nonEmptyGroups, group) + } + } + + return nonEmptyGroups +} diff --git a/internal/pkg/utils/presenters/index_description.go b/internal/pkg/utils/presenters/index_description.go deleted file mode 100644 index 993c78f..0000000 --- a/internal/pkg/utils/presenters/index_description.go +++ /dev/null @@ -1,85 +0,0 @@ -package presenters - -import ( - "strings" - - "github.com/pinecone-io/cli/internal/pkg/utils/log" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" - "github.com/pinecone-io/cli/internal/pkg/utils/style" - "github.com/pinecone-io/cli/internal/pkg/utils/text" - "github.com/pinecone-io/go-pinecone/v4/pinecone" -) - -func ColorizeState(state pinecone.IndexStatusState) string { - switch state { - case pinecone.Ready: - return style.SuccessStyle().Render(string(state)) - case pinecone.Initializing, pinecone.Terminating, pinecone.ScalingDown, pinecone.ScalingDownPodSize, pinecone.ScalingUp, pinecone.ScalingUpPodSize: - return style.WarningStyle().Render(string(state)) - case pinecone.InitializationFailed: - return style.ErrorStyle().Render(string(state)) - default: - return string(state) - } -} - -func ColorizeDeletionProtection(deletionProtection pinecone.DeletionProtection) string { - if deletionProtection == pinecone.DeletionProtectionEnabled { - return style.SuccessStyle().Render("enabled") - } - return style.ErrorStyle().Render("disabled") -} - -func PrintDescribeIndexTable(idx *pinecone.Index) { - writer := NewTabWriter() - log.Debug().Str("name", idx.Name).Msg("Printing index description") - - columns := []string{"ATTRIBUTE", "VALUE"} - header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) - - pcio.Fprintf(writer, "Name\t%s\n", idx.Name) - pcio.Fprintf(writer, "Dimension\t%v\n", DisplayOrNone(idx.Dimension)) - pcio.Fprintf(writer, "Metric\t%s\n", string(idx.Metric)) - pcio.Fprintf(writer, "Deletion Protection\t%s\n", ColorizeDeletionProtection(idx.DeletionProtection)) - pcio.Fprintf(writer, "Vector Type\t%s\n", DisplayOrNone(idx.VectorType)) - pcio.Fprintf(writer, "\t\n") - pcio.Fprintf(writer, "State\t%s\n", ColorizeState(idx.Status.State)) - pcio.Fprintf(writer, "Ready\t%s\n", ColorizeBool(idx.Status.Ready)) - pcio.Fprintf(writer, "Host\t%s\n", style.Emphasis(idx.Host)) - pcio.Fprintf(writer, "Private Host\t%s\n", DisplayOrNone(idx.PrivateHost)) - pcio.Fprintf(writer, "\t\n") - - var specType string - if idx.Spec.Serverless == nil { - specType = "pod" - pcio.Fprintf(writer, "Spec\t%s\n", specType) - pcio.Fprintf(writer, "Environment\t%s\n", idx.Spec.Pod.Environment) - pcio.Fprintf(writer, "PodType\t%s\n", idx.Spec.Pod.PodType) - pcio.Fprintf(writer, "Replicas\t%d\n", idx.Spec.Pod.Replicas) - pcio.Fprintf(writer, "ShardCount\t%d\n", idx.Spec.Pod.ShardCount) - pcio.Fprintf(writer, "PodCount\t%d\n", idx.Spec.Pod.PodCount) - pcio.Fprintf(writer, "MetadataConfig\t%s\n", text.InlineJSON(idx.Spec.Pod.MetadataConfig)) - pcio.Fprintf(writer, "Source Collection\t%s\n", DisplayOrNone(idx.Spec.Pod.SourceCollection)) - } else { - specType = "serverless" - pcio.Fprintf(writer, "Spec\t%s\n", specType) - pcio.Fprintf(writer, "Cloud\t%s\n", idx.Spec.Serverless.Cloud) - pcio.Fprintf(writer, "Region\t%s\n", idx.Spec.Serverless.Region) - pcio.Fprintf(writer, "Source Collection\t%s\n", DisplayOrNone(idx.Spec.Serverless.SourceCollection)) - } - pcio.Fprintf(writer, "\t\n") - - if idx.Embed != nil { - pcio.Fprintf(writer, "Model\t%s\n", idx.Embed.Model) - pcio.Fprintf(writer, "Field Map\t%s\n", text.InlineJSON(idx.Embed.FieldMap)) - pcio.Fprintf(writer, "Read Parameters\t%s\n", text.InlineJSON(idx.Embed.ReadParameters)) - pcio.Fprintf(writer, "Write Parameters\t%s\n", text.InlineJSON(idx.Embed.WriteParameters)) - } - - if idx.Tags != nil { - pcio.Fprintf(writer, "Tags\t%s\n", text.InlineJSON(idx.Tags)) - } - - writer.Flush() -} diff --git a/internal/pkg/utils/presenters/table.go b/internal/pkg/utils/presenters/table.go new file mode 100644 index 0000000..39f550c --- /dev/null +++ b/internal/pkg/utils/presenters/table.go @@ -0,0 +1,191 @@ +package presenters + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/table" + "github.com/pinecone-io/cli/internal/pkg/utils/log" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/go-pinecone/v4/pinecone" +) + +// Column represents a table column with title and width +type Column struct { + Title string + Width int +} + +// Row represents a table row as a slice of strings +type Row []string + +// TableOptions contains configuration options for creating a table +type TableOptions struct { + Columns []Column + Rows []Row +} + +// PrintTable creates and renders a bubbles table with the given options +func PrintTable(options TableOptions) { + // Convert abstract types to bubbles table types + bubblesColumns := make([]table.Column, len(options.Columns)) + for i, col := range options.Columns { + bubblesColumns[i] = table.Column{ + Title: col.Title, + Width: col.Width, + } + } + + bubblesRows := make([]table.Row, len(options.Rows)) + for i, row := range options.Rows { + bubblesRows[i] = table.Row(row) + } + + // Create and configure the table + t := table.New( + table.WithColumns(bubblesColumns), + table.WithRows(bubblesRows), + table.WithFocused(false), // Always disable focus to prevent row selection + table.WithHeight(len(options.Rows)), + ) + + // Use centralized color scheme for table styling (no selection version) + s, _ := style.GetBrandedTableNoSelectionStyles() + t.SetStyles(s) + + // Always ensure no row is selected/highlighted + // This must be done after setting styles + t.SetCursor(-1) + + // Render the table directly + pcio.Println(t.View()) +} + +// PrintTableWithTitle creates and renders a bubbles table with a title +func PrintTableWithTitle(title string, options TableOptions) { + pcio.Println() + pcio.Printf("%s\n\n", style.Heading(title)) + PrintTable(options) + pcio.Println() +} + +// PrintIndexTableWithIndexAttributesGroups creates and renders a table for index information with custom index attribute groups +func PrintIndexTableWithIndexAttributesGroups(indexes []*pinecone.Index, groups []IndexAttributesGroup) { + // Filter out groups that have no meaningful data + nonEmptyGroups := filterNonEmptyIndexAttributesGroups(indexes, groups) + if len(nonEmptyGroups) == 0 { + return + } + + // Get columns for the non-empty groups + columns := GetColumnsForIndexAttributesGroups(nonEmptyGroups) + + // Build table rows + var rows []Row + for _, idx := range indexes { + values := ExtractValuesForIndexAttributesGroups(idx, nonEmptyGroups) + rows = append(rows, Row(values)) + } + + // Use the table utility + PrintTable(TableOptions{ + Columns: columns, + Rows: rows, + }) + + pcio.Println() + + // Add a note about full URLs if state info is shown + hasStateGroup := false + for _, group := range nonEmptyGroups { + if group == IndexAttributesGroupState { + hasStateGroup = true + break + } + } + if hasStateGroup && len(indexes) > 0 { + hint := fmt.Sprintf("Use %s to see index details", style.Code("pc index describe ")) + pcio.Println(style.Hint(hint)) + } +} + +// PrintDescribeIndexTable creates and renders a table for index description with right-aligned first column and secondary text styling +func PrintDescribeIndexTable(idx *pinecone.Index) { + log.Debug().Str("name", idx.Name).Msg("Printing index description") + + // Print title + pcio.Println(style.Heading("Index Configuration")) + pcio.Println() + + // Print all groups with their information + PrintDescribeIndexTableWithIndexAttributesGroups(idx, AllIndexAttributesGroups()) +} + +// PrintDescribeIndexTableWithIndexAttributesGroups creates and renders a table for index description with specified index attribute groups +func PrintDescribeIndexTableWithIndexAttributesGroups(idx *pinecone.Index, groups []IndexAttributesGroup) { + // Filter out groups that have no meaningful data for this specific index + nonEmptyGroups := filterNonEmptyIndexAttributesGroupsForIndex(idx, groups) + if len(nonEmptyGroups) == 0 { + return + } + + // Build rows for the table using the same order as the table view + var rows []Row + for i, group := range nonEmptyGroups { + // Get the columns with full names for this specific group + groupColumns := getColumnsWithNamesForIndexAttributesGroup(group) + groupValues := getValuesForIndexAttributesGroup(idx, group) + + // Add spacing before each group (except the first) + if i > 0 { + rows = append(rows, Row{"", ""}) + } + + // Add rows for this group using full names + for j, col := range groupColumns { + if j < len(groupValues) { + rows = append(rows, Row{col.FullTitle, groupValues[j]}) + } + } + } + + // Print each row with right-aligned first column and secondary text styling + for _, row := range rows { + if len(row) >= 2 { + // Right align the first column content + rightAlignedFirstCol := fmt.Sprintf("%20s", row[0]) + + // Apply secondary text styling to the first column + styledFirstCol := style.SecondaryTextStyle().Render(rightAlignedFirstCol) + + // Print the row + rowText := fmt.Sprintf("%s %s", styledFirstCol, row[1]) + pcio.Println(rowText) + } else if len(row) == 1 && row[0] == "" { + // Empty row for spacing + pcio.Println() + } + } +} + +// ColorizeState applies appropriate styling to index state +func ColorizeState(state pinecone.IndexStatusState) string { + switch state { + case pinecone.Ready: + return style.SuccessStyle().Render(string(state)) + case pinecone.Initializing, pinecone.Terminating, pinecone.ScalingDown, pinecone.ScalingDownPodSize, pinecone.ScalingUp, pinecone.ScalingUpPodSize: + return style.WarningStyle().Render(string(state)) + case pinecone.InitializationFailed: + return style.ErrorStyle().Render(string(state)) + default: + return string(state) + } +} + +// ColorizeDeletionProtection applies appropriate styling to deletion protection status +func ColorizeDeletionProtection(deletionProtection pinecone.DeletionProtection) string { + if deletionProtection == pinecone.DeletionProtectionEnabled { + return style.SuccessStyle().Render("enabled") + } + return style.ErrorStyle().Render("disabled") +} diff --git a/internal/pkg/utils/style/styles.go b/internal/pkg/utils/style/styles.go index ae4e192..ee34734 100644 --- a/internal/pkg/utils/style/styles.go +++ b/internal/pkg/utils/style/styles.go @@ -187,23 +187,48 @@ func GetBrandedTableStyles() (table.Styles, bool) { return s, colorsEnabled } -// GetBrandedConfirmationStyles returns confirmation dialog styles using the centralized color scheme -func GetBrandedConfirmationStyles() (lipgloss.Style, lipgloss.Style, lipgloss.Style, bool) { +// GetBrandedTableNoSelectionStyles returns table styles for read-only tables without row selection +func GetBrandedTableNoSelectionStyles() (table.Styles, bool) { colors := GetLipglossColorScheme() colorsEnabled := config.Color.Get() - var questionStyle, promptStyle, keyStyle lipgloss.Style + s := table.DefaultStyles() if colorsEnabled { - questionStyle = lipgloss.NewStyle(). + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colors.PrimaryBlue). Foreground(colors.PrimaryBlue). - Bold(true). - MarginBottom(1) + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + // Empty selected style since cell style is already applied to each cell + // and we don't want any additional styling for selected rows + s.Selected = lipgloss.NewStyle() + } else { + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + // Empty selected style since cell style is already applied to each cell + // and we don't want any additional styling for selected rows + s.Selected = lipgloss.NewStyle() + } - promptStyle = lipgloss.NewStyle(). - Foreground(colors.SecondaryText). - MarginBottom(1) + return s, colorsEnabled +} +// GetBrandedConfirmationStyles returns confirmation dialog styles using the centralized color scheme +func GetBrandedConfirmationStyles() (lipgloss.Style, lipgloss.Style, lipgloss.Style, bool) { + colors := GetLipglossColorScheme() + colorsEnabled := config.Color.Get() + + var questionStyle, promptStyle, keyStyle lipgloss.Style + + if colorsEnabled { + questionStyle = HeadingStyle() + promptStyle = SecondaryTextStyle().MarginBottom(1) keyStyle = lipgloss.NewStyle(). Foreground(colors.InfoBlue). Bold(true) From dc6a81d042f040ac79fc9eba86ac77ed7c672122 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Fri, 5 Sep 2025 07:52:38 +0200 Subject: [PATCH 17/19] Don't use `pcio` to silence explicitly requested output --- CONTRIBUTING.md | 157 ++++++++++++++++++ internal/pkg/cli/command/apiKey/list.go | 18 +- .../pkg/cli/command/collection/describe.go | 4 +- internal/pkg/cli/command/collection/list.go | 8 +- internal/pkg/cli/command/index/describe.go | 5 +- internal/pkg/cli/command/index/list.go | 6 +- internal/pkg/cli/command/organization/list.go | 8 +- internal/pkg/cli/command/project/list.go | 9 +- internal/pkg/utils/msg/message.go | 2 + internal/pkg/utils/pcio/print.go | 26 ++- internal/pkg/utils/presenters/table.go | 27 +-- 11 files changed, 230 insertions(+), 40 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7494d5..e141e19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,163 @@ Some facts that could be useful: - You can enable debug output with the `PINECONE_LOG_LEVEL=DEBUG` env var - Are you pointed at the correct environment? The current value of the environment setting (i.e. prod or staging) is controlled through `pc config set-environment staging` is not clearly surfaced through the printed output. If things aren't working as you expect, you might be pointed in the wrong place. See `cat ~/.config/pinecone/config.yaml` to confirm. +## Development Practices & Tools + +This project follows several established patterns and provides utilities to ensure consistency across the codebase. + +### Output Functions & Quiet Mode + +The CLI supports a `-q` (quiet) flag that suppresses non-essential output while preserving essential data. Follow these guidelines: + +**Use `pcio` functions for:** + +- User-facing messages (success, error, warning, info) +- Progress indicators and status updates +- Interactive prompts and confirmations +- Help text and documentation +- Any output that should be suppressed with `-q` flag + +**Use `fmt` functions for:** + +- Data output from informational commands (list, describe) +- JSON output that should always be displayed +- Table rendering and structured data display +- Any output that should NOT be suppressed with `-q` flag + +```go +// ✅ Correct usage +pcio.Println("Creating index...") // User message - suppressed with -q +msg.SuccessMsg("Index created!") // User message - suppressed with -q +fmt.Println(jsonData) // Data output - always displayed + +// ❌ Incorrect usage +pcio.Println(jsonData) // Wrong! Data would be suppressed +fmt.Println("Creating index...") // Wrong! Ignores quiet mode +``` + +### Error Handling + +Use the centralized error handling utilities: + +```go +// For API errors with structured responses +errorutil.HandleIndexAPIError(err, cmd, args) + +// For program termination +exit.Error(err) // Logs error and exits with code 1 +exit.ErrorMsg("msg") // Logs message and exits with code 1 +exit.Success() // Logs success and exits with code 0 +``` + +### User Messages & Styling + +Use the `msg` package for consistent user messaging: + +```go +msg.SuccessMsg("Operation completed successfully!") +msg.FailMsg("Operation failed: %s", err) +msg.WarnMsg("This will delete the resource") +msg.InfoMsg("Processing...") +msg.HintMsg("Use --help for more options") + +// Multi-line messages +msg.WarnMsgMultiLine("Warning 1", "Warning 2", "Warning 3") +``` + +Use the `style` package for consistent text formatting: + +```go +style.Heading("Section Title") +style.Emphasis("important text") +style.Code("command-name") +style.URL("https://example.com") +``` + +### Interactive Components + +For user confirmations, use the interactive package: + +```go +result := interactive.AskForConfirmation("Delete this resource?") +switch result { +case interactive.ConfirmationYes: + // Proceed with deletion +case interactive.ConfirmationNo: + // Cancel operation +case interactive.ConfirmationQuit: + // Exit program +} +``` + +### Table Rendering + +Use the `presenters` package for consistent table output: + +```go +// For data tables (always displayed, not suppressed by -q) +presenters.PrintTable(presenters.TableOptions{ + Columns: []presenters.Column{{Title: "Name", Width: 20}}, + Rows: []presenters.Row{{"example"}}, +}) + +// For index-specific tables +presenters.PrintIndexTableWithIndexAttributesGroups(indexes, groups) +``` + +### Testing Utilities + +Use the `testutils` package for consistent command testing: + +```go +// Test command arguments and flags +tests := []testutils.CommandTestConfig{ + { + Name: "valid arguments", + Args: []string{"my-arg"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + ExpectedArgs: []string{"my-arg"}, + }, +} +testutils.TestCommandArgsAndFlags(t, cmd, tests) + +// Test JSON flag configuration +testutils.AssertJSONFlag(t, cmd) +``` + +### Validation Utilities + +Use centralized validation functions: + +```go +// For index name validation +index.ValidateIndexNameArgs(cmd, args) + +// For other validations, check the respective utility packages +``` + +### Logging + +Use structured logging with the `log` package: + +```go +log.Debug().Str("index", name).Msg("Creating index") +log.Error().Err(err).Msg("Failed to create index") +log.Info().Msg("Operation completed") +``` + +### Configuration Management + +Use the configuration utilities for consistent config handling: + +```go +// Get current state +org := state.TargetOrg.Get() +proj := state.TargetProj.Get() + +// Configuration files are managed through the config package +``` + ## Making a Pull Request Please fork this repo and make a PR with your changes. Run `gofmt` and `goimports` on all proposed diff --git a/internal/pkg/cli/command/apiKey/list.go b/internal/pkg/cli/command/apiKey/list.go index 1698c8a..0430745 100644 --- a/internal/pkg/cli/command/apiKey/list.go +++ b/internal/pkg/cli/command/apiKey/list.go @@ -1,6 +1,7 @@ package apiKey import ( + "fmt" "sort" "strings" @@ -9,7 +10,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" @@ -61,7 +61,7 @@ func NewListKeysCmd() *cobra.Command { if options.json { json := text.IndentJSON(sortedKeys) - pcio.Println(json) + fmt.Println(json) } else { printTable(sortedKeys) } @@ -74,17 +74,17 @@ func NewListKeysCmd() *cobra.Command { } func printTable(keys []*pinecone.APIKey) { - pcio.Printf("Organization: %s (ID: %s)\n", style.Emphasis(state.TargetOrg.Get().Name), style.Emphasis(state.TargetOrg.Get().Id)) - pcio.Printf("Project: %s (ID: %s)\n", style.Emphasis(state.TargetProj.Get().Name), style.Emphasis(state.TargetProj.Get().Id)) - pcio.Println() - pcio.Println(style.Heading("API Keys")) - pcio.Println() + fmt.Printf("Organization: %s (ID: %s)\n", style.Emphasis(state.TargetOrg.Get().Name), style.Emphasis(state.TargetOrg.Get().Id)) + fmt.Printf("Project: %s (ID: %s)\n", style.Emphasis(state.TargetProj.Get().Name), style.Emphasis(state.TargetProj.Get().Id)) + fmt.Println() + fmt.Println(style.Heading("API Keys")) + fmt.Println() writer := presenters.NewTabWriter() columns := []string{"NAME", "ID", "PROJECT ID", "ROLES"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, key := range keys { values := []string{ @@ -93,7 +93,7 @@ func printTable(keys []*pinecone.APIKey) { key.ProjectId, strings.Join(key.Roles, ", "), } - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() diff --git a/internal/pkg/cli/command/collection/describe.go b/internal/pkg/cli/command/collection/describe.go index 35d24d8..7b84f9b 100644 --- a/internal/pkg/cli/command/collection/describe.go +++ b/internal/pkg/cli/command/collection/describe.go @@ -2,10 +2,10 @@ package collection import ( "context" + "fmt" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -35,7 +35,7 @@ func NewDescribeCollectionCmd() *cobra.Command { if options.json { json := text.IndentJSON(collection) - pcio.Println(json) + fmt.Println(json) } else { presenters.PrintDescribeCollectionTable(collection) } diff --git a/internal/pkg/cli/command/collection/list.go b/internal/pkg/cli/command/collection/list.go index d693758..9f7878f 100644 --- a/internal/pkg/cli/command/collection/list.go +++ b/internal/pkg/cli/command/collection/list.go @@ -2,6 +2,7 @@ package collection import ( "context" + "fmt" "os" "sort" "strconv" @@ -10,7 +11,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/spf13/cobra" @@ -45,7 +45,7 @@ func NewListCollectionsCmd() *cobra.Command { if options.json { json := text.IndentJSON(collections) - pcio.Println(json) + fmt.Println(json) } else { printTable(collections) } @@ -63,11 +63,11 @@ func printTable(collections []*pinecone.Collection) { columns := []string{"NAME", "DIMENSION", "SIZE", "STATUS", "VECTORS", "ENVIRONMENT"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, coll := range collections { values := []string{coll.Name, string(coll.Dimension), strconv.FormatInt(coll.Size, 10), string(coll.Status), string(coll.VectorCount), coll.Environment} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() } diff --git a/internal/pkg/cli/command/index/describe.go b/internal/pkg/cli/command/index/describe.go index f0acbd1..d55d0d8 100644 --- a/internal/pkg/cli/command/index/describe.go +++ b/internal/pkg/cli/command/index/describe.go @@ -1,10 +1,11 @@ package index import ( + "fmt" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/index" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -36,7 +37,7 @@ func NewDescribeCmd() *cobra.Command { if options.json { json := text.IndentJSON(idx) - pcio.Println(json) + fmt.Println(json) } else { presenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/list.go b/internal/pkg/cli/command/index/list.go index 6203e2e..3560ee4 100644 --- a/internal/pkg/cli/command/index/list.go +++ b/internal/pkg/cli/command/index/list.go @@ -2,11 +2,11 @@ package index import ( "context" + "fmt" "sort" errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -39,10 +39,12 @@ func NewListCmd() *cobra.Command { }) if options.json { + // Use fmt for data output - should not be suppressed by -q flag json := text.IndentJSON(idxs) - pcio.Println(json) + fmt.Println(json) } else { // Show essential and state information + // Note: presenters functions now use fmt internally for data output presenters.PrintIndexTableWithIndexAttributesGroups(idxs, []presenters.IndexAttributesGroup{ presenters.IndexAttributesGroupEssential, presenters.IndexAttributesGroupState, diff --git a/internal/pkg/cli/command/organization/list.go b/internal/pkg/cli/command/organization/list.go index 0c8f445..0fceba5 100644 --- a/internal/pkg/cli/command/organization/list.go +++ b/internal/pkg/cli/command/organization/list.go @@ -1,6 +1,7 @@ package organization import ( + "fmt" "os" "strings" "text/tabwriter" @@ -11,7 +12,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/pinecone-io/go-pinecone/v4/pinecone" @@ -42,7 +42,7 @@ func NewListOrganizationsCmd() *cobra.Command { if options.json { json := text.IndentJSON(orgs) - pcio.Println(json) + fmt.Println(json) return } @@ -60,7 +60,7 @@ func printTable(orgs []*pinecone.Organization) { columns := []string{"NAME", "ID", "CREATED AT", "PAYMENT STATUS", "PLAN", "SUPPORT TIER"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, org := range orgs { values := []string{ @@ -71,7 +71,7 @@ func printTable(orgs []*pinecone.Organization) { org.Plan, org.SupportTier, } - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() } diff --git a/internal/pkg/cli/command/project/list.go b/internal/pkg/cli/command/project/list.go index 7128ad8..535c36a 100644 --- a/internal/pkg/cli/command/project/list.go +++ b/internal/pkg/cli/command/project/list.go @@ -2,6 +2,7 @@ package project import ( "context" + "fmt" "os" "strconv" "strings" @@ -15,8 +16,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/pinecone-io/go-pinecone/v4/pinecone" "github.com/spf13/cobra" - - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" ) type ListProjectCmdOptions struct { @@ -45,7 +44,7 @@ func NewListProjectsCmd() *cobra.Command { if options.json { json := text.IndentJSON(projects) - pcio.Println(json) + fmt.Println(json) } else { printTable(projects) } @@ -62,7 +61,7 @@ func printTable(projects []*pinecone.Project) { columns := []string{"NAME", "ID", "ORGANIZATION ID", "CREATED AT", "FORCE ENCRYPTION", "MAX PODS"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, proj := range projects { values := []string{ @@ -72,7 +71,7 @@ func printTable(projects []*pinecone.Project) { proj.CreatedAt.String(), strconv.FormatBool(proj.ForceEncryptionWithCmek), strconv.Itoa(proj.MaxPods)} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() } diff --git a/internal/pkg/utils/msg/message.go b/internal/pkg/utils/msg/message.go index 51ca818..6821240 100644 --- a/internal/pkg/utils/msg/message.go +++ b/internal/pkg/utils/msg/message.go @@ -5,6 +5,8 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/style" ) +// FailMsg displays an error message to the user. +// Uses pcio functions so the message is suppressed with -q flag. func FailMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) pcio.Println("\n" + style.FailMsg(formatted) + "\n") diff --git a/internal/pkg/utils/pcio/print.go b/internal/pkg/utils/pcio/print.go index 043e401..3c3739f 100644 --- a/internal/pkg/utils/pcio/print.go +++ b/internal/pkg/utils/pcio/print.go @@ -5,6 +5,24 @@ import ( "io" ) +// Package pcio provides output functions that respect the global quiet mode. +// +// USAGE GUIDELINES: +// +// Use pcio functions for: +// - User-facing messages (success, error, warning, info) +// - Progress indicators and status updates +// - Interactive prompts and confirmations +// - Help text and documentation +// - Any output that should be suppressed with -q flag +// +// Use fmt functions for: +// - Data output from informational commands (list, describe) +// - JSON output that should always be displayed +// - Table rendering and structured data display +// - String formatting (Sprintf, Errorf, Error) +// - Any output that should NOT be suppressed with -q flag +// // The purpose of this package is to stub out the fmt package so that // the -q quiet mode can be implemented in a consistent way across all // commands. @@ -57,6 +75,12 @@ func Fprint(w io.Writer, a ...any) { } } +// NOTE: The following three functions are aliases to `fmt` functions and do not check the quiet flag. +// This creates inconsistency with the guidelines to use `fmt` directly (not `pcio`) for non-quiet output. +// These wrappers are kept for now because: +// 1) They don't break quiet mode behavior (they're just aliases) +// 2) A mass refactoring would require updating 100+ usages across the codebase + // alias Sprintf to fmt.Sprintf func Sprintf(format string, a ...any) string { return fmt.Sprintf(format, a...) @@ -69,5 +93,5 @@ func Errorf(format string, a ...any) error { // alias Error to fmt.Errorf func Error(a ...any) error { - return fmt.Errorf(fmt.Sprint(a...)) + return fmt.Errorf("%s", fmt.Sprint(a...)) } diff --git a/internal/pkg/utils/presenters/table.go b/internal/pkg/utils/presenters/table.go index 39f550c..e6e4c12 100644 --- a/internal/pkg/utils/presenters/table.go +++ b/internal/pkg/utils/presenters/table.go @@ -1,3 +1,9 @@ +// Package presenters provides table rendering functions for data display. +// +// NOTE: This package uses fmt functions directly (not pcio) because: +// - Data output should NOT be suppressed by the -q flag +// - Informational commands (list, describe) need to display data even in quiet mode +// - Only user-facing messages (progress, confirmations) should respect quiet mode package presenters import ( @@ -5,7 +11,6 @@ import ( "github.com/charmbracelet/bubbles/table" "github.com/pinecone-io/cli/internal/pkg/utils/log" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/pinecone-io/go-pinecone/v4/pinecone" ) @@ -58,15 +63,15 @@ func PrintTable(options TableOptions) { t.SetCursor(-1) // Render the table directly - pcio.Println(t.View()) + fmt.Println(t.View()) } // PrintTableWithTitle creates and renders a bubbles table with a title func PrintTableWithTitle(title string, options TableOptions) { - pcio.Println() - pcio.Printf("%s\n\n", style.Heading(title)) + fmt.Println() + fmt.Printf("%s\n\n", style.Heading(title)) PrintTable(options) - pcio.Println() + fmt.Println() } // PrintIndexTableWithIndexAttributesGroups creates and renders a table for index information with custom index attribute groups @@ -93,7 +98,7 @@ func PrintIndexTableWithIndexAttributesGroups(indexes []*pinecone.Index, groups Rows: rows, }) - pcio.Println() + fmt.Println() // Add a note about full URLs if state info is shown hasStateGroup := false @@ -105,7 +110,7 @@ func PrintIndexTableWithIndexAttributesGroups(indexes []*pinecone.Index, groups } if hasStateGroup && len(indexes) > 0 { hint := fmt.Sprintf("Use %s to see index details", style.Code("pc index describe ")) - pcio.Println(style.Hint(hint)) + fmt.Println(style.Hint(hint)) } } @@ -114,8 +119,8 @@ func PrintDescribeIndexTable(idx *pinecone.Index) { log.Debug().Str("name", idx.Name).Msg("Printing index description") // Print title - pcio.Println(style.Heading("Index Configuration")) - pcio.Println() + fmt.Println(style.Heading("Index Configuration")) + fmt.Println() // Print all groups with their information PrintDescribeIndexTableWithIndexAttributesGroups(idx, AllIndexAttributesGroups()) @@ -160,10 +165,10 @@ func PrintDescribeIndexTableWithIndexAttributesGroups(idx *pinecone.Index, group // Print the row rowText := fmt.Sprintf("%s %s", styledFirstCol, row[1]) - pcio.Println(rowText) + fmt.Println(rowText) } else if len(row) == 1 && row[0] == "" { // Empty row for spacing - pcio.Println() + fmt.Println() } } } From e558610f4b243fd7ddad17f86bcce719943c30d7 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Fri, 5 Sep 2025 08:28:24 +0200 Subject: [PATCH 18/19] Dedicated style for resource names --- internal/pkg/cli/command/index/configure.go | 2 +- internal/pkg/cli/command/index/create.go | 11 +++++++---- internal/pkg/cli/command/index/delete.go | 4 ++-- internal/pkg/cli/command/login/whoami.go | 2 +- internal/pkg/utils/presenters/table.go | 2 ++ internal/pkg/utils/style/functions.go | 8 ++++++++ 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/internal/pkg/cli/command/index/configure.go b/internal/pkg/cli/command/index/configure.go index 7355fb7..d246164 100644 --- a/internal/pkg/cli/command/index/configure.go +++ b/internal/pkg/cli/command/index/configure.go @@ -69,6 +69,6 @@ func runConfigureIndexCmd(options configureIndexOptions, cmd *cobra.Command, arg } describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) - msg.SuccessMsg("Index %s configured successfully. Run %s to check status. \n\n", style.Emphasis(idx.Name), style.Code(describeCommand)) + msg.SuccessMsg("Index %s configured successfully. Run %s to check status. \n\n", style.ResourceName(idx.Name), style.Code(describeCommand)) presenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/create.go b/internal/pkg/cli/command/index/create.go index 3cb2660..20f2f93 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -288,9 +288,12 @@ func printCreatePreview(options createIndexOptions, idxType indexType) { // Print title pcio.Println() - pcio.Printf("%s\n\n", style.Heading(pcio.Sprintf("Creating %s index %s with the following configuration:", - style.Emphasis(string(idxType)), - style.Code(options.name)))) + pcio.Printf("%s\n\n", + pcio.Sprintf("Creating %s index %s with the following configuration:", + style.Emphasis(string(idxType)), + style.ResourceName(options.name), + ), + ) // Use the specialized index table without status info (second column set) presenters.PrintDescribeIndexTable(mockIndex) @@ -304,7 +307,7 @@ func renderSuccessOutput(idx *pinecone.Index, options createIndexOptions) { } describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) - msg.SuccessMsg("Index %s created successfully. Run %s to check status. \n\n", style.Emphasis(idx.Name), style.Code(describeCommand)) + msg.SuccessMsg("Index %s created successfully. Run %s to check status. \n\n", style.ResourceName(idx.Name), style.Code(describeCommand)) presenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/delete.go b/internal/pkg/cli/command/index/delete.go index 03f475b..05b044b 100644 --- a/internal/pkg/cli/command/index/delete.go +++ b/internal/pkg/cli/command/index/delete.go @@ -31,7 +31,7 @@ func NewDeleteCmd() *cobra.Command { // Ask for user confirmation msg.WarnMsgMultiLine( - pcio.Sprintf("This will delete the index %s and all its data.", style.Emphasis(options.name)), + pcio.Sprintf("This will delete the index %s and all its data.", style.ResourceName(options.name)), "This action cannot be undone.", ) question := "Are you sure you want to proceed with the deletion?" @@ -49,7 +49,7 @@ func NewDeleteCmd() *cobra.Command { exit.Error(err) } - msg.SuccessMsg("Index %s deleted.\n", style.Emphasis(options.name)) + msg.SuccessMsg("Index %s deleted.\n", style.ResourceName(options.name)) }, } diff --git a/internal/pkg/cli/command/login/whoami.go b/internal/pkg/cli/command/login/whoami.go index 02b4d3a..b5afc3a 100644 --- a/internal/pkg/cli/command/login/whoami.go +++ b/internal/pkg/cli/command/login/whoami.go @@ -32,7 +32,7 @@ func NewWhoAmICmd() *cobra.Command { exit.Error(pcio.Errorf("error parsing claims from access token: %s", err)) return } - msg.InfoMsg("Logged in as " + style.Emphasis(claims.Email)) + msg.InfoMsg("Logged in as " + style.ResourceName(claims.Email)) }, } diff --git a/internal/pkg/utils/presenters/table.go b/internal/pkg/utils/presenters/table.go index e6e4c12..18bd461 100644 --- a/internal/pkg/utils/presenters/table.go +++ b/internal/pkg/utils/presenters/table.go @@ -171,6 +171,8 @@ func PrintDescribeIndexTableWithIndexAttributesGroups(idx *pinecone.Index, group fmt.Println() } } + // Add spacing after the last row + fmt.Println() } // ColorizeState applies appropriate styling to index state diff --git a/internal/pkg/utils/style/functions.go b/internal/pkg/utils/style/functions.go index 9268e67..a76e97c 100644 --- a/internal/pkg/utils/style/functions.go +++ b/internal/pkg/utils/style/functions.go @@ -42,6 +42,14 @@ func Code(s string) string { return CodeStyle().Render(s) } +func ResourceName(s string) string { + if color.NoColor { + // Add backticks for code formatting if color is disabled + return "`" + s + "`" + } + return HeavyEmphasisStyle().Render(s) +} + func URL(s string) string { return URLStyle().Render(s) } From 62c658c8045cb09325e8394e94b30c215b9c7b14 Mon Sep 17 00:00:00 2001 From: Milen Dyankov Date: Fri, 5 Sep 2025 18:06:23 +0200 Subject: [PATCH 19/19] Index column mapping update/cleanup --- .../pkg/utils/presenters/index_columns.go | 282 +++++++----------- 1 file changed, 102 insertions(+), 180 deletions(-) diff --git a/internal/pkg/utils/presenters/index_columns.go b/internal/pkg/utils/presenters/index_columns.go index 773d820..34e3904 100644 --- a/internal/pkg/utils/presenters/index_columns.go +++ b/internal/pkg/utils/presenters/index_columns.go @@ -1,7 +1,9 @@ package presenters import ( - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" + "fmt" + "strings" + "github.com/pinecone-io/go-pinecone/v4/pinecone" ) @@ -29,52 +31,17 @@ func AllIndexAttributesGroups() []IndexAttributesGroup { } } -// IndexAttributesGroupsToStrings converts a slice of IndexAttributesGroup to strings -func IndexAttributesGroupsToStrings(groups []IndexAttributesGroup) []string { - strings := make([]string, len(groups)) - for i, group := range groups { - strings[i] = string(group) - } - return strings -} - -// StringsToIndexAttributesGroups converts a slice of strings to IndexAttributesGroup (validates input) -func StringsToIndexAttributesGroups(groups []string) []IndexAttributesGroup { - indexGroups := make([]IndexAttributesGroup, 0, len(groups)) - validGroups := map[string]IndexAttributesGroup{ - "essential": IndexAttributesGroupEssential, - "state": IndexAttributesGroupState, - "pod_spec": IndexAttributesGroupPodSpec, - "serverless_spec": IndexAttributesGroupServerlessSpec, - "inference": IndexAttributesGroupInference, - "other": IndexAttributesGroupOther, - } - - for _, group := range groups { - if indexGroup, exists := validGroups[group]; exists { - indexGroups = append(indexGroups, indexGroup) - } - } - return indexGroups -} - -// ColumnGroup represents a group of related columns for index display -type ColumnGroup struct { - Name string - Columns []Column -} - -// ColumnWithNames represents a table column with both short and full names -type ColumnWithNames struct { +// IndexColumn represents a table column with both short and full names +type IndexColumn struct { ShortTitle string FullTitle string Width int } -// ColumnGroupWithNames represents a group of columns with both short and full names -type ColumnGroupWithNames struct { +// ColumnGroup represents a group of columns with both short and full names +type ColumnGroup struct { Name string - Columns []ColumnWithNames + Columns []IndexColumn } // IndexColumnGroups defines the available column groups for index tables @@ -89,66 +56,7 @@ var IndexColumnGroups = struct { }{ Essential: ColumnGroup{ Name: "essential", - Columns: []Column{ - {Title: "NAME", Width: 20}, - {Title: "SPEC", Width: 12}, - {Title: "TYPE", Width: 8}, - {Title: "METRIC", Width: 8}, - {Title: "DIM", Width: 8}, - }, - }, - State: ColumnGroup{ - Name: "state", - Columns: []Column{ - {Title: "STATUS", Width: 10}, - {Title: "HOST", Width: 60}, - {Title: "PROT", Width: 8}, - }, - }, - PodSpec: ColumnGroup{ - Name: "pod_spec", - Columns: []Column{ - {Title: "ENV", Width: 12}, - {Title: "POD_TYPE", Width: 12}, - {Title: "REPLICAS", Width: 8}, - {Title: "SHARDS", Width: 8}, - {Title: "PODS", Width: 8}, - }, - }, - ServerlessSpec: ColumnGroup{ - Name: "serverless_spec", - Columns: []Column{ - {Title: "CLOUD", Width: 12}, - {Title: "REGION", Width: 15}, - }, - }, - Inference: ColumnGroup{ - Name: "inference", - Columns: []Column{ - {Title: "MODEL", Width: 25}, - {Title: "EMBED_DIM", Width: 10}, - }, - }, - Other: ColumnGroup{ - Name: "other", - Columns: []Column{ - {Title: "TAGS", Width: 30}, - }, - }, -} - -// IndexColumnGroupsWithNames defines the available column groups with both short and full names -var IndexColumnGroupsWithNames = struct { - Essential ColumnGroupWithNames // Basic index information (name, spec, type, metric, dimension) - State ColumnGroupWithNames // Runtime state information (status, host, protection) - PodSpec ColumnGroupWithNames // Pod-specific configuration (environment, pod type, replicas, etc.) - ServerlessSpec ColumnGroupWithNames // Serverless-specific configuration (cloud, region) - Inference ColumnGroupWithNames // Inference/embedding model information - Other ColumnGroupWithNames // Other information (tags, custom fields, etc.) -}{ - Essential: ColumnGroupWithNames{ - Name: "essential", - Columns: []ColumnWithNames{ + Columns: []IndexColumn{ {ShortTitle: "NAME", FullTitle: "Name", Width: 20}, {ShortTitle: "SPEC", FullTitle: "Specification", Width: 12}, {ShortTitle: "TYPE", FullTitle: "Vector Type", Width: 8}, @@ -156,17 +64,17 @@ var IndexColumnGroupsWithNames = struct { {ShortTitle: "DIM", FullTitle: "Dimension", Width: 8}, }, }, - State: ColumnGroupWithNames{ + State: ColumnGroup{ Name: "state", - Columns: []ColumnWithNames{ + Columns: []IndexColumn{ {ShortTitle: "STATUS", FullTitle: "Status", Width: 10}, {ShortTitle: "HOST", FullTitle: "Host URL", Width: 60}, {ShortTitle: "PROT", FullTitle: "Deletion Protection", Width: 8}, }, }, - PodSpec: ColumnGroupWithNames{ + PodSpec: ColumnGroup{ Name: "pod_spec", - Columns: []ColumnWithNames{ + Columns: []IndexColumn{ {ShortTitle: "ENV", FullTitle: "Environment", Width: 12}, {ShortTitle: "POD_TYPE", FullTitle: "Pod Type", Width: 12}, {ShortTitle: "REPLICAS", FullTitle: "Replicas", Width: 8}, @@ -174,23 +82,26 @@ var IndexColumnGroupsWithNames = struct { {ShortTitle: "PODS", FullTitle: "Pod Count", Width: 8}, }, }, - ServerlessSpec: ColumnGroupWithNames{ + ServerlessSpec: ColumnGroup{ Name: "serverless_spec", - Columns: []ColumnWithNames{ + Columns: []IndexColumn{ {ShortTitle: "CLOUD", FullTitle: "Cloud Provider", Width: 12}, {ShortTitle: "REGION", FullTitle: "Region", Width: 15}, }, }, - Inference: ColumnGroupWithNames{ + Inference: ColumnGroup{ Name: "inference", - Columns: []ColumnWithNames{ + Columns: []IndexColumn{ {ShortTitle: "MODEL", FullTitle: "Model", Width: 25}, - {ShortTitle: "EMBED_DIM", FullTitle: "Embedding Dimension", Width: 10}, + {ShortTitle: "EMBED DIM", FullTitle: "Embedding Dimension", Width: 10}, + {ShortTitle: "FIELD MAP", FullTitle: "Field Map", Width: 20}, + {ShortTitle: "READ PARAMS", FullTitle: "Read Parameters", Width: 20}, + {ShortTitle: "WRITE PARAMS", FullTitle: "Write Parameters", Width: 20}, }, }, - Other: ColumnGroupWithNames{ + Other: ColumnGroup{ Name: "other", - Columns: []ColumnWithNames{ + Columns: []IndexColumn{ {ShortTitle: "TAGS", FullTitle: "Tags", Width: 30}, }, }, @@ -202,39 +113,29 @@ func GetColumnsForIndexAttributesGroups(groups []IndexAttributesGroup) []Column for _, group := range groups { switch group { case IndexAttributesGroupEssential: - columns = append(columns, IndexColumnGroups.Essential.Columns...) - case IndexAttributesGroupState: - columns = append(columns, IndexColumnGroups.State.Columns...) - case IndexAttributesGroupPodSpec: - columns = append(columns, IndexColumnGroups.PodSpec.Columns...) - case IndexAttributesGroupServerlessSpec: - columns = append(columns, IndexColumnGroups.ServerlessSpec.Columns...) - case IndexAttributesGroupInference: - columns = append(columns, IndexColumnGroups.Inference.Columns...) - case IndexAttributesGroupOther: - columns = append(columns, IndexColumnGroups.Other.Columns...) - } - } - return columns -} - -// GetColumnsForIndexAttributesGroupsWithNames returns columns for the specified index attribute groups with both short and full names -func GetColumnsForIndexAttributesGroupsWithNames(groups []IndexAttributesGroup) []ColumnWithNames { - var columns []ColumnWithNames - for _, group := range groups { - switch group { - case IndexAttributesGroupEssential: - columns = append(columns, IndexColumnGroupsWithNames.Essential.Columns...) + for _, col := range IndexColumnGroups.Essential.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } case IndexAttributesGroupState: - columns = append(columns, IndexColumnGroupsWithNames.State.Columns...) + for _, col := range IndexColumnGroups.State.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } case IndexAttributesGroupPodSpec: - columns = append(columns, IndexColumnGroupsWithNames.PodSpec.Columns...) + for _, col := range IndexColumnGroups.PodSpec.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } case IndexAttributesGroupServerlessSpec: - columns = append(columns, IndexColumnGroupsWithNames.ServerlessSpec.Columns...) + for _, col := range IndexColumnGroups.ServerlessSpec.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } case IndexAttributesGroupInference: - columns = append(columns, IndexColumnGroupsWithNames.Inference.Columns...) + for _, col := range IndexColumnGroups.Inference.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } case IndexAttributesGroupOther: - columns = append(columns, IndexColumnGroupsWithNames.Other.Columns...) + for _, col := range IndexColumnGroups.Other.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } } } return columns @@ -259,9 +160,9 @@ func ExtractEssentialValues(idx *pinecone.Index) []string { } // Get dimension - dimension := "nil" + dimension := "" if idx.Dimension != nil && *idx.Dimension > 0 { - dimension = pcio.Sprintf("%d", *idx.Dimension) + dimension = fmt.Sprintf("%d", *idx.Dimension) } return []string{ @@ -281,8 +182,13 @@ func ExtractStateValues(idx *pinecone.Index) []string { protected = "yes" } + status := "" + if idx.Status != nil { + status = string(idx.Status.State) + } + return []string{ - string(idx.Status.State), + status, idx.Host, protected, } @@ -297,9 +203,9 @@ func ExtractPodSpecValues(idx *pinecone.Index) []string { return []string{ idx.Spec.Pod.Environment, idx.Spec.Pod.PodType, - pcio.Sprintf("%d", idx.Spec.Pod.Replicas), - pcio.Sprintf("%d", idx.Spec.Pod.ShardCount), - pcio.Sprintf("%d", idx.Spec.Pod.PodCount), + fmt.Sprintf("%d", idx.Spec.Pod.Replicas), + fmt.Sprintf("%d", idx.Spec.Pod.ShardCount), + fmt.Sprintf("%d", idx.Spec.Pod.PodCount), } } @@ -318,17 +224,50 @@ func ExtractServerlessSpecValues(idx *pinecone.Index) []string { // ExtractInferenceValues extracts inference-related values from an index func ExtractInferenceValues(idx *pinecone.Index) []string { if idx.Embed == nil { - return []string{"", ""} + return []string{"", "", "", "", ""} } - embedDim := "nil" + embedDim := "" if idx.Embed.Dimension != nil && *idx.Embed.Dimension > 0 { - embedDim = pcio.Sprintf("%d", *idx.Embed.Dimension) + embedDim = fmt.Sprintf("%d", *idx.Embed.Dimension) + } + + // Format field map + fieldMapStr := "" + if idx.Embed.FieldMap != nil && len(*idx.Embed.FieldMap) > 0 { + var fieldMapPairs []string + for k, v := range *idx.Embed.FieldMap { + fieldMapPairs = append(fieldMapPairs, fmt.Sprintf("%s=%v", k, v)) + } + fieldMapStr = strings.Join(fieldMapPairs, ", ") + } + + // Format read parameters + readParamsStr := "" + if idx.Embed.ReadParameters != nil && len(*idx.Embed.ReadParameters) > 0 { + var readParamsPairs []string + for k, v := range *idx.Embed.ReadParameters { + readParamsPairs = append(readParamsPairs, fmt.Sprintf("%s=%v", k, v)) + } + readParamsStr = strings.Join(readParamsPairs, ", ") + } + + // Format write parameters + writeParamsStr := "" + if idx.Embed.WriteParameters != nil && len(*idx.Embed.WriteParameters) > 0 { + var writeParamsPairs []string + for k, v := range *idx.Embed.WriteParameters { + writeParamsPairs = append(writeParamsPairs, fmt.Sprintf("%s=%v", k, v)) + } + writeParamsStr = strings.Join(writeParamsPairs, ", ") } return []string{ idx.Embed.Model, embedDim, + fieldMapStr, + readParamsStr, + writeParamsStr, } } @@ -338,9 +277,12 @@ func ExtractOtherValues(idx *pinecone.Index) []string { return []string{""} } - // Convert tags to a simple string representation - // For now, just show the count, could be enhanced to show key-value pairs - return []string{pcio.Sprintf("%d tags", len(*idx.Tags))} + // Convert tags to a string representation showing key-value pairs + var tagStrings []string + for key, value := range *idx.Tags { + tagStrings = append(tagStrings, fmt.Sprintf("%s=%s", key, value)) + } + return []string{fmt.Sprint(strings.Join(tagStrings, ", "))} } // ExtractValuesForIndexAttributesGroups extracts values for the specified index attribute groups from an index @@ -365,43 +307,23 @@ func ExtractValuesForIndexAttributesGroups(idx *pinecone.Index, groups []IndexAt return values } -// GetGroupDescription returns a description of what each group contains -func GetGroupDescription(group IndexAttributesGroup) string { - switch group { - case IndexAttributesGroupEssential: - return "Basic index information (name, spec type, vector type, metric, dimension)" - case IndexAttributesGroupState: - return "Runtime state information (status, host URL, deletion protection)" - case IndexAttributesGroupPodSpec: - return "Pod-specific configuration (environment, pod type, replicas, shards, pod count)" - case IndexAttributesGroupServerlessSpec: - return "Serverless-specific configuration (cloud provider, region)" - case IndexAttributesGroupInference: - return "Inference/embedding model information (model name, embedding dimension)" - case IndexAttributesGroupOther: - return "Other information (tags, custom fields, etc.)" - default: - return "" - } -} - // getColumnsWithNamesForIndexAttributesGroup returns columns with both short and full names for a specific index attribute group -func getColumnsWithNamesForIndexAttributesGroup(group IndexAttributesGroup) []ColumnWithNames { +func getColumnsWithNamesForIndexAttributesGroup(group IndexAttributesGroup) []IndexColumn { switch group { case IndexAttributesGroupEssential: - return IndexColumnGroupsWithNames.Essential.Columns + return IndexColumnGroups.Essential.Columns case IndexAttributesGroupState: - return IndexColumnGroupsWithNames.State.Columns + return IndexColumnGroups.State.Columns case IndexAttributesGroupPodSpec: - return IndexColumnGroupsWithNames.PodSpec.Columns + return IndexColumnGroups.PodSpec.Columns case IndexAttributesGroupServerlessSpec: - return IndexColumnGroupsWithNames.ServerlessSpec.Columns + return IndexColumnGroups.ServerlessSpec.Columns case IndexAttributesGroupInference: - return IndexColumnGroupsWithNames.Inference.Columns + return IndexColumnGroups.Inference.Columns case IndexAttributesGroupOther: - return IndexColumnGroupsWithNames.Other.Columns + return IndexColumnGroups.Other.Columns default: - return []ColumnWithNames{} + return []IndexColumn{} } }