Repository: spf13/cobra
Pull request #1192 (2020-08-17 20:56:55)

Have Cobra create 'completion' command automatically
This PR has Cobra automatically provide a `completion` command at the root level if the program does not provide its own, just like for the `help` command.  This lowers the barrier-to-entry and will make shell completion available to all Cobra users.

With this, a program developer does not need to learn or do anything about completion for users of the program to have access to shell completion for all four shells supported by Cobra.

The change is backwards-compatible, since existing handling for completion has not been changed.  However, when no `completion` command is provided by the program, Cobra will now create a standard one.

Cobra will automatically include a `--no-descriptions` flag for `zsh` and `fish`, to allow users to turn off completion descriptions.

The PR also provides three configuration options:
1. Disable the creation of this new default completion command - for programs that:
    * don't want to provide completion, or 
    * want to name the command something else, or
    * want to place the command elsewhere on the command tree.
1. Disable completion descriptions completely - for programs that just don't want to offer completion descriptions.
1. Disable the `--no-descriptions` flag - for programs that want to have completion descriptions enabled all the time.
 

marckhouzam: @jpmcb Do you think the idea of providing a `completion` command automatically is something of value?  I'm not asking to merge but just to know if this suggestion has value in the maintainers' eyes.  If not, we can close this.
jpmcb: @marckhouzam In my opinion, yes this would be useful. A frequent pattern I've seen in CLI tools is to have a `completion` command that can be injected into a `.rc` type file for completions. 

I think we would want the ability for users to opt out of using this feature (for whatever reason) but otherwise, yes I think it'd be useful!
marckhouzam: Thanks @jpmcb for the feedback. The current PR does provide a way to opt-out of the completion command as well as a couple of other configurations (see the PR main description). 👍
marckhouzam: So a colleague wrote a command-line tool using Cobra recently. Looking at it I saw he hasn't implemented shell completion at all.  This reminded me of this PR which would automatically add the `completion` command.

I was thinking it would be nice to include it in the next Cobra release of others agreed.
github-actions[bot]: This PR is being marked as stale due to a long period of inactivity
marckhouzam: Let's keep this one alive.
jpmcb: Hi Marc - where is this one at? I can review if it's in a good state! 😄 

On initial look, this is awesome. I think auto-generating completions will be really powerful and remove steps for users to get completions into their CLIs 

Just let me know what this needs and we can get it in!
marckhouzam: Thanks @jpmcb!  This should be ready for review.  You may want to pay particular attention at how I defined the new completion command options, just to make sure that is a good API.
marckhouzam: At the same time @jpmcb, @Luap99, how about I rename the `custom_completion.go` file to simply `completion.go`.  The original name was from the early functionality what was only providing custom completions; however, when we added support for fish shell, that file learned full completion support but I didn't think about changing the name.  The poor naming has been bothering me for a while.  I'll push that change too, but if you disagree I'll revert.
marckhouzam: Done and ready.
marckhouzam: I had missed enabling the `[flags]` string in the help message for `powershell` completion.  The last push fixed it.
marckhouzam: And I just updated the documentation to mention the default `completion` command and explain its options.

This is ready @jpmcb 

