feat: add support for SSH RemoteCommand and RequestTTY in host configuration and TUI forms

- Allow users to specify a RemoteCommand to execute on SSH connection, both via TUI and config file
- Add RequestTTY option (yes, no, force, auto) to host configuration and forms
- Update config parsing and writing to handle new fields
- Improve TUI forms to support editing and adding these options
- Fix edit form standalone mode to allow proper quit/save via keyboard shortcuts
This commit is contained in:
Gu1llaum-3 2025-10-12 20:25:20 +02:00
parent 12d97270f0
commit c1457af73a
4 changed files with 171 additions and 58 deletions

View File

@ -140,11 +140,17 @@ func connectToHost(hostName string) {
fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd
var args []string
if configFile != "" {
sshCmd = exec.Command("ssh", "-F", configFile, hostName)
} else {
sshCmd = exec.Command("ssh", hostName)
args = append(args, "-F", configFile)
}
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.
sshCmd = exec.Command("ssh", args...)
// Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd.Stdin = os.Stdin

View File

@ -13,15 +13,17 @@ import (
// SSHHost represents an SSH host configuration
type SSHHost struct {
Name string
Hostname string
User string
Port string
Identity string
ProxyJump string
Options string
Tags []string
SourceFile string // Path to the config file where this host is defined
Name string
Hostname string
User string
Port string
Identity string
ProxyJump string
Options string
RemoteCommand string // Command to execute after SSH connection
RequestTTY string // Request TTY (yes, no, force, auto)
Tags []string
SourceFile string // Path to the config file where this host is defined
// Temporary field to handle multiple aliases during parsing
aliasNames []string `json:"-"` // Do not serialize this field
@ -326,6 +328,14 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
if currentHost != nil {
currentHost.ProxyJump = value
}
case "remotecommand":
if currentHost != nil {
currentHost.RemoteCommand = value
}
case "requesttty":
if currentHost != nil {
currentHost.RequestTTY = value
}
default:
// Handle other SSH options
if currentHost != nil && strings.TrimSpace(line) != "" {
@ -603,6 +613,20 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
}
}
if host.RemoteCommand != "" {
_, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand))
if err != nil {
return err
}
}
if host.RequestTTY != "" {
_, err = file.WriteString(fmt.Sprintf(" RequestTTY %s\n", host.RequestTTY))
if err != nil {
return err
}
}
// Write SSH options
if host.Options != "" {
// Split options by newlines and write each one
@ -1020,6 +1044,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options
if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n")
@ -1068,6 +1098,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options
if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n")
@ -1152,6 +1188,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options
if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n")
@ -1200,6 +1242,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options
if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n")
@ -1694,6 +1742,12 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
}
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options
if commonProperties.Options != "" {
@ -1774,6 +1828,12 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
}
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options
if commonProperties.Options != "" {

View File

@ -47,7 +47,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
}
}
inputs := make([]textinput.Model, 8)
inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY
// Name input
inputs[nameInput] = textinput.New()
@ -101,6 +101,18 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50
// Remote Command input
inputs[remoteCommandInput] = textinput.New()
inputs[remoteCommandInput].Placeholder = "ls -la, htop, bash"
inputs[remoteCommandInput].CharLimit = 300
inputs[remoteCommandInput].Width = 70
// RequestTTY input
inputs[requestTTYInput] = textinput.New()
inputs[requestTTYInput].Placeholder = "yes, no, force, auto"
inputs[requestTTYInput].CharLimit = 10
inputs[requestTTYInput].Width = 30
return &addFormModel{
inputs: inputs,
focused: nameInput,
@ -120,6 +132,8 @@ const (
proxyJumpInput
optionsInput
tagsInput
remoteCommandInput
requestTTYInput
)
// Messages for communication with parent model
@ -225,6 +239,8 @@ func (m *addFormModel) View() string {
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
"Remote Command",
"Request TTY",
}
for i, field := range fields {
@ -291,6 +307,8 @@ func (m *addFormModel) submitForm() tea.Cmd {
identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value())
remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value())
requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value())
// Set defaults
if user == "" {
@ -319,14 +337,16 @@ func (m *addFormModel) submitForm() tea.Cmd {
// Create host configuration
host := config.SSHHost{
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
Tags: tags,
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
}
// Add to config

View File

@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
@ -30,7 +29,6 @@ type editFormModel struct {
focusArea int // 0=hosts, 1=properties
focused int
err string
success bool
styles Styles
originalName string
originalHosts []string // Store original host names for multi-host detection
@ -92,7 +90,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
}
}
inputs := make([]textinput.Model, 7) // Reduced from 8 since we removed nameInput
inputs := make([]textinput.Model, 9) // Increased from 8 to 9 for RequestTTY
// Hostname input
inputs[0] = textinput.New()
@ -147,6 +145,20 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs[6].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)
// 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)
return &editFormModel{
hostInputs: hostInputs,
inputs: inputs,
@ -247,7 +259,6 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
m.err = ""
m.success = false
return m, func() tea.Msg { return editFormCancelMsg{} }
case "ctrl+s":
@ -306,10 +317,10 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editFormSubmitMsg:
if msg.err != nil {
m.err = msg.err.Error()
m.success = false
} else {
m.success = true
m.err = ""
// Success: let the wrapper handle this
// In TUI mode, this will be handled by the parent
// In standalone mode, the wrapper will quit
}
return m, nil
}
@ -334,13 +345,6 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editFormModel) View() string {
var b strings.Builder
if m.success {
b.WriteString(m.styles.FormField.Foreground(lipgloss.Color("#10B981")).Render("✓ Host updated successfully!"))
b.WriteString("\n\n")
b.WriteString(m.styles.FormHelp.Render("Press Ctrl+C or Esc to go back"))
return b.String()
}
if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n")
@ -385,6 +389,8 @@ func (m *editFormModel) View() string {
"Proxy Jump",
"SSH Options",
"Tags (comma-separated)",
"Remote Command",
"Request TTY",
}
for i, field := range fields {
@ -416,6 +422,30 @@ func (m *editFormModel) View() string {
return b.String()
}
// Standalone wrapper for edit form
type standaloneEditForm struct {
*editFormModel
}
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case editFormSubmitMsg:
if msg.err != nil {
m.editFormModel.err = msg.err.Error()
return m, nil
} else {
// Success: quit the program
return m, tea.Quit
}
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm.(*editFormModel)
return m, cmd
}
// RunEditForm runs the edit form as a standalone program
func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80) // Default width
@ -424,17 +454,10 @@ func RunEditForm(hostName string, configFile string) error {
return err
}
p := tea.NewProgram(editForm, tea.WithAltScreen())
m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
if err != nil {
return err
}
if editForm.err != "" {
return fmt.Errorf(editForm.err)
}
return nil
return err
}
func (m *editFormModel) submitEditForm() tea.Cmd {
@ -453,12 +476,14 @@ 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
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput
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
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput
remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput
requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput
// Set defaults
if port == "" {
@ -491,13 +516,15 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
// Create the common host configuration
commonHost := config.SSHHost{
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: options,
Tags: tags,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: options,
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
}
var err error