Persistent SSH sessions, interactive PTY shells, SFTP/SCP file transfers, and background jobs for Claude Code and OpenCode

Persistent interactive SSH sessions, profiles, PTY shells, SFTP/SCP file transfers, and remote background job supervision for Claude Code, OpenCode, and MCP clients.
Dual-compatible as an OpenCode Plugin and a standalone Model Context Protocol (MCP) Server.
claude mcp add), Cursor, Claude Desktop, and OpenCode.TERM=dumb, PAGER=cat, and CI=1 to prevent interactive hangs.Run directly in your terminal:
claude mcp add ssh -- npx -y opencode-plugin-ssh
.mcp.jsonAdd a .mcp.json file in your repository root:
{
"mcpServers": {
"ssh": {
"command": "npx",
"args": ["-y", "opencode-plugin-ssh"]
}
}
}
Install directly into Claude Code:
claude plugin add opencode-ssh
Or load locally during development:
claude --plugin-dir /path/to/opencode-ssh
When loaded as a Claude Code plugin/mod, it automatically adds:
/ssh-profiles: Browse configured hosts and connection parameters from ~/.ssh/config./ssh-status: View real-time active SSH tool operations and history./ssh-help: Instant cheatsheet of available SSH commands and MCP tools.· SSH: <operation> on the Claude Code spinner whenever a remote SSH or SCP task is running.Add to your OpenCode configuration (opencode.json or ~/.config/opencode/opencode.jsonc):
{
"plugin": [
"opencode-plugin-ssh"
]
}
Or install as a local dependency:
npm install -D opencode-plugin-ssh
Add to your claude_desktop_config.json or Cursor MCP configuration:
{
"mcpServers": {
"ssh": {
"command": "npx",
"args": ["-y", "opencode-plugin-ssh"]
}
}
}
All tools are available across both OpenCode and MCP clients:
ssh_list_profiles: List profiles from ~/.ssh/config and saved configurations.ssh_save_profile: Store host connection parameters for quick reuse.ssh_connect: Open or verify an active SSH session.ssh_list_sessions: List connected sessions and their active status.ssh_switch_session: Change the default active session.ssh_close: Disconnect and clean up a session.ssh_broadcast: Run a command across multiple connected sessions.ssh_exec: Run a non-interactive command and return stdout, stderr, exit code, and executedAs.ssh_interactive_cmd: Execute commands inside a persistent PTY shell (returns output and executedAs).ssh_switch_user: Switch user (e.g. to root) in the persistent shell via sudo/su with automatic password handling.ssh_pty_send: Send raw text or control characters (e.g. \x03) to the PTY.ssh_pty_read: Retrieve recent buffer output from the PTY shell.ssh_scp: Securely copy files or directories between local machine and remote host (upload / download), or stream directly between two remote sessions (remote_to_remote) with recursive and chunked transfer support.ssh_read_file: Read remote files with pagination and line numbering.ssh_write_file: Write or overwrite remote files over SFTP.ssh_edit_file: Find and replace exact substrings within remote files.ssh_list_dir: Structured directory listing (name, size, mode, permissions, isDirectory, isSymlink, mtime).ssh_stat: Inspect file/directory metadata or test for existence (size, permissions, timestamps, link targets).ssh_mkdir: Create remote directory with optional recursive creation of parent directories (-p).ssh_rm: Delete a remote file or folder with optional recursive directory removal.ssh_rename: Move or rename remote files and directories over SFTP.ssh_chmod: Change remote file/directory permissions via octal mode (e.g. '0755', '0644', '0600').ssh_system_inspect: Inspect system metrics, listening ports, and installed runtimes.ssh_job_spawn: Start a supervised background job.ssh_job_status: Check status and exit code of a spawned job.ssh_job_logs: Fetch stdout and stderr output from a background job.ssh_job_kill: Terminate a background job with a specified signal.hooks/register.js 108 lines1import { parseSshConfigFile, formatProfileList, getHelpText } from "./ssh-helper.js"
2
3// Track active SSH tool calls for live UI feedback
4let activeSshTool = null
5let activeSshHost = null
6let lastExecutionTime = null
7
8export function register(on) {
9 // 1. Session start: Register custom slash commands
10 on("session.start", async ($, e, next) => {
11 await $.command.register({
12 name: "ssh-profiles",
13 description: "List configured SSH hosts and connection details from ~/.ssh/config",
14 })
15
16 await $.command.register({
17 name: "ssh-status",
18 description: "Show active SSH tool status and recent connection history",
19 })
20
21 await $.command.register({
22 name: "ssh-help",
23 description: "Show SSH tools cheatsheet and available commands",
24 })
25
26 return next(e)
27 })
28
29 // 2. Command run: /ssh-profiles
30 on("command.run", { command: "ssh-profiles" }, async ($, e) => {
31 try {
32 const homeRaw = (await $.env.get("HOME")) || (await $.env.get("USERPROFILE")) || ""
33 const home = typeof homeRaw === "string" ? homeRaw.replace(/\\/g, "/") : ""
34 const configPath = home ? `${home}/.ssh/config` : ""
35 let configContent = ""
36
37 if (configPath && (await $.fs.exists(configPath))) {
38 configContent = await $.fs.read(configPath)
39 }
40
41 const profiles = parseSshConfigFile(configContent)
42 return { text: formatProfileList(profiles, configPath) }
43 } catch (err) {
44 return { text: `Failed to inspect SSH profiles: ${err?.message || String(err)}` }
45 }
46 })
47
48 // 3. Command run: /ssh-status
49 on("command.run", { command: "ssh-status" }, async ($, e) => {
50 const lines = [
51 "### SSH Tool & Connection Status",
52 "",
53 activeSshTool
54 ? `• **Currently Active Operation**: \`${activeSshTool}\`${activeSshHost ? ` on \`${activeSshHost}\`` : ""}`
55 : "• **Currently Active Operation**: None (idle)",
56 lastExecutionTime
57 ? `• **Last Operation**: completed at ${new Date(lastExecutionTime).toLocaleTimeString()}`
58 : "• **Last Operation**: None recorded this session",
59 "",
60 "_Ask Claude to list sessions, connect, or execute remote commands anytime._",
61 ]
62 return { text: lines.join("\n") }
63 })
64
65 // 4. Command run: /ssh-help
66 on("command.run", { command: "ssh-help" }, async () => {
67 return { text: getHelpText() }
68 })
69
70 // 5. Tool call hook: Observe SSH tool calls and track active remote operation
71 on("tool.call", async ($, e, next) => {
72 const toolName = e.tool || ""
73 if (typeof toolName === "string" && toolName.startsWith("ssh_")) {
74 activeSshTool = toolName
75 const args = e.input || e.parameters || {}
76 activeSshHost = args.host || args.sessionID || args.profile || null
77 $.ui.invalidate("ui.render")
78 }
79 return next(e)
80 })
81
82 // 6. Turn complete: Clear active spinner status and timestamp operation
83 on("turn.complete", async ($, e, next) => {
84 if (activeSshTool) {
85 lastExecutionTime = Date.now()
86 activeSshTool = null
87 activeSshHost = null
88 $.ui.invalidate("ui.render")
89 }
90 return next(e)
91 })
92
93 // 7. UI Spinner hook: Display active remote operation alongside the Claude Code spinner
94 on("ui.render", { component: "Spinner" }, async ($, e, next) => {
95 if (activeSshTool) {
96 const target = activeSshHost ? ` (${activeSshHost})` : ""
97 return next({
98 ...e,
99 props: {
100 ...e.props,
101 suffix: ` · SSH: ${activeSshTool}${target}…`,
102 },
103 })
104 }
105 return next(e)
106 })
107}
108hooks/ssh-helper.js 124 lines1/**
2 * Helpers for Claude Code SSH mod
3 * @typedef {Object} ParsedProfile
4 * @property {string} name
5 * @property {string} hostName
6 * @property {string} [user]
7 * @property {number} [port]
8 * @property {string} [identityFile]
9 */
10
11export function parseSshConfigFile(content) {
12 if (!content || typeof content !== 'string') return []
13
14 const lines = content.split(/\r?\n/)
15 const profiles = []
16 let current = null
17
18 for (const rawLine of lines) {
19 const line = rawLine.trim()
20 if (!line || line.startsWith('#')) continue
21
22 const match = line.match(/^(\w+)\s+(.+)$/)
23 if (!match) continue
24
25 const key = match[1].toLowerCase()
26 const value = match[2].trim()
27
28 if (key === 'host') {
29 if (current && current.name) {
30 profiles.push({
31 name: current.name,
32 hostName: current.hostName || current.name,
33 user: current.user,
34 port: current.port || 22,
35 identityFile: current.identityFile,
36 })
37 }
38
39 // Ignore wildcard Host entries like *
40 if (value === '*' || value.includes('*') || value.includes('?')) {
41 current = null
42 continue
43 }
44
45 current = { name: value, hostName: value }
46 } else if (current) {
47 if (key === 'hostname') {
48 current.hostName = value
49 } else if (key === 'user') {
50 current.user = value
51 } else if (key === 'port') {
52 const p = parseInt(value, 10)
53 if (!isNaN(p)) current.port = p
54 } else if (key === 'identityfile') {
55 current.identityFile = value
56 }
57 }
58 }
59
60 if (current && current.name) {
61 profiles.push({
62 name: current.name,
63 hostName: current.hostName || current.name,
64 user: current.user,
65 port: current.port || 22,
66 identityFile: current.identityFile,
67 })
68 }
69
70 return profiles
71}
72
73export function formatProfileList(profiles, configPath) {
74 if (!profiles || profiles.length === 0) {
75 return [
76 '### Configured SSH Profiles',
77 '',
78 configPath ? `Checked \`${configPath}\`: No specific host configurations found.` : 'No SSH profiles found.',
79 '',
80 '_To connect to a remote host, provide connection details to Claude (e.g., `connect to root@192.168.1.50`)._',
81 ].join('\n')
82 }
83
84 const rows = profiles.map((p) => {
85 const userStr = p.user ? `${p.user}@` : ''
86 const portStr = p.port && p.port !== 22 ? `:${p.port}` : ''
87 const target = `${userStr}${p.hostName}${portStr}`
88 const key = p.identityFile ? ` (key: \`${p.identityFile}\`)` : ''
89 return `• **\`${p.name}\`** -> \`${target}\`${key}`
90 })
91
92 return [
93 `### Configured SSH Profiles (${profiles.length})`,
94 configPath ? `_Loaded from \`${configPath}\`_` : '',
95 '',
96 ...rows,
97 '',
98 'To connect or run commands on any host, ask Claude, e.g.:',
99 `> "Connect to ${profiles[0].name} and inspect uptime"`,
100 ].filter(Boolean).join('\n')
101}
102
103export function getHelpText() {
104 return `### SSH Tools & Claude Code Mod Cheatsheet
105
106**Commands**:
107• \`/ssh-profiles\` - List configured SSH hosts from ~/.ssh/config
108• \`/ssh-status\` - Show active SSH tool status and execution history
109• \`/ssh-help\` - Show this help guide
110
111**Available MCP Tools for Claude**:
112• \`ssh_connect\` - Open persistent authenticated SSH connection
113• \`ssh_exec\` - Run discrete shell command with stdout/stderr
114• \`ssh_interactive_cmd\` - Run command in persistent shell preserving cd/env
115• \`ssh_switch_user\` - Switch user to root via sudo/su in PTY
116• \`ssh_scp\` - Fast chunked file/directory transfer (upload/download/remote-to-remote)
117• \`ssh_read_file\` / \`ssh_write_file\` / \`ssh_edit_file\` - SFTP file management
118• \`ssh_list_dir\` / \`ssh_stat\` / \`ssh_mkdir\` / \`ssh_rm\` - Remote filesystem operations
119• \`ssh_job_spawn\` / \`ssh_job_logs\` / \`ssh_job_status\` - Detached background jobs
120• \`ssh_system_inspect\` - Single-call OS, CPU, RAM, disk, and runtimes audit
121• \`ssh_broadcast\` - Concurrently execute across multiple active sessions
122`
123}
124