SLOPSHOPPER

ssh-connect

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…

newcommandprocess
★ 1v0.1.0MITupdated 2026-10-15samuelbfernandes/moddeck/mods/ssh-connect
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ssh-connect
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ssh ⎿ ssh-connect: usage: /ssh [connect|shell|status|disconnect|hosts] [host] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ssh-connect

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

Commands

/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 hoststhe 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].

Keep the connection open (so other tools reuse it)

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.

Platforms

  • macOS: Terminal.app, opened with 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).
  • No terminal found or it would not open: the reply gives the command and puts ! ssh … in the prompt box.
  • Windows' OpenSSH has no connection sharing, so status and disconnect say they are not available there.

Limits

  • Host names must be letters, digits, . _ -; anything else is refused. Nothing is run through a shell string.
  • Tested here: the command logic and each platform's argv (unit tests). Opening a real window was exercised on none of the three platforms by the test suite.
  • Turn it off: the enabled setting.
Source 3 files
hooks/register.tsx 57 lines
1// 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}
57
hooks/ssh.ts 119 lines
1// 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}
119
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'ssh-connect': Record<string, never>
4  }
5}
6