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<TAB>` 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 <willian@ufpa.br>
This commit is contained in:
willian.eth
2026-01-15 21:07:11 +01:00
committed by Manu NALEPA
parent 574b7fb520
commit f2c8c5204e
7 changed files with 280 additions and 3 deletions

View File

@@ -0,0 +1,3 @@
### Added
- Added shell completion support for `beacon-chain` and `validator` CLI tools.

View File

@@ -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",

View File

@@ -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() {

63
cmd/completion.go Normal file
View File

@@ -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
},
},
},
}
}

99
cmd/completion_scripts.go Normal file
View File

@@ -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)
}

105
cmd/completion_test.go Normal file
View File

@@ -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)
})
}
}

View File

@@ -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 {