mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
feat: add shell completion for host names (#37)
- Add ValidArgsFunction to RootCmd for dynamic host completion - Add 'sshm completion' subcommand for bash/zsh/fish/powershell - Support prefix matching and case-insensitive filtering - Respect --config flag for custom SSH config files - Add comprehensive tests for completion functionality - Document setup instructions in README Co-authored-by: Guillaume Archambault <67098259+Gu1llaum-3@users.noreply.github.com>
This commit is contained in:
60
cmd/completion.go
Normal file
60
cmd/completion.go
Normal file
@@ -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)
|
||||
}
|
||||
285
cmd/completion_test.go
Normal file
285
cmd/completion_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
31
cmd/root.go
31
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 {
|
||||
|
||||
Reference in New Issue
Block a user