├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── alfredssh.py ├── icon.png └── info.plist /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.* 7 | 8 | jobs: 9 | release: 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: create-alfredworkflow 14 | run: make 15 | - uses: ncipollo/release-action@v1 16 | with: 17 | artifacts: "*.alfredworkflow" 18 | draft: true 19 | generateReleaseNotes: true 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._* 3 | *.py[cod] 4 | *~ 5 | prefs.plist 6 | ssh.alfred*workflow 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Robin Breathe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | zip -j9 --filesync ssh.alfredworkflow *.{plist,png,py} 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh workflow for Alfred 2 | 3 | A workflow for [Alfred](http://www.alfredapp.com/) Powerpack users to rapidly open Secure SHell (ssh) sessions with smart hostname autocompletion based on the contents of `~/.ssh/known_hosts`, `~/.ssh/config`, `/etc/hosts` and (optionally) Bonjour. 4 | 5 | ![Example 1](https://raw.github.com/isometry/alfredworkflows/master/screenshots/ssh_local.png) 6 | 7 | ![Example 2](https://raw.github.com/isometry/alfredworkflows/master/screenshots/ssh_user@local.png) 8 | 9 | ## Releases 10 | 11 | - [v1.3 for Alfred 2.4+](https://github.com/isometry/alfred-ssh/releases/tag/v1.3) 12 | - [v2.3 for Alfred 3.1+](https://github.com/isometry/alfred-ssh/releases/tag/v2.3) 13 | - [v3.x for Alfred 4.0+](https://github.com/isometry/alfred-ssh/releases/tag/v3.1) 14 | - [v4.x for Alfred 5.0+](https://github.com/isometry/alfred-ssh/releases/latest) 15 | 16 | ## Prerequisites 17 | 18 | - [Alfred](http://www.alfredapp.com/) (version 2.4+/3.1+/4.0+/5.0+) 19 | - The [Alfred Powerpack](http://www.alfredapp.com/powerpack/). 20 | - Python3 for v3.x+ (most easily installed/maintained with `sudo xcode-select --install` or [Homebrew](https://brew.sh/)) 21 | 22 | ## Usage 23 | 24 | Type `ssh` in Alfred followed by either a literal hostname or by some letters from the hostname of a host referenced in any of `~/.ssh/known_hosts`, `~/.ssh/config`, `/etc/hosts`, or (with `pybonjour` installed) Bonjour. 25 | 26 | If you wish to have [iTerm2](https://www.iterm2.com/) act as ssh protocol handler rather than Terminal.app, create a new iTerm2 profile with “Name” `$$USER$$@$$HOST$$`, “Command” `$$` and “Schemes handled” `ssh` (e.g. [here](http://apple.stackexchange.com/questions/28938/set-iterm2-as-the-ssh-url-handler) and [here](http://www.alfredforum.com/topic/826-ssh-with-smart-hostname-autocompletion/#entry4147)). 27 | 28 | ## Contributions & Thanks 29 | 30 | - [nikipore](https://github.com/nikipore) 31 | -------------------------------------------------------------------------------- /alfredssh.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # ssh.alfredworkflow, v4.0 4 | # Robin Breathe, 2013-2023 5 | 6 | import json 7 | import re 8 | import sys 9 | import os 10 | 11 | from collections import defaultdict 12 | 13 | DEFAULT_MAX_RESULTS = 36 14 | 15 | 16 | class Hosts(defaultdict): 17 | def __init__(self, query, user=None): 18 | super(Hosts, self).__init__(list) 19 | self[query].append('input') 20 | self.query = query 21 | self.user = user 22 | 23 | def merge(self, source, hosts=()): 24 | for host in hosts: 25 | self[host].append(source) 26 | 27 | def _alfred_item(self, host, source): 28 | _arg = self.user and '@'.join([self.user, host]) or host 29 | _uri = 'ssh://{}'.format(_arg) 30 | _sub = 'source: {}'.format(', '.join(source)) 31 | return { 32 | "uid": _uri, 33 | "title": _uri, 34 | "subtitle": _sub, 35 | "arg": _arg, 36 | "icon": {"path": "icon.png"}, 37 | "autocomplete": _arg 38 | } 39 | 40 | def alfred_json(self, _filter=(lambda x: True), maxresults=DEFAULT_MAX_RESULTS): 41 | items = [ 42 | self._alfred_item(host, self[host]) for host in self.keys() 43 | if _filter(host) 44 | ] 45 | return json.dumps({"items": items[:maxresults]}) 46 | 47 | 48 | def cache_file(filename, volatile=True): 49 | parent = os.path.expanduser( 50 | ( 51 | os.getenv('alfred_workflow_data'), 52 | os.getenv('alfred_workflow_cache') 53 | )[bool(volatile)] or os.getenv('TMPDIR') 54 | ) 55 | if not os.path.isdir(parent): 56 | os.mkdir(parent) 57 | if not os.access(parent, os.W_OK): 58 | raise IOError('No write access: %s' % parent) 59 | return os.path.join(parent, filename) 60 | 61 | 62 | def fetch_file(file_path, cache_prefix, parser, env_flag): 63 | """ 64 | Parse and cache a file with the named parser 65 | """ 66 | # Allow default sources to be disabled 67 | if env_flag is not None and int(os.getenv('alfredssh_{}'.format(env_flag), 1)) != 1: 68 | return (file_path, ()) 69 | 70 | # Expand the specified file path 71 | master = os.path.expanduser(file_path) 72 | 73 | # Skip a missing file 74 | if not os.path.isfile(master): 75 | return (file_path, ()) 76 | 77 | # Read from JSON cache if it's up-to-date 78 | if cache_prefix is not None: 79 | cache = cache_file('{}.1.json'.format(cache_prefix)) 80 | if os.path.isfile(cache) and os.path.getmtime(cache) > os.path.getmtime(master): 81 | return (file_path, json.load(open(cache, 'r'))) 82 | 83 | # Open and parse the file 84 | try: 85 | with open(master, 'r') as f: 86 | results = parse_file(f, parser) 87 | except IOError: 88 | pass 89 | else: 90 | # Update the JSON cache 91 | if cache_prefix is not None: 92 | json.dump(list(results), open(cache, 'w')) 93 | # Return results 94 | return (file_path, results) 95 | 96 | 97 | def parse_file(open_file, parser): 98 | parsers = { 99 | 'ssh_config': 100 | ( 101 | host for line in open_file 102 | if line[:5].lower() == 'host ' 103 | for host in line.split()[1:] 104 | if not ('*' in host or '?' in host or '!' in host) 105 | ), 106 | 'known_hosts': 107 | ( 108 | host for line in open_file 109 | if line.strip() and not line.startswith('|') 110 | for host in line.split()[0].split(',') 111 | ), 112 | 'hosts': 113 | ( 114 | host for line in open_file 115 | if not line.startswith('#') and not line.startswith("127.0.0.1") 116 | for host in line.split()[1:] 117 | if host != 'broadcasthost' 118 | ), 119 | 'extra_file': 120 | ( 121 | host for line in open_file 122 | if not line.startswith('#') 123 | for host in line.split() 124 | ) 125 | } 126 | return set(parsers[parser]) 127 | 128 | 129 | def complete(): 130 | query = ''.join(sys.argv[1:]) 131 | maxresults = int(os.getenv('alfredssh_max_results', DEFAULT_MAX_RESULTS)) 132 | 133 | if '@' in query: 134 | (user, host) = query.split('@', 1) 135 | else: 136 | (user, host) = (None, query) 137 | 138 | host_chars = (('\\.' if x == '.' else x) for x in list(host)) 139 | pattern = re.compile('.*?\b?'.join(host_chars), flags=re.IGNORECASE) 140 | 141 | hosts = Hosts(query=host, user=user) 142 | 143 | for results in ( 144 | fetch_file('~/.ssh/config', 'ssh_config', 'ssh_config', 'ssh_config'), 145 | fetch_file('~/.ssh/known_hosts', 'known_hosts', 'known_hosts', 'known_hosts'), 146 | fetch_file('/etc/ssh/ssh_known_hosts', 'systemwide_known_hosts', 'known_hosts', 'known_hosts'), 147 | fetch_file('/usr/local/etc/ssh/ssh_known_hosts', 'localetc_known_hosts', 'known_hosts', 'known_hosts'), 148 | fetch_file('/etc/hosts', 'hosts', 'hosts', 'hosts'), 149 | ): 150 | hosts.merge(*results) 151 | 152 | extra_files = os.getenv('alfredssh_extra_files') 153 | if extra_files: 154 | for file_path in extra_files.split(): 155 | file_prefix = os.path.basename(file_path) 156 | hosts.merge(*fetch_file(file_path, file_prefix, 'extra_file', None)) 157 | 158 | return hosts.alfred_json(pattern.search, maxresults=maxresults) 159 | 160 | 161 | if __name__ == '__main__': 162 | print(complete()) 163 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isometry/alfred-ssh/97377722fe89cab5e9debaa26bfdda7ad0e3ff96/icon.png -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | net.isometry.alfred.ssh 7 | category 8 | Tools 9 | connections 10 | 11 | 73503A72-F4BD-4C29-B531-ACE7CF405F6B 12 | 13 | 14 | destinationuid 15 | 027D62F5-14E9-4EA0-BE27-57C38B1ECC1F 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 89086C8B-52A6-4FC4-BD85-FC672CC36238 25 | 26 | 27 | destinationuid 28 | 73503A72-F4BD-4C29-B531-ACE7CF405F6B 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | 8A4E0C7A-B3E2-4872-B51D-26A1CF571253 38 | 39 | 40 | destinationuid 41 | 73503A72-F4BD-4C29-B531-ACE7CF405F6B 42 | modifiers 43 | 0 44 | modifiersubtext 45 | 46 | vitoclose 47 | 48 | 49 | 50 | 51 | createdby 52 | Robin Breathe 53 | description 54 | Open Secure SHell with smart hostname autocompletion 55 | disabled 56 | 57 | name 58 | ssh 59 | objects 60 | 61 | 62 | config 63 | 64 | action 65 | 0 66 | argument 67 | 0 68 | focusedappvariable 69 | 70 | focusedappvariablename 71 | 72 | hotkey 73 | 41 74 | hotmod 75 | 1310720 76 | hotstring 77 | S 78 | leftcursor 79 | 80 | modsmode 81 | 0 82 | relatedAppsMode 83 | 0 84 | 85 | type 86 | alfred.workflow.trigger.hotkey 87 | uid 88 | 89086C8B-52A6-4FC4-BD85-FC672CC36238 89 | version 90 | 2 91 | 92 | 93 | config 94 | 95 | alfredfiltersresults 96 | 97 | alfredfiltersresultsmatchmode 98 | 0 99 | argumenttreatemptyqueryasnil 100 | 101 | argumenttrimmode 102 | 0 103 | argumenttype 104 | 0 105 | escaping 106 | 0 107 | keyword 108 | {var:keyword} 109 | queuedelaycustom 110 | 3 111 | queuedelayimmediatelyinitially 112 | 113 | queuedelaymode 114 | 0 115 | queuemode 116 | 1 117 | runningsubtext 118 | Please Wait: matching host… 119 | script 120 | 121 | scriptargtype 122 | 1 123 | scriptfile 124 | alfredssh.py 125 | subtext 126 | Open Secure SHell with smart hostname autocompletion 127 | title 128 | Open SSH 129 | type 130 | 8 131 | withspace 132 | 133 | 134 | type 135 | alfred.workflow.input.scriptfilter 136 | uid 137 | 73503A72-F4BD-4C29-B531-ACE7CF405F6B 138 | version 139 | 3 140 | 141 | 142 | config 143 | 144 | browser 145 | 146 | skipqueryencode 147 | 148 | skipvarencode 149 | 150 | spaces 151 | 152 | url 153 | ssh://{query} 154 | 155 | type 156 | alfred.workflow.action.openurl 157 | uid 158 | 027D62F5-14E9-4EA0-BE27-57C38B1ECC1F 159 | version 160 | 1 161 | 162 | 163 | config 164 | 165 | availableviaurlhandler 166 | 167 | triggerid 168 | ssh 169 | 170 | type 171 | alfred.workflow.trigger.external 172 | uid 173 | 8A4E0C7A-B3E2-4872-B51D-26A1CF571253 174 | version 175 | 1 176 | 177 | 178 | readme 179 | Easily open remote SSH sessions using your default ssh: protocol handler (the default being Terminal.app) with full anchored hostname autocompletion against the contents of ~/.ssh/config (e.g. Host aliases with Hostname override), ~/.ssh/known_hosts (historical connections), /etc/hosts and, optionally, other user-specified files. 180 | uidata 181 | 182 | 027D62F5-14E9-4EA0-BE27-57C38B1ECC1F 183 | 184 | note 185 | Launch default ssh: protocol-handler 186 | xpos 187 | 500 188 | ypos 189 | 15 190 | 191 | 73503A72-F4BD-4C29-B531-ACE7CF405F6B 192 | 193 | note 194 | Parse arguments and return matching hosts 195 | xpos 196 | 320 197 | ypos 198 | 15 199 | 200 | 89086C8B-52A6-4FC4-BD85-FC672CC36238 201 | 202 | note 203 | Optionally load workflow directly via hotkey 204 | xpos 205 | 150 206 | ypos 207 | 15 208 | 209 | 8A4E0C7A-B3E2-4872-B51D-26A1CF571253 210 | 211 | xpos 212 | 150 213 | ypos 214 | 200 215 | 216 | 217 | userconfigurationconfig 218 | 219 | 220 | config 221 | 222 | default 223 | ssh 224 | placeholder 225 | ssh 226 | required 227 | 228 | trim 229 | 230 | 231 | description 232 | 233 | label 234 | Keyword 235 | type 236 | textfield 237 | variable 238 | keyword 239 | 240 | 241 | config 242 | 243 | default 244 | 36 245 | placeholder 246 | 36 247 | required 248 | 249 | trim 250 | 251 | 252 | description 253 | Maximum number of search results to return 254 | label 255 | Max. Results 256 | type 257 | textfield 258 | variable 259 | alfredssh_max_results 260 | 261 | 262 | config 263 | 264 | default 265 | 266 | required 267 | 268 | text 269 | Enable parsing ~/.ssh/config 270 | 271 | description 272 | 273 | label 274 | ssh_config 275 | type 276 | checkbox 277 | variable 278 | alfredssh_ssh_config 279 | 280 | 281 | config 282 | 283 | default 284 | 285 | required 286 | 287 | text 288 | Enable parsing of user and system known_hosts files 289 | 290 | description 291 | Includes: 292 | * ~/.ssh/known_hosts 293 | * /etc/ssh/ssh_known_hosts 294 | * /usr/local/etc/ssh/ssh_known_hosts 295 | label 296 | known_hosts 297 | type 298 | checkbox 299 | variable 300 | alfredssh_known_hosts 301 | 302 | 303 | config 304 | 305 | default 306 | 307 | required 308 | 309 | text 310 | Enable parsing /etc/hosts 311 | 312 | description 313 | 314 | label 315 | hosts file 316 | type 317 | checkbox 318 | variable 319 | alfredssh_hosts 320 | 321 | 322 | config 323 | 324 | default 325 | Extra files containing hostnames to match 326 | filtermode 327 | 2 328 | placeholder 329 | Extra files 330 | required 331 | 332 | 333 | description 334 | Every line not starting with a # is split on whitespace, and all resulting "words" are matched 335 | label 336 | Extra files 337 | type 338 | filepicker 339 | variable 340 | alfredssh_extra_files 341 | 342 | 343 | version 344 | 4.0 345 | webaddress 346 | https://github.com/isometry/alfred-ssh 347 | 348 | 349 | --------------------------------------------------------------------------------