From a06162bd493892b7ccdb9a34c0857e085685d19e Mon Sep 17 00:00:00 2001 From: Dominik Roos Date: Thu, 9 Jul 2026 21:23:03 +0200 Subject: [PATCH] Add completion for plugins Available plugins are now made available in the tab completion. The completion request is forwarded to the plugin. If the plugin reacts appropriately to `--generate-bash-completion`, completion is also available for the sub-commands. Example using cobra: ``` func main() { cmd := &cobra.Command{ Use: "example", } cmd.AddCommand(func() *cobra.Command { var world string cmd := &cobra.Command{ Use: "hello", Run: func(_ *cobra.Command, _ []string) { fmt.Println("world:", world) }, } cmd.Flags().StringVar(&world, "world", "world", "World") return cmd }()) maybeCompletion(cmd) if err := cmd.Execute(); err != nil { os.Exit(1) } } func maybeCompletion(cmd *cobra.Command) { args := os.Args[1:] if n := len(args); n > 0 && args[n-1] == "--generate-bash-completion" { args = args[:n-1] // step's scripts include the partial word only if it starts with "-" toComplete := "" if len(args) > 0 && strings.HasPrefix(args[len(args)-1], "-") { toComplete, args = args[len(args)-1], args[:len(args)-1] } var buf bytes.Buffer cmd.SetOut(&buf) cmd.SetArgs(append(append([]string{cobra.ShellCompRequestCmd}, args...), toComplete)) _ = cmd.Execute() for _, line := range strings.Split(buf.String(), "\n") { if line == "" || strings.HasPrefix(line, ":") { // directive line, e.g. ":4" continue } // cobra emits "candidate\tdescription"; step's compgen wants bare words fmt.Println(strings.SplitN(line, "\t", 2)[0]) } return } } ```` --- internal/cmd/root.go | 15 ++++++ internal/plugin/plugin.go | 111 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 37eea4e69..380ec8b2f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -123,6 +123,21 @@ func newApp(stdout, stderr io.Writer) *cli.App { app.Flags = append(app.Flags, cli.HelpFlag) app.EnableBashCompletion = true app.Copyright = fmt.Sprintf("(c) 2018-%d Smallstep Labs, Inc.", time.Now().Year()) + app.BashComplete = func(c *cli.Context) { + // If the first argument resolves to a plugin, let the plugin generate + // its own completions (subcommands, flags, etc.). urfave/cli doesn't do + // this for us because plugins aren't registered commands. + if name := c.Args().First(); name != "" { + if file, err := plugin.LookPath(name); err == nil { + _ = plugin.Complete(c, file) + return + } + } + cli.DefaultAppComplete(c) + for _, name := range plugin.Names() { + fmt.Fprintln(c.App.Writer, name) + } + } // Flag of custom configuration flag app.Flags = append(app.Flags, cli.StringFlag{ diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 4c6201636..3b163d6ee 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strings" "github.com/urfave/cli" @@ -12,6 +13,11 @@ import ( "github.com/smallstep/cli-utils/step" ) +const ( + pluginPrefix = "step-" + pluginSuffix = "-plugin" +) + // LookPath searches for an executable named step--plugin in the $(step // path)/plugins directory or in the directories named by the PATH environment // variable. On Windows, files with the .com, .exe, .bat and .cmd extension @@ -71,6 +77,111 @@ func Run(ctx *cli.Context, file string) error { return cmd.Run() } +// Names returns the sorted, de-duplicated list of plugin names found on this +// system. A plugin is an executable named step--plugin located in the +// $(step path)/plugins directory or in one of the directories named by the PATH +// environment variable. The returned names are the portion of the +// executable file name (without the step- prefix, the -plugin suffix, or, on +// Windows, the file extension). +func Names() []string { + dirs := []string{filepath.Join(step.BasePath(), "plugins")} + if path := os.Getenv("PATH"); path != "" { + dirs = append(dirs, filepath.SplitList(path)...) + } + + var exts []string + if runtime.GOOS == "windows" { + if x := os.Getenv(`PATHEXT`); x != "" { + for _, e := range strings.Split(strings.ToLower(x), `;`) { + if e == "" { + continue + } + if e[0] != '.' { + e = "." + e + } + exts = append(exts, e) + } + } else { + exts = []string{".com", ".exe", ".bat", ".cmd", ".ps1"} + } + } + + var names []string + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + // Skip directories that don't exist or can't be read; a missing + // plugins directory or PATH entry is not an error. + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := pluginName(entry.Name(), exts) + if name == "" { + continue + } + if slices.Contains(names, name) { + continue + } + names = append(names, name) + } + } + slices.Sort(names) + return names +} + +// pluginName extracts the plugin name from an executable file name, or returns +// the empty string if the file name doesn't match the step--plugin +// convention. On Windows the file extension (if listed in exts) is stripped +// before matching. +func pluginName(fileName string, exts []string) string { + for _, ext := range exts { + if strings.HasSuffix(strings.ToLower(fileName), ext) { + fileName = fileName[:len(fileName)-len(ext)] + break + } + } + if !strings.HasPrefix(fileName, pluginPrefix) || !strings.HasSuffix(fileName, pluginSuffix) { + return "" + } + return fileName[len(pluginPrefix) : len(fileName)-len(pluginSuffix)] +} + +// Complete runs the plugin's shell completion and writes the candidate +// completions to the cli context's app writer. It execs the plugin binary with +// the arguments following the plugin name, plus the --generate-bash-completion +// flag, mirroring how the shell would invoke a native command for completion. +// +// This is needed because plugins are not registered cli commands, so urfave/cli +// never delegates completion to them on its own; instead it falls back to the +// app-level completion. Calling Complete from the app's BashComplete handler +// lets the plugin provide its own subcommand and flag completions. +func Complete(ctx *cli.Context, file string) error { + // ctx.Args() holds the positional arguments with the shell-completion flag + // already stripped, e.g. ["example", "hello"] for `step example hello + // `. Drop the plugin name itself and forward the rest to the plugin. + tail := ctx.Args().Tail() + args := make([]string, 0, len(tail)+1) + args = append(args, tail...) + args = append(args, "--generate-bash-completion") + + cmdName := file + if runtime.GOOS == "windows" && strings.ToLower(filepath.Ext(file)) == ".ps1" { + cmdName = "powershell" + args = append([]string{"-noprofile", "-nologo", file}, args...) + } + + cmd := exec.Command(cmdName, args...) + cmd.Stdout = ctx.App.Writer + // Discard the plugin's stderr: only stdout is consumed by the shell during + // completion, and tools like cobra write noise (e.g. "Completion ended with + // directive: ...") to stderr that would otherwise leak to the terminal. + cmd.Stderr = nil + return cmd.Run() +} + // GetURL returns the project or the download URL of a well-known plugin. func GetURL(name string) string { switch name {