diff --git a/README.md b/README.md index c9ad6e6..21c58a1 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,53 @@ sshm --version sshm --help ``` +### Shell Completion + +SSHM supports shell completion for host names, making it easy to connect to hosts without typing full names: + +```bash +sshm # Lists all available hosts +sshm pro # Completes to hosts starting with "pro" (e.g., prod-server) +``` + +**Setup Instructions:** + +**Bash:** +```bash +# Enable for current session +source <(sshm completion bash) + +# Enable permanently (add to ~/.bashrc) +echo 'source <(sshm completion bash)' >> ~/.bashrc +``` + +**Zsh:** +```bash +# Enable for current session +source <(sshm completion zsh) + +# Enable permanently (add to ~/.zshrc) +echo 'source <(sshm completion zsh)' >> ~/.zshrc +``` + +**Fish:** +```bash +# Enable for current session +sshm completion fish | source + +# Enable permanently +sshm completion fish > ~/.config/fish/completions/sshm.fish +``` + +**PowerShell:** +```powershell +# Enable for current session +sshm completion powershell | Out-String | Invoke-Expression + +# Enable permanently (add to your PowerShell profile) +Add-Content $PROFILE 'sshm completion powershell | Out-String | Invoke-Expression' +``` + ### Direct Host Connection SSHM supports direct connection to hosts via the command line, making it easy to integrate into your existing workflow: diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..57de307 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script", + Long: `Generate shell completion script for sshm. + +To load completions: + +Bash: + $ source <(sshm completion bash) + + # To load completions for each session, add to your ~/.bashrc: + # echo 'source <(sshm completion bash)' >> ~/.bashrc + +Zsh: + $ source <(sshm completion zsh) + + # To load completions for each session, add to your ~/.zshrc: + # echo 'source <(sshm completion zsh)' >> ~/.zshrc + +Fish: + $ sshm completion fish | source + + # To load completions for each session: + $ sshm completion fish > ~/.config/fish/completions/sshm.fish + +PowerShell: + PS> sshm completion powershell | Out-String | Invoke-Expression + + # To load completions for each session, add to your PowerShell profile: + # Add-Content $PROFILE 'sshm completion powershell | Out-String | Invoke-Expression' +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletionV2(os.Stdout, true) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + } + return nil + }, +} + +func init() { + RootCmd.AddCommand(completionCmd) +} diff --git a/cmd/completion_test.go b/cmd/completion_test.go new file mode 100644 index 0000000..1bbb6a2 --- /dev/null +++ b/cmd/completion_test.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestCompletionCommand(t *testing.T) { + if completionCmd.Use != "completion [bash|zsh|fish|powershell]" { + t.Errorf("Expected Use 'completion [bash|zsh|fish|powershell]', got '%s'", completionCmd.Use) + } + + if completionCmd.Short != "Generate shell completion script" { + t.Errorf("Expected Short description, got '%s'", completionCmd.Short) + } +} + +func TestCompletionCommandValidArgs(t *testing.T) { + expected := []string{"bash", "zsh", "fish", "powershell"} + + if len(completionCmd.ValidArgs) != len(expected) { + t.Errorf("Expected %d valid args, got %d", len(expected), len(completionCmd.ValidArgs)) + } + + for i, arg := range expected { + if completionCmd.ValidArgs[i] != arg { + t.Errorf("Expected ValidArgs[%d] to be '%s', got '%s'", i, arg, completionCmd.ValidArgs[i]) + } + } +} + +func TestCompletionCommandRegistered(t *testing.T) { + found := false + for _, cmd := range RootCmd.Commands() { + if cmd.Name() == "completion" { + found = true + break + } + } + + if !found { + t.Error("Expected 'completion' command to be registered") + } +} + +func TestCompletionBashOutput(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + RootCmd.SetArgs([]string{"completion", "bash"}) + err := RootCmd.Execute() + + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Errorf("Expected no error for bash completion, got %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "bash completion") || !strings.Contains(output, "sshm") { + t.Error("Bash completion output should contain bash completion markers and sshm") + } +} + +func TestCompletionZshOutput(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + RootCmd.SetArgs([]string{"completion", "zsh"}) + err := RootCmd.Execute() + + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Errorf("Expected no error for zsh completion, got %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "compdef") || !strings.Contains(output, "sshm") { + t.Error("Zsh completion output should contain compdef and sshm") + } +} + +func TestCompletionFishOutput(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + RootCmd.SetArgs([]string{"completion", "fish"}) + err := RootCmd.Execute() + + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Errorf("Expected no error for fish completion, got %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "complete") || !strings.Contains(output, "sshm") { + t.Error("Fish completion output should contain complete command and sshm") + } +} + +func TestCompletionPowershellOutput(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + RootCmd.SetArgs([]string{"completion", "powershell"}) + err := RootCmd.Execute() + + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Errorf("Expected no error for powershell completion, got %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "Register-ArgumentCompleter") || !strings.Contains(output, "sshm") { + t.Error("PowerShell completion output should contain Register-ArgumentCompleter and sshm") + } +} + +func TestCompletionInvalidShell(t *testing.T) { + RootCmd.SetArgs([]string{"completion", "invalid"}) + err := RootCmd.Execute() + + if err == nil { + t.Error("Expected error for invalid shell type") + } +} + +func TestCompletionNoArgs(t *testing.T) { + RootCmd.SetArgs([]string{"completion"}) + err := RootCmd.Execute() + + if err == nil { + t.Error("Expected error when no shell type provided") + } +} + +func TestValidArgsFunction(t *testing.T) { + if RootCmd.ValidArgsFunction == nil { + t.Fatal("Expected ValidArgsFunction to be set on RootCmd") + } +} + +func TestValidArgsFunctionWithSSHConfig(t *testing.T) { + tmpDir := t.TempDir() + testConfigFile := filepath.Join(tmpDir, "config") + + sshConfig := `Host prod-server + HostName 192.168.1.1 + User admin + +Host dev-server + HostName 192.168.1.2 + User developer + +Host staging-db + HostName 192.168.1.3 + User dbadmin +` + err := os.WriteFile(testConfigFile, []byte(sshConfig), 0600) + if err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + originalConfigFile := configFile + defer func() { configFile = originalConfigFile }() + configFile = testConfigFile + + tests := []struct { + name string + toComplete string + args []string + wantCount int + wantHosts []string + }{ + { + name: "empty prefix returns all hosts", + toComplete: "", + args: []string{}, + wantCount: 3, + wantHosts: []string{"prod-server", "dev-server", "staging-db"}, + }, + { + name: "prefix filters hosts", + toComplete: "prod", + args: []string{}, + wantCount: 1, + wantHosts: []string{"prod-server"}, + }, + { + name: "prefix case insensitive", + toComplete: "DEV", + args: []string{}, + wantCount: 1, + wantHosts: []string{"dev-server"}, + }, + { + name: "no match returns empty", + toComplete: "nonexistent", + args: []string{}, + wantCount: 0, + wantHosts: []string{}, + }, + { + name: "already has host arg returns nothing", + toComplete: "", + args: []string{"existing-host"}, + wantCount: 0, + wantHosts: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions, directive := RootCmd.ValidArgsFunction(RootCmd, tt.args, tt.toComplete) + + if len(completions) != tt.wantCount { + t.Errorf("Expected %d completions, got %d: %v", tt.wantCount, len(completions), completions) + } + + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("Expected ShellCompDirectiveNoFileComp, got %v", directive) + } + + for _, wantHost := range tt.wantHosts { + found := false + for _, comp := range completions { + if comp == wantHost { + found = true + break + } + } + if !found { + t.Errorf("Expected completion '%s' not found in %v", wantHost, completions) + } + } + }) + } +} + +func TestValidArgsFunctionWithNonExistentConfig(t *testing.T) { + tmpDir := t.TempDir() + nonExistentConfig := filepath.Join(tmpDir, "nonexistent") + + originalConfigFile := configFile + defer func() { configFile = originalConfigFile }() + configFile = nonExistentConfig + + completions, directive := RootCmd.ValidArgsFunction(RootCmd, []string{}, "") + + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("Expected ShellCompDirectiveNoFileComp for non-existent config, got %v", directive) + } + + if len(completions) != 0 { + t.Errorf("Expected empty completions for non-existent config, got %v", completions) + } +} diff --git a/cmd/root.go b/cmd/root.go index cf9926c..5443eb5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -54,6 +54,37 @@ Examples: Version: AppVersion, Args: cobra.ArbitraryArgs, SilenceUsage: true, + SilenceErrors: true, // We'll handle errors ourselves + // ValidArgsFunction provides shell completion for host names + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Only complete the first positional argument (host name) + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var hosts []config.SSHHost + var err error + + if configFile != "" { + hosts, err = config.ParseSSHConfigFile(configFile) + } else { + hosts, err = config.ParseSSHConfig() + } + + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + toCompleteLower := strings.ToLower(toComplete) + for _, host := range hosts { + if strings.HasPrefix(strings.ToLower(host.Name), toCompleteLower) { + completions = append(completions, host.Name) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + }, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 {