From ce9d6786524e64ef40ee51a17563b9a177f81262 Mon Sep 17 00:00:00 2001 From: Simon Gaufreteau Date: Sun, 4 Jan 2026 17:49:04 +0100 Subject: [PATCH 1/9] feat: ProxyCommand support (#26) * Add base for ProxyCommand * Fix crashes with ProxyCommand * Add ProxyCommand to README --------- Co-authored-by: Simon Gaufreteau --- README.md | 5 ++- cmd/search.go | 1 + internal/config/ssh.go | 30 ++++++++++++++++++ internal/ui/add_form.go | 16 ++++++++-- internal/ui/edit_form.go | 66 +++++++++++++++++++++++----------------- internal/ui/info_form.go | 1 + 6 files changed, 87 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ae96fa2..c54c51a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect - **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats - **🔄 Automatic Backups** - Backup configurations automatically before changes - **✅ Validation** - Prevent configuration errors with built-in validation -- **🔗 ProxyJump Support** - Secure connection tunneling through bastion hosts +- **🔗 ProxyJump/ProxyCommand Support** - Secure connection tunneling through bastion hosts - **⌨️ Keyboard Shortcuts** - Power user navigation with vim-like shortcuts - **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows - **⚡ Lightweight** - Single binary with no dependencies, zero configuration required @@ -129,6 +129,7 @@ The interactive forms will guide you through configuration: - **Port** - SSH port (default: 22) - **Identity File** - Private key path - **ProxyJump** - Jump server for connection tunneling +- **ProxyCommand** - Jump command for connection tunneling - **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) - **Tags** - Comma-separated tags for organization @@ -504,6 +505,7 @@ Host backend-prod User app Port 22 ProxyJump bastion.company.com + ProxyCommand ssh -W %h:%p Jumphost IdentityFile ~/.ssh/production_key Compression yes ServerAliveInterval 300 @@ -520,6 +522,7 @@ SSHM supports all standard SSH configuration options: - `Port` - SSH port number - `IdentityFile` - Path to private key file - `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`) +- `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`) - `Tags` - Custom tags (SSHM extension) **Additional SSH Options:** diff --git a/cmd/search.go b/cmd/search.go index 1636f5d..d9afc10 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -205,6 +205,7 @@ func outputJSON(hosts []config.SSHHost) { fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port)) fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity)) fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump)) + fmt.Printf(" \"proxy_command\": \"%s\",\n", escapeJSON(host.ProxyCommand)) fmt.Printf(" \"options\": \"%s\",\n", escapeJSON(host.Options)) fmt.Printf(" \"tags\": [") for j, tag := range host.Tags { diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 9f7ae91..d308d56 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -19,6 +19,7 @@ type SSHHost struct { Port string Identity string ProxyJump string + ProxyCommand string Options string RemoteCommand string // Command to execute after SSH connection RequestTTY string // Request TTY (yes, no, force, auto) @@ -328,6 +329,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ if currentHost != nil { currentHost.ProxyJump = value } + case "proxycommand": + if currentHost != nil { + currentHost.ProxyCommand = value + } case "remotecommand": if currentHost != nil { currentHost.RemoteCommand = value @@ -613,6 +618,13 @@ func AddSSHHostToFile(host SSHHost, configPath string) error { } } + if host.ProxyCommand != "" { + _, err = file.WriteString(fmt.Sprintf(" ProxyCommand=%s\n", host.ProxyCommand)) + if err != nil { + return err + } + } + if host.RemoteCommand != "" { _, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand)) if err != nil { @@ -1044,6 +1056,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1098,6 +1113,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1188,6 +1206,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1242,6 +1263,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1742,6 +1766,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH if commonProperties.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) } + if commonProperties.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand) + } if commonProperties.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand) } @@ -1828,6 +1855,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH if commonProperties.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) } + if commonProperties.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand) + } if commonProperties.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand) } diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index 75a7cb4..8cbeda4 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -49,7 +49,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st } } - inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY + inputs := make([]textinput.Model, 11) // Name input inputs[nameInput] = textinput.New() @@ -91,6 +91,12 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st inputs[proxyJumpInput].CharLimit = 200 inputs[proxyJumpInput].Width = 50 + // ProxyCommand input + inputs[proxyCommandInput] = textinput.New() + inputs[proxyCommandInput].Placeholder = "ssh -W %h:%p Jumphost" + inputs[proxyCommandInput].CharLimit = 200 + inputs[proxyCommandInput].Width = 50 + // SSH Options input inputs[optionsInput] = textinput.New() inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" @@ -138,6 +144,7 @@ const ( portInput identityInput proxyJumpInput + proxyCommandInput tagsInput // Advanced tab inputs optionsInput @@ -229,11 +236,11 @@ func (m *addFormModel) getFirstInputForTab(tab int) int { func (m *addFormModel) getInputsForCurrentTab() []int { switch m.currentTab { case tabGeneral: - return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput} + return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, proxyCommandInput, tagsInput} case tabAdvanced: return []int{optionsInput, remoteCommandInput, requestTTYInput} default: - return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput} + return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, proxyCommandInput, tagsInput} } } @@ -401,6 +408,7 @@ func (m *addFormModel) renderGeneralTab() string { {portInput, "Port"}, {identityInput, "Identity File"}, {proxyJumpInput, "ProxyJump"}, + {proxyCommandInput, "ProxyCommand"}, {tagsInput, "Tags (comma-separated)"}, } @@ -489,6 +497,7 @@ func (m *addFormModel) submitForm() tea.Cmd { port := strings.TrimSpace(m.inputs[portInput].Value()) identity := strings.TrimSpace(m.inputs[identityInput].Value()) proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) + proxyCommand := strings.TrimSpace(m.inputs[proxyCommandInput].Value()) options := strings.TrimSpace(m.inputs[optionsInput].Value()) remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value()) requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value()) @@ -526,6 +535,7 @@ func (m *addFormModel) submitForm() tea.Cmd { Port: port, Identity: identity, ProxyJump: proxyJump, + ProxyCommand: proxyCommand, Options: config.ParseSSHOptionsFromCommand(options), RemoteCommand: remoteCommand, RequestTTY: requestTTY, diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index fdac316..7dfa48d 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -91,7 +91,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s } } - inputs := make([]textinput.Model, 9) // Increased from 8 to 9 for RequestTTY + inputs := make([]textinput.Model, 10) // Hostname input inputs[0] = textinput.New() @@ -128,37 +128,44 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s inputs[4].Width = 30 inputs[4].SetValue(host.ProxyJump) - // Options input + // ProxyCommand input inputs[5] = textinput.New() - inputs[5].Placeholder = "-o StrictHostKeyChecking=no" + inputs[5].Placeholder = "ssh -W %h:%p Jumphost" inputs[5].CharLimit = 200 inputs[5].Width = 50 + inputs[5].SetValue(host.ProxyCommand) + + // Options input + inputs[6] = textinput.New() + inputs[6].Placeholder = "-o StrictHostKeyChecking=no" + inputs[6].CharLimit = 200 + inputs[6].Width = 50 if host.Options != "" { - inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options)) + inputs[6].SetValue(config.FormatSSHOptionsForCommand(host.Options)) } // Tags input - inputs[6] = textinput.New() - inputs[6].Placeholder = "production, web, database" - inputs[6].CharLimit = 200 - inputs[6].Width = 50 + inputs[7] = textinput.New() + inputs[7].Placeholder = "production, web, database" + inputs[7].CharLimit = 200 + inputs[7].Width = 50 if len(host.Tags) > 0 { - inputs[6].SetValue(strings.Join(host.Tags, ", ")) + inputs[7].SetValue(strings.Join(host.Tags, ", ")) } // Remote Command input - inputs[7] = textinput.New() - inputs[7].Placeholder = "ls -la, htop, bash" - inputs[7].CharLimit = 300 - inputs[7].Width = 70 - inputs[7].SetValue(host.RemoteCommand) + inputs[8] = textinput.New() + inputs[8].Placeholder = "ls -la, htop, bash" + inputs[8].CharLimit = 300 + inputs[8].Width = 70 + inputs[8].SetValue(host.RemoteCommand) // RequestTTY input - inputs[8] = textinput.New() - inputs[8].Placeholder = "yes, no, force, auto" - inputs[8].CharLimit = 10 - inputs[8].Width = 30 - inputs[8].SetValue(host.RequestTTY) + inputs[9] = textinput.New() + inputs[9].Placeholder = "yes, no, force, auto" + inputs[9].CharLimit = 10 + inputs[9].Width = 30 + inputs[9].SetValue(host.RequestTTY) return &editFormModel{ hostInputs: hostInputs, @@ -253,19 +260,19 @@ func (m *editFormModel) updateFocus() tea.Cmd { func (m *editFormModel) getPropertiesForCurrentTab() []int { switch m.currentTab { case 0: // General - return []int{0, 1, 2, 3, 4, 6} // hostname, user, port, identity, proxyjump, tags + return []int{0, 1, 2, 3, 4, 5, 7} // hostname, user, port, identity, proxyjump, proxycommand, tags case 1: // Advanced - return []int{5, 7, 8} // options, remotecommand, requesttty + return []int{6, 8, 9} // options, remotecommand, requesttty default: - return []int{0, 1, 2, 3, 4, 6} + return []int{0, 1, 2, 3, 4, 5, 7} } } // getFirstPropertyForTab returns the first property index for a given tab func (m *editFormModel) getFirstPropertyForTab(tab int) int { - properties := []int{0, 1, 2, 3, 4, 6} // General tab + properties := []int{0, 1, 2, 3, 4, 5, 7} // General tab if tab == 1 { - properties = []int{5, 7, 8} // Advanced tab + properties = []int{6, 8, 9} // Advanced tab } if len(properties) > 0 { return properties[0] @@ -580,7 +587,8 @@ func (m *editFormModel) renderEditGeneralTab() string { {2, "Port"}, {3, "Identity File"}, {4, "Proxy Jump"}, - {6, "Tags (comma-separated)"}, + {5, "Proxy Command"}, + {7, "Tags (comma-separated)"}, } for _, field := range fields { @@ -683,9 +691,10 @@ func (m *editFormModel) submitEditForm() tea.Cmd { port := strings.TrimSpace(m.inputs[2].Value()) // portInput identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput - options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput - remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput - requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput + proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput + options := strings.TrimSpace(m.inputs[6].Value()) // optionsInput + remoteCommand := strings.TrimSpace(m.inputs[8].Value()) // remoteCommandInput + requestTTY := strings.TrimSpace(m.inputs[9].Value()) // requestTTYInput // Set defaults if port == "" { @@ -723,6 +732,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd { Port: port, Identity: identity, ProxyJump: proxyJump, + ProxyCommand: proxyCommand, Options: options, RemoteCommand: remoteCommand, RequestTTY: requestTTY, diff --git a/internal/ui/info_form.go b/internal/ui/info_form.go index 8b50364..50f386e 100644 --- a/internal/ui/info_form.go +++ b/internal/ui/info_form.go @@ -97,6 +97,7 @@ func (m *infoFormModel) View() string { {"Port", formatOptionalValue(m.host.Port)}, {"Identity File", formatOptionalValue(m.host.Identity)}, {"ProxyJump", formatOptionalValue(m.host.ProxyJump)}, + {"ProxyCommand", formatOptionalValue(m.host.ProxyCommand)}, {"SSH Options", formatSSHOptions(m.host.Options)}, {"Tags", formatTags(m.host.Tags)}, } From 49f01b74944386d1a46daf4755c79695eaa0e54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Dreux?= Date: Sun, 4 Jan 2026 17:59:14 +0100 Subject: [PATCH 2/9] feat: focus on search input at startup (#27) --- cmd/root.go | 6 +++++- internal/ui/tui.go | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 75b2c4e..710a44c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,9 @@ var AppVersion = "dev" // configFile holds the path to the SSH config file var configFile string +// searchMode enables the focus on search mode at startup +var searchMode bool + // RootCmd is the base command when called without any subcommands var RootCmd = &cobra.Command{ Use: "sshm [host]", @@ -97,7 +100,7 @@ func runInteractiveMode() { } // Run the interactive TUI - if err := ui.RunInteractiveMode(hosts, configFile, AppVersion); err != nil { + if err := ui.RunInteractiveMode(hosts, configFile, searchMode, AppVersion); err != nil { log.Fatalf("Error running interactive mode: %v", err) } } @@ -219,6 +222,7 @@ func Execute() { func init() { // Add the config file flag RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") + RootCmd.PersistentFlags().BoolVarP(&searchMode, "search", "s", false, "Focus on search input at startup") // Set custom version template with update check RootCmd.SetVersionTemplate(getVersionWithUpdateCheck()) diff --git a/internal/ui/tui.go b/internal/ui/tui.go index cdc44a1..d8f5b1d 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -16,7 +16,7 @@ import ( ) // NewModel creates a new TUI model with the given SSH hosts -func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { +func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string) Model { // Load application configuration appConfig, err := config.LoadAppConfig() if err != nil { @@ -54,6 +54,7 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { height: 24, ready: false, viewMode: ViewList, + searchMode: searchMode, } // Sort hosts according to the default sort mode @@ -64,6 +65,9 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { ti.Placeholder = "Search hosts or tags..." ti.CharLimit = 50 ti.Width = 25 + if searchMode { + ti.Focus() + } // Use dynamic column width calculation (will fallback to static if width not available) nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts) @@ -147,8 +151,8 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { } // RunInteractiveMode starts the interactive TUI interface -func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error { - m := NewModel(hosts, configFile, currentVersion) +func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string) error { + m := NewModel(hosts, configFile, searchMode, currentVersion) // Start the application in alt screen mode for clean output p := tea.NewProgram(m, tea.WithAltScreen()) From e4570e612e762ca8e0e1b8b28efa9410097e8c6a Mon Sep 17 00:00:00 2001 From: Francesco Raso <116293603+0xfraso@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:15:57 +0100 Subject: [PATCH 3/9] fix(add-form): align add/edit form behavior (#28) Co-authored-by: francesco.raso --- internal/ui/add_form.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index 8cbeda4..5ccb1cd 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -282,11 +282,29 @@ func (m *addFormModel) handleNavigation(key string) tea.Cmd { currentPos++ } - // Wrap around within current tab + // Handle transitions between tabs if currentPos >= len(currentTabInputs) { - currentPos = 0 + // Move to next tab + if m.currentTab == tabGeneral { + // Move to advanced tab + m.currentTab = tabAdvanced + m.focused = m.getFirstInputForTab(tabAdvanced) + return m.updateFocus() + } else { + // Wrap around to first field of current tab + currentPos = 0 + } } else if currentPos < 0 { - currentPos = len(currentTabInputs) - 1 + // Move to previous tab + if m.currentTab == tabAdvanced { + // Move to general tab + m.currentTab = tabGeneral + currentTabInputs = m.getInputsForCurrentTab() + currentPos = len(currentTabInputs) - 1 + } else { + // Wrap around to last field of current tab + currentPos = len(currentTabInputs) - 1 + } } m.focused = currentTabInputs[currentPos] From 66cb80f29c3c74fef85ae93a03240a2a20b0af14 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Sun, 4 Jan 2026 18:39:39 +0100 Subject: [PATCH 4/9] fix(edit): correct Advanced tab field indices mapping --- internal/ui/edit_form.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index 7dfa48d..b1106bf 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -613,9 +613,9 @@ func (m *editFormModel) renderEditAdvancedTab() string { index int label string }{ - {5, "SSH Options"}, - {7, "Remote Command"}, - {8, "Request TTY"}, + {6, "SSH Options"}, + {8, "Remote Command"}, + {9, "Request TTY"}, } for _, field := range fields { From 435597f6942ae387e8b8d6ae000cd0bd38e28679 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Sun, 4 Jan 2026 19:24:31 +0100 Subject: [PATCH 5/9] feat: add remote command execution support (#36) Allow executing commands on remote hosts via 'sshm '. Add -t/--tty flag for forcing TTY allocation on interactive commands. Co-authored-by: Guillaume Archambault <67098259+Gu1llaum-3@users.noreply.github.com> --- README.md | 36 ++++++++++++++++++++++++++++ cmd/root.go | 61 ++++++++++++++++++++++++++---------------------- cmd/root_test.go | 30 ++++++++++++++++-------- 3 files changed, 89 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index c54c51a..c9ad6e6 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,15 @@ sshm # Connect directly to a specific host (with history tracking) sshm my-server +# Execute a command on a remote host +sshm my-server uptime + +# Execute command with arguments +sshm my-server ls -la /var/log + +# Force TTY allocation for interactive commands +sshm -t my-server sudo systemctl restart nginx + # Launch TUI with custom SSH config file sshm -c /path/to/custom/ssh_config @@ -286,6 +295,33 @@ sshm web-01 - **Error handling** - Clear messages if host doesn't exist or configuration issues - **Config file support** - Works with custom config files using `-c` flag +### Remote Command Execution + +Execute commands on remote hosts without opening an interactive shell: + +```bash +# Execute a single command +sshm prod-server uptime + +# Execute command with arguments +sshm prod-server ls -la /var/log + +# Check disk usage +sshm prod-server df -h + +# View logs (pipe to local commands) +sshm prod-server 'cat /var/log/nginx/access.log' | grep 404 + +# Force TTY allocation for interactive commands (sudo, vim, etc.) +sshm -t prod-server sudo systemctl restart nginx +``` + +**Features:** +- **Exit code propagation** - Remote command exit codes are passed through +- **TTY support** - Use `-t` flag for commands requiring terminal interaction +- **Pipe-friendly** - Output can be piped to local commands for processing +- **History tracking** - Command executions are recorded in connection history + ### Backup Configuration SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe. diff --git a/cmd/root.go b/cmd/root.go index 710a44c..cf9926c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,36 +24,49 @@ var AppVersion = "dev" // configFile holds the path to the SSH config file var configFile string +// forceTTY forces pseudo-TTY allocation for remote commands +var forceTTY bool + // searchMode enables the focus on search mode at startup var searchMode bool // RootCmd is the base command when called without any subcommands var RootCmd = &cobra.Command{ - Use: "sshm [host]", + Use: "sshm [host] [command...]", Short: "SSH Manager - A modern SSH connection manager", Long: `SSHM is a modern SSH manager for your terminal. Main usage: Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically. Running 'sshm ' connects directly to the specified host and records the connection in your history. + Running 'sshm ' executes the command on the remote host and returns the output. You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts. -Hosts are read from your ~/.ssh/config file by default.`, +Hosts are read from your ~/.ssh/config file by default. + +Examples: + sshm # Open interactive TUI + sshm prod-server # Connect to host interactively + sshm prod-server uptime # Execute 'uptime' on remote host + sshm prod-server ls -la /var # Execute command with arguments + sshm -t prod-server sudo reboot # Force TTY for interactive commands`, Version: AppVersion, Args: cobra.ArbitraryArgs, SilenceUsage: true, - SilenceErrors: true, // We'll handle errors ourselves + SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { - // If no arguments provided, run interactive mode if len(args) == 0 { runInteractiveMode() return nil } - // If a host name is provided, connect directly hostName := args[0] - connectToHost(hostName) + var remoteCommand []string + if len(args) > 1 { + remoteCommand = args[1:] + } + connectToHost(hostName, remoteCommand) return nil }, } @@ -105,8 +118,7 @@ func runInteractiveMode() { } } -func connectToHost(hostName string) { - // Quick check if host exists without full parsing (optimized for connection) +func connectToHost(hostName string, remoteCommand []string) { var hostFound bool var err error @@ -126,45 +138,42 @@ func connectToHost(hostName string) { os.Exit(1) } - // Record the connection in history historyManager, err := history.NewHistoryManager() if err != nil { - // Log the error but don't prevent the connection fmt.Printf("Warning: Could not initialize connection history: %v\n", err) } else { err = historyManager.RecordConnection(hostName) if err != nil { - // Log the error but don't prevent the connection fmt.Printf("Warning: Could not record connection history: %v\n", err) } } - // Build and execute the SSH command - fmt.Printf("Connecting to %s...\n", hostName) - - var sshCmd *exec.Cmd var args []string if configFile != "" { args = append(args, "-F", configFile) } + + if forceTTY { + args = append(args, "-t") + } + args = append(args, hostName) - // Note: We don't add RemoteCommand here because if it's configured in SSH config, - // SSH will handle it automatically. Adding it as a command line argument would conflict. + if len(remoteCommand) > 0 { + args = append(args, remoteCommand...) + } else { + fmt.Printf("Connecting to %s...\n", hostName) + } - sshCmd = exec.Command("ssh", args...) - - // Set up the command to use the same stdin, stdout, and stderr as the parent process + sshCmd := exec.Command("ssh", args...) sshCmd.Stdin = os.Stdin sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr - // Execute the SSH command err = sshCmd.Run() if err != nil { if exitError, ok := err.(*exec.ExitError); ok { - // SSH command failed, exit with the same code if status, ok := exitError.Sys().(syscall.WaitStatus); ok { os.Exit(status.ExitStatus()) } @@ -200,17 +209,13 @@ func getVersionWithUpdateCheck() string { // Execute adds all child commands to the root command and sets flags appropriately. func Execute() { - // Custom error handling for unknown commands that might be host names if err := RootCmd.Execute(); err != nil { - // Check if this is an "unknown command" error and the argument might be a host name errStr := err.Error() if strings.Contains(errStr, "unknown command") { - // Extract the command name from the error parts := strings.Split(errStr, "\"") if len(parts) >= 2 { potentialHost := parts[1] - // Try to connect to this as a host - connectToHost(potentialHost) + connectToHost(potentialHost, nil) return } } @@ -220,8 +225,8 @@ func Execute() { } func init() { - // Add the config file flag RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") + RootCmd.Flags().BoolVarP(&forceTTY, "tty", "t", false, "Force pseudo-TTY allocation (useful for interactive remote commands)") RootCmd.PersistentFlags().BoolVarP(&searchMode, "search", "s", false, "Focus on search input at startup") // Set custom version template with update check diff --git a/cmd/root_test.go b/cmd/root_test.go index 9571305..e56c39b 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -7,9 +7,8 @@ import ( ) func TestRootCommand(t *testing.T) { - // Test that the root command is properly configured - if RootCmd.Use != "sshm [host]" { - t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use) + if RootCmd.Use != "sshm [host] [command...]" { + t.Errorf("Expected Use 'sshm [host] [command...]', got '%s'", RootCmd.Use) } if RootCmd.Short != "SSH Manager - A modern SSH connection manager" { @@ -22,10 +21,8 @@ func TestRootCommand(t *testing.T) { } func TestRootCommandFlags(t *testing.T) { - // Test that persistent flags are properly configured flags := RootCmd.PersistentFlags() - // Check config flag configFlag := flags.Lookup("config") if configFlag == nil { t.Error("Expected --config flag to be defined") @@ -34,6 +31,15 @@ func TestRootCommandFlags(t *testing.T) { if configFlag.Shorthand != "c" { t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand) } + + ttyFlag := RootCmd.Flags().Lookup("tty") + if ttyFlag == nil { + t.Error("Expected --tty flag to be defined") + return + } + if ttyFlag.Shorthand != "t" { + t.Errorf("Expected tty flag shorthand 't', got '%s'", ttyFlag.Shorthand) + } } func TestRootCommandSubcommands(t *testing.T) { @@ -103,13 +109,17 @@ func TestExecuteFunction(t *testing.T) { } func TestConnectToHostFunction(t *testing.T) { - // Test that connectToHost function exists and can be called - // Note: We can't easily test the actual connection without a valid SSH config - // and without actually connecting to a host, but we can verify the function exists t.Log("connectToHost function exists and is accessible") +} - // The function will handle errors internally (like host not found) - // We don't want to actually test the SSH connection in unit tests +func TestRemoteCommandUsage(t *testing.T) { + if !strings.Contains(RootCmd.Long, "command") { + t.Error("Long description should mention remote command execution") + } + + if !strings.Contains(RootCmd.Long, "uptime") { + t.Error("Long description should include command examples") + } } func TestRunInteractiveModeFunction(t *testing.T) { From 2f9587c8c82138323bc1b47c8d1e324c39133106 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Sun, 4 Jan 2026 19:37:52 +0100 Subject: [PATCH 6/9] 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> --- README.md | 47 +++++++ cmd/completion.go | 60 +++++++++ cmd/completion_test.go | 285 +++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 31 +++++ 4 files changed, 423 insertions(+) create mode 100644 cmd/completion.go create mode 100644 cmd/completion_test.go 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 { From def2b4fa8d399f54ceea6cd422f9c05e3c423271 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Sun, 4 Jan 2026 20:44:01 +0100 Subject: [PATCH 7/9] fix: correct field mapping in forms and prevent double -o prefix in SSH options --- cmd/root.go | 1 - internal/config/ssh.go | 29 ++++++++++++++++++++++++++++- internal/ui/add_form.go | 2 +- internal/ui/edit_form.go | 20 ++++++++++---------- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 5443eb5..d80b77b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -85,7 +85,6 @@ Examples: return completions, cobra.ShellCompDirectiveNoFileComp }, - SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { runInteractiveMode() diff --git a/internal/config/ssh.go b/internal/config/ssh.go index d308d56..792a0cd 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -658,13 +658,34 @@ func AddSSHHostToFile(host SSHHost, configPath string) error { } // ParseSSHOptionsFromCommand converts SSH command line options to config format -// Input: "-o Compression=yes -o ServerAliveInterval=60" +// Input: "-o Compression=yes -o ServerAliveInterval=60" or "ForwardX11 true" or "Compression yes" // Output: "Compression yes\nServerAliveInterval 60" func ParseSSHOptionsFromCommand(options string) string { if options == "" { return "" } + options = strings.TrimSpace(options) + + // If it doesn't contain -o, assume it's already in config format + if !strings.Contains(options, "-o") { + // Just normalize spaces and ensure newlines between options + lines := strings.Split(options, "\n") + var result []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Normalize spacing (replace multiple spaces with single space) + parts := strings.Fields(line) + if len(parts) > 0 { + result = append(result, strings.Join(parts, " ")) + } + } + return strings.Join(result, "\n") + } + var result []string parts := strings.Split(options, "-o") @@ -690,6 +711,12 @@ func FormatSSHOptionsForCommand(options string) string { return "" } + // If already in command format (starts with -o), return as is + trimmed := strings.TrimSpace(options) + if strings.HasPrefix(trimmed, "-o ") { + return trimmed + } + var result []string lines := strings.Split(options, "\n") diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index 5ccb1cd..12c3242 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -145,9 +145,9 @@ const ( identityInput proxyJumpInput proxyCommandInput + optionsInput tagsInput // Advanced tab inputs - optionsInput remoteCommandInput requestTTYInput ) diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index b1106bf..ab34b5c 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -686,15 +686,15 @@ func (m *editFormModel) submitEditForm() tea.Cmd { } // Get property values using direct indices - hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput - user := strings.TrimSpace(m.inputs[1].Value()) // userInput - port := strings.TrimSpace(m.inputs[2].Value()) // portInput - identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput - proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput - proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput - options := strings.TrimSpace(m.inputs[6].Value()) // optionsInput - remoteCommand := strings.TrimSpace(m.inputs[8].Value()) // remoteCommandInput - requestTTY := strings.TrimSpace(m.inputs[9].Value()) // requestTTYInput + hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput + user := strings.TrimSpace(m.inputs[1].Value()) // userInput + port := strings.TrimSpace(m.inputs[2].Value()) // portInput + identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput + proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput + proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput + options := config.ParseSSHOptionsFromCommand(strings.TrimSpace(m.inputs[6].Value())) // optionsInput + remoteCommand := strings.TrimSpace(m.inputs[8].Value()) // remoteCommandInput + requestTTY := strings.TrimSpace(m.inputs[9].Value()) // requestTTYInput // Set defaults if port == "" { @@ -714,7 +714,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd { } // Parse tags - tagsStr := strings.TrimSpace(m.inputs[6].Value()) // tagsInput + tagsStr := strings.TrimSpace(m.inputs[7].Value()) // tagsInput var tags []string if tagsStr != "" { for _, tag := range strings.Split(tagsStr, ",") { From 8f780e288c247a43bfc6e7b47385701709ae45a3 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Sun, 4 Jan 2026 21:34:09 +0100 Subject: [PATCH 8/9] fix: use line numbers to prevent deleting all duplicate SSH hosts when removing one --- internal/config/ssh.go | 62 +++++++++++++++++++++++++++++++++++------- internal/ui/model.go | 4 +-- internal/ui/update.go | 24 ++++++++-------- internal/ui/view.go | 6 +++- 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 792a0cd..9b13d50 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -25,6 +25,7 @@ type SSHHost struct { RequestTTY string // Request TTY (yes, no, force, auto) Tags []string SourceFile string // Path to the config file where this host is defined + LineNumber int // Line number in the source file where this host block starts (1-indexed) // Temporary field to handle multiple aliases during parsing aliasNames []string `json:"-"` // Do not serialize this field @@ -212,8 +213,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ var currentHost *SSHHost var pendingTags []string scanner := bufio.NewScanner(file) + lineNumber := 0 for scanner.Scan() { + lineNumber++ line := strings.TrimSpace(scanner.Text()) // Ignore empty lines @@ -300,6 +303,7 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ Port: "22", // Default port Tags: pendingTags, // Assign pending tags to this host SourceFile: absPath, // Track which file this host comes from + LineNumber: lineNumber, // Track the line number where Host declaration starts } // Store additional host names for later processing @@ -1334,11 +1338,21 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err // DeleteSSHHost removes an SSH host configuration from the config file func DeleteSSHHost(hostName string) error { - return DeleteSSHHostV2(hostName) + return DeleteSSHHostV2(hostName, 0) // Legacy: without line number +} + +// DeleteSSHHostWithLine deletes a specific SSH host by name and line number +func DeleteSSHHostWithLine(host SSHHost) error { + return DeleteSSHHostFromFileWithLine(host.Name, host.SourceFile, host.LineNumber) } // DeleteSSHHostFromFile deletes an SSH host from a specific config file func DeleteSSHHostFromFile(hostName, configPath string) error { + return DeleteSSHHostFromFileWithLine(hostName, configPath, 0) // Legacy: without line number +} + +// DeleteSSHHostFromFileWithLine deletes an SSH host from a specific config file at a specific line +func DeleteSSHHostFromFileWithLine(hostName, configPath string, targetLineNumber int) error { configMutex.Lock() defer configMutex.Unlock() @@ -1365,11 +1379,13 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { hostFound := false for i < len(lines) { + currentLineNumber := i + 1 // Convert 0-indexed to 1-indexed line := strings.TrimSpace(lines[i]) // Check for tags comment followed by Host if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { nextLine := strings.TrimSpace(lines[i+1]) + nextLineNumber := i + 2 // The Host line is at i+1, so its 1-indexed number is i+2 // Check if this is a Host line that contains our target host if strings.HasPrefix(nextLine, "Host ") { @@ -1385,7 +1401,10 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { } } - if targetHostIndex != -1 { + // Only proceed if: + // 1. We found the host name + // 2. Either no line number was specified (targetLineNumber == 0) OR the line numbers match + if targetHostIndex != -1 && (targetLineNumber == 0 || nextLineNumber == targetLineNumber) { hostFound = true if isMultiHost && len(hostNames) > 1 { @@ -1423,7 +1442,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } else { // Single host or last host in multi-host block, delete entire block // Skip tags comment and Host line @@ -1439,7 +1463,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } } } @@ -1459,7 +1488,10 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { } } - if targetHostIndex != -1 { + // Only proceed if: + // 1. We found the host name + // 2. Either no line number was specified (targetLineNumber == 0) OR the line numbers match + if targetHostIndex != -1 && (targetLineNumber == 0 || currentLineNumber == targetLineNumber) { hostFound = true if isMultiHost && len(hostNames) > 1 { @@ -1494,7 +1526,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } else { // Single host, delete entire block // Skip Host line @@ -1510,7 +1547,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } } } @@ -1593,15 +1635,15 @@ func UpdateSSHHostV2(oldName string, newHost SSHHost) error { } // DeleteSSHHostV2 removes an SSH host configuration, searching in all config files -func DeleteSSHHostV2(hostName string) error { +func DeleteSSHHostV2(hostName string, targetLineNumber int) error { // Find the host to determine which file it's in existingHost, err := FindHostInAllConfigs(hostName) if err != nil { return err } - // Delete the host from its source file - return DeleteSSHHostFromFile(hostName, existingHost.SourceFile) + // Delete the host from its source file using line number if provided + return DeleteSSHHostFromFileWithLine(hostName, existingHost.SourceFile, targetLineNumber) } // AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file diff --git a/internal/ui/model.go b/internal/ui/model.go index da6e7e9..bb809a0 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -74,14 +74,14 @@ type Model struct { filteredHosts []config.SSHHost searchMode bool deleteMode bool - deleteHost string + deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting) historyManager *history.HistoryManager pingManager *connectivity.PingManager sortMode SortMode configFile string // Path to the SSH config file // Application configuration - appConfig *config.AppConfig + appConfig *config.AppConfig // Version update information updateInfo *version.UpdateInfo diff --git a/internal/ui/update.go b/internal/ui/update.go index 828f1ff..a7c4aee 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -452,7 +452,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.deleteMode { // Exit delete mode m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } @@ -508,15 +508,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else if m.deleteMode { // Confirm deletion var err error - if m.configFile != "" { - err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile) - } else { - err = config.DeleteSSHHost(m.deleteHost) + if m.deleteHost != nil { + err = config.DeleteSSHHostWithLine(*m.deleteHost) } if err != nil { // Could display an error message here m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } @@ -533,7 +531,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if parseErr != nil { // Could display an error message here m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } @@ -548,7 +546,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updateTableRows() m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } else { @@ -673,11 +671,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "d": if !m.searchMode && !m.deleteMode { // Delete the selected host - selected := m.table.SelectedRow() - if len(selected) > 0 { - hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column + cursor := m.table.Cursor() + if cursor >= 0 && cursor < len(m.filteredHosts) { + // Get the host at the cursor position (which corresponds to filteredHosts index) + targetHost := &m.filteredHosts[cursor] + m.deleteMode = true - m.deleteHost = hostName + m.deleteHost = targetHost m.table.Blur() return m, nil } diff --git a/internal/ui/view.go b/internal/ui/view.go index 9f5f94e..0d6e8a6 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -144,7 +144,11 @@ func (m Model) renderListView() string { func (m Model) renderDeleteConfirmation() string { // Remove emojis (uncertain width depending on terminal) to stabilize the frame title := "DELETE SSH HOST" - question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost) + hostName := "" + if m.deleteHost != nil { + hostName = m.deleteHost.Name + } + question := fmt.Sprintf("Are you sure you want to delete host '%s'?", hostName) action := "This action cannot be undone." help := "Enter: confirm • Esc: cancel" From 87f8fb9c6c24cc166b1c7dfff91da734868345e0 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Sun, 4 Jan 2026 22:21:13 +0100 Subject: [PATCH 9/9] fix: problems with quotes from Host names and support SSH tokens (issue #32) --- internal/config/ssh.go | 7 +++ internal/config/ssh_test.go | 86 +++++++++++++++++++++++++++++++++ internal/validation/ssh.go | 14 +++++- internal/validation/ssh_test.go | 13 +++++ 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 9b13d50..5d95d83 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -283,8 +283,12 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ hostNames := strings.Fields(value) // Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts + // Also remove surrounding quotes from host names var validHostNames []string for _, hostName := range hostNames { + // Remove surrounding double quotes if present + hostName = strings.Trim(hostName, `"`) + if !strings.ContainsAny(hostName, "*?") { validHostNames = append(validHostNames, hostName) } @@ -896,6 +900,9 @@ func quickHostSearchInFile(hostName string, configPath string, processedFiles ma // Check if our target host is in this Host declaration for _, candidateHostName := range hostNames { + // Remove surrounding double quotes if present + candidateHostName = strings.Trim(candidateHostName, `"`) + // Skip hosts with wildcards (*, ?) as they are typically patterns if !strings.ContainsAny(candidateHostName, "*?") && candidateHostName == hostName { return true, nil // Found the host! diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index 1229249..d6c36ff 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -1694,3 +1694,89 @@ Host production-server } } } + +func TestParseSSHConfigWithQuotedHostNames(t *testing.T) { + tempDir := t.TempDir() + + configFile := filepath.Join(tempDir, "config") + configContent := `# Test hosts with quoted names (issue #32) +Host "my-host-name-01" + HostName my-host-name-01.cwd.pub.domain.net + Port 2222 + User my_user + +Host "qa-test-vm" + HostName qa-test-vm.example.com + User guillaume + Port 22 + +Host normal-host + HostName normal.example.com + User testuser + +Host "quoted1" "quoted2" + HostName multi.example.com + User multiuser +` + + err := os.WriteFile(configFile, []byte(configContent), 0600) + if err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + hosts, err := ParseSSHConfigFile(configFile) + if err != nil { + t.Fatalf("ParseSSHConfigFile() error = %v", err) + } + + // Should get 5 hosts: my-host-name-01, qa-test-vm, normal-host, quoted1, quoted2 + // All without quotes + expectedHosts := map[string]struct{}{ + "my-host-name-01": {}, + "qa-test-vm": {}, + "normal-host": {}, + "quoted1": {}, + "quoted2": {}, + } + + if len(hosts) != len(expectedHosts) { + t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts)) + for _, host := range hosts { + t.Logf("Found host: %q", host.Name) + } + } + + hostMap := make(map[string]SSHHost) + for _, host := range hosts { + // Verify no quotes in host names + if strings.Contains(host.Name, `"`) { + t.Errorf("Host name %q still contains quotes", host.Name) + } + hostMap[host.Name] = host + } + + for expectedHostName := range expectedHosts { + if _, found := hostMap[expectedHostName]; !found { + t.Errorf("Expected host %q not found", expectedHostName) + } + } + + // Verify specific host details + if host, found := hostMap["my-host-name-01"]; found { + if host.Hostname != "my-host-name-01.cwd.pub.domain.net" { + t.Errorf("Host my-host-name-01 has wrong hostname: %q", host.Hostname) + } + if host.Port != "2222" { + t.Errorf("Host my-host-name-01 has wrong port: %q", host.Port) + } + if host.User != "my_user" { + t.Errorf("Host my-host-name-01 has wrong user: %q", host.User) + } + } + + if host, found := hostMap["qa-test-vm"]; found { + if host.Hostname != "qa-test-vm.example.com" { + t.Errorf("Host qa-test-vm has wrong hostname: %q", host.Hostname) + } + } +} diff --git a/internal/validation/ssh.go b/internal/validation/ssh.go index 07c24aa..93bee63 100644 --- a/internal/validation/ssh.go +++ b/internal/validation/ssh.go @@ -11,6 +11,7 @@ import ( ) // ValidateHostname checks if a hostname is valid +// Accepts regular hostnames, IP addresses, and SSH tokens like %h, %p, %r, %u, %n, %C, %d, %i, %k, %L, %l, %T func ValidateHostname(hostname string) bool { if len(hostname) == 0 || len(hostname) > 253 { return false @@ -22,7 +23,18 @@ func ValidateHostname(hostname string) bool { return false } - hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) + // Check if hostname contains SSH tokens (e.g., %h, %p, %r, %u, %n, etc.) + // SSH tokens are documented in ssh_config(5) man page + sshTokenRegex := regexp.MustCompile(`%[hprunCdiklLT]`) + if sshTokenRegex.MatchString(hostname) { + // If it contains SSH tokens, it's a valid SSH config construct + return true + } + + // RFC 1123: each label must start with alphanumeric, end with alphanumeric, + // and contain only alphanumeric and hyphens. Labels are 1-63 chars. + // A hostname is one or more labels separated by dots. + hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]|[a-zA-Z0-9]{0,62})?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]|[a-zA-Z0-9]{0,62})?)*$`) return hostnameRegex.MatchString(hostname) } diff --git a/internal/validation/ssh_test.go b/internal/validation/ssh_test.go index 673838c..32dca0c 100644 --- a/internal/validation/ssh_test.go +++ b/internal/validation/ssh_test.go @@ -24,6 +24,19 @@ func TestValidateHostname(t *testing.T) { {"hostname ending with dot", "example.com.", false}, {"hostname with hyphen", "my-server.com", true}, {"hostname starting with number", "1example.com", true}, + {"multiple hyphens and subdomains", "my-host-name-01.cwd.pub.domain.net", true}, + {"multiple hyphens", "my-host-name-01", true}, + {"complex hostname with hyphens", "server-01-prod.data-center.example.com", true}, + {"hostname with consecutive hyphens", "my--server.com", true}, + {"single char labels", "a.b.c.d.com", true}, + // SSH tokens support (issue #32 comment) + {"SSH token %h", "%h.server.com", true}, + {"SSH token %p", "server.com:%p", true}, + {"SSH token %r", "%r@server.com", true}, + {"SSH token %u", "%u.example.com", true}, + {"SSH token %n", "%n.domain.net", true}, + {"SSH token %C", "host-%C.com", true}, + {"multiple SSH tokens", "%h.%u.server.com", true}, } for _, tt := range tests {