From f2c8c5204e3607bd2fa4516f7b082cffb64172f5 Mon Sep 17 00:00:00 2001 From: "willian.eth" Date: Thu, 15 Jan 2026 21:07:11 +0100 Subject: [PATCH] Add shell completion for beacon-chain and validator CLI (#16245) **What type of PR is this?** Feature **What does this PR do? Why is it needed?** Introduces a `completion` subcommand to `beacon-chain` and `validator` that outputs shell completion scripts. Supports Bash, Zsh, and Fish shells. ```bash # Load completions in current session source <(beacon-chain completion bash) # Persist for future sessions beacon-chain completion zsh > "${fpath[1]}/_beacon-chain" validator completion fish > ~/.config/fish/completions/validator.fish ``` Once loaded, users can press TAB to complete subcommands, nested commands, and flags. Flag completion supports prefix matching (e.g., typing `--exec` suggests `--execution-endpoint`, `--execution-headers`). **Which issues(s) does this PR fix?** Fixes #16244 **Other notes for review** The implementation adds three files to the existing `cmd` package: - `completion.go` - Defines `CompletionCommand()` returning a `*cli.Command` with `bash`, `zsh`, `fish` subcommands - `completion_scripts.go` - Contains the shell script templates - `completion_test.go` - Unit tests for command structure and script content Changes to `beacon-chain` and `validator`: - Import `cmd.CompletionCommand("binary-name")` in the Commands slice - Set `EnableBashCompletion: true` on the cli.App to activate urfave/cli's `--generate-bash-completion` hidden flag The shell scripts call the binary with `--generate-bash-completion` appended to get context-aware suggestions. This means completions automatically reflect the current binary's flags and commands. **Acknowledgements** - [x] I have read [CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md). - [x] I have included a uniquely named [changelog fragment file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd). - [x] I have added a description with sufficient context for reviewers to understand this PR. - [x] I have tested that my changes work as expected and I added a testing plan to the PR description (if applicable). Signed-off-by: Willian Paixao --- .../willianpaixao_shell-autocompletion.md | 3 + cmd/BUILD.bazel | 3 + cmd/beacon-chain/main.go | 6 +- cmd/completion.go | 63 +++++++++++ cmd/completion_scripts.go | 99 +++++++++++++++++ cmd/completion_test.go | 105 ++++++++++++++++++ cmd/validator/main.go | 4 +- 7 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 changelog/willianpaixao_shell-autocompletion.md create mode 100644 cmd/completion.go create mode 100644 cmd/completion_scripts.go create mode 100644 cmd/completion_test.go diff --git a/changelog/willianpaixao_shell-autocompletion.md b/changelog/willianpaixao_shell-autocompletion.md new file mode 100644 index 0000000000..f05eb36525 --- /dev/null +++ b/changelog/willianpaixao_shell-autocompletion.md @@ -0,0 +1,3 @@ +### Added + +- Added shell completion support for `beacon-chain` and `validator` CLI tools. diff --git a/cmd/BUILD.bazel b/cmd/BUILD.bazel index 05ec4f08c9..5bcce71a48 100644 --- a/cmd/BUILD.bazel +++ b/cmd/BUILD.bazel @@ -3,6 +3,8 @@ load("@prysm//tools/go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", srcs = [ + "completion.go", + "completion_scripts.go", "config.go", "defaults.go", "flags.go", @@ -28,6 +30,7 @@ go_test( name = "go_default_test", size = "small", srcs = [ + "completion_test.go", "config_test.go", "flags_test.go", "helpers_test.go", diff --git a/cmd/beacon-chain/main.go b/cmd/beacon-chain/main.go index 3af661b9cd..6f8028121b 100644 --- a/cmd/beacon-chain/main.go +++ b/cmd/beacon-chain/main.go @@ -271,9 +271,11 @@ func main() { Commands: []*cli.Command{ dbcommands.Commands, jwtcommands.Commands, + cmd.CompletionCommand("beacon-chain"), }, - Flags: appFlags, - Before: before, + Flags: appFlags, + Before: before, + EnableBashCompletion: true, } defer func() { diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000000..7ec7634247 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + + "github.com/urfave/cli/v2" +) + +// CompletionCommand returns the completion command for the given binary name. +// The binaryName parameter should be "beacon-chain" or "validator". +func CompletionCommand(binaryName string) *cli.Command { + return &cli.Command{ + Name: "completion", + Category: "completion", + Usage: "Generate shell completion scripts", + Description: fmt.Sprintf(`Generate shell completion scripts for bash, zsh, or fish. + +To load completions: + +Bash: + $ source <(%[1]s completion bash) + # To load completions for each session, execute once: + $ %[1]s completion bash > /etc/bash_completion.d/%[1]s + +Zsh: + # To load completions for each session, execute once: + $ %[1]s completion zsh > "${fpath[1]}/_%[1]s" + + # You may need to start a new shell for completions 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 +`, binaryName), + Subcommands: []*cli.Command{ + { + Name: "bash", + Usage: "Generate bash completion script", + Action: func(_ *cli.Context) error { + fmt.Println(bashCompletionScript(binaryName)) + return nil + }, + }, + { + Name: "zsh", + Usage: "Generate zsh completion script", + Action: func(_ *cli.Context) error { + fmt.Println(zshCompletionScript(binaryName)) + return nil + }, + }, + { + Name: "fish", + Usage: "Generate fish completion script", + Action: func(_ *cli.Context) error { + fmt.Println(fishCompletionScript(binaryName)) + return nil + }, + }, + }, + } +} diff --git a/cmd/completion_scripts.go b/cmd/completion_scripts.go new file mode 100644 index 0000000000..6b8d3351fc --- /dev/null +++ b/cmd/completion_scripts.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "strings" +) + +// bashCompletionScript returns the bash completion script for the given binary. +func bashCompletionScript(binaryName string) string { + // Convert hyphens to underscores for bash function names + funcName := strings.ReplaceAll(binaryName, "-", "_") + return fmt.Sprintf(`#!/bin/bash + +_%[1]s_completions() { + local cur prev words cword opts + COMPREPLY=() + + # Use bash-completion if available, otherwise set variables directly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n "=:" || return + else + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + fi + + # Build command array for completion - flag must be at the END + local -a requestComp + if [[ "$cur" == "-"* ]]; then + requestComp=("${COMP_WORDS[@]:0:COMP_CWORD}" "$cur" --generate-bash-completion) + else + requestComp=("${COMP_WORDS[@]:0:COMP_CWORD}" --generate-bash-completion) + fi + + opts=$("${requestComp[@]}" 2>/dev/null) + COMPREPLY=($(compgen -W "${opts}" -- "${cur}")) + return 0 +} + +complete -o bashdefault -o default -o nospace -F _%[1]s_completions %[2]s +`, funcName, binaryName) +} + +// zshCompletionScript returns the zsh completion script for the given binary. +func zshCompletionScript(binaryName string) string { + // Convert hyphens to underscores for zsh function names + funcName := strings.ReplaceAll(binaryName, "-", "_") + return fmt.Sprintf(`#compdef %[2]s + +_%[1]s() { + local curcontext="$curcontext" ret=1 + local -a completions + + # Build command array with --generate-bash-completion at the END + local -a requestComp + if [[ "${words[CURRENT]}" == -* ]]; then + requestComp=(${words[1,CURRENT]} --generate-bash-completion) + else + requestComp=(${words[1,CURRENT-1]} --generate-bash-completion) + fi + + completions=($("${requestComp[@]}" 2>/dev/null)) + + if [[ ${#completions[@]} -gt 0 ]]; then + _describe -t commands '%[2]s' completions && ret=0 + fi + + # Fallback to file completion + _files && ret=0 + + return ret +} + +compdef _%[1]s %[2]s +`, funcName, binaryName) +} + +// fishCompletionScript returns the fish completion script for the given binary. +func fishCompletionScript(binaryName string) string { + // Convert hyphens to underscores for fish function names + funcName := strings.ReplaceAll(binaryName, "-", "_") + return fmt.Sprintf(`# Fish completion for %[2]s + +function __fish_%[1]s_complete + set -l args (commandline -opc) + set -l cur (commandline -ct) + + # Build command with --generate-bash-completion at the END + if string match -q -- '-*' "$cur" + %[2]s $args $cur --generate-bash-completion 2>/dev/null + else + %[2]s $args --generate-bash-completion 2>/dev/null + end +end + +complete -c %[2]s -f -a "(__fish_%[1]s_complete)" +`, funcName, binaryName) +} diff --git a/cmd/completion_test.go b/cmd/completion_test.go new file mode 100644 index 0000000000..6d5b11e465 --- /dev/null +++ b/cmd/completion_test.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/OffchainLabs/prysm/v7/testing/assert" + "github.com/OffchainLabs/prysm/v7/testing/require" + "github.com/urfave/cli/v2" +) + +func TestCompletionCommand(t *testing.T) { + t.Run("creates command with correct name", func(t *testing.T) { + cmd := CompletionCommand("beacon-chain") + require.Equal(t, "completion", cmd.Name) + }) + + t.Run("has three subcommands", func(t *testing.T) { + cmd := CompletionCommand("beacon-chain") + require.Equal(t, 3, len(cmd.Subcommands)) + + names := make([]string, len(cmd.Subcommands)) + for i, sub := range cmd.Subcommands { + names[i] = sub.Name + } + assert.DeepEqual(t, []string{"bash", "zsh", "fish"}, names) + }) + + t.Run("description contains binary name", func(t *testing.T) { + cmd := CompletionCommand("validator") + assert.Equal(t, true, strings.Contains(cmd.Description, "validator")) + }) +} + +func TestBashCompletionScript(t *testing.T) { + script := bashCompletionScript("beacon-chain") + + assert.Equal(t, true, strings.Contains(script, "beacon-chain"), "script should contain binary name") + assert.Equal(t, true, strings.Contains(script, "_beacon_chain_completions"), "script should contain function name with underscores") + assert.Equal(t, true, strings.Contains(script, "complete -o bashdefault"), "script should contain complete command") + assert.Equal(t, true, strings.Contains(script, "--generate-bash-completion"), "script should use generate-bash-completion flag") +} + +func TestZshCompletionScript(t *testing.T) { + script := zshCompletionScript("validator") + + assert.Equal(t, true, strings.Contains(script, "#compdef validator"), "script should contain compdef directive") + assert.Equal(t, true, strings.Contains(script, "_validator"), "script should contain function name") + assert.Equal(t, true, strings.Contains(script, "--generate-bash-completion"), "script should use generate-bash-completion flag") +} + +func TestFishCompletionScript(t *testing.T) { + script := fishCompletionScript("beacon-chain") + + assert.Equal(t, true, strings.Contains(script, "complete -c beacon-chain"), "script should contain complete command") + assert.Equal(t, true, strings.Contains(script, "__fish_beacon_chain_complete"), "script should contain function name with underscores") + assert.Equal(t, true, strings.Contains(script, "--generate-bash-completion"), "script should use generate-bash-completion flag") +} + +func TestScriptFunctionNames(t *testing.T) { + // Test that hyphens are converted to underscores in function names + bashScript := bashCompletionScript("beacon-chain") + assert.Equal(t, true, strings.Contains(bashScript, "_beacon_chain_completions")) + assert.Equal(t, false, strings.Contains(bashScript, "_beacon-chain_completions")) + + zshScript := zshCompletionScript("beacon-chain") + assert.Equal(t, true, strings.Contains(zshScript, "_beacon_chain")) + + fishScript := fishCompletionScript("beacon-chain") + assert.Equal(t, true, strings.Contains(fishScript, "__fish_beacon_chain_complete")) +} + +func TestCompletionSubcommandActions(t *testing.T) { + // Test that Action functions execute without errors + cmd := CompletionCommand("beacon-chain") + + tests := []struct { + name string + subcommand string + }{ + {"bash action executes", "bash"}, + {"zsh action executes", "zsh"}, + {"fish action executes", "fish"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var subCmd *cli.Command + for _, sub := range cmd.Subcommands { + if sub.Name == tt.subcommand { + subCmd = sub + break + } + } + require.NotNil(t, subCmd, "subcommand should exist") + require.NotNil(t, subCmd.Action, "subcommand should have an action") + + // Action should not return an error; use a real cli.Context + app := &cli.App{} + ctx := cli.NewContext(app, nil, nil) + err := subCmd.Action(ctx) + require.NoError(t, err) + }) + } +} diff --git a/cmd/validator/main.go b/cmd/validator/main.go index 7b4d8bea61..6dd93bbe5b 100644 --- a/cmd/validator/main.go +++ b/cmd/validator/main.go @@ -140,8 +140,10 @@ func main() { slashingprotectioncommands.Commands, dbcommands.Commands, web.Commands, + cmd.CompletionCommand("validator"), }, - Flags: appFlags, + Flags: appFlags, + EnableBashCompletion: true, Before: func(ctx *cli.Context) error { // Load flags from config file, if specified. if err := cmd.LoadFlagsFromConfig(ctx, appFlags); err != nil {