From 4717745d77595df5ce7864a6b6a8dd4bebf9f45c Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 11 Mar 2026 17:02:40 +0100 Subject: [PATCH 1/7] feat(cmd): add Example fields with validation utilities Adds Example fields to init, workspace list, and workspace remove commands to improve help documentation. Includes a validation utility in pkg/cmd/testutil that parses and validates examples against the actual command structure, ensuring examples stay accurate as the codebase evolves. The validator catches invalid flags, wrong binary names, invalid subcommands, while correctly allowing valid positional arguments. Updated AGENTS.md to require examples and validation tests for all new commands. Closes #53 Co-Authored-By: Claude Code (Claude Sonnet 4.5) Signed-off-by: Philippe Martin --- AGENTS.md | 83 ++++ pkg/cmd/init.go | 11 + pkg/cmd/init_test.go | 32 ++ pkg/cmd/testutil/example_validator.go | 252 ++++++++++++ pkg/cmd/testutil/example_validator_test.go | 427 +++++++++++++++++++++ pkg/cmd/workspace_list.go | 14 +- pkg/cmd/workspace_list_test.go | 31 ++ pkg/cmd/workspace_remove.go | 8 +- pkg/cmd/workspace_remove_test.go | 32 ++ 9 files changed, 884 insertions(+), 6 deletions(-) create mode 100644 pkg/cmd/testutil/example_validator.go create mode 100644 pkg/cmd/testutil/example_validator_test.go diff --git a/AGENTS.md b/AGENTS.md index 974e047..960d8ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,11 +205,13 @@ Skills are reusable capabilities that can be discovered and executed by AI agent 2. In the `NewCmd()` function: - Create and configure the `cobra.Command` - **IMPORTANT**: Always define the `Args` field to specify argument validation + - **IMPORTANT**: Always add an `Example` field with usage examples - Set up any flags or subcommands - Return the configured command 3. Register the command in `pkg/cmd/root.go` by adding `rootCmd.AddCommand(NewCmd())` in the `NewRootCmd()` function 4. Create corresponding test file `pkg/cmd/_test.go` 5. In tests, create command instances using `NewRootCmd()` or `NewCmd()` as needed +6. **IMPORTANT**: Add a `TestCmd_Examples` function to validate the examples **Command Argument Validation:** @@ -227,6 +229,8 @@ func NewExampleCmd() *cobra.Command { return &cobra.Command{ Use: "example", Short: "An example command", + Example: `# Run the example command +kortex-cli example`, Args: cobra.NoArgs, // Always declare Args field Run: func(cmd *cobra.Command, args []string) { // Command logic here @@ -238,6 +242,85 @@ func NewExampleCmd() *cobra.Command { rootCmd.AddCommand(NewExampleCmd()) ``` +**Command Examples:** + +All commands **MUST** include an `Example` field with usage examples. Examples improve help documentation and are automatically validated to ensure they stay accurate as the code evolves. + +**Example Format:** +```go +func NewExampleCmd() *cobra.Command { + return &cobra.Command{ + Use: "example [arg]", + Short: "An example command", + Example: `# Basic usage with comment +kortex-cli example + +# With argument +kortex-cli example value + +# With flag +kortex-cli example --flag value`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Command logic here + }, + } +} +``` + +**Example Guidelines:** +- Use comments (lines starting with `#`) to describe what each example does +- Show the most common use cases (typically 3-5 examples) +- Include examples for all important flags +- Examples must use the actual binary name (`kortex-cli`) +- All commands and flags in examples must exist +- Keep examples concise and realistic + +**Validating Examples:** + +Every command with an `Example` field **MUST** have a corresponding validation test: + +```go +func TestCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the command + cmd := NewCmd() + + // Verify Example field is not empty + if cmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + // Parse the examples + commands, err := testutil.ParseExampleCommands(cmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 3 // Adjust based on your examples + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, cmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +} +``` + +**What the validator checks:** +- Binary name is `kortex-cli` +- All commands exist in the command tree +- All flags (both long and short) are valid for the command +- No invalid subcommands are used + +**Reference:** See `pkg/cmd/init.go` and `pkg/cmd/init_test.go` for complete examples. + ### Command Implementation Pattern Commands should follow a consistent structure for maintainability and testability: diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 47dbba7..b19513d 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -139,6 +139,17 @@ func NewInitCmd() *cobra.Command { The sources directory defaults to the current directory (.) if not specified. The workspace configuration directory defaults to .kortex/ inside the sources directory if not specified.`, + Example: `# Register current directory as workspace +kortex-cli init + +# Register specific directory as workspace +kortex-cli init /path/to/project + +# Register with custom workspace name +kortex-cli init --name my-project + +# Show detailed output +kortex-cli init --verbose`, Args: cobra.MaximumNArgs(1), PreRunE: c.preRun, RunE: c.run, diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index 5a325d0..8632e6d 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -25,6 +25,7 @@ import ( "strings" "testing" + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/kortex-hub/kortex-cli/pkg/instances" "github.com/spf13/cobra" ) @@ -1003,3 +1004,34 @@ func TestInitCmd_E2E(t *testing.T) { } }) } + +func TestInitCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the init command + initCmd := NewInitCmd() + + // Verify Example field is not empty + if initCmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + // Parse the examples + commands, err := testutil.ParseExampleCommands(initCmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 4 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, initCmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +} diff --git a/pkg/cmd/testutil/example_validator.go b/pkg/cmd/testutil/example_validator.go new file mode 100644 index 0000000..b2ff173 --- /dev/null +++ b/pkg/cmd/testutil/example_validator.go @@ -0,0 +1,252 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testutil + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// ExampleCommand represents a parsed command from an Example string +type ExampleCommand struct { + Raw string // Original command line + Binary string // Binary name (should be "kortex-cli") + Args []string // Subcommands and positional arguments + Flags map[string]string // Flag names to values (without -- or -) +} + +// ParseExampleCommands extracts kortex-cli commands from Example string +// - Ignores empty lines and comment lines (starting with #) +// - Returns error if non-comment, non-kortex-cli lines exist +func ParseExampleCommands(example string) ([]ExampleCommand, error) { + var commands []ExampleCommand + lines := strings.Split(example, "\n") + + for i, line := range lines { + line = strings.TrimSpace(line) + + // Skip empty lines + if line == "" { + continue + } + + // Skip comment lines + if strings.HasPrefix(line, "#") { + continue + } + + // Parse command line + cmd, err := parseCommandLine(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", i+1, err) + } + + commands = append(commands, cmd) + } + + return commands, nil +} + +// parseCommandLine parses a single command line into ExampleCommand +func parseCommandLine(line string) (ExampleCommand, error) { + // Split by whitespace, respecting quotes (simple parsing) + parts := splitCommandLine(line) + if len(parts) == 0 { + return ExampleCommand{}, fmt.Errorf("empty command line") + } + + // First part should be kortex-cli + if parts[0] != "kortex-cli" { + return ExampleCommand{}, fmt.Errorf("command must start with 'kortex-cli', got '%s'", parts[0]) + } + + cmd := ExampleCommand{ + Raw: line, + Binary: parts[0], + Args: []string{}, + Flags: make(map[string]string), + } + + // Parse remaining parts as args and flags + for i := 1; i < len(parts); i++ { + part := parts[i] + + if strings.HasPrefix(part, "--") { + // Long flag + flagName, flagValue := parseLongFlag(part, parts, &i) + cmd.Flags[flagName] = flagValue + } else if strings.HasPrefix(part, "-") && len(part) > 1 { + // Short flag + flagName, flagValue := parseShortFlag(part, parts, &i) + cmd.Flags[flagName] = flagValue + } else { + // Positional argument + cmd.Args = append(cmd.Args, part) + } + } + + return cmd, nil +} + +// parseLongFlag parses a long flag (--flag or --flag=value or --flag value) +func parseLongFlag(part string, parts []string, i *int) (string, string) { + // Remove -- prefix + flagPart := strings.TrimPrefix(part, "--") + + // Check for --flag=value format + if idx := strings.Index(flagPart, "="); idx != -1 { + return flagPart[:idx], flagPart[idx+1:] + } + + // Check for --flag value format + if *i+1 < len(parts) && !strings.HasPrefix(parts[*i+1], "-") { + *i++ + return flagPart, parts[*i] + } + + // Boolean flag (--flag with no value) + return flagPart, "" +} + +// parseShortFlag parses a short flag (-f or -f value) +func parseShortFlag(part string, parts []string, i *int) (string, string) { + // Remove - prefix + flagPart := strings.TrimPrefix(part, "-") + + // Check for -f value format + if *i+1 < len(parts) && !strings.HasPrefix(parts[*i+1], "-") { + *i++ + return flagPart, parts[*i] + } + + // Boolean flag (-f with no value) + return flagPart, "" +} + +// splitCommandLine splits a command line by whitespace +func splitCommandLine(line string) []string { + var parts []string + var current strings.Builder + inQuote := false + quoteChar := rune(0) + + for _, char := range line { + switch { + case (char == '"' || char == '\'') && !inQuote: + inQuote = true + quoteChar = char + case char == quoteChar && inQuote: + inQuote = false + quoteChar = 0 + case char == ' ' && !inQuote: + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + default: + current.WriteRune(char) + } + } + + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +} + +// ValidateExampleCommand verifies command exists and flags are valid +// - Uses Cobra's Find() to locate command in tree +// - Checks both command-specific and persistent flags +func ValidateExampleCommand(rootCmd *cobra.Command, exampleCmd ExampleCommand) error { + // Find the command in the tree + cmd, remainingArgs, err := rootCmd.Find(exampleCmd.Args) + if err != nil { + return fmt.Errorf("command not found: %s: %w", strings.Join(exampleCmd.Args, " "), err) + } + + // Check if there are remaining args that weren't recognized as commands + // This catches cases like "workspace nonexistent" where "nonexistent" would be + // treated as a positional argument instead of being validated as a subcommand. + // However, we should only reject if the command has subcommands (meaning the + // remaining args were likely meant to be subcommands, not positional arguments). + if len(remainingArgs) > 0 && cmd.HasSubCommands() { + return fmt.Errorf("unknown command %q for %q", strings.Join(remainingArgs, " "), cmd.CommandPath()) + } + + // Validate each flag + for flagName := range exampleCmd.Flags { + var flag *pflag.Flag + + // Determine if this is a short flag (single character) + isShortFlag := len(flagName) == 1 + + if isShortFlag { + // For short flags, use ShorthandLookup + // Check local flags + flag = cmd.Flags().ShorthandLookup(flagName) + if flag == nil { + // Check persistent flags + flag = cmd.PersistentFlags().ShorthandLookup(flagName) + } + if flag == nil { + // Check inherited flags from parent commands + flag = cmd.InheritedFlags().ShorthandLookup(flagName) + } + } else { + // For long flags, use regular Lookup + // Check local flags + flag = cmd.Flags().Lookup(flagName) + if flag == nil { + // Check persistent flags + flag = cmd.PersistentFlags().Lookup(flagName) + } + if flag == nil { + // Check inherited flags from parent commands + flag = cmd.InheritedFlags().Lookup(flagName) + } + } + + if flag == nil { + cmdPath := cmd.CommandPath() + prefix := "--" + if isShortFlag { + prefix = "-" + } + return fmt.Errorf("flag %s%s not found in command: %s", prefix, flagName, cmdPath) + } + } + + return nil +} + +// ValidateCommandExamples combines parsing and validation +func ValidateCommandExamples(rootCmd *cobra.Command, example string) error { + commands, err := ParseExampleCommands(example) + if err != nil { + return fmt.Errorf("failed to parse examples: %w", err) + } + + for _, cmd := range commands { + if err := ValidateExampleCommand(rootCmd, cmd); err != nil { + return fmt.Errorf("invalid example '%s': %w", cmd.Raw, err) + } + } + + return nil +} diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go new file mode 100644 index 0000000..baf6209 --- /dev/null +++ b/pkg/cmd/testutil/example_validator_test.go @@ -0,0 +1,427 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testutil + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestParseExampleCommands(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + example string + wantCount int + wantErr bool + checkCommands func(t *testing.T, commands []ExampleCommand) + }{ + { + name: "single command", + example: `kortex-cli init`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Binary != "kortex-cli" { + t.Errorf("Expected binary 'kortex-cli', got '%s'", commands[0].Binary) + } + if len(commands[0].Args) != 1 || commands[0].Args[0] != "init" { + t.Errorf("Expected args [init], got %v", commands[0].Args) + } + }, + }, + { + name: "multiple commands with comments", + example: `# Initialize workspace +kortex-cli init + +# List workspaces +kortex-cli workspace list`, + wantCount: 2, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if len(commands[0].Args) != 1 || commands[0].Args[0] != "init" { + t.Errorf("Expected first command args [init], got %v", commands[0].Args) + } + if len(commands[1].Args) != 2 || commands[1].Args[0] != "workspace" || commands[1].Args[1] != "list" { + t.Errorf("Expected second command args [workspace list], got %v", commands[1].Args) + } + }, + }, + { + name: "command with long flag using equals", + example: `kortex-cli workspace list --output=json`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["output"] != "json" { + t.Errorf("Expected flag output=json, got %v", commands[0].Flags) + } + }, + }, + { + name: "command with long flag using space", + example: `kortex-cli workspace list --output json`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["output"] != "json" { + t.Errorf("Expected flag output=json, got %v", commands[0].Flags) + } + }, + }, + { + name: "command with short flag", + example: `kortex-cli workspace list -o json`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["o"] != "json" { + t.Errorf("Expected flag o=json, got %v", commands[0].Flags) + } + }, + }, + { + name: "command with positional argument", + example: `kortex-cli workspace remove abc123`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if len(commands[0].Args) != 3 { + t.Errorf("Expected 3 args, got %d", len(commands[0].Args)) + } + if commands[0].Args[2] != "abc123" { + t.Errorf("Expected arg 'abc123', got '%s'", commands[0].Args[2]) + } + }, + }, + { + name: "command with path argument", + example: `kortex-cli init /path/to/project`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if len(commands[0].Args) != 2 { + t.Errorf("Expected 2 args, got %d", len(commands[0].Args)) + } + if commands[0].Args[1] != "/path/to/project" { + t.Errorf("Expected arg '/path/to/project', got '%s'", commands[0].Args[1]) + } + }, + }, + { + name: "command with boolean flag", + example: `kortex-cli init --verbose`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if _, exists := commands[0].Flags["verbose"]; !exists { + t.Errorf("Expected flag 'verbose' to exist") + } + }, + }, + { + name: "empty example", + example: ``, + wantCount: 0, + }, + { + name: "only comments and empty lines", + example: `# This is a comment + +# Another comment +`, + wantCount: 0, + }, + { + name: "invalid command - wrong binary", + example: `other-cli init`, + wantErr: true, + }, + { + name: "command with multiple flags", + example: `kortex-cli init --name my-project --verbose`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["name"] != "my-project" { + t.Errorf("Expected flag name=my-project, got %v", commands[0].Flags) + } + if _, exists := commands[0].Flags["verbose"]; !exists { + t.Errorf("Expected flag 'verbose' to exist") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + commands, err := ParseExampleCommands(tt.example) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(commands) != tt.wantCount { + t.Errorf("Expected %d commands, got %d", tt.wantCount, len(commands)) + return + } + + if tt.checkCommands != nil { + tt.checkCommands(t, commands) + } + }) + } +} + +func TestValidateExampleCommand(t *testing.T) { + t.Parallel() + + // Helper function to create a fresh command tree for each test + createTestCommandTree := func() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "kortex-cli", + } + + workspaceCmd := &cobra.Command{ + Use: "workspace", + } + + listCmd := &cobra.Command{ + Use: "list", + } + listCmd.Flags().String("output", "text", "output format") + listCmd.Flags().StringP("format", "f", "text", "alias for output") + + removeCmd := &cobra.Command{ + Use: "remove", + } + + initCmd := &cobra.Command{ + Use: "init", + } + initCmd.Flags().String("name", "", "workspace name") + initCmd.Flags().Bool("verbose", false, "verbose output") + + workspaceCmd.AddCommand(listCmd) + workspaceCmd.AddCommand(removeCmd) + rootCmd.AddCommand(workspaceCmd) + rootCmd.AddCommand(initCmd) + + // Add a global flag + rootCmd.PersistentFlags().String("storage", "", "storage directory") + + return rootCmd + } + + tests := []struct { + name string + exampleCmd ExampleCommand + wantErr bool + }{ + { + name: "valid command - init", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init", + Binary: "kortex-cli", + Args: []string{"init"}, + Flags: map[string]string{}, + }, + wantErr: false, + }, + { + name: "valid command - workspace list", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + Flags: map[string]string{}, + }, + wantErr: false, + }, + { + name: "valid command with flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list --output json", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + Flags: map[string]string{"output": "json"}, + }, + wantErr: false, + }, + { + name: "valid command with short flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list -f json", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + Flags: map[string]string{"f": "json"}, + }, + wantErr: false, + }, + { + name: "valid command with persistent flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --storage /tmp/storage", + Binary: "kortex-cli", + Args: []string{"init"}, + Flags: map[string]string{"storage": "/tmp/storage"}, + }, + wantErr: false, + }, + { + name: "invalid command - non-existent", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli nonexistent", + Binary: "kortex-cli", + Args: []string{"nonexistent"}, + Flags: map[string]string{}, + }, + wantErr: true, + }, + { + name: "invalid flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --nonexistent-flag value", + Binary: "kortex-cli", + Args: []string{"init"}, + Flags: map[string]string{"nonexistent-flag": "value"}, + }, + wantErr: true, + }, + { + name: "valid command with multiple flags", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --name test --verbose", + Binary: "kortex-cli", + Args: []string{"init"}, + Flags: map[string]string{"name": "test", "verbose": ""}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a fresh command tree for this test to avoid data races + rootCmd := createTestCommandTree() + + err := ValidateExampleCommand(rootCmd, tt.exampleCmd) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, got nil") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateCommandExamples(t *testing.T) { + t.Parallel() + + // Helper function to create a fresh command tree for each test + createTestCommandTree := func() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "kortex-cli", + } + + initCmd := &cobra.Command{ + Use: "init", + } + initCmd.Flags().String("name", "", "workspace name") + initCmd.Flags().Bool("verbose", false, "verbose output") + + rootCmd.AddCommand(initCmd) + rootCmd.PersistentFlags().String("storage", "", "storage directory") + + return rootCmd + } + + tests := []struct { + name string + example string + wantErr bool + }{ + { + name: "valid examples", + example: `# Initialize workspace +kortex-cli init + +# Initialize with name +kortex-cli init --name my-project + +# Verbose output +kortex-cli init --verbose`, + wantErr: false, + }, + { + name: "invalid command in examples", + example: `# Valid command +kortex-cli init + +# Invalid command +kortex-cli nonexistent`, + wantErr: true, + }, + { + name: "invalid flag in examples", + example: `# Invalid flag +kortex-cli init --invalid-flag value`, + wantErr: true, + }, + { + name: "empty examples", + example: ``, + wantErr: false, + }, + { + name: "only comments", + example: `# This is just a comment +# Another comment`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a fresh command tree for this test to avoid data races + rootCmd := createTestCommandTree() + + err := ValidateCommandExamples(rootCmd, tt.example) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, got nil") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} diff --git a/pkg/cmd/workspace_list.go b/pkg/cmd/workspace_list.go index 301756b..d01867b 100644 --- a/pkg/cmd/workspace_list.go +++ b/pkg/cmd/workspace_list.go @@ -136,9 +136,17 @@ func NewWorkspaceListCmd() *cobra.Command { c := &workspaceListCmd{} cmd := &cobra.Command{ - Use: "list", - Short: "List all registered workspaces", - Long: "List all workspaces registered with kortex-cli init", + Use: "list", + Short: "List all registered workspaces", + Long: "List all workspaces registered with kortex-cli init", + Example: `# List all workspaces +kortex-cli workspace list + +# List workspaces in JSON format +kortex-cli workspace list --output json + +# List using short flag +kortex-cli workspace list -o json`, Args: cobra.NoArgs, PreRunE: c.preRun, RunE: c.run, diff --git a/pkg/cmd/workspace_list_test.go b/pkg/cmd/workspace_list_test.go index c5f7f46..2f8dbb2 100644 --- a/pkg/cmd/workspace_list_test.go +++ b/pkg/cmd/workspace_list_test.go @@ -26,6 +26,7 @@ import ( "testing" api "github.com/kortex-hub/kortex-cli-api/cli/go" + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/kortex-hub/kortex-cli/pkg/instances" "github.com/spf13/cobra" ) @@ -479,5 +480,35 @@ func TestWorkspaceListCmd_E2E(t *testing.T) { t.Errorf("Expected Configuration %s, got %s", addedInstance.GetConfigDir(), workspace.Paths.Configuration) } }) +} + +func TestWorkspaceListCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the workspace list command + listCmd := NewWorkspaceListCmd() + + // Verify Example field is not empty + if listCmd.Example == "" { + t.Fatal("Example field should not be empty") + } + // Parse the examples + commands, err := testutil.ParseExampleCommands(listCmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 3 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, listCmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } } diff --git a/pkg/cmd/workspace_remove.go b/pkg/cmd/workspace_remove.go index e91db5e..75cb38e 100644 --- a/pkg/cmd/workspace_remove.go +++ b/pkg/cmd/workspace_remove.go @@ -79,9 +79,11 @@ func NewWorkspaceRemoveCmd() *cobra.Command { c := &workspaceRemoveCmd{} cmd := &cobra.Command{ - Use: "remove ID", - Short: "Remove a workspace", - Long: "Remove a workspace by its ID", + Use: "remove ID", + Short: "Remove a workspace", + Long: "Remove a workspace by its ID", + Example: `# Remove workspace by ID +kortex-cli workspace remove abc123`, Args: cobra.ExactArgs(1), PreRunE: c.preRun, RunE: c.run, diff --git a/pkg/cmd/workspace_remove_test.go b/pkg/cmd/workspace_remove_test.go index de4edc3..9a8224b 100644 --- a/pkg/cmd/workspace_remove_test.go +++ b/pkg/cmd/workspace_remove_test.go @@ -24,6 +24,7 @@ import ( "strings" "testing" + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/kortex-hub/kortex-cli/pkg/instances" "github.com/spf13/cobra" ) @@ -327,3 +328,34 @@ func TestWorkspaceRemoveCmd_E2E(t *testing.T) { } }) } + +func TestWorkspaceRemoveCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the workspace remove command + removeCmd := NewWorkspaceRemoveCmd() + + // Verify Example field is not empty + if removeCmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + // Parse the examples + commands, err := testutil.ParseExampleCommands(removeCmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 1 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, removeCmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +} From 381b0a1e89e37640d8e0c697fd4cf6408f830fa4 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 11 Mar 2026 17:13:23 +0100 Subject: [PATCH 2/7] feat(cmd): add Example support for alias commands Adds AdaptExampleForAlias utility function that allows alias commands to inherit examples from their original commands while adapting them to show the alias syntax. The function replaces the original command with the alias only in command lines, preserving comments unchanged. Applied to list and remove alias commands. Updated AGENTS.md with documentation and usage examples for creating aliases with examples. Co-Authored-By: Claude Code (Claude Sonnet 4.5) Signed-off-by: Philippe Martin --- AGENTS.md | 57 ++++++++++++ pkg/cmd/list.go | 2 + pkg/cmd/remove.go | 2 + pkg/cmd/testutil/example_validator.go | 36 +++++++ pkg/cmd/testutil/example_validator_test.go | 103 +++++++++++++++++++++ 5 files changed, 200 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 960d8ba..b7cc7b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,6 +321,63 @@ func TestCmd_Examples(t *testing.T) { **Reference:** See `pkg/cmd/init.go` and `pkg/cmd/init_test.go` for complete examples. +**Alias Commands:** + +Alias commands are shortcuts that delegate to existing commands (e.g., `list` as an alias for `workspace list`). For alias commands: + +1. **Inherit the Example field** from the original command +2. **Adapt the examples** to show the alias syntax instead of the full command +3. **Do NOT create validation tests** for aliases (they use the same validation as the original command) + +Use the `testutil.AdaptExampleForAlias()` function to automatically replace the command name in examples while preserving comments: + +```go +func NewListCmd() *cobra.Command { + // Create the workspace list command + workspaceListCmd := NewWorkspaceListCmd() + + // Create an alias command that delegates to workspace list + cmd := &cobra.Command{ + Use: "list", + Short: workspaceListCmd.Short, + Long: workspaceListCmd.Long, + Example: testutil.AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), + Args: workspaceListCmd.Args, + PreRunE: workspaceListCmd.PreRunE, + RunE: workspaceListCmd.RunE, + } + + // Copy flags from workspace list command + cmd.Flags().AddFlagSet(workspaceListCmd.Flags()) + + return cmd +} +``` + +The `AdaptExampleForAlias()` function: +- Replaces the original command with the alias **only in command lines** (lines starting with `kortex-cli`) +- **Preserves comments unchanged** (lines starting with `#`) +- Maintains formatting and indentation + +**Example transformation:** +```go +// Original (from workspace list): +`# List all workspaces +kortex-cli workspace list + +# List in JSON format +kortex-cli workspace list --output json` + +// After AdaptExampleForAlias(..., "workspace list", "list"): +`# List all workspaces +kortex-cli list + +# List in JSON format +kortex-cli list --output json` +``` + +**Reference:** See `pkg/cmd/list.go` and `pkg/cmd/remove.go` for complete alias examples. + ### Command Implementation Pattern Commands should follow a consistent structure for maintainability and testability: diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index ac89459..e70ca65 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -19,6 +19,7 @@ package cmd import ( + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/spf13/cobra" ) @@ -31,6 +32,7 @@ func NewListCmd() *cobra.Command { Use: "list", Short: workspaceListCmd.Short, Long: workspaceListCmd.Long, + Example: testutil.AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), Args: workspaceListCmd.Args, PreRunE: workspaceListCmd.PreRunE, RunE: workspaceListCmd.RunE, diff --git a/pkg/cmd/remove.go b/pkg/cmd/remove.go index 68bbaa9..2932f55 100644 --- a/pkg/cmd/remove.go +++ b/pkg/cmd/remove.go @@ -19,6 +19,7 @@ package cmd import ( + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/spf13/cobra" ) @@ -31,6 +32,7 @@ func NewRemoveCmd() *cobra.Command { Use: "remove ID", Short: workspaceRemoveCmd.Short, Long: workspaceRemoveCmd.Long, + Example: testutil.AdaptExampleForAlias(workspaceRemoveCmd.Example, "workspace remove", "remove"), Args: workspaceRemoveCmd.Args, PreRunE: workspaceRemoveCmd.PreRunE, RunE: workspaceRemoveCmd.RunE, diff --git a/pkg/cmd/testutil/example_validator.go b/pkg/cmd/testutil/example_validator.go index b2ff173..33a10ae 100644 --- a/pkg/cmd/testutil/example_validator.go +++ b/pkg/cmd/testutil/example_validator.go @@ -250,3 +250,39 @@ func ValidateCommandExamples(rootCmd *cobra.Command, example string) error { return nil } + +// AdaptExampleForAlias replaces the original command with the alias command +// in the example string, but only in command lines (not in comments). +// This is useful for alias commands that want to inherit examples from +// their original commands while showing the alias syntax. +// +// Example: +// +// original := `# List all workspaces +// kortex-cli workspace list +// +// # List in JSON format +// kortex-cli workspace list --output json` +// +// adapted := AdaptExampleForAlias(original, "workspace list", "list") +// // Result: +// // `# List all workspaces +// // kortex-cli list +// // +// // # List in JSON format +// // kortex-cli list --output json` +func AdaptExampleForAlias(example, originalCmd, aliasCmd string) string { + lines := strings.Split(example, "\n") + var result []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Only replace in command lines (starting with kortex-cli), not in comments + if strings.HasPrefix(trimmed, "kortex-cli ") { + line = strings.Replace(line, originalCmd, aliasCmd, 1) + } + result = append(result, line) + } + + return strings.Join(result, "\n") +} diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go index baf6209..19d6260 100644 --- a/pkg/cmd/testutil/example_validator_test.go +++ b/pkg/cmd/testutil/example_validator_test.go @@ -425,3 +425,106 @@ kortex-cli init --invalid-flag value`, }) } } + +func TestAdaptExampleForAlias(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + example string + originalCmd string + aliasCmd string + want string + }{ + { + name: "replaces command in simple example", + example: `# List all workspaces +kortex-cli workspace list`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list`, + }, + { + name: "replaces command with flags", + example: `# List workspaces in JSON format +kortex-cli workspace list --output json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List workspaces in JSON format +kortex-cli list --output json`, + }, + { + name: "replaces multiple occurrences", + example: `# List all workspaces +kortex-cli workspace list + +# List in JSON format +kortex-cli workspace list --output json + +# List using short flag +kortex-cli workspace list -o json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list + +# List in JSON format +kortex-cli list --output json + +# List using short flag +kortex-cli list -o json`, + }, + { + name: "does not replace in comments", + example: `# Use workspace list to see all workspaces +kortex-cli workspace list`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# Use workspace list to see all workspaces +kortex-cli list`, + }, + { + name: "replaces remove command", + example: `# Remove workspace by ID +kortex-cli workspace remove abc123`, + originalCmd: "workspace remove", + aliasCmd: "remove", + want: `# Remove workspace by ID +kortex-cli remove abc123`, + }, + { + name: "handles empty example", + example: ``, + originalCmd: "workspace list", + aliasCmd: "list", + want: ``, + }, + { + name: "preserves indentation", + example: `# List all workspaces +kortex-cli workspace list + +# Another example + kortex-cli workspace list --output json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list + +# Another example + kortex-cli list --output json`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := AdaptExampleForAlias(tt.example, tt.originalCmd, tt.aliasCmd) + if got != tt.want { + t.Errorf("AdaptExampleForAlias() mismatch:\nGot:\n%s\n\nWant:\n%s", got, tt.want) + } + }) + } +} From e9a2e74803f82561427bc0e631ec5f2495d158bd Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 11 Mar 2026 17:17:56 +0100 Subject: [PATCH 3/7] fix: format Signed-off-by: Philippe Martin --- pkg/cmd/testutil/example_validator_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go index 19d6260..ccb1186 100644 --- a/pkg/cmd/testutil/example_validator_test.go +++ b/pkg/cmd/testutil/example_validator_test.go @@ -494,11 +494,11 @@ kortex-cli workspace remove abc123`, kortex-cli remove abc123`, }, { - name: "handles empty example", - example: ``, + name: "handles empty example", + example: ``, originalCmd: "workspace list", aliasCmd: "list", - want: ``, + want: ``, }, { name: "preserves indentation", From 9f91284839597fdb886519eea62da4ca0f4e4085 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 12 Mar 2026 08:45:52 +0100 Subject: [PATCH 4/7] refactor(cmd): move AdaptExampleForAlias to runtime helpers Runtime command code (list.go, remove.go) was importing testutil solely for AdaptExampleForAlias, which coupled runtime code to test-only utilities. Moved the function to pkg/cmd/helpers.go as a neutral runtime helper, along with its tests. Co-Authored-By: Claude Code (Claude Sonnet 4.5) Signed-off-by: Philippe Martin --- AGENTS.md | 18 ++-- pkg/cmd/helpers.go | 53 +++++++++ pkg/cmd/helpers_test.go | 120 +++++++++++++++++++++ pkg/cmd/list.go | 3 +- pkg/cmd/remove.go | 3 +- pkg/cmd/testutil/example_validator.go | 36 ------- pkg/cmd/testutil/example_validator_test.go | 102 ------------------ 7 files changed, 184 insertions(+), 151 deletions(-) create mode 100644 pkg/cmd/helpers.go create mode 100644 pkg/cmd/helpers_test.go diff --git a/AGENTS.md b/AGENTS.md index b7cc7b5..cc32420 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -329,7 +329,7 @@ Alias commands are shortcuts that delegate to existing commands (e.g., `list` as 2. **Adapt the examples** to show the alias syntax instead of the full command 3. **Do NOT create validation tests** for aliases (they use the same validation as the original command) -Use the `testutil.AdaptExampleForAlias()` function to automatically replace the command name in examples while preserving comments: +Use the `AdaptExampleForAlias()` helper function (from `pkg/cmd/helpers.go`) to automatically replace the command name in examples while preserving comments: ```go func NewListCmd() *cobra.Command { @@ -341,7 +341,7 @@ func NewListCmd() *cobra.Command { Use: "list", Short: workspaceListCmd.Short, Long: workspaceListCmd.Long, - Example: testutil.AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), + Example: AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), Args: workspaceListCmd.Args, PreRunE: workspaceListCmd.PreRunE, RunE: workspaceListCmd.RunE, @@ -360,20 +360,20 @@ The `AdaptExampleForAlias()` function: - Maintains formatting and indentation **Example transformation:** -```go -// Original (from workspace list): -`# List all workspaces +```bash +# Original (from workspace list): +# List all workspaces kortex-cli workspace list # List in JSON format -kortex-cli workspace list --output json` +kortex-cli workspace list --output json -// After AdaptExampleForAlias(..., "workspace list", "list"): -`# List all workspaces +# After AdaptExampleForAlias(..., "workspace list", "list"): +# List all workspaces kortex-cli list # List in JSON format -kortex-cli list --output json` +kortex-cli list --output json ``` **Reference:** See `pkg/cmd/list.go` and `pkg/cmd/remove.go` for complete alias examples. diff --git a/pkg/cmd/helpers.go b/pkg/cmd/helpers.go new file mode 100644 index 0000000..2f131b1 --- /dev/null +++ b/pkg/cmd/helpers.go @@ -0,0 +1,53 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import "strings" + +// AdaptExampleForAlias replaces the original command with the alias command +// in the example string, but only in command lines (not in comments). +// This is useful for alias commands that want to inherit examples from +// their original commands while showing the alias syntax. +// +// Example: +// +// original := `# List all workspaces +// kortex-cli workspace list +// +// # List in JSON format +// kortex-cli workspace list --output json` +// +// adapted := AdaptExampleForAlias(original, "workspace list", "list") +// // Result: +// // `# List all workspaces +// // kortex-cli list +// // +// // # List in JSON format +// // kortex-cli list --output json` +func AdaptExampleForAlias(example, originalCmd, aliasCmd string) string { + lines := strings.Split(example, "\n") + var result []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Only replace in command lines (starting with kortex-cli), not in comments + if strings.HasPrefix(trimmed, "kortex-cli ") { + line = strings.Replace(line, originalCmd, aliasCmd, 1) + } + result = append(result, line) + } + + return strings.Join(result, "\n") +} diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go new file mode 100644 index 0000000..72dc004 --- /dev/null +++ b/pkg/cmd/helpers_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import "testing" + +func TestAdaptExampleForAlias(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + example string + originalCmd string + aliasCmd string + want string + }{ + { + name: "replaces command in simple example", + example: `# List all workspaces +kortex-cli workspace list`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list`, + }, + { + name: "replaces command with flags", + example: `# List workspaces in JSON format +kortex-cli workspace list --output json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List workspaces in JSON format +kortex-cli list --output json`, + }, + { + name: "replaces multiple occurrences", + example: `# List all workspaces +kortex-cli workspace list + +# List in JSON format +kortex-cli workspace list --output json + +# List using short flag +kortex-cli workspace list -o json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list + +# List in JSON format +kortex-cli list --output json + +# List using short flag +kortex-cli list -o json`, + }, + { + name: "does not replace in comments", + example: `# Use workspace list to see all workspaces +kortex-cli workspace list`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# Use workspace list to see all workspaces +kortex-cli list`, + }, + { + name: "replaces remove command", + example: `# Remove workspace by ID +kortex-cli workspace remove abc123`, + originalCmd: "workspace remove", + aliasCmd: "remove", + want: `# Remove workspace by ID +kortex-cli remove abc123`, + }, + { + name: "handles empty example", + example: ``, + originalCmd: "workspace list", + aliasCmd: "list", + want: ``, + }, + { + name: "preserves indentation", + example: `# List all workspaces +kortex-cli workspace list + +# Another example + kortex-cli workspace list --output json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list + +# Another example + kortex-cli list --output json`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := AdaptExampleForAlias(tt.example, tt.originalCmd, tt.aliasCmd) + if got != tt.want { + t.Errorf("AdaptExampleForAlias() mismatch:\nGot:\n%s\n\nWant:\n%s", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index e70ca65..784bf7c 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -19,7 +19,6 @@ package cmd import ( - "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/spf13/cobra" ) @@ -32,7 +31,7 @@ func NewListCmd() *cobra.Command { Use: "list", Short: workspaceListCmd.Short, Long: workspaceListCmd.Long, - Example: testutil.AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), + Example: AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), Args: workspaceListCmd.Args, PreRunE: workspaceListCmd.PreRunE, RunE: workspaceListCmd.RunE, diff --git a/pkg/cmd/remove.go b/pkg/cmd/remove.go index 2932f55..e6e29de 100644 --- a/pkg/cmd/remove.go +++ b/pkg/cmd/remove.go @@ -19,7 +19,6 @@ package cmd import ( - "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/spf13/cobra" ) @@ -32,7 +31,7 @@ func NewRemoveCmd() *cobra.Command { Use: "remove ID", Short: workspaceRemoveCmd.Short, Long: workspaceRemoveCmd.Long, - Example: testutil.AdaptExampleForAlias(workspaceRemoveCmd.Example, "workspace remove", "remove"), + Example: AdaptExampleForAlias(workspaceRemoveCmd.Example, "workspace remove", "remove"), Args: workspaceRemoveCmd.Args, PreRunE: workspaceRemoveCmd.PreRunE, RunE: workspaceRemoveCmd.RunE, diff --git a/pkg/cmd/testutil/example_validator.go b/pkg/cmd/testutil/example_validator.go index 33a10ae..b2ff173 100644 --- a/pkg/cmd/testutil/example_validator.go +++ b/pkg/cmd/testutil/example_validator.go @@ -250,39 +250,3 @@ func ValidateCommandExamples(rootCmd *cobra.Command, example string) error { return nil } - -// AdaptExampleForAlias replaces the original command with the alias command -// in the example string, but only in command lines (not in comments). -// This is useful for alias commands that want to inherit examples from -// their original commands while showing the alias syntax. -// -// Example: -// -// original := `# List all workspaces -// kortex-cli workspace list -// -// # List in JSON format -// kortex-cli workspace list --output json` -// -// adapted := AdaptExampleForAlias(original, "workspace list", "list") -// // Result: -// // `# List all workspaces -// // kortex-cli list -// // -// // # List in JSON format -// // kortex-cli list --output json` -func AdaptExampleForAlias(example, originalCmd, aliasCmd string) string { - lines := strings.Split(example, "\n") - var result []string - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - // Only replace in command lines (starting with kortex-cli), not in comments - if strings.HasPrefix(trimmed, "kortex-cli ") { - line = strings.Replace(line, originalCmd, aliasCmd, 1) - } - result = append(result, line) - } - - return strings.Join(result, "\n") -} diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go index ccb1186..d4e2d41 100644 --- a/pkg/cmd/testutil/example_validator_test.go +++ b/pkg/cmd/testutil/example_validator_test.go @@ -426,105 +426,3 @@ kortex-cli init --invalid-flag value`, } } -func TestAdaptExampleForAlias(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - example string - originalCmd string - aliasCmd string - want string - }{ - { - name: "replaces command in simple example", - example: `# List all workspaces -kortex-cli workspace list`, - originalCmd: "workspace list", - aliasCmd: "list", - want: `# List all workspaces -kortex-cli list`, - }, - { - name: "replaces command with flags", - example: `# List workspaces in JSON format -kortex-cli workspace list --output json`, - originalCmd: "workspace list", - aliasCmd: "list", - want: `# List workspaces in JSON format -kortex-cli list --output json`, - }, - { - name: "replaces multiple occurrences", - example: `# List all workspaces -kortex-cli workspace list - -# List in JSON format -kortex-cli workspace list --output json - -# List using short flag -kortex-cli workspace list -o json`, - originalCmd: "workspace list", - aliasCmd: "list", - want: `# List all workspaces -kortex-cli list - -# List in JSON format -kortex-cli list --output json - -# List using short flag -kortex-cli list -o json`, - }, - { - name: "does not replace in comments", - example: `# Use workspace list to see all workspaces -kortex-cli workspace list`, - originalCmd: "workspace list", - aliasCmd: "list", - want: `# Use workspace list to see all workspaces -kortex-cli list`, - }, - { - name: "replaces remove command", - example: `# Remove workspace by ID -kortex-cli workspace remove abc123`, - originalCmd: "workspace remove", - aliasCmd: "remove", - want: `# Remove workspace by ID -kortex-cli remove abc123`, - }, - { - name: "handles empty example", - example: ``, - originalCmd: "workspace list", - aliasCmd: "list", - want: ``, - }, - { - name: "preserves indentation", - example: `# List all workspaces -kortex-cli workspace list - -# Another example - kortex-cli workspace list --output json`, - originalCmd: "workspace list", - aliasCmd: "list", - want: `# List all workspaces -kortex-cli list - -# Another example - kortex-cli list --output json`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := AdaptExampleForAlias(tt.example, tt.originalCmd, tt.aliasCmd) - if got != tt.want { - t.Errorf("AdaptExampleForAlias() mismatch:\nGot:\n%s\n\nWant:\n%s", got, tt.want) - } - }) - } -} From 03c91f55ee412385312db502ed219eef740dd56e Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 12 Mar 2026 08:54:57 +0100 Subject: [PATCH 5/7] feat(testutil): validate flags requiring values have values The ExampleCommand structure now tracks flag presence and values separately using FlagPresent and FlagValues maps, allowing the validator to distinguish between flags with no value vs empty values. This enables validation that string flags requiring values (NoOptDefVal == "") actually have values provided, while still allowing boolean flags to be used without values. Co-Authored-By: Claude Code (Claude Sonnet 4.5) Signed-off-by: Philippe Martin --- pkg/cmd/testutil/example_validator.go | 70 +++++++++++----- pkg/cmd/testutil/example_validator_test.go | 94 ++++++++++++++-------- 2 files changed, 112 insertions(+), 52 deletions(-) diff --git a/pkg/cmd/testutil/example_validator.go b/pkg/cmd/testutil/example_validator.go index b2ff173..9610e9c 100644 --- a/pkg/cmd/testutil/example_validator.go +++ b/pkg/cmd/testutil/example_validator.go @@ -24,10 +24,12 @@ import ( // ExampleCommand represents a parsed command from an Example string type ExampleCommand struct { - Raw string // Original command line - Binary string // Binary name (should be "kortex-cli") - Args []string // Subcommands and positional arguments - Flags map[string]string // Flag names to values (without -- or -) + Raw string // Original command line + Binary string // Binary name (should be "kortex-cli") + Args []string // Subcommands and positional arguments + FlagPresent map[string]bool // Flags that were present in the command + FlagValues map[string]string // Values for flags (empty string if no value provided) + Flags map[string]string // Deprecated: use FlagPresent and FlagValues instead } // ParseExampleCommands extracts kortex-cli commands from Example string @@ -76,10 +78,12 @@ func parseCommandLine(line string) (ExampleCommand, error) { } cmd := ExampleCommand{ - Raw: line, - Binary: parts[0], - Args: []string{}, - Flags: make(map[string]string), + Raw: line, + Binary: parts[0], + Args: []string{}, + FlagPresent: make(map[string]bool), + FlagValues: make(map[string]string), + Flags: make(map[string]string), } // Parse remaining parts as args and flags @@ -88,12 +92,20 @@ func parseCommandLine(line string) (ExampleCommand, error) { if strings.HasPrefix(part, "--") { // Long flag - flagName, flagValue := parseLongFlag(part, parts, &i) + flagName, flagValue, hasValue := parseLongFlag(part, parts, &i) + cmd.FlagPresent[flagName] = true + cmd.FlagValues[flagName] = flagValue + // Maintain backward compatibility with deprecated Flags field cmd.Flags[flagName] = flagValue + _ = hasValue // Will be used in validation } else if strings.HasPrefix(part, "-") && len(part) > 1 { // Short flag - flagName, flagValue := parseShortFlag(part, parts, &i) + flagName, flagValue, hasValue := parseShortFlag(part, parts, &i) + cmd.FlagPresent[flagName] = true + cmd.FlagValues[flagName] = flagValue + // Maintain backward compatibility with deprecated Flags field cmd.Flags[flagName] = flagValue + _ = hasValue // Will be used in validation } else { // Positional argument cmd.Args = append(cmd.Args, part) @@ -104,38 +116,40 @@ func parseCommandLine(line string) (ExampleCommand, error) { } // parseLongFlag parses a long flag (--flag or --flag=value or --flag value) -func parseLongFlag(part string, parts []string, i *int) (string, string) { +// Returns: flagName, flagValue, hasValue +func parseLongFlag(part string, parts []string, i *int) (string, string, bool) { // Remove -- prefix flagPart := strings.TrimPrefix(part, "--") // Check for --flag=value format if idx := strings.Index(flagPart, "="); idx != -1 { - return flagPart[:idx], flagPart[idx+1:] + return flagPart[:idx], flagPart[idx+1:], true } // Check for --flag value format if *i+1 < len(parts) && !strings.HasPrefix(parts[*i+1], "-") { *i++ - return flagPart, parts[*i] + return flagPart, parts[*i], true } - // Boolean flag (--flag with no value) - return flagPart, "" + // Flag with no value (--flag) + return flagPart, "", false } // parseShortFlag parses a short flag (-f or -f value) -func parseShortFlag(part string, parts []string, i *int) (string, string) { +// Returns: flagName, flagValue, hasValue +func parseShortFlag(part string, parts []string, i *int) (string, string, bool) { // Remove - prefix flagPart := strings.TrimPrefix(part, "-") // Check for -f value format if *i+1 < len(parts) && !strings.HasPrefix(parts[*i+1], "-") { *i++ - return flagPart, parts[*i] + return flagPart, parts[*i], true } - // Boolean flag (-f with no value) - return flagPart, "" + // Flag with no value (-f) + return flagPart, "", false } // splitCommandLine splits a command line by whitespace @@ -190,7 +204,7 @@ func ValidateExampleCommand(rootCmd *cobra.Command, exampleCmd ExampleCommand) e } // Validate each flag - for flagName := range exampleCmd.Flags { + for flagName := range exampleCmd.FlagPresent { var flag *pflag.Flag // Determine if this is a short flag (single character) @@ -230,6 +244,22 @@ func ValidateExampleCommand(rootCmd *cobra.Command, exampleCmd ExampleCommand) e } return fmt.Errorf("flag %s%s not found in command: %s", prefix, flagName, cmdPath) } + + // Validate that flags requiring values have values + // A flag requires a value if: + // - It's present in the command line + // - It has no value (empty string) + // - NoOptDefVal is empty (meaning the flag doesn't accept being used without a value) + // - It's not a boolean flag + flagValue := exampleCmd.FlagValues[flagName] + if flagValue == "" && flag.NoOptDefVal == "" && flag.Value.Type() != "bool" { + cmdPath := cmd.CommandPath() + prefix := "--" + if isShortFlag { + prefix = "-" + } + return fmt.Errorf("flag %s%s requires a value in command: %s", prefix, flagName, cmdPath) + } } return nil diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go index d4e2d41..55e307d 100644 --- a/pkg/cmd/testutil/example_validator_test.go +++ b/pkg/cmd/testutil/example_validator_test.go @@ -237,80 +237,110 @@ func TestValidateExampleCommand(t *testing.T) { { name: "valid command - init", exampleCmd: ExampleCommand{ - Raw: "kortex-cli init", - Binary: "kortex-cli", - Args: []string{"init"}, - Flags: map[string]string{}, + Raw: "kortex-cli init", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, }, wantErr: false, }, { name: "valid command - workspace list", exampleCmd: ExampleCommand{ - Raw: "kortex-cli workspace list", - Binary: "kortex-cli", - Args: []string{"workspace", "list"}, - Flags: map[string]string{}, + Raw: "kortex-cli workspace list", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, }, wantErr: false, }, { name: "valid command with flag", exampleCmd: ExampleCommand{ - Raw: "kortex-cli workspace list --output json", - Binary: "kortex-cli", - Args: []string{"workspace", "list"}, - Flags: map[string]string{"output": "json"}, + Raw: "kortex-cli workspace list --output json", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + FlagPresent: map[string]bool{"output": true}, + FlagValues: map[string]string{"output": "json"}, }, wantErr: false, }, { name: "valid command with short flag", exampleCmd: ExampleCommand{ - Raw: "kortex-cli workspace list -f json", - Binary: "kortex-cli", - Args: []string{"workspace", "list"}, - Flags: map[string]string{"f": "json"}, + Raw: "kortex-cli workspace list -f json", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + FlagPresent: map[string]bool{"f": true}, + FlagValues: map[string]string{"f": "json"}, }, wantErr: false, }, { name: "valid command with persistent flag", exampleCmd: ExampleCommand{ - Raw: "kortex-cli init --storage /tmp/storage", - Binary: "kortex-cli", - Args: []string{"init"}, - Flags: map[string]string{"storage": "/tmp/storage"}, + Raw: "kortex-cli init --storage /tmp/storage", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"storage": true}, + FlagValues: map[string]string{"storage": "/tmp/storage"}, }, wantErr: false, }, { name: "invalid command - non-existent", exampleCmd: ExampleCommand{ - Raw: "kortex-cli nonexistent", - Binary: "kortex-cli", - Args: []string{"nonexistent"}, - Flags: map[string]string{}, + Raw: "kortex-cli nonexistent", + Binary: "kortex-cli", + Args: []string{"nonexistent"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, }, wantErr: true, }, { name: "invalid flag", exampleCmd: ExampleCommand{ - Raw: "kortex-cli init --nonexistent-flag value", - Binary: "kortex-cli", - Args: []string{"init"}, - Flags: map[string]string{"nonexistent-flag": "value"}, + Raw: "kortex-cli init --nonexistent-flag value", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"nonexistent-flag": true}, + FlagValues: map[string]string{"nonexistent-flag": "value"}, }, wantErr: true, }, { name: "valid command with multiple flags", exampleCmd: ExampleCommand{ - Raw: "kortex-cli init --name test --verbose", - Binary: "kortex-cli", - Args: []string{"init"}, - Flags: map[string]string{"name": "test", "verbose": ""}, + Raw: "kortex-cli init --name test --verbose", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"name": true, "verbose": true}, + FlagValues: map[string]string{"name": "test", "verbose": ""}, + }, + wantErr: false, + }, + { + name: "string flag missing value", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --name", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"name": true}, + FlagValues: map[string]string{"name": ""}, + }, + wantErr: true, + }, + { + name: "boolean flag without value is valid", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --verbose", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"verbose": true}, + FlagValues: map[string]string{"verbose": ""}, }, wantErr: false, }, From cab50fac61c331907dcba3b76f859ffea7c265bf Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 12 Mar 2026 08:59:39 +0100 Subject: [PATCH 6/7] feat(testutil): use Cobra Args validators for example validation ValidateExampleCommand now invokes cmd.Args(cmd, remainingArgs) to validate remaining arguments, respecting each command's Args validator (cobra.NoArgs, cobra.ExactArgs, etc.) instead of only checking HasSubCommands(). This provides more accurate validation that matches runtime command behavior. Co-Authored-By: Claude Code (Claude Sonnet 4.5) Signed-off-by: Philippe Martin --- pkg/cmd/testutil/example_validator.go | 17 ++++++++++------- pkg/cmd/testutil/example_validator_test.go | 14 +++++++++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/testutil/example_validator.go b/pkg/cmd/testutil/example_validator.go index 9610e9c..fb8340c 100644 --- a/pkg/cmd/testutil/example_validator.go +++ b/pkg/cmd/testutil/example_validator.go @@ -194,13 +194,16 @@ func ValidateExampleCommand(rootCmd *cobra.Command, exampleCmd ExampleCommand) e return fmt.Errorf("command not found: %s: %w", strings.Join(exampleCmd.Args, " "), err) } - // Check if there are remaining args that weren't recognized as commands - // This catches cases like "workspace nonexistent" where "nonexistent" would be - // treated as a positional argument instead of being validated as a subcommand. - // However, we should only reject if the command has subcommands (meaning the - // remaining args were likely meant to be subcommands, not positional arguments). - if len(remainingArgs) > 0 && cmd.HasSubCommands() { - return fmt.Errorf("unknown command %q for %q", strings.Join(remainingArgs, " "), cmd.CommandPath()) + // Validate remaining arguments using the command's Args validator + // This respects cobra.NoArgs, cobra.ExactArgs, etc. + if cmd.Args != nil { + if err := cmd.Args(cmd, remainingArgs); err != nil { + // Provide a clearer error message for unknown subcommands + if len(remainingArgs) > 0 && cmd.HasSubCommands() { + return fmt.Errorf("unknown command %q for %q", strings.Join(remainingArgs, " "), cmd.CommandPath()) + } + return fmt.Errorf("invalid arguments for %q: %w", cmd.CommandPath(), err) + } } // Validate each flag diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go index 55e307d..c1f1586 100644 --- a/pkg/cmd/testutil/example_validator_test.go +++ b/pkg/cmd/testutil/example_validator_test.go @@ -203,7 +203,8 @@ func TestValidateExampleCommand(t *testing.T) { } listCmd := &cobra.Command{ - Use: "list", + Use: "list", + Args: cobra.NoArgs, } listCmd.Flags().String("output", "text", "output format") listCmd.Flags().StringP("format", "f", "text", "alias for output") @@ -344,6 +345,17 @@ func TestValidateExampleCommand(t *testing.T) { }, wantErr: false, }, + { + name: "extra positional to leaf command", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list extra", + Binary: "kortex-cli", + Args: []string{"workspace", "list", "extra"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, + }, + wantErr: true, + }, } for _, tt := range tests { From 9350940275724d87c9baa67f3fddaff1779f0df9 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 12 Mar 2026 09:03:39 +0100 Subject: [PATCH 7/7] fix: fmt Signed-off-by: Philippe Martin --- pkg/cmd/testutil/example_validator_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go index c1f1586..722942d 100644 --- a/pkg/cmd/testutil/example_validator_test.go +++ b/pkg/cmd/testutil/example_validator_test.go @@ -467,4 +467,3 @@ kortex-cli init --invalid-flag value`, }) } } -