feat(ssh-launcher): add /ssh launcher for SSH config hosts (#138)

* feat(ssh-launcher): add /ssh launcher for SSH config hosts

Provide a launcher provider that reads ~/.ssh/config, lists host aliases
with fuzzy search, and opens an SSH session in the terminal on activate.

* docs(ssh-launcher): update store thumbnail

Replace the plugin card image with the final SSH Launcher artwork,
blur server IP addresses, and fix the displayed category tags.

* fix(ssh-launcher): parse SSH config directives case-insensitively

Match Host, Hostname, and User keywords regardless of casing so
lowercase blocks are included, consistent with OpenSSH behavior.
This commit is contained in:
Cleboost
2026-07-29 08:48:14 -04:00
committed by GitHub
parent 6cee9bbcc7
commit a2b1062856
6 changed files with 348 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# SSH Launcher
![SSH Launcher thumbnail](thumbnail.webp)
SSH Launcher lists hosts from your OpenSSH config and opens an SSH session in a
terminal from the Noctalia launcher.
## Plugin
| Field | Value |
| --- | --- |
| ID | `cleboost/ssh-launcher` |
| Entry | Launcher provider: `launcher` |
| Launcher Prefix | `/ssh` |
## Requirements
Install [OpenSSH](https://www.openssh.com/) and ensure `ssh` is available on
`PATH`.
## Usage
Open the Noctalia launcher and type `/ssh` to list hosts from your SSH config.
Continue typing to filter by host alias, hostname, or user, then select one to
open a terminal running `ssh <host>`.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `config_path` | `file` | `~/.ssh/config` | Path to your SSH config file. |
| `max_results` | `int` | `30` | Maximum number of hosts shown. |
| `extra_args` | `string` | `""` | Extra arguments appended to every `ssh` command. |
| `terminal` | `string` | `""` | Custom terminal command. Empty uses Noctalia's terminal discovery. |
## Notes
Hosts are read from simple `Host` entries in your SSH config. Wildcard patterns
such as `Host *` or `Host *.example.com` are ignored. `Include` directives are
not expanded in this version. The host list is cached for the launcher session.
+201
View File
@@ -0,0 +1,201 @@
--!nonstrict
local cachedHosts = nil
local function trim(s)
return noctalia.string.trim(s or "")
end
local function shellQuote(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local function getConfigPath()
local configured = noctalia.getConfig("config_path")
if type(configured) == "string" and configured ~= "" then
return noctalia.expandPath(configured)
end
return noctalia.expandPath("~/.ssh/config")
end
local function getMaxResults()
local n = noctalia.getConfig("max_results")
return (type(n) == "number" and n > 0) and math.floor(n) or 30
end
local function statusRow(title, subtitle, glyph)
return { id = "", title = title, subtitle = subtitle, glyph = glyph }
end
local function isValidHostAlias(alias)
return alias ~= "*" and not alias:match("[*?]")
end
local function hostSubtitle(host)
if host.user and host.hostname then
return host.user .. "@" .. host.hostname
end
return host.hostname or host.user or ""
end
local function parseSshConfig(content)
local entries = {}
local current = nil
local function flushBlock()
if not current or #current.aliases == 0 then
return
end
local subtitle = hostSubtitle(current)
for _, alias in current.aliases do
table.insert(entries, { alias = alias, subtitle = subtitle })
end
end
for line in content:gmatch("[^\r\n]+") do
local trimmed = trim(line)
if trimmed ~= "" and not trimmed:match("^#") then
local key, value = trimmed:match("^(%S+)%s+(.+)$")
if key and value then
key = key:lower()
if key == "host" then
flushBlock()
current = { aliases = {}, hostname = nil, user = nil }
for alias in value:gmatch("%S+") do
if isValidHostAlias(alias) then
table.insert(current.aliases, alias)
end
end
elseif current then
if key == "hostname" then
current.hostname = trim(value)
elseif key == "user" then
current.user = trim(value)
end
end
end
end
end
flushBlock()
return entries
end
local function loadHosts()
local content = noctalia.readFile(getConfigPath())
if type(content) ~= "string" or content == "" then
return nil
end
return parseSshConfig(content)
end
local function launchTerminal(cmd)
local term = trim(noctalia.getConfig("terminal") or "")
if term == "" then
return noctalia.runInTerminal(cmd)
end
local first = term:match("^%S+") or term
local bin = first:match("([^/]+)$") or first
local separator = (bin == "gnome-terminal" or bin == "kgx" or bin == "ptyxis") and "--" or "-e"
return noctalia.runAsync(term .. " " .. separator .. " sh -lc " .. shellQuote(cmd))
end
local function fuzzyScoreForHost(filter, host)
for _, text in { host.alias, host.subtitle } do
if text ~= "" then
local score = noctalia.fuzzyScore(filter, text)
if score then
return score
end
end
end
end
local function makeRows(hosts, filter)
local rows = {}
local limit = getMaxResults()
for _, host in hosts do
local score = filter == "" and nil or fuzzyScoreForHost(filter, host)
if filter == "" or score ~= nil then
table.insert(rows, {
id = host.alias,
title = host.alias,
subtitle = host.subtitle ~= "" and host.subtitle or nil,
glyph = "terminal",
score = score,
})
end
end
if filter == "" then
table.sort(rows, function(a, b)
return a.title:lower() < b.title:lower()
end)
while #rows > limit do
table.remove(rows)
end
end
return rows
end
local function showResults(query, hosts, filter)
local rows = makeRows(hosts, filter)
if #rows == 0 then
launcher.setResults(query, {
statusRow(
noctalia.tr("no-hosts-found"),
filter ~= "" and noctalia.tr("filter-empty", { filter = filter }) or noctalia.tr("config-empty"),
"terminal"
),
})
else
launcher.setResults(query, rows)
end
end
function onQuery(query)
local filter = trim(query)
if not noctalia.commandExists("ssh") then
launcher.setResults(query, {
statusRow(noctalia.tr("err-no-ssh"), noctalia.tr("err-no-ssh-subtitle"), "triangle-alert"),
})
return
end
if cachedHosts then
showResults(query, cachedHosts, filter)
return
end
launcher.setResults(query, {
statusRow(noctalia.tr("loading"), noctalia.tr("loading-subtitle"), "loader"),
})
local hosts = loadHosts()
if not hosts then
launcher.setResults(query, {
statusRow(noctalia.tr("config-missing"), getConfigPath(), "file-x"),
})
return
end
cachedHosts = hosts
showResults(query, hosts, filter)
end
function onActivate(id)
if id == "" then
return
end
local extra = trim(noctalia.getConfig("extra_args") or "")
local cmd = "ssh " .. shellQuote(id) .. (extra ~= "" and (" " .. extra) or "")
if not launchTerminal(cmd) then
noctalia.notifyError("SSH Launcher", noctalia.tr("err-terminal"))
end
end
+49
View File
@@ -0,0 +1,49 @@
id = "cleboost/ssh-launcher"
name = "SSH Launcher"
version = "1.0.0"
plugin_api = 3
author = "Cleboost"
license = "MIT"
icon = "terminal"
description = "Connect to SSH hosts from ~/.ssh/config via /ssh."
tags = ["launcher", "network", "utility", "development"]
dependencies = ["ssh"]
[[launcher_provider]]
id = "launcher"
entry = "launcher.luau"
prefix = "ssh"
glyph = "terminal"
include_in_global_search = false
debounce_ms = 0
[[setting]]
key = "config_path"
type = "file"
label_key = "settings.config_path.label"
description_key = "settings.config_path.description"
default = "~/.ssh/config"
[[setting]]
key = "max_results"
type = "int"
label_key = "settings.max_results.label"
description_key = "settings.max_results.description"
default = 30
min = 1
max = 100
[[setting]]
key = "extra_args"
type = "string"
label_key = "settings.extra_args.label"
description_key = "settings.extra_args.description"
default = ""
[[setting]]
key = "terminal"
type = "string"
label_key = "settings.terminal.label"
description_key = "settings.terminal.description"
default = ""
advanced = true
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+29
View File
@@ -0,0 +1,29 @@
{
"config-empty": "No SSH hosts found in config",
"config-missing": "SSH config file not found",
"err-no-ssh": "ssh command not found",
"err-no-ssh-subtitle": "Install OpenSSH client and ensure ssh is on PATH",
"err-terminal": "Could not open a terminal",
"filter-empty": "Filter: \"{filter}\"",
"loading": "Loading…",
"loading-subtitle": "Reading SSH config",
"no-hosts-found": "No SSH hosts found",
"settings": {
"config_path": {
"description": "Path to your SSH config file.",
"label": "SSH config path"
},
"extra_args": {
"description": "Extra arguments appended to every ssh command (for example -A).",
"label": "Extra SSH arguments"
},
"max_results": {
"description": "Maximum number of hosts shown in the launcher.",
"label": "Maximum results"
},
"terminal": {
"description": "Custom terminal command. Leave empty to use Noctalia's terminal discovery.",
"label": "Terminal command"
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"config-empty": "Aucun hôte SSH trouvé dans la config",
"config-missing": "Fichier de config SSH introuvable",
"err-no-ssh": "Commande ssh introuvable",
"err-no-ssh-subtitle": "Installez le client OpenSSH et vérifiez que ssh est dans le PATH",
"err-terminal": "Impossible d'ouvrir un terminal",
"filter-empty": "Filtre : « {filter} »",
"loading": "Chargement…",
"loading-subtitle": "Lecture de la config SSH",
"no-hosts-found": "Aucun hôte SSH trouvé",
"settings": {
"config_path": {
"description": "Chemin vers votre fichier de config SSH.",
"label": "Chemin de la config SSH"
},
"extra_args": {
"description": "Arguments ajoutés à chaque commande ssh (par exemple -A).",
"label": "Arguments SSH supplémentaires"
},
"max_results": {
"description": "Nombre maximum d'hôtes affichés dans le launcher.",
"label": "Nombre maximum de résultats"
},
"terminal": {
"description": "Commande terminal personnalisée. Laissez vide pour la détection automatique de Noctalia.",
"label": "Commande terminal"
}
}
}