One-click SSH from the moddeck side bar: /ssh connect|shell|status|disconnect opens your terminal on a host from ~/.ssh/config, so you type any password or 2FA…

One-click SSH from the moddeck side bar. /ssh opens a terminal window on a host from your ~/.ssh/config, so you type any password or 2FA code yourself: the mod never sees, stores or sends it.
Install: /plugin install ssh-connect --marketplace samuelbfernandes/moddeck
/ssh connect [host] | macOS/Linux: terminal window running ssh -fN host (a background connection). Windows: an interactive session. |
/ssh shell [host] | terminal window with an interactive ssh host |
/ssh status [host] | ssh -O check: connected or not (macOS/Linux) |
/ssh disconnect [host] | ssh -O exit (macOS/Linux) |
/ssh hosts | the Host names found in your config |
host is optional: a name you type, else the default host setting (/config), else the only host in your config, else a list. The side bar shows ssh with [shell], [status] and [off].
Add this to the Host block in ~/.ssh/config (macOS/Linux):
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 4h
After one login (and one 2FA code) every ssh host and every tool that uses it, a skill or script included, shares that connection until it times out.
osascript. Linux: the first of x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm on your PATH. Windows 10+: a new console window running the built-in OpenSSH client (turn it on under Optional Features).! ssh … in the prompt box.status and disconnect say they are not available there.. _ -; anything else is refused. Nothing is run through a shell string.hooks/register.tsx 57 lines1// ssh-connect: /ssh opens your terminal on a host from ~/.ssh/config; see ssh.ts. FAIL-OPEN: every error ends in one plain
2// message, never a block. Passwords and 2FA codes are typed in the terminal window, never here.
3import type { EngineInterface, Register } from 'claude-code'
4
5import { runCommand } from './ssh'
6import type { Deps, Platform } from './ssh'
7
8const COMMAND = 'ssh'
9const OFF = 'ssh · off · the enabled setting is false (change it in /config)'
10
11async function attempt<T>(f: () => Promise<T>): Promise<T | undefined> {
12 try {
13 return await f()
14 } catch {
15 return undefined
16 }
17}
18
19async function platformOf($: EngineInterface): Promise<Platform> {
20 if ((await attempt(() => $.env.get('OS'))) === 'Windows_NT') return 'win32'
21 const u = await attempt(() => $.process.run(['uname', '-s'], { timeoutMs: 3000 }))
22 return u?.stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
23}
24
25async function configOf($: EngineInterface): Promise<string | undefined> {
26 const home = (await attempt(() => $.env.get('HOME'))) ?? (await attempt(() => $.env.get('USERPROFILE')))
27 if (!home) return undefined
28 const text = await attempt(() => $.fs.read(`${home.replace(/[\\/]+$/, '')}/.ssh/config`))
29 return typeof text === 'string' ? text : undefined
30}
31
32export const register: Register = (on, options) => {
33 const isEnabled = options.enabled !== false
34 const defaultHost = typeof options.defaultHost === 'string' ? options.defaultHost : ''
35
36 on('session.start', async ($, e, next) => {
37 try {
38 await $.command.register({
39 name: COMMAND,
40 description: 'Open a terminal on an SSH host from ~/.ssh/config: /ssh connect|shell|status|disconnect|hosts [host]',
41 argumentHint: '[connect | shell | status | disconnect | hosts] [host]',
42 })
43 } catch {
44 // fail-open: without the command the mod is simply quiet
45 }
46 return next(e)
47 }).catch(($, e, next) => next(e))
48
49 on('command.run', { command: COMMAND }, async ($, e) => {
50 if (!isEnabled) return { text: OFF }
51 const deps: Deps = { run: (argv, o) => $.process.run(argv, o), platform: await platformOf($), config: await configOf($), defaultHost }
52 const out = await runCommand(deps, String((e as { args?: unknown }).args ?? ''))
53 if (out.fill) await attempt(() => $.prompt.fill({ text: out.fill } as never))
54 return { text: out.text }
55 }).catch(() => ({ text: 'ssh · that did not work, nothing was changed' }))
56}
57hooks/ssh.ts 119 lines1// ssh-connect: pure logic for /ssh (no engine access). Reads Host names from the person's ~/.ssh/config, builds argv arrays
2// (never a shell string) and the one platform-specific step: opening a terminal window, so any password or 2FA code is typed
3// by the person and never passes through this mod.
4
5export type Platform = 'darwin' | 'linux' | 'win32'
6export type Run = (argv: readonly string[], o?: { timeoutMs?: number }) => Promise<{ exitCode: number; stdout: string; stderr: string }>
7export type Deps = {
8 run: Run
9 platform: Platform
10 /** The text of ~/.ssh/config, or undefined when it cannot be read. */
11 config: string | undefined
12 defaultHost: string
13}
14export type Reply = { text: string; fill?: string }
15
16const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
17const LINUX_TERMINALS: readonly (readonly string[])[] = [['x-terminal-emulator', '-e'], ['gnome-terminal', '--'], ['konsole', '-e'], ['xfce4-terminal', '-x'], ['xterm', '-e']]
18export const USAGE = 'usage: /ssh [connect|shell|status|disconnect|hosts] [host]'
19
20export const isHostName = (s: string): boolean => HOST_RE.test(s)
21
22/** The concrete `Host` names of an ssh config: patterns (`*`, `?`) and negations (`!`) are skipped, repeats kept once. */
23export function parseHosts(config: string | undefined): string[] {
24 const out: string[] = []
25 for (const line of (config ?? '').split(/\r?\n/)) {
26 const m = /^\s*Host\s+(.+?)\s*$/i.exec(line)
27 if (!m) continue
28 for (const h of m[1]!.split(/\s+/)) if (!/[*?!]/.test(h) && isHostName(h) && !out.includes(h)) out.push(h)
29 }
30 return out
31}
32
33/** Whether the config turns on connection sharing anywhere (`ControlMaster auto|yes|ask|autoask`). */
34export const hasControlMaster = (config: string | undefined): boolean => /^\s*ControlMaster\s+(auto|yes|ask|autoask)\b/im.test(config ?? '')
35
36export const connectArgv = (host: string, platform: Platform): string[] => (platform === 'win32' ? ['ssh', host] : ['ssh', '-fN', host])
37export const shellArgv = (host: string): string[] => ['ssh', host]
38export const checkArgv = (host: string): string[] => ['ssh', '-O', 'check', host]
39export const exitArgv = (host: string): string[] => ['ssh', '-O', 'exit', host]
40
41/** AppleScript string literal: only validated host names and flags go in, but escape anyway. */
42const q = (s: string): string => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
43
44/** The argv that opens a terminal window running `ssh`; undefined when the platform has none we know (linux: `found` names the first on PATH). */
45export function terminalArgv(platform: Platform, ssh: readonly string[], found?: readonly string[]): string[] | undefined {
46 if (platform === 'win32') return ['cmd', '/c', 'start', '', ...ssh]
47 if (platform === 'darwin') return ['osascript', '-e', `tell application "Terminal" to do script ${q(ssh.join(' '))}`, '-e', 'tell application "Terminal" to activate']
48 return found ? [...found, ...ssh] : undefined
49}
50
51async function firstTerminal(run: Run): Promise<readonly string[] | undefined> {
52 for (const t of LINUX_TERMINALS) {
53 try {
54 if ((await run(['which', t[0]!], { timeoutMs: 3000 })).exitCode === 0) return t
55 } catch {
56 // not found: try the next one
57 }
58 }
59 return undefined
60}
61
62function pickHost(d: Deps, arg: string): { host: string } | { error: string } {
63 const hosts = parseHosts(d.config)
64 const want = arg || d.defaultHost.trim()
65 if (want) return isHostName(want) ? { host: want } : { error: `ssh · "${want}" is not a valid host name (letters, digits, . _ -)` }
66 if (hosts.length === 1) return { host: hosts[0]! }
67 if (hosts.length === 0) return { error: 'ssh · no Host entries in ~/.ssh/config yet. Add one, then /ssh connect <host> (see the README)' }
68 return { error: `ssh · which host? ${hosts.join(', ')} (/ssh connect <host>, or set the default host in /config)` }
69}
70
71async function open(d: Deps, ssh: string[], what: string, host: string): Promise<Reply> {
72 const fallback = `! ${ssh.join(' ')}`
73 const found = d.platform === 'linux' ? await firstTerminal(d.run) : undefined
74 const argv = terminalArgv(d.platform, ssh, found)
75 if (!argv) return { text: `ssh · no terminal program found; run it yourself: ${ssh.join(' ')}`, fill: fallback }
76 try {
77 const r = await d.run(argv, { timeoutMs: 8000 })
78 if (r.exitCode === 0) return { text: `ssh · ${what} ${host} in a new terminal window; type your password or 2FA code there` }
79 } catch {
80 // fall through
81 }
82 return { text: `ssh · could not open a terminal window; run it yourself: ${ssh.join(' ')}`, fill: fallback }
83}
84
85/** `ssh -O check` reports on stderr: "Master running (pid=…)" or an error when there is none. */
86export async function status(d: Deps, host: string): Promise<string> {
87 if (d.platform === 'win32') return `ssh · status is not available on Windows (its OpenSSH has no connection sharing); /ssh shell ${host} opens a session`
88 if (!hasControlMaster(d.config)) return `ssh · ${host}: no ControlMaster in ~/.ssh/config, so connections are not shared (see the README)`
89 try {
90 const r = await d.run(checkArgv(host), { timeoutMs: 5000 })
91 return r.exitCode === 0 ? `ssh · ${host} connected (${(r.stderr || r.stdout).trim().split('\n')[0]})` : `ssh · ${host} not connected; /ssh connect ${host}`
92 } catch {
93 return 'ssh · ssh is not installed or could not run'
94 }
95}
96
97export async function runCommand(d: Deps, args: string): Promise<Reply> {
98 const [sub = 'hosts', arg = ''] = args.trim().split(/\s+/)
99 if (!['connect', 'shell', 'status', 'disconnect', 'hosts'].includes(sub)) return { text: USAGE }
100 if (sub === 'hosts') {
101 const hosts = parseHosts(d.config)
102 return { text: hosts.length ? `ssh · hosts: ${hosts.join(', ')}` : 'ssh · no Host entries found in ~/.ssh/config' }
103 }
104 const p = pickHost(d, arg)
105 if ('error' in p) return { text: p.error }
106 if (sub === 'status') return { text: await status(d, p.host) }
107 if (sub === 'disconnect') {
108 if (d.platform === 'win32') return { text: 'ssh · nothing to close on Windows: close the terminal window' }
109 try {
110 const r = await d.run(exitArgv(p.host), { timeoutMs: 5000 })
111 return { text: r.exitCode === 0 ? `ssh · ${p.host} disconnected` : `ssh · ${p.host} was not connected` }
112 } catch {
113 return { text: 'ssh · ssh is not installed or could not run' }
114 }
115 }
116 if (sub === 'shell') return open(d, shellArgv(p.host), 'opened', p.host)
117 return open(d, connectArgv(p.host, d.platform), d.platform === 'win32' ? 'opened' : 'connecting', p.host)
118}
119types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'ssh-connect': Record<string, never>
4 }
5}
6