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:
47
README.md
47
README.md
@@ -275,6 +275,53 @@ sshm --version
|
|||||||
sshm --help
|
sshm --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Shell Completion
|
||||||
|
|
||||||
|
SSHM supports shell completion for host names, making it easy to connect to hosts without typing full names:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshm <TAB> # Lists all available hosts
|
||||||
|
sshm pro<TAB> # 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
|
### Direct Host Connection
|
||||||
|
|
||||||
SSHM supports direct connection to hosts via the command line, making it easy to integrate into your existing workflow:
|
SSHM supports direct connection to hosts via the command line, making it easy to integrate into your existing workflow:
|
||||||
|
|||||||
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,
|
Version: AppVersion,
|
||||||
Args: cobra.ArbitraryArgs,
|
Args: cobra.ArbitraryArgs,
|
||||||
SilenceUsage: true,
|
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,
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user