Patch:
diff --git a/command.go b/command.go
index d6732ad..ce94d40 100644
--- a/command.go
+++ b/command.go
@@ -168,6 +168,9 @@ type Command struct {
 	//FParseErrWhitelist flag parse errors to be ignored
 	FParseErrWhitelist FParseErrWhitelist
 
+	// CompletionOptions is a set of options to control the handling of shell completion
+	CompletionOptions CompletionOptions
+
 	// commandsAreSorted defines, if command slice are sorted or not.
 	commandsAreSorted bool
 	// commandCalledAs is the name or alias value used to call this command.
@@ -914,9 +917,10 @@ func (c *Command) ExecuteC() (cmd *Command, err error) {
 		preExecHookFn(c)
 	}
 
-	// initialize help as the last point possible to allow for user
-	// overriding
+	// initialize help at the last point to allow for user overriding
 	c.InitDefaultHelpCmd()
+	// initialize completion at the last point to allow for user overriding
+	c.initDefaultCompletionCmd()
 
 	args := c.args
 
diff --git a/custom_completions.go b/completions.go
similarity index 79%
rename from custom_completions.go
rename to completions.go
index fa060c1..fea2c6f 100644
--- a/custom_completions.go
+++ b/completions.go
@@ -63,6 +63,32 @@ const (
 	ShellCompDirectiveDefault ShellCompDirective = 0
 )
 
+const (
+	// Constants for the completion command
+	compCmdName              = "completion"
+	compCmdNoDescFlagName    = "no-descriptions"
+	compCmdNoDescFlagDesc    = "disable completion descriptions"
+	compCmdNoDescFlagDefault = false
+)
+
+// CompletionOptions are the options to control shell completion
+type CompletionOptions struct {
+	// DisableDefaultCmd prevents Cobra from creating a default 'completion' command
+	DisableDefaultCmd bool
+	// DisableNoDescFlag prevents Cobra from creating the '--no-descriptions' flag
+	// for shells that support completion descriptions
+	DisableNoDescFlag bool
+	// DisableDescriptions turns off all completion descriptions for shells
+	// that support them
+	DisableDescriptions bool
+}
+
+// NoFileCompletions can be used to disable file completion for commands that should
+// not trigger file completions.
+func NoFileCompletions(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
+	return nil, ShellCompDirectiveNoFileComp
+}
+
 // RegisterFlagCompletionFunc should be called to register a function to provide completion for a flag.
 func (c *Command) RegisterFlagCompletionFunc(flagName string, f func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective)) error {
 	flag := c.Flag(flagName)
@@ -494,6 +520,150 @@ func checkIfFlagCompletion(finalCmd *Command, args []string, lastArg string) (*p
 	return flag, trimmedArgs, lastArg, nil
 }
 
+// initDefaultCompletionCmd adds a default 'completion' command to c.
+// This function will do nothing if any of the following is true:
+// 1- the feature has been explicitly disabled by the program,
+// 2- c has no subcommands (to avoid creating one),
+// 3- c already has a 'completion' command provided by the program.
+func (c *Command) initDefaultCompletionCmd() {
+	if c.CompletionOptions.DisableDefaultCmd || !c.HasSubCommands() {
+		return
+	}
+
+	for _, cmd := range c.commands {
+		if cmd.Name() == compCmdName || cmd.HasAlias(compCmdName) {
+			// A completion command is already available
+			return
+		}
+	}
+
+	haveNoDescFlag := !c.CompletionOptions.DisableNoDescFlag && !c.CompletionOptions.DisableDescriptions
+
+	completionCmd := &Command{
+		Use:   compCmdName,
+		Short: "generate the autocompletion script for the specified shell",
+		Long: fmt.Sprintf(`
+Generate the autocompletion script for %[1]s for the specified shell.
+See each sub-command's help for details on how to use the generated script.
+`, c.Root().Name()),
+		Args:              NoArgs,
+		ValidArgsFunction: NoFileCompletions,
+	}
+	c.AddCommand(completionCmd)
+
+	out := c.OutOrStdout()
+	noDesc := c.CompletionOptions.DisableDescriptions
+	shortDesc := "generate the autocompletion script for %s"
+	bash := &Command{
+		Use:   "bash",
+		Short: fmt.Sprintf(shortDesc, "bash"),
+		Long: fmt.Sprintf(`
+Generate the autocompletion script for the bash shell.
+
+This script depends on the 'bash-completion' package.
+If it is not installed already, you can install it via your OS's package manager.
+
+To load completions in your current shell session:
+$ source <(%[1]s completion bash)
+
+To load completions for every new session, execute once:
+Linux:
+  $ %[1]s completion bash > /etc/bash_completion.d/%[1]s
+MacOS:
+  $ %[1]s completion bash > /usr/local/etc/bash_completion.d/%[1]s
+
+You will need to start a new shell for this setup to take effect.
+  `, c.Root().Name()),
+		Args:                  NoArgs,
+		DisableFlagsInUseLine: true,
+		ValidArgsFunction:     NoFileCompletions,
+		RunE: func(cmd *Command, args []string) error {
+			return cmd.Root().GenBashCompletion(out)
+		},
+	}
+
+	zsh := &Command{
+		Use:   "zsh",
+		Short: fmt.Sprintf(shortDesc, "zsh"),
+		Long: fmt.Sprintf(`
+Generate the autocompletion script for the zsh shell.
+
+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 every new 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.
+`, c.Root().Name()),
+		Args:              NoArgs,
+		ValidArgsFunction: NoFileCompletions,
+		RunE: func(cmd *Command, args []string) error {
+			if noDesc {
+				return cmd.Root().GenZshCompletionNoDesc(out)
+			}
+			return cmd.Root().GenZshCompletion(out)
+		},
+	}
+	if haveNoDescFlag {
+		zsh.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
+	}
+
+	fish := &Command{
+		Use:   "fish",
+		Short: fmt.Sprintf(shortDesc, "fish"),
+		Long: fmt.Sprintf(`
+Generate the autocompletion script for the fish shell.
+
+To load completions in your current shell session:
+$ %[1]s completion fish | source
+
+To load completions for every new session, execute once:
+$ %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish
+
+You will need to start a new shell for this setup to take effect.
+`, c.Root().Name()),
+		Args:              NoArgs,
+		ValidArgsFunction: NoFileCompletions,
+		RunE: func(cmd *Command, args []string) error {
+			return cmd.Root().GenFishCompletion(out, !noDesc)
+		},
+	}
+	if haveNoDescFlag {
+		fish.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
+	}
+
+	powershell := &Command{
+		Use:   "powershell",
+		Short: fmt.Sprintf(shortDesc, "powershell"),
+		Long: fmt.Sprintf(`
+Generate the autocompletion script for powershell.
+
+To load completions in your current shell session:
+PS C:\> %[1]s completion powershell | Out-String | Invoke-Expression
+
+To load completions for every new session, add the output of the above command
+to your powershell profile.
+`, c.Root().Name()),
+		Args:              NoArgs,
+		ValidArgsFunction: NoFileCompletions,
+		RunE: func(cmd *Command, args []string) error {
+			if noDesc {
+				return cmd.Root().GenPowerShellCompletion(out)
+			}
+			return cmd.Root().GenPowerShellCompletionWithDesc(out)
+
+		},
+	}
+	if haveNoDescFlag {
+		powershell.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
+	}
+
+	completionCmd.AddCommand(bash, zsh, fish, powershell)
+}
+
 func findFlag(cmd *Command, name string) *pflag.Flag {
 	flagSet := cmd.Flags()
 	if len(name) == 1 {
diff --git a/shell_completions.md b/shell_completions.md
index cd533ac..0b51cff 100644
--- a/shell_completions.md
+++ b/shell_completions.md
@@ -7,6 +7,15 @@ The currently supported shells are:
 - fish
 - PowerShell
 
+Cobra will automatically provide your program with a fully functional `completion` command,
+similarly to how it provides the `help` command.
+
+## Creating your own completion command
+
+If you do not wish to use the default `completion` command, you can choose to
+provide your own, which will take precedence over the default one. (This also provides
+backwards-compatibility with programs that already have their own `completion` command.)
+
 If you are using the generator, you can create a completion command by running
 
 ```bash
@@ -70,7 +79,7 @@ PowerShell:
 		case "fish":
 			cmd.Root().GenFishCompletion(os.Stdout, true)
 		case "powershell":
-			cmd.Root().GenPowerShellCompletion(os.Stdout)
+			cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
 		}
 	},
 }
@@ -78,6 +87,26 @@ PowerShell:
 
 **Note:** The cobra generator may include messages printed to stdout, for example, if the config file is loaded; this will break the auto-completion script so must be removed.
 
+## Adapting the default completion command
+
+Cobra provides a few options for the default `completion` command.  To configure such options you must set
+the `CompletionOptions` field on the *root* command.
+
+To tell Cobra *not* to provide the default `completion` command:
+```
+rootCmd.CompletionOptions.DisableDefaultCmd = true
+```
+
+To tell Cobra *not* to provide the user with the `--no-descriptions` flag to the completion sub-commands:
+```
+rootCmd.CompletionOptions.DisableNoDescFlag = true
+```
+
+To tell Cobra to completely disable descriptions for completions:
+```
+rootCmd.CompletionOptions.DisableDescriptions = true
+```
+
 # Customizing completions
 
 The generated completion scripts will automatically handle completing commands and flags.  However, you can make your completions much more powerful by providing information to complete your program's nouns and flag values.


Test patch:
diff --git a/custom_completions_test.go b/completions_test.go
similarity index 89%
rename from custom_completions_test.go
rename to completions_test.go
index ede809e..603c409 100644
--- a/custom_completions_test.go
+++ b/completions_test.go
@@ -75,6 +75,7 @@ func TestCmdNameCompletionInGo(t *testing.T) {
 
 	expected := strings.Join([]string{
 		"aliased",
+		"completion",
 		"firstChild",
 		"help",
 		"secondChild",
@@ -123,6 +124,7 @@ func TestCmdNameCompletionInGo(t *testing.T) {
 
 	expected = strings.Join([]string{
 		"aliased\tA command with aliases",
+		"completion\tgenerate the autocompletion script for the specified shell",
 		"firstChild\tFirst command",
 		"help\tHelp about any command",
 		"secondChild",
@@ -202,6 +204,7 @@ func TestNoCmdNameCompletionInGo(t *testing.T) {
 
 	expected = strings.Join([]string{
 		"childCmd1",
+		"completion",
 		"help",
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -373,6 +376,7 @@ func TestValidArgsAndCmdCompletionInGo(t *testing.T) {
 	}
 
 	expected := strings.Join([]string{
+		"completion",
 		"help",
 		"thechild",
 		"one",
@@ -423,6 +427,7 @@ func TestValidArgsFuncAndCmdCompletionInGo(t *testing.T) {
 	}
 
 	expected := strings.Join([]string{
+		"completion",
 		"help",
 		"thechild",
 		"one",
@@ -490,6 +495,7 @@ func TestFlagNameCompletionInGo(t *testing.T) {
 
 	expected := strings.Join([]string{
 		"childCmd",
+		"completion",
 		"help",
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -573,6 +579,7 @@ func TestFlagNameCompletionInGoWithDesc(t *testing.T) {
 
 	expected := strings.Join([]string{
 		"childCmd\tfirst command",
+		"completion\tgenerate the autocompletion script for the specified shell",
 		"help\tHelp about any command",
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -801,6 +808,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) {
 
 	expected := strings.Join([]string{
 		"childCmd",
+		"completion",
 		"help",
 		"--requiredFlag",
 		"-r",
@@ -926,6 +934,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) {
 
 	expected = strings.Join([]string{
 		"childCmd",
+		"completion",
 		"help",
 		"--requiredFlag",
 		"-r",
@@ -1918,6 +1927,7 @@ func TestCompleteHelp(t *testing.T) {
 	expected := strings.Join([]string{
 		"child1",
 		"child2",
+		"completion",
 		"help",
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -1935,6 +1945,7 @@ func TestCompleteHelp(t *testing.T) {
 	expected = strings.Join([]string{
 		"child1",
 		"child2",
+		"completion",
 		"help", // "<program> help help" is a valid command, so should be completed
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -1958,3 +1969,192 @@ func TestCompleteHelp(t *testing.T) {
 		t.Errorf("expected: %q, got: %q", expected, output)
 	}
 }
+
+func removeCompCmd(rootCmd *Command) {
+	// Remove completion command for the next test
+	for _, cmd := range rootCmd.commands {
+		if cmd.Name() == compCmdName {
+			rootCmd.RemoveCommand(cmd)
+			return
+		}
+	}
+}
+
+func TestDefaultCompletionCmd(t *testing.T) {
+	rootCmd := &Command{
+		Use:  "root",
+		Args: NoArgs,
+		Run:  emptyRun,
+	}
+
+	// Test that no completion command is created if there are not other sub-commands
+	assertNoErr(t, rootCmd.Execute())
+	for _, cmd := range rootCmd.commands {
+		if cmd.Name() == compCmdName {
+			t.Errorf("Should not have a 'completion' command when there are no other sub-commands of root")
+			break
+		}
+	}
+
+	subCmd := &Command{
+		Use: "sub",
+		Run: emptyRun,
+	}
+	rootCmd.AddCommand(subCmd)
+
+	// Test that a completion command is created if there are other sub-commands
+	found := false
+	assertNoErr(t, rootCmd.Execute())
+	for _, cmd := range rootCmd.commands {
+		if cmd.Name() == compCmdName {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Errorf("Should have a 'completion' command when there are other sub-commands of root")
+	}
+	// Remove completion command for the next test
+	removeCompCmd(rootCmd)
+
+	// Test that the default completion command can be disabled
+	rootCmd.CompletionOptions.DisableDefaultCmd = true
+	assertNoErr(t, rootCmd.Execute())
+	for _, cmd := range rootCmd.commands {
+		if cmd.Name() == compCmdName {
+			t.Errorf("Should not have a 'completion' command when the feature is disabled")
+			break
+		}
+	}
+	// Re-enable for next test
+	rootCmd.CompletionOptions.DisableDefaultCmd = false
+
+	// Test that completion descriptions are enabled by default
+	output, err := executeCommand(rootCmd, compCmdName, "zsh")
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+
+	check(t, output, ShellCompRequestCmd)
+	checkOmit(t, output, ShellCompNoDescRequestCmd)
+	// Remove completion command for the next test
+	removeCompCmd(rootCmd)
+
+	// Test that completion descriptions can be disabled completely
+	rootCmd.CompletionOptions.DisableDescriptions = true
+	output, err = executeCommand(rootCmd, compCmdName, "zsh")
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+
+	check(t, output, ShellCompNoDescRequestCmd)
+	// Re-enable for next test
+	rootCmd.CompletionOptions.DisableDescriptions = false
+	// Remove completion command for the next test
+	removeCompCmd(rootCmd)
+
+	var compCmd *Command
+	// Test that the --no-descriptions flag is present for the relevant shells only
+	assertNoErr(t, rootCmd.Execute())
+	for _, shell := range []string{"fish", "powershell", "zsh"} {
+		if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil {
+			t.Errorf("Unexpected error: %v", err)
+		}
+		if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag == nil {
+			t.Errorf("Missing --%s flag for %s shell", compCmdNoDescFlagName, shell)
+		}
+	}
+	for _, shell := range []string{"bash"} {
+		if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil {
+			t.Errorf("Unexpected error: %v", err)
+		}
+		if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag != nil {
+			t.Errorf("Unexpected --%s flag for %s shell", compCmdNoDescFlagName, shell)
+		}
+	}
+	// Remove completion command for the next test
+	removeCompCmd(rootCmd)
+
+	// Test that the '--no-descriptions' flag can be disabled
+	rootCmd.CompletionOptions.DisableNoDescFlag = true
+	assertNoErr(t, rootCmd.Execute())
+	for _, shell := range []string{"fish", "zsh", "bash", "powershell"} {
+		if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil {
+			t.Errorf("Unexpected error: %v", err)
+		}
+		if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag != nil {
+			t.Errorf("Unexpected --%s flag for %s shell", compCmdNoDescFlagName, shell)
+		}
+	}
+	// Re-enable for next test
+	rootCmd.CompletionOptions.DisableNoDescFlag = false
+	// Remove completion command for the next test
+	removeCompCmd(rootCmd)
+
+	// Test that the '--no-descriptions' flag is disabled when descriptions are disabled
+	rootCmd.CompletionOptions.DisableDescriptions = true
+	assertNoErr(t, rootCmd.Execute())
+	for _, shell := range []string{"fish", "zsh", "bash", "powershell"} {
+		if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil {
+			t.Errorf("Unexpected error: %v", err)
+		}
+		if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag != nil {
+			t.Errorf("Unexpected --%s flag for %s shell", compCmdNoDescFlagName, shell)
+		}
+	}
+	// Re-enable for next test
+	rootCmd.CompletionOptions.DisableDescriptions = false
+	// Remove completion command for the next test
+	removeCompCmd(rootCmd)
+}
+
+func TestCompleteCompletion(t *testing.T) {
+	rootCmd := &Command{Use: "root", Args: NoArgs, Run: emptyRun}
+	subCmd := &Command{
+		Use: "sub",
+		Run: emptyRun,
+	}
+	rootCmd.AddCommand(subCmd)
+
+	// Test sub-commands of the completion command
+	output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "completion", "")
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+
+	expected := strings.Join([]string{
+		"bash",
+		"fish",
+		"powershell",
+		"zsh",
+		":4",
+		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
+
+	if output != expected {
+		t.Errorf("expected: %q, got: %q", expected, output)
+	}
+
+	// Test there are no completions for the sub-commands of the completion command
+	var compCmd *Command
+	for _, cmd := range rootCmd.Commands() {
+		if cmd.Name() == compCmdName {
+			compCmd = cmd
+			break
+		}
+	}
+
+	for _, shell := range compCmd.Commands() {
+		output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, compCmdName, shell.Name(), "")
+		if err != nil {
+			t.Errorf("Unexpected error: %v", err)
+		}
+
+		expected = strings.Join([]string{
+			":4",
+			"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
+
+		if output != expected {
+			t.Errorf("expected: %q, got: %q", expected, output)
+		}
+	}
+}