diff --git a/README.md b/README.md index 18b5411..5fa083f 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,6 @@ if err := cmd.Run(context.TODO(), os.Args[1:]); err != nil { - [ ] Audit bare `err` returns - [ ] Two types of errors: config and parse -- [ ] Tab completion +- [x] Tab completion - [ ] Allow variadic arguments - [ ] Allow slice and map based flags? diff --git a/command_options.go b/command_options.go index c4b7cc1..ce1ea8f 100644 --- a/command_options.go +++ b/command_options.go @@ -1,6 +1,8 @@ package cli import ( + "context" + "fmt" "sort" "github.com/broothie/option" @@ -91,3 +93,35 @@ func AddVersionFlag(options ...option.Option[*Flag]) option.Func[*Command] { defaultOptions := option.NewOptions(setFlagIsVersion(true), SetFlagDefault(false)) return AddFlag(versionFlagName, "Print version.", append(defaultOptions, options...)...) } + +// EnableCompletion adds a hidden completion command to the root command +func EnableCompletion() option.Func[*Command] { + return func(command *Command) (*Command, error) { + // Only add to root commands + if command.parent != nil { + return command, nil + } + + completionCmd := CompletionCommand(command) + return MountSubCmd(completionCmd).Apply(command) + } +} + +// AddCompletionCommand adds a "completion" subcommand that generates shell completion scripts +func AddCompletionCommand() option.Func[*Command] { + return func(command *Command) (*Command, error) { + // Only add to root commands + if command.parent != nil { + return command, nil + } + + return AddSubCmd("completion", "Generate shell completion scripts", + AddSubCmd("bash", "Generate bash completion script", + SetHandler(func(ctx context.Context) error { + fmt.Print(command.GenerateBashCompletion()) + return nil + }), + ), + ).Apply(command) + } +} diff --git a/completion.go b/completion.go new file mode 100644 index 0000000..1729a2a --- /dev/null +++ b/completion.go @@ -0,0 +1,392 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/samber/lo" +) + +// CompletionResult represents a single completion suggestion +type CompletionResult struct { + Value string + Description string +} + +// CompletionContext contains information about the current completion request +type CompletionContext struct { + Command *Command + Args []string + CurrentWord string + PreviousWord string + WordIndex int +} + +// Completer is a function that generates completion suggestions +type Completer func(ctx CompletionContext) []CompletionResult + +// FileCompleter generates file and directory completions +func FileCompleter(ctx CompletionContext) []CompletionResult { + var results []CompletionResult + + pattern := ctx.CurrentWord + if pattern == "" { + pattern = "*" + } + + // Handle glob patterns + if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") { + pattern = pattern + "*" + } + + matches, err := filepath.Glob(pattern) + if err != nil { + return results + } + + for _, match := range matches { + stat, err := os.Stat(match) + if err != nil { + continue + } + + result := CompletionResult{Value: match} + if stat.IsDir() { + result.Description = "directory" + result.Value = match + "/" + } else { + result.Description = "file" + } + + results = append(results, result) + } + + return results +} + +// DirectoryCompleter generates only directory completions +func DirectoryCompleter(ctx CompletionContext) []CompletionResult { + var results []CompletionResult + + pattern := ctx.CurrentWord + if pattern == "" { + pattern = "*" + } + + if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") { + pattern = pattern + "*" + } + + matches, err := filepath.Glob(pattern) + if err != nil { + return results + } + + for _, match := range matches { + stat, err := os.Stat(match) + if err != nil || !stat.IsDir() { + continue + } + + results = append(results, CompletionResult{ + Value: match + "/", + Description: "directory", + }) + } + + return results +} + +// generateCompletions generates completion results for the given context +func (c *Command) generateCompletions(ctx CompletionContext) []CompletionResult { + var results []CompletionResult + + // If current word starts with a flag prefix, complete flags + if strings.HasPrefix(ctx.CurrentWord, "--") { + results = append(results, c.completeLongFlags(ctx)...) + } else if strings.HasPrefix(ctx.CurrentWord, "-") && len(ctx.CurrentWord) > 1 { + results = append(results, c.completeShortFlags(ctx)...) + } else { + // Check if we're expecting a flag value + if ctx.PreviousWord != "" && c.isFlagExpectingValue(ctx.PreviousWord) { + // For now, provide file completion for flag values + results = append(results, FileCompleter(ctx)...) + } else { + // Complete subcommands first + results = append(results, c.completeSubcommands(ctx)...) + + // If no subcommands match, try arguments + if len(results) == 0 { + results = append(results, c.completeArguments(ctx)...) + } + } + } + + return results +} + +// completeLongFlags generates completions for long flags (--flag) +func (c *Command) completeLongFlags(ctx CompletionContext) []CompletionResult { + var results []CompletionResult + prefix := strings.TrimPrefix(ctx.CurrentWord, "--") + + flags := c.flagsUpToRoot() + for _, flag := range flags { + if flag.isHidden { + continue + } + + // Check flag name + if strings.HasPrefix(flag.name, prefix) { + results = append(results, CompletionResult{ + Value: "--" + flag.name, + Description: flag.description, + }) + } + + // Check flag aliases + for _, alias := range flag.aliases { + if strings.HasPrefix(alias, prefix) { + results = append(results, CompletionResult{ + Value: "--" + alias, + Description: flag.description + " (alias)", + }) + } + } + } + + return results +} + +// completeShortFlags generates completions for short flags (-f) +func (c *Command) completeShortFlags(ctx CompletionContext) []CompletionResult { + var results []CompletionResult + + // For short flags, we only complete single character flags + if len(ctx.CurrentWord) != 2 { + return results + } + + prefix := ctx.CurrentWord[1] // Get the character after '-' + + flags := c.flagsUpToRoot() + for _, flag := range flags { + if flag.isHidden { + continue + } + + for _, short := range flag.shorts { + if rune(prefix) == short { + results = append(results, CompletionResult{ + Value: fmt.Sprintf("-%c", short), + Description: flag.description, + }) + } + } + } + + return results +} + +// completeSubcommands generates completions for subcommands +func (c *Command) completeSubcommands(ctx CompletionContext) []CompletionResult { + var results []CompletionResult + + for _, subCmd := range c.subCommands { + if strings.HasPrefix(subCmd.name, ctx.CurrentWord) { + results = append(results, CompletionResult{ + Value: subCmd.name, + Description: subCmd.description, + }) + } + + // Check aliases + for _, alias := range subCmd.aliases { + if strings.HasPrefix(alias, ctx.CurrentWord) { + results = append(results, CompletionResult{ + Value: alias, + Description: subCmd.description + " (alias)", + }) + } + } + } + + return results +} + +// completeArguments generates completions for positional arguments +func (c *Command) completeArguments(ctx CompletionContext) []CompletionResult { + // Count non-flag arguments to determine which argument we're completing + argCount := c.countNonFlagArgs(ctx.Args[:ctx.WordIndex]) + + if argCount >= len(c.arguments) { + return []CompletionResult{} + } + + // For now, provide file completion for all arguments + // This can be enhanced later with custom argument completers + return FileCompleter(ctx) +} + +// countNonFlagArgs counts arguments that are not flags or flag values +func (c *Command) countNonFlagArgs(args []string) int { + count := 0 + skipNext := false + + for i, arg := range args { + if skipNext { + skipNext = false + continue + } + + if strings.HasPrefix(arg, "-") { + // Check if this flag expects a value + if c.isFlagExpectingValue(arg) && i+1 < len(args) { + skipNext = true + } + } else { + // Check if this is a subcommand + if _, found := lo.Find(c.subCommands, func(cmd *Command) bool { + return cmd.name == arg || lo.Contains(cmd.aliases, arg) + }); !found { + count++ + } + } + } + + return count +} + +// isFlagExpectingValue checks if a flag expects a value +func (c *Command) isFlagExpectingValue(flagArg string) bool { + var flag *Flag + var found bool + + if strings.HasPrefix(flagArg, "--") { + flagName := strings.TrimPrefix(flagArg, "--") + flag, found = c.findLongFlag(flagName) + } else if strings.HasPrefix(flagArg, "-") && len(flagArg) == 2 { + short := rune(flagArg[1]) + flag, found = c.findShortFlag(short) + } + + if !found { + return false + } + + return !flag.isBool() +} + +// Complete generates completion suggestions for the given arguments +func (c *Command) Complete(args []string) []CompletionResult { + if len(args) == 0 { + return c.generateCompletions(CompletionContext{ + Command: c, + Args: args, + CurrentWord: "", + WordIndex: 0, + }) + } + + // Find the current word being completed (last argument) + currentWord := args[len(args)-1] + previousWord := "" + if len(args) > 1 { + previousWord = args[len(args)-2] + } + + ctx := CompletionContext{ + Command: c, + Args: args, + CurrentWord: currentWord, + PreviousWord: previousWord, + WordIndex: len(args) - 1, + } + + // Check if we need to delegate to a subcommand + for _, subCmd := range c.subCommands { + if len(args) > 0 && (subCmd.name == args[0] || lo.Contains(subCmd.aliases, args[0])) { + // Delegate to subcommand + return subCmd.Complete(args[1:]) + } + } + + results := c.generateCompletions(ctx) + + // Sort results alphabetically + sort.Slice(results, func(i, j int) bool { + return results[i].Value < results[j].Value + }) + + return results +} + +// GenerateBashCompletion generates a bash completion script for the command +func (c *Command) GenerateBashCompletion() string { + rootName := c.root().name + + script := fmt.Sprintf(`#!/bin/bash + +_%s_completion() { + local cur prev words cword + _init_completion || return + + # Set up completion environment + export COMP_LINE="${COMP_LINE}" + export COMP_POINT="${COMP_POINT}" + + # Get completions from the command + local completions + completions=$(%s __complete 2>/dev/null) + + if [[ $? -eq 0 ]]; then + COMPREPLY=($(compgen -W "${completions}" -- "${cur}")) + fi +} + +# Register the completion function +complete -F _%s_completion %s +`, rootName, rootName, rootName, rootName) + + return script +} + +// CompletionCommand creates a hidden completion command +func CompletionCommand(rootCmd *Command) *Command { + cmd, _ := NewCommand("__complete", "Generate shell completions (hidden)", + SetHandler(func(ctx context.Context) error { + // Parse completion arguments from environment or command line + compLine := os.Getenv("COMP_LINE") + if compLine == "" { + // Fallback: try to get from remaining args + if len(os.Args) > 2 { + compLine = strings.Join(os.Args[2:], " ") + } + } + + if compLine == "" { + return nil + } + + // Parse the completion line + args := strings.Fields(compLine) + if len(args) > 0 { + // Remove the first argument (the command name itself) + args = args[1:] + } + + completions := rootCmd.Complete(args) + for _, completion := range completions { + fmt.Println(completion.Value) + } + + return nil + }), + ) + + return cmd +} \ No newline at end of file diff --git a/completion_test.go b/completion_test.go new file mode 100644 index 0000000..4d776b1 --- /dev/null +++ b/completion_test.go @@ -0,0 +1,376 @@ +package cli + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/broothie/test" +) + +func TestCompleteCommands(t *testing.T) { + cmd, err := NewCommand("git", "Version control system", + AddSubCmd("clone", "Clone a repository"), + AddSubCmd("commit", "Create a commit"), + AddSubCmd("push", "Push changes"), + ) + test.NoError(t, err) + + tests := []struct { + name string + args []string + expected []string + }{ + { + name: "complete empty", + args: []string{}, + expected: []string{"clone", "commit", "push"}, + }, + { + name: "complete c", + args: []string{"c"}, + expected: []string{"clone", "commit"}, + }, + { + name: "complete cl", + args: []string{"cl"}, + expected: []string{"clone"}, + }, + { + name: "complete p", + args: []string{"p"}, + expected: []string{"push"}, + }, + { + name: "complete nonexistent", + args: []string{"xyz"}, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions := cmd.Complete(tt.args) + var values []string + for _, c := range completions { + values = append(values, c.Value) + } + test.Equal(t, tt.expected, values) + }) + } +} + +func TestCompleteFlags(t *testing.T) { + cmd, err := NewCommand("test", "Test command", + AddFlag("verbose", "Verbose output", AddFlagShort('v')), + AddFlag("output", "Output file", AddFlagShort('o')), + AddFlag("debug", "Debug mode", SetFlagDefault(false)), + ) + test.NoError(t, err) + + tests := []struct { + name string + args []string + expected []string + }{ + { + name: "complete long flags", + args: []string{"--"}, + expected: []string{"--debug", "--output", "--verbose"}, + }, + { + name: "complete long flags with prefix", + args: []string{"--v"}, + expected: []string{"--verbose"}, + }, + { + name: "complete long flags with prefix o", + args: []string{"--o"}, + expected: []string{"--output"}, + }, + { + name: "complete short flag v", + args: []string{"-v"}, + expected: []string{"-v"}, + }, + { + name: "complete short flag o", + args: []string{"-o"}, + expected: []string{"-o"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions := cmd.Complete(tt.args) + var values []string + for _, c := range completions { + values = append(values, c.Value) + } + test.Equal(t, tt.expected, values) + }) + } +} + +func TestCompleteWithAliases(t *testing.T) { + cmd, err := NewCommand("docker", "Container management", + AddSubCmd("container", "Manage containers", + AddAlias("c"), + ), + AddSubCmd("image", "Manage images", + AddAlias("img"), + ), + ) + test.NoError(t, err) + + tests := []struct { + name string + args []string + expected []string + }{ + { + name: "complete with aliases", + args: []string{"c"}, + expected: []string{"c", "container"}, + }, + { + name: "complete img alias", + args: []string{"img"}, + expected: []string{"img"}, + }, + { + name: "complete i prefix", + args: []string{"i"}, + expected: []string{"image", "img"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions := cmd.Complete(tt.args) + var values []string + for _, c := range completions { + values = append(values, c.Value) + } + test.Equal(t, tt.expected, values) + }) + } +} + +func TestCompleteSubcommandFlags(t *testing.T) { + cmd, err := NewCommand("git", "Version control", + AddFlag("help", "Show help", AddFlagShort('h'), SetFlagIsInherited(true)), + AddSubCmd("clone", "Clone repository", + AddFlag("depth", "Clone depth"), + AddFlag("branch", "Branch to clone", AddFlagShort('b')), + ), + ) + test.NoError(t, err) + + // Test completing flags for subcommand + completions := cmd.Complete([]string{"clone", "--"}) + var values []string + for _, c := range completions { + values = append(values, c.Value) + } + + expected := []string{"--branch", "--depth", "--help"} + test.Equal(t, expected, values) +} + +func TestCompleteHiddenFlags(t *testing.T) { + cmd, err := NewCommand("test", "Test command", + AddFlag("verbose", "Verbose output"), + AddFlag("debug", "Debug mode", SetFlagIsHidden(true)), + ) + test.NoError(t, err) + + completions := cmd.Complete([]string{"--"}) + var values []string + for _, c := range completions { + values = append(values, c.Value) + } + + // Hidden flags should not appear in completions + expected := []string{"--verbose"} + test.Equal(t, expected, values) +} + +func TestFileCompleter(t *testing.T) { + // Create a temporary directory with some files + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + os.Chdir(tmpDir) + + // Create test files + os.WriteFile("test.txt", []byte("test"), 0644) + os.WriteFile("example.md", []byte("example"), 0644) + os.Mkdir("subdir", 0755) + + ctx := CompletionContext{ + CurrentWord: "", + } + + completions := FileCompleter(ctx) + + // Should find our test files + var found []string + for _, c := range completions { + found = append(found, c.Value) + } + + // Check that we have our expected files (order may vary) + hasTestTxt := false + hasExampleMd := false + hasSubdir := false + + for _, f := range found { + if f == "test.txt" { + hasTestTxt = true + } + if f == "example.md" { + hasExampleMd = true + } + if f == "subdir/" { + hasSubdir = true + } + } + + test.True(t, hasTestTxt, "Should find test.txt") + test.True(t, hasExampleMd, "Should find example.md") + test.True(t, hasSubdir, "Should find subdir/") +} + +func TestDirectoryCompleter(t *testing.T) { + // Create a temporary directory with some files and subdirectories + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + os.Chdir(tmpDir) + + // Create test files and directories + os.WriteFile("test.txt", []byte("test"), 0644) + os.Mkdir("subdir1", 0755) + os.Mkdir("subdir2", 0755) + + ctx := CompletionContext{ + CurrentWord: "", + } + + completions := DirectoryCompleter(ctx) + + // Should only find directories + var found []string + for _, c := range completions { + found = append(found, c.Value) + } + + // Should not include files, only directories + hasTestTxt := false + hasSubdir1 := false + hasSubdir2 := false + + for _, f := range found { + if f == "test.txt" { + hasTestTxt = true + } + if f == "subdir1/" { + hasSubdir1 = true + } + if f == "subdir2/" { + hasSubdir2 = true + } + } + + test.False(t, hasTestTxt, "Should not find test.txt (not a directory)") + test.True(t, hasSubdir1, "Should find subdir1/") + test.True(t, hasSubdir2, "Should find subdir2/") +} + +func TestGenerateBashCompletion(t *testing.T) { + cmd, err := NewCommand("myapp", "My application") + test.NoError(t, err) + + script := cmd.GenerateBashCompletion() + + test.True(t, strings.Contains(script, "#!/bin/bash"), "Should contain shebang") + test.True(t, strings.Contains(script, "_myapp_completion"), "Should contain completion function") + test.True(t, strings.Contains(script, "complete -F _myapp_completion myapp"), "Should register completion") + test.True(t, strings.Contains(script, "myapp __complete"), "Should call completion command") +} + +func TestCompletionCommand(t *testing.T) { + rootCmd, err := NewCommand("test", "Test command", + AddSubCmd("sub", "Subcommand"), + AddFlag("flag", "Test flag"), + ) + test.NoError(t, err) + + completionCmd := CompletionCommand(rootCmd) + + test.Equal(t, "__complete", completionCmd.name) + test.NotNil(t, completionCmd.handler) +} + +func TestEnableCompletion(t *testing.T) { + cmd, err := NewCommand("test", "Test command", + EnableCompletion(), + ) + test.NoError(t, err) + + // Should have added the completion command + hasCompletionCmd := false + for _, subCmd := range cmd.subCommands { + if subCmd.name == "__complete" { + hasCompletionCmd = true + break + } + } + + test.True(t, hasCompletionCmd, "Should have added __complete subcommand") +} + +func TestAddCompletionCommand(t *testing.T) { + cmd, err := NewCommand("test", "Test command", + AddCompletionCommand(), + ) + test.NoError(t, err) + + // Should have added the completion command + hasCompletionCmd := false + for _, subCmd := range cmd.subCommands { + if subCmd.name == "completion" { + hasCompletionCmd = true + // Check that it has a bash subcommand + hasBashCmd := false + for _, bashCmd := range subCmd.subCommands { + if bashCmd.name == "bash" { + hasBashCmd = true + break + } + } + test.True(t, hasBashCmd, "completion command should have bash subcommand") + break + } + } + + test.True(t, hasCompletionCmd, "Should have added completion subcommand") +} + +func TestCompleteWithContext(t *testing.T) { + cmd, err := NewCommand("git", "Version control", + AddSubCmd("clone", "Clone repository", + AddArg("url", "Repository URL"), + AddArg("dir", "Target directory", SetArgDefault(".")), + ), + ) + test.NoError(t, err) + + // Test that subcommand completion works + completions := cmd.Complete([]string{"clone", "https://github.com/user/repo.git"}) + + // Should complete files/directories for the second argument + test.True(t, len(completions) >= 0, "Should return file completions for directory argument") +} \ No newline at end of file diff --git a/examples/completion_example.go b/examples/completion_example.go new file mode 100644 index 0000000..03bbc7c --- /dev/null +++ b/examples/completion_example.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/broothie/cli" +) + +func main() { + // Create a CLI application with completion support + cmd, err := cli.NewCommand("fileserver", "An HTTP file server with tab completion", + // Enable the hidden completion functionality + cli.EnableCompletion(), + + // Add a user-facing completion command + cli.AddCompletionCommand(), + + // Set version for version flag + cli.SetVersion("1.0.0"), + + // Add version flag + cli.AddVersionFlag(cli.AddFlagShort('V')), + + // Add help flag that's inherited by subcommands + cli.AddHelpFlag( + cli.AddFlagShort('h'), + cli.SetFlagIsInherited(true), + ), + + // Add some flags for the main command + cli.AddFlag("port", "Port to serve on", + cli.AddFlagShort('p'), + cli.SetFlagDefault(8080), + ), + cli.AddFlag("host", "Host to bind to", + cli.SetFlagDefault("localhost"), + ), + cli.AddFlag("verbose", "Enable verbose logging", + cli.AddFlagShort('v'), + cli.SetFlagDefault(false), + ), + + // Add a positional argument for the directory to serve + cli.AddArg("directory", "Directory to serve files from", + cli.SetArgDefault("."), + ), + + // Add some subcommands to demonstrate completion + cli.AddSubCmd("serve", "Start the file server", + cli.AddFlag("tls", "Enable TLS", + cli.SetFlagDefault(false), + ), + cli.AddFlag("cert", "TLS certificate file"), + cli.AddFlag("key", "TLS private key file"), + + cli.SetHandler(func(ctx context.Context) error { + directory, _ := cli.ArgValue[string](ctx, "directory") + port, _ := cli.FlagValue[int](ctx, "port") + host, _ := cli.FlagValue[string](ctx, "host") + verbose, _ := cli.FlagValue[bool](ctx, "verbose") + tls, _ := cli.FlagValue[bool](ctx, "tls") + + if verbose { + fmt.Printf("Starting server on %s:%d serving %s\n", host, port, directory) + if tls { + fmt.Println("TLS enabled") + } + } + + fmt.Printf("Server would start on %s:%d serving %s (TLS: %v)\n", host, port, directory, tls) + return nil + }), + ), + + cli.AddSubCmd("config", "Manage configuration", + cli.AddSubCmd("show", "Show current configuration", + cli.SetHandler(func(ctx context.Context) error { + fmt.Println("Configuration:") + fmt.Println(" port: 8080") + fmt.Println(" host: localhost") + return nil + }), + ), + cli.AddSubCmd("set", "Set configuration value", + cli.AddArg("key", "Configuration key"), + cli.AddArg("value", "Configuration value"), + + cli.SetHandler(func(ctx context.Context) error { + key, _ := cli.ArgValue[string](ctx, "key") + value, _ := cli.ArgValue[string](ctx, "value") + fmt.Printf("Would set %s = %s\n", key, value) + return nil + }), + ), + ), + + // Default handler for the root command + cli.SetHandler(func(ctx context.Context) error { + directory, _ := cli.ArgValue[string](ctx, "directory") + port, _ := cli.FlagValue[int](ctx, "port") + host, _ := cli.FlagValue[string](ctx, "host") + + fmt.Printf("File server starting on %s:%d serving %s\n", host, port, directory) + fmt.Println("Use 'fileserver serve' for advanced options") + fmt.Println("Use 'fileserver completion bash' to generate bash completion") + return nil + }), + ) + + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating command: %v\n", err) + os.Exit(1) + } + + // Run the command + if err := cmd.Run(context.Background(), os.Args[1:]); err != nil { + cli.ExitWithError(err) + } +} + +/* +Example usage and tab completion behavior: + +1. Basic command completion: + $ fileserver + completion config serve + +2. Flag completion: + $ fileserver -- + --help --host --port --verbose --version + +3. Short flag completion: + $ fileserver - + -h -p -v -V + +4. Subcommand flag completion: + $ fileserver serve -- + --cert --help --host --key --port --tls --verbose + +5. Nested subcommand completion: + $ fileserver config + set show + +6. File/directory completion for arguments: + $ fileserver /path/to/ + [shows files and directories in /path/to/] + +7. Generate bash completion script: + $ fileserver completion bash + [outputs bash completion script] + +To install completion: + $ fileserver completion bash > /etc/bash_completion.d/fileserver + $ source /etc/bash_completion.d/fileserver + +Or temporarily: + $ source <(fileserver completion bash) +*/ \ No newline at end of file