diff --git a/README.md b/README.md index c9d8186..cf4091c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,31 @@ make build # Build from source make install # Install globally (optional) ``` +## Shell Completion + +Enable tab completion for commands, services, and arguments: + +```bash +# Bash +source <(xcli completion bash) + +# To load completions for each session (Linux): +xcli completion bash > /etc/bash_completion.d/xcli + +# To load completions for each session (macOS): +xcli completion bash > $(brew --prefix)/etc/bash_completion.d/xcli + +# Zsh +echo "autoload -U compinit; compinit" >> ~/.zshrc # Enable completion (once) +xcli completion zsh > "${fpath[1]}/_xcli" + +# Fish +xcli completion fish | source + +# To load completions for each session: +xcli completion fish > ~/.config/fish/completions/xcli.fish +``` + ## Quick Start ```bash diff --git a/cmd/xcli/main.go b/cmd/xcli/main.go index 459b7f2..a070554 100644 --- a/cmd/xcli/main.go +++ b/cmd/xcli/main.go @@ -98,6 +98,7 @@ func main() { // Add root-level commands rootCmd.AddCommand(commands.NewInitCommand(log, configPath)) rootCmd.AddCommand(commands.NewConfigCommand(log, configPath)) + rootCmd.AddCommand(commands.NewCompletionCommand()) // Add stack commands rootCmd.AddCommand(commands.NewLabCommand(log, configPath)) diff --git a/pkg/commands/completion.go b/pkg/commands/completion.go new file mode 100644 index 0000000..098b157 --- /dev/null +++ b/pkg/commands/completion.go @@ -0,0 +1,69 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// NewCompletionCommand creates the completion command for generating shell completion scripts. +func NewCompletionCommand() *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: fmt.Sprintf(`Generate shell completion scripts for xcli. + +To load completions: + +Bash: + $ source <(%[1]s completion bash) + + # To load completions for each session, execute once: + # Linux: + $ %[1]s completion bash > /etc/bash_completion.d/%[1]s + # macOS: + $ %[1]s completion bash > $(brew --prefix)/etc/bash_completion.d/%[1]s + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ %[1]s completion zsh > "${fpath[1]}/_%[1]s" + + # You will need to start a new shell for this setup to take effect. + +Fish: + $ %[1]s completion fish | source + + # To load completions for each session, execute once: + $ %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish + +PowerShell: + PS> %[1]s completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, run: + PS> %[1]s completion powershell > %[1]s.ps1 + # and source this file from your PowerShell profile. +`, "xcli"), + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + default: + return fmt.Errorf("unsupported shell: %s", args[0]) + } + }, + } +} diff --git a/pkg/commands/completion_helpers.go b/pkg/commands/completion_helpers.go new file mode 100644 index 0000000..eb5e249 --- /dev/null +++ b/pkg/commands/completion_helpers.go @@ -0,0 +1,92 @@ +package commands + +import ( + "io" + + "github.com/ethpandaops/xcli/pkg/config" + "github.com/ethpandaops/xcli/pkg/constants" + "github.com/ethpandaops/xcli/pkg/orchestrator" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// completeServices returns a ValidArgsFunction that completes service names. +// It loads the config and creates an orchestrator to get the dynamic service list. +func completeServices(configPath string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + // Don't complete if we already have an argument (for single-arg commands) + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Create a silent logger for completion (no output) + log := logrus.New() + log.SetOutput(io.Discard) + + // Try to load config - fail gracefully + labCfg, cfgPath, err := config.LoadLabConfig(configPath) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Try to create orchestrator - fail gracefully + orch, err := orchestrator.NewOrchestrator(log, labCfg, cfgPath) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return orch.GetValidServices(), cobra.ShellCompDirectiveNoFileComp + } +} + +// completeModes returns a ValidArgsFunction that completes mode values. +func completeModes() func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return []string{constants.ModeLocal, constants.ModeHybrid}, cobra.ShellCompDirectiveNoFileComp + } +} + +// completeRebuildProjects returns a ValidArgsFunction that completes rebuild project names. +func completeRebuildProjects() func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return []string{ + "xatu-cbt", + "all", + "cbt", + "cbt-api", + "lab-backend", + "lab-frontend", + "prometheus", + "grafana", + }, cobra.ShellCompDirectiveNoFileComp + } +} + +// completeReleasableProjects returns a ValidArgsFunction that completes releasable project names. +// Supports multiple arguments since release accepts multiple projects. +func completeReleasableProjects() func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + // Filter out already-provided arguments + alreadyUsed := make(map[string]bool, len(args)) + for _, arg := range args { + alreadyUsed[arg] = true + } + + completions := make([]string, 0, len(constants.ReleasableProjects)) + for _, project := range constants.ReleasableProjects { + if !alreadyUsed[project] { + completions = append(completions, project) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/pkg/commands/lab_logs.go b/pkg/commands/lab_logs.go index 17d2c81..bc88f78 100644 --- a/pkg/commands/lab_logs.go +++ b/pkg/commands/lab_logs.go @@ -14,9 +14,10 @@ func NewLabLogsCommand(log logrus.FieldLogger, configPath string) *cobra.Command var follow bool cmd := &cobra.Command{ - Use: "logs [service]", - Short: "Show lab service logs", - Long: `Show logs for all lab services or a specific service.`, + Use: "logs [service]", + Short: "Show lab service logs", + Long: `Show logs for all lab services or a specific service.`, + ValidArgsFunction: completeServices(configPath), RunE: func(cmd *cobra.Command, args []string) error { labCfg, cfgPath, err := config.LoadLabConfig(configPath) if err != nil { diff --git a/pkg/commands/lab_mode.go b/pkg/commands/lab_mode.go index 3ca3de6..3e612b1 100644 --- a/pkg/commands/lab_mode.go +++ b/pkg/commands/lab_mode.go @@ -39,7 +39,8 @@ After switching modes, restart the stack for changes to take effect: Examples: xcli lab mode local # Switch to fully local mode xcli lab mode hybrid # Switch to hybrid mode (requires external ClickHouse config)`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeModes(), RunE: func(cmd *cobra.Command, args []string) error { mode := args[0] if mode != constants.ModeLocal && mode != constants.ModeHybrid { diff --git a/pkg/commands/lab_rebuild.go b/pkg/commands/lab_rebuild.go index ce11f2d..5ef0fac 100644 --- a/pkg/commands/lab_rebuild.go +++ b/pkg/commands/lab_rebuild.go @@ -70,7 +70,8 @@ Examples: xcli lab rebuild grafana # Reload custom dashboards Note: All rebuild commands automatically restart their respective services if running.`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeRebuildProjects(), RunE: func(cmd *cobra.Command, args []string) error { project := args[0] diff --git a/pkg/commands/lab_release.go b/pkg/commands/lab_release.go index a055080..0f38c7c 100644 --- a/pkg/commands/lab_release.go +++ b/pkg/commands/lab_release.go @@ -65,7 +65,8 @@ Options: xcli lab release xatu-cbt --no-deps # Skip dependency prompts Note: Requires GitHub CLI (gh) to be installed and authenticated.`, - Args: cobra.ArbitraryArgs, + Args: cobra.ArbitraryArgs, + ValidArgsFunction: completeReleasableProjects(), RunE: func(cmd *cobra.Command, args []string) error { return runRelease(cmd.Context(), log, args, stackFlag, bumpFlag, yesFlag, !noWatchFlag, noDepsFlag, timeoutFlag) diff --git a/pkg/commands/lab_restart.go b/pkg/commands/lab_restart.go index cf330db..84faafa 100644 --- a/pkg/commands/lab_restart.go +++ b/pkg/commands/lab_restart.go @@ -13,10 +13,11 @@ import ( // NewLabRestartCommand creates the lab restart command. func NewLabRestartCommand(log logrus.FieldLogger, configPath string) *cobra.Command { return &cobra.Command{ - Use: "restart ", - Short: "Restart a lab service", - Long: `Restart a specific lab service.`, - Args: cobra.ExactArgs(1), + Use: "restart ", + Short: "Restart a lab service", + Long: `Restart a specific lab service.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeServices(configPath), RunE: func(cmd *cobra.Command, args []string) error { labCfg, cfgPath, err := config.LoadLabConfig(configPath) if err != nil { diff --git a/pkg/commands/lab_start.go b/pkg/commands/lab_start.go index df643d7..264fd58 100644 --- a/pkg/commands/lab_start.go +++ b/pkg/commands/lab_start.go @@ -26,7 +26,8 @@ Available services: Example: xcli lab start lab-backend xcli lab start cbt-mainnet`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeServices(configPath), RunE: func(cmd *cobra.Command, args []string) error { labCfg, cfgPath, err := config.LoadLabConfig(configPath) if err != nil { diff --git a/pkg/commands/lab_stop.go b/pkg/commands/lab_stop.go index ece5eac..16b8c1d 100644 --- a/pkg/commands/lab_stop.go +++ b/pkg/commands/lab_stop.go @@ -26,7 +26,8 @@ Available services: Example: xcli lab stop lab-backend xcli lab stop cbt-mainnet`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeServices(configPath), RunE: func(cmd *cobra.Command, args []string) error { labCfg, cfgPath, err := config.LoadLabConfig(configPath) if err != nil {