From 463ba739d2095521617d2c1a36c4fc0c7a6cd8dd Mon Sep 17 00:00:00 2001 From: Mike North Date: Sun, 15 Mar 2026 16:20:44 -0700 Subject: [PATCH] Add plugin completion protocol Enables dynamic shell completion for plugin subcommands by invoking the plugin binary with Cobra's __complete protocol and parsing the response. When a user presses tab on a plugin command, the host CLI delegates completion to the plugin process. - GetPluginCompletions: invokes plugin binary with __complete args, parses Cobra-format output (completions + directive) - parseCompletionOutput: parses the tab-separated completion format - resolvePluginBinary / buildPluginCommand: locate and construct the plugin invocation, including Node.js runtime detection - Wires ValidArgsFunction into plugin template commands Committed-By-Agent: claude --- pkg/cmd/plugin_cmds.go | 3 + pkg/cmd/plugin_cmds_test.go | 5 + pkg/plugins/completion.go | 162 +++++++++++++++++++++++++++++++++ pkg/plugins/completion_test.go | 95 +++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 pkg/plugins/completion.go create mode 100644 pkg/plugins/completion_test.go diff --git a/pkg/cmd/plugin_cmds.go b/pkg/cmd/plugin_cmds.go index de41ff1cb..ad06ce6fd 100644 --- a/pkg/cmd/plugin_cmds.go +++ b/pkg/cmd/plugin_cmds.go @@ -37,6 +37,9 @@ func newPluginTemplateCmd(config *config.Config, plugin *plugins.Plugin) *plugin pluginArgs := subsliceAfter(os.Args, cmd.Name()) return ptc.runPluginCmd(cmd, pluginArgs) }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return plugins.GetPluginCompletions(cmd.Context(), ptc.cfg, ptc.fs, plugin.Shortname, args, toComplete) + }, Annotations: map[string]string{"scope": "plugin"}, FParseErrWhitelist: cobra.FParseErrWhitelist{ UnknownFlags: true, diff --git a/pkg/cmd/plugin_cmds_test.go b/pkg/cmd/plugin_cmds_test.go index bf681e50c..7497467f9 100644 --- a/pkg/cmd/plugin_cmds_test.go +++ b/pkg/cmd/plugin_cmds_test.go @@ -133,6 +133,11 @@ func TestAddPluginSubcommandStubsSkipsEmptyName(t *testing.T) { assert.Equal(t, "valid", cmds[1].Name()) } +func TestNewPluginTemplateCmdSetsValidArgsFunction(t *testing.T) { + pluginCmd := createPluginCmd() + assert.NotNil(t, pluginCmd.cmd.ValidArgsFunction, "ValidArgsFunction should be set on plugin commands for shell completion") +} + func TestSubsliceAfter(t *testing.T) { tests := []struct { name string diff --git a/pkg/plugins/completion.go b/pkg/plugins/completion.go new file mode 100644 index 000000000..10192e348 --- /dev/null +++ b/pkg/plugins/completion.go @@ -0,0 +1,162 @@ +package plugins + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/stripe/stripe-cli/pkg/config" +) + +const completionTimeout = 3 * time.Second + +// GetPluginCompletions invokes a plugin binary with Cobra's __complete protocol +// to get dynamic shell completions. It returns completions and a shell directive. +// On any error, it returns nil completions and ShellCompDirectiveError. +func GetPluginCompletions(ctx context.Context, cfg config.IConfig, fs afero.Fs, pluginName string, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + logger := log.WithFields(log.Fields{ + "prefix": "plugins.completion.GetPluginCompletions", + }) + + plugin, err := LookUpPlugin(ctx, cfg, fs, pluginName) + if err != nil { + logger.Debugf("Could not look up plugin %s: %s", pluginName, err) + return nil, cobra.ShellCompDirectiveError + } + + binaryPath, err := resolvePluginBinary(cfg, &plugin) + if err != nil { + logger.Debugf("Could not resolve binary for plugin %s: %s", pluginName, err) + return nil, cobra.ShellCompDirectiveError + } + + // Build the __complete command args + completeArgs := make([]string, 0, len(args)+2) + completeArgs = append(completeArgs, "__complete") + completeArgs = append(completeArgs, args...) + completeArgs = append(completeArgs, toComplete) + + // Determine how to invoke the binary (directly or via Node.js) + cmdPath, cmdArgs := buildPluginCommand(cfg, &plugin, binaryPath, completeArgs) + + // Set timeout + timeoutCtx, cancel := context.WithTimeout(ctx, completionTimeout) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, cmdPath, cmdArgs...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + logger.Debugf("Plugin completion command failed for %s: %s (stderr: %s)", pluginName, err, stderr.String()) + return nil, cobra.ShellCompDirectiveError + } + + completions, directive := parseCompletionOutput(stdout.String()) + return completions, directive +} + +// resolvePluginBinary finds the installed binary path for a plugin. +func resolvePluginBinary(cfg config.IConfig, plugin *Plugin) (string, error) { + var version string + + if PluginsPath != "" { + version = "local.build.dev" + } else { + pluginDir := filepath.Join(getPluginsDir(cfg), plugin.Shortname, "*.*.*") + existingLocalPlugin, err := filepath.Glob(pluginDir) + if err != nil { + return "", err + } + + if len(existingLocalPlugin) == 0 { + return "", fmt.Errorf("plugin %s is not installed", plugin.Shortname) + } + + version = filepath.Base(existingLocalPlugin[0]) + } + + pluginDir := plugin.getPluginInstallPath(cfg, version) + binaryPath := filepath.Join(pluginDir, plugin.Binary) + binaryPath += GetBinaryExtension() + + return binaryPath, nil +} + +// buildPluginCommand determines the command path and args to invoke a plugin, +// handling Node.js runtime detection. +func buildPluginCommand(cfg config.IConfig, plugin *Plugin, binaryPath string, args []string) (string, []string) { + version := plugin.LookUpLatestVersion() + release := plugin.getReleaseForVersion(version) + + if release != nil { + if nodeVersion, requiresNode := GetRuntimeRequirement(*release); requiresNode { + nodePath := GetNodeBinaryPath(cfg, nodeVersion) + if nodePath != "" { + // Invoke via node: node + return nodePath, append([]string{binaryPath}, args...) + } + } + } + + // Direct execution + return binaryPath, args +} + +// parseCompletionOutput parses the stdout of a Cobra __complete command. +// The format is: +// +// completion1\tdescription1 +// completion2\tdescription2 +// : +// +// Returns the completion strings and the ShellCompDirective. +// On parse failure, returns nil and ShellCompDirectiveError. +func parseCompletionOutput(output string) ([]string, cobra.ShellCompDirective) { + output = strings.TrimRight(output, "\n") + if output == "" { + return nil, cobra.ShellCompDirectiveError + } + + var lines []string + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + if len(lines) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + // Last line must be : + lastLine := lines[len(lines)-1] + if !strings.HasPrefix(lastLine, ":") { + return nil, cobra.ShellCompDirectiveError + } + + directiveStr := lastLine[1:] + directiveInt, err := strconv.Atoi(directiveStr) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + completions := lines[:len(lines)-1] + if len(completions) == 0 { + completions = nil + } + + return completions, cobra.ShellCompDirective(directiveInt) +} diff --git a/pkg/plugins/completion_test.go b/pkg/plugins/completion_test.go new file mode 100644 index 000000000..4eadeb4a6 --- /dev/null +++ b/pkg/plugins/completion_test.go @@ -0,0 +1,95 @@ +package plugins + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// TestParseCompletionOutput tests parsing of Cobra's __complete protocol output. +// +// @see https://github.com/spf13/cobra/blob/main/completions.go +func TestParseCompletionOutput(t *testing.T) { + tests := []struct { + name string + output string + wantCompletions []string + wantDirective cobra.ShellCompDirective + }{ + { + name: "valid output with descriptions", + output: "create\tCreate a new app\nstart\tStart the dev server\nupload\tUpload your app\n:4\n", + wantCompletions: []string{"create\tCreate a new app", "start\tStart the dev server", "upload\tUpload your app"}, + wantDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "valid output without descriptions", + output: "create\nstart\nupload\n:4\n", + wantCompletions: []string{"create", "start", "upload"}, + wantDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "valid output with default directive", + output: "create\n:0\n", + wantCompletions: []string{"create"}, + wantDirective: cobra.ShellCompDirectiveDefault, + }, + { + name: "directive only, no completions", + output: ":4\n", + wantCompletions: nil, + wantDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "empty output", + output: "", + wantCompletions: nil, + wantDirective: cobra.ShellCompDirectiveError, + }, + { + name: "missing directive line", + output: "create\nstart\n", + wantCompletions: nil, + wantDirective: cobra.ShellCompDirectiveError, + }, + { + name: "non-numeric directive", + output: "create\n:abc\n", + wantCompletions: nil, + wantDirective: cobra.ShellCompDirectiveError, + }, + { + name: "output with no trailing newline", + output: "create\n:4", + wantCompletions: []string{"create"}, + wantDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "combined directives", + output: "flag-value\n:6\n", + wantCompletions: []string{"flag-value"}, + wantDirective: cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace, + }, + { + name: "whitespace-only output", + output: " \n", + wantCompletions: nil, + wantDirective: cobra.ShellCompDirectiveError, + }, + { + name: "empty directive value after colon", + output: "create\n:\n", + wantCompletions: nil, + wantDirective: cobra.ShellCompDirectiveError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions, directive := parseCompletionOutput(tt.output) + assert.Equal(t, tt.wantCompletions, completions) + assert.Equal(t, tt.wantDirective, directive) + }) + } +}