From 59ef0637923a2ac1c429fc5428b5aea34ece4cd4 Mon Sep 17 00:00:00 2001 From: Sergey Morozov Date: Wed, 22 Apr 2026 15:55:00 +0300 Subject: [PATCH 1/4] fix(ssh-completion): correctly include hosts from nested SSH Include files Normalize Include parsing and preserve multiline config flow when building completion candidates. Add visited-file tracking to avoid infinite recursion on cyclic includes. --- README.md | 62 ++++++++++++++++++++++++++++++++--------------------- zsh-ssh.zsh | 40 ++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a01edf1..ca52db0 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ Better host completion for ssh in Zsh. [![asciicast](https://asciinema.org/a/381405.svg)](https://asciinema.org/a/381405) - [zsh-ssh](#zsh-ssh) - - [Installation](#installation) - - [Zinit](#zinit) - - [Antigen](#antigen) - - [Oh My Zsh](#oh-my-zsh) - - [Sheldon](#sheldon) - - [Manual (Git Clone)](#manual-git-clone) - - [Usage](#usage) - - [SSH Config Example](#ssh-config-example) + - [Installation](#installation) + - [Zinit](#zinit) + - [Antigen](#antigen) + - [Oh My Zsh](#oh-my-zsh) + - [Sheldon](#sheldon) + - [Manual (Git Clone)](#manual-git-clone) + - [Usage](#usage) + - [SSH Config Example](#ssh-config-example) ## Installation @@ -34,15 +34,15 @@ antigen bundle sunlei/zsh-ssh 1. Clone this repository into `$ZSH_CUSTOM/plugins` (by default `~/.oh-my-zsh/custom/plugins`) - ```shell - git clone https://github.com/sunlei/zsh-ssh ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-ssh - ``` + ```shell + git clone https://github.com/sunlei/zsh-ssh ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-ssh + ``` 2. Add the plugin to the list of plugins for Oh My Zsh to load (inside `~/.zshrc`): - ```shell - plugins=(zsh-ssh $plugins) - ``` + ```shell + plugins=(zsh-ssh $plugins) + ``` 3. Start a new terminal session. @@ -50,10 +50,10 @@ antigen bundle sunlei/zsh-ssh 1. Add this config to `~/.config/sheldon/plugins.toml` - ```toml - [plugins.zsh-ssh] - github = 'sunlei/zsh-ssh' - ``` + ```toml + [plugins.zsh-ssh] + github = 'sunlei/zsh-ssh' + ``` 2. Run `sheldon lock` to install the plugin. @@ -63,15 +63,15 @@ antigen bundle sunlei/zsh-ssh 1. Clone this repository somewhere on your machine. For example: `~/.zsh/zsh-ssh`. - ```shell - git clone https://github.com/sunlei/zsh-ssh ~/.zsh/zsh-ssh - ``` + ```shell + git clone https://github.com/sunlei/zsh-ssh ~/.zsh/zsh-ssh + ``` 2. Add the following to your `.zshrc`: - ```shell - source ~/.zsh/zsh-ssh/zsh-ssh.zsh - ``` + ```shell + source ~/.zsh/zsh-ssh/zsh-ssh.zsh + ``` 3. Start a new terminal session. @@ -95,3 +95,17 @@ Host Development-Host IdentityFile ~/.ssh/development-host #_Desc For Development ``` + +Include files are also supported. For example, your main config can include separate files: + +~/.ssh/config + +```text +Include ~/.ssh/config.d/company.ssh_config +Include ~/.ssh/config.d/home.ssh_config +Include ~/.ssh/config.d/work.ssh_config + +# OR + +Include ~/.ssh/config.d/*.ssh_config +``` diff --git a/zsh-ssh.zsh b/zsh-ssh.zsh index c402950..f560bab 100644 --- a/zsh-ssh.zsh +++ b/zsh-ssh.zsh @@ -9,6 +9,8 @@ setopt no_beep # don't beep zstyle ':completion:*:ssh:*' hosts off # disable built-in hosts completion SSH_CONFIG_FILE="${SSH_CONFIG_FILE:-$HOME/.ssh/config}" +typeset -gi _zsh_ssh_parse_depth=0 +typeset -gA _zsh_ssh_seen_config_files # Parse the file and handle the include directive. _parse_config_file() { @@ -16,20 +18,31 @@ _parse_config_file() { setopt localoptions rematchpcre unsetopt nomatch + local input_path="$1" + local config_file_path + local line raw_path expanded include_file_path + local -a include_paths + # Resolve the full path of the input config file - local config_file_path=$(realpath "$1") + config_file_path=$(realpath "$input_path" 2>/dev/null) || return 0 + + (( _zsh_ssh_parse_depth++ )) + if [[ -n "${_zsh_ssh_seen_config_files[$config_file_path]}" ]]; then + (( _zsh_ssh_parse_depth-- )) + return 0 + fi + _zsh_ssh_seen_config_files[$config_file_path]=1 # Read the file line by line while IFS= read -r line || [[ -n "$line" ]]; do # Match lines starting with 'Include' - if [[ $line =~ ^[Ii]nclude[[:space:]=]+(.*) ]] && (( $#match > 0 )); then + if [[ $line =~ ^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]=]+(.*) ]] && (( $#match > 0 )); then # Split the rest of the line into individual paths - local include_paths=(${(z)match[1]}) + include_paths=(${(z)match[1]}) for raw_path in "${include_paths[@]}"; do # Expand ~ and environment variables in the path - eval "local expanded=\${(e)raw_path}" - # local expanded="${raw_path/#~/$HOME}" + expanded="${(e)raw_path}" # If path is relative, resolve it relative to the current config file if [[ "$expanded" != /* ]]; then @@ -55,17 +68,22 @@ _parse_config_file() { echo "$line" fi done < "$config_file_path" + + (( _zsh_ssh_parse_depth-- )) + if (( _zsh_ssh_parse_depth == 0 )); then + unset _zsh_ssh_seen_config_files + fi } _ssh_host_list() { local ssh_config host_list - ssh_config=$(_parse_config_file $SSH_CONFIG_FILE) - ssh_config=$(echo $ssh_config | command grep -v -E "^\s*#[^_]") + ssh_config=$(_parse_config_file "$SSH_CONFIG_FILE") + ssh_config=$(printf "%s\n" "$ssh_config" | command grep -v -E "^\s*#[^_]") # Ensure blank line before each Host/Match block for AWK paragraph mode (RS="") - ssh_config=$(echo $ssh_config | command awk '/^[[:space:]]*[Hh]ost[[:space:]]|^[[:space:]]*[Mm]atch[[:space:]]/{print ""} {print}') + ssh_config=$(printf "%s\n" "$ssh_config" | command awk '/^[[:space:]]*[Hh]ost[[:space:]]|^[[:space:]]*[Mm]atch[[:space:]]/{print ""} {print}') - host_list=$(echo $ssh_config | command awk ' + host_list=$(printf "%s\n" "$ssh_config" | command awk ' function join(array, start, end, sep, result, i) { # https://www.gnu.org/software/gawk/manual/html_node/Join-Function.html if (sep == "") @@ -183,7 +201,7 @@ _ssh_host_list() { fi host_list=$(printf "%s\n" "$host_list" | command sort -u) - echo $host_list + printf "%s\n" "$host_list" } @@ -203,7 +221,7 @@ Alias|->|Hostname|User|Desc host_list="${header}\n${host_list}" - echo $host_list | command column -t -s '|' + printf "%s\n" "$host_list" | command column -t -s '|' } _set_lbuffer() { From 42b6e28d5e8795b2f3586c17bf4cd24be5860fa3 Mon Sep 17 00:00:00 2001 From: Sergey Morozov Date: Wed, 22 Apr 2026 16:07:25 +0300 Subject: [PATCH 2/4] fix(ssh-completion): render header separator with real newline Use an actual newline when combining the fzf header and host list so aliases no longer get a literal "\\n" prefix in completion output. --- zsh-ssh.zsh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zsh-ssh.zsh b/zsh-ssh.zsh index f560bab..6595a75 100644 --- a/zsh-ssh.zsh +++ b/zsh-ssh.zsh @@ -219,7 +219,7 @@ Alias|->|Hostname|User|Desc ─────|──|────────|────|──── " - host_list="${header}\n${host_list}" + host_list="${header}"$'\n'"${host_list}" printf "%s\n" "$host_list" | command column -t -s '|' } From 85d7a05232dd4f1270387ff775fb9a05251c6dc2 Mon Sep 17 00:00:00 2001 From: Sergey Morozov Date: Wed, 22 Apr 2026 16:45:21 +0300 Subject: [PATCH 3/4] fix(ssh-completion): recover from stale parse state in interactive sessions Reset parser globals when state is inconsistent and always clean up recursion tracking. This prevents empty completion output after interrupted include parsing. --- zsh-ssh.zsh | 94 +++++++++++++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/zsh-ssh.zsh b/zsh-ssh.zsh index 6595a75..84e940c 100644 --- a/zsh-ssh.zsh +++ b/zsh-ssh.zsh @@ -26,53 +26,63 @@ _parse_config_file() { # Resolve the full path of the input config file config_file_path=$(realpath "$input_path" 2>/dev/null) || return 0 - (( _zsh_ssh_parse_depth++ )) - if [[ -n "${_zsh_ssh_seen_config_files[$config_file_path]}" ]]; then - (( _zsh_ssh_parse_depth-- )) - return 0 + # If previous parse was interrupted, reset stale global state. + if (( _zsh_ssh_parse_depth <= 0 )); then + _zsh_ssh_parse_depth=0 + unset _zsh_ssh_seen_config_files + typeset -gA _zsh_ssh_seen_config_files fi - _zsh_ssh_seen_config_files[$config_file_path]=1 - - # Read the file line by line - while IFS= read -r line || [[ -n "$line" ]]; do - # Match lines starting with 'Include' - if [[ $line =~ ^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]=]+(.*) ]] && (( $#match > 0 )); then - # Split the rest of the line into individual paths - include_paths=(${(z)match[1]}) - - for raw_path in "${include_paths[@]}"; do - # Expand ~ and environment variables in the path - expanded="${(e)raw_path}" - - # If path is relative, resolve it relative to the current config file - if [[ "$expanded" != /* ]]; then - if [[ "$expanded" == ~* ]]; then - expanded="${expanded/#\~/$HOME}" - else - expanded="$(dirname "$config_file_path")/$expanded" - fi - fi - # Expand wildcards (e.g. *.conf) and loop over each matched file - for include_file_path in $~expanded; do - if [[ -f "$include_file_path" ]]; then - # Separate includes with a blank line (for readability) - echo "" - # Recursively parse included files - _parse_config_file "$include_file_path" + (( _zsh_ssh_parse_depth++ )) + { + if [[ -n "${_zsh_ssh_seen_config_files[$config_file_path]}" ]]; then + return 0 + fi + _zsh_ssh_seen_config_files[$config_file_path]=1 + + # Read the file line by line + while IFS= read -r line || [[ -n "$line" ]]; do + # Match lines starting with 'Include' + if [[ $line =~ ^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]=]+(.*) ]] && (( $#match > 0 )); then + # Split the rest of the line into individual paths + include_paths=(${(z)match[1]}) + + for raw_path in "${include_paths[@]}"; do + # Expand ~ and environment variables in the path + expanded="${(e)raw_path}" + + # If path is relative, resolve it relative to the current config file + if [[ "$expanded" != /* ]]; then + if [[ "$expanded" == ~* ]]; then + expanded="${expanded/#\~/$HOME}" + else + expanded="$(dirname "$config_file_path")/$expanded" + fi fi + + # Expand wildcards (e.g. *.conf) and loop over each matched file + for include_file_path in $~expanded; do + if [[ -f "$include_file_path" ]]; then + # Separate includes with a blank line (for readability) + echo "" + # Recursively parse included files + _parse_config_file "$include_file_path" + fi + done done - done - else - # Print normal (non-Include) lines - echo "$line" + else + # Print normal (non-Include) lines + echo "$line" + fi + done < "$config_file_path" + } always { + (( _zsh_ssh_parse_depth-- )) + if (( _zsh_ssh_parse_depth <= 0 )); then + _zsh_ssh_parse_depth=0 + unset _zsh_ssh_seen_config_files + typeset -gA _zsh_ssh_seen_config_files fi - done < "$config_file_path" - - (( _zsh_ssh_parse_depth-- )) - if (( _zsh_ssh_parse_depth == 0 )); then - unset _zsh_ssh_seen_config_files - fi + } } _ssh_host_list() { From cfad86f0e9fa036fadacd20735ef817bd57a6d16 Mon Sep 17 00:00:00 2001 From: Sergey Morozov Date: Wed, 22 Apr 2026 17:02:35 +0300 Subject: [PATCH 4/4] fix(ssh-completion): expand tilde paths in SSH Include parsing Normalize literal `~` prefixes before resolving relative include paths so hosts from configs like `~/.ssh/config.d/*.ssh_config` are included in fzf completion. Made-with: Cursor --- zsh-ssh.zsh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/zsh-ssh.zsh b/zsh-ssh.zsh index 84e940c..134d408 100644 --- a/zsh-ssh.zsh +++ b/zsh-ssh.zsh @@ -51,17 +51,18 @@ _parse_config_file() { # Expand ~ and environment variables in the path expanded="${(e)raw_path}" + # Expand a literal leading ~ before testing whether the path is relative. + if [[ $expanded == '~'* ]]; then + expanded="${expanded/#\~/$HOME}" + fi + # If path is relative, resolve it relative to the current config file if [[ "$expanded" != /* ]]; then - if [[ "$expanded" == ~* ]]; then - expanded="${expanded/#\~/$HOME}" - else - expanded="$(dirname "$config_file_path")/$expanded" - fi + expanded="$(dirname "$config_file_path")/$expanded" fi # Expand wildcards (e.g. *.conf) and loop over each matched file - for include_file_path in $~expanded; do + for include_file_path in ${~expanded}(N); do if [[ -f "$include_file_path" ]]; then # Separate includes with a blank line (for readability) echo ""