mcp-ssh-terminal 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kirill Zhdanov
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.
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # mcp-ssh-terminal
2
+
3
+ [![npm](https://img.shields.io/npm/v/mcp-ssh-terminal)](https://www.npmjs.com/package/mcp-ssh-terminal)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+ [![Node.js ≥ 22](https://img.shields.io/badge/node-%E2%89%A5%2022-brightgreen)](https://nodejs.org)
6
+ [![MCP](https://img.shields.io/badge/protocol-MCP-blueviolet)](https://modelcontextprotocol.io)
7
+
8
+ An [MCP](https://modelcontextprotocol.io) server that gives an AI agent **persistent, fully interactive SSH sessions** — a real shell into a remote host that behaves as if you were typing at a local terminal.
9
+
10
+ - **Sessions persist across tool calls** — connect once, run commands, inspect output, run more; state (working directory, environment, running programs) is preserved.
11
+ - **A real terminal, not a command runner** — arbitrary control characters (Ctrl-C, Ctrl-X, arrows, Tab, function keys) pass through faithfully, so interactive TUIs, pagers, tab-completion, and prompts all work.
12
+ - **Non-Unix CLIs work too** — network appliances like **Mikrotik RouterOS** are first-class citizens because the session is a genuine PTY.
13
+ - **Your existing SSH setup just works** — `~/.ssh/config` aliases, `IdentityFile`, ssh-agent, `ProxyJump` chains, and known_hosts are all handled by the real OpenSSH client, untouched.
14
+
15
+ ```text
16
+ ssh_connect { "host": "prod-web" } → s1 + login screen
17
+ ssh_send { "session": "s1", "text": "htop", "appendEnter": true }
18
+ ssh_send { "session": "s1", "keys": ["F5"] } → tree view, rendered
19
+ ssh_interrupt { "session": "s1" } → Ctrl-C, back to prompt
20
+ ```
21
+
22
+ ## Table of contents
23
+
24
+ - [How it works](#how-it-works)
25
+ - [Requirements](#requirements)
26
+ - [Install](#install)
27
+ - [Register with an MCP client](#register-with-an-mcp-client)
28
+ - [Docker](#docker-optional)
29
+ - [Tools](#tools)
30
+ - [Special keys](#special-keys-for-ssh_send)
31
+ - [Usage walkthroughs](#usage-walkthroughs)
32
+ - [Security notes](#security-notes)
33
+ - [Troubleshooting](#troubleshooting)
34
+ - [Development](#development)
35
+
36
+ ## How it works
37
+
38
+ ### Decision 1: drive the real OpenSSH client (`ssh`) inside a PTY
39
+
40
+ Rather than reimplementing SSH in JavaScript, each session spawns your actual `ssh` binary attached to a pseudo-terminal. Every connection requirement you'd otherwise have to hand-build is something OpenSSH already does correctly:
41
+
42
+ | Requirement | How it's satisfied |
43
+ | --- | --- |
44
+ | Honor `~/.ssh/config` | `ssh` reads it natively — `Host` aliases, `IdentityFile`, `Match`, `Include`, everything. No config parsing of our own is in the connection path. |
45
+ | ProxyJump A → B → C | `ProxyJump` in your config, or the `jump` argument (`ssh -J`), handled entirely by `ssh`. |
46
+ | known_hosts verification | `ssh`'s default `StrictHostKeyChecking` behavior is left intact. A new host's fingerprint prompt appears on the screen; you approve it with `ssh_send` (`yes`). |
47
+ | Key-based auth | `ssh` uses your `IdentityFile`, ssh-agent, encrypted keys, FIDO tokens — unchanged. Passphrase/password/2FA prompts surface on the screen and you answer them with `ssh_send`. |
48
+ | Arbitrary control characters | The child runs on a PTY, so raw bytes (0x00–0x1F, 0x7F, escape sequences) reach the remote program exactly. The ssh escape char is disabled (`-e none`) so nothing is intercepted. |
49
+ | Non-Unix shells (RouterOS) | Because it's a faithful terminal (PTY + `-tt` to force a remote PTY), interactive CLIs, paging, and tab-completion behave normally. |
50
+ | Uninterrupted sessions | The `ssh` process is long-lived and tracked by session id; you connect once and interact repeatedly. Keepalives (`ServerAliveInterval=30`, `ServerAliveCountMax=3`) are set by default so NAT/conntrack doesn't silently drop idle sessions; override via `extraArgs`. |
51
+
52
+ A pure-JS library (e.g. `ssh2`) would force us to re-implement ssh_config resolution, `Match` logic, ProxyJump chaining, known_hosts hashing/verification, and key handling — a large, security-sensitive surface that OpenSSH already gets right. Delegating to `ssh` means the server behaves **exactly like your own `ssh <host>` command**.
53
+
54
+ ### Decision 2: a headless terminal emulator for reading output
55
+
56
+ Raw PTY output is a byte stream full of cursor moves, colors, and redraws. Feeding that to an agent is unreadable. So output is piped through [`@xterm/headless`](https://www.npmjs.com/package/@xterm/headless), which maintains a virtual screen. Tools return the **rendered screen a human would see** — redraws collapsed to their final state, escape codes resolved to plain text. (A `raw` mode is still available on `ssh_read` when you need the underlying bytes.) The emulator also answers terminal queries from the remote side (cursor-position reports, device attributes), so programs that probe the terminal don't hang.
57
+
58
+ ### How "is the output done?" is decided
59
+
60
+ After input is sent, the server waits until output has been **quiet for `settleMs`** (default 500 ms) or a `timeoutMs` cap is hit, then renders. A long-running command returns partial output at the timeout with a hint to call `ssh_read` again — no output is lost, and short commands feel snappy.
61
+
62
+ ### Terminal multiplexing
63
+
64
+ Two levels:
65
+
66
+ - **Multiple concurrent sessions** — connect to many hosts at once; each has its own session id (up to 32 concurrent).
67
+ - **tmux/screen inside a session** — since each session is a real terminal, you can run `tmux` on the remote host and drive it with control keys (`ssh_send { keys: ["C-b", "c"] }`, etc.).
68
+
69
+ ## Requirements
70
+
71
+ - **Node.js ≥ 22** (`fs.glob`, used by the config lister, does not exist on Node 20)
72
+ - The **OpenSSH client** (`ssh`) on `PATH` — the same one your shell uses.
73
+ - macOS/Linux. (`node-pty` ships prebuilt binaries; on macOS the bundled `spawn-helper` must be executable — see [Troubleshooting](#troubleshooting).)
74
+
75
+ ## Install
76
+
77
+ **From npm** — nothing to do up front; register it straight with `npx` (next section). Or install globally:
78
+
79
+ ```bash
80
+ npm install -g mcp-ssh-terminal
81
+ ```
82
+
83
+ **From source:**
84
+
85
+ ```bash
86
+ git clone https://github.com/zhdkirill/mcp-ssh-terminal.git
87
+ cd mcp-ssh-terminal
88
+ npm install
89
+ npm run build # compiles TypeScript to dist/
90
+ npm test # optional: runs the tests (keys, argv builder, ssh_config, live PTY sessions)
91
+ ```
92
+
93
+ ## Register with an MCP client
94
+
95
+ **Claude Code (CLI):**
96
+
97
+ ```bash
98
+ claude mcp add ssh -- npx -y mcp-ssh-terminal
99
+ # or, from a source checkout:
100
+ claude mcp add ssh -- node /path/to/mcp-ssh-terminal/dist/index.js
101
+ ```
102
+
103
+ **Project-scoped `.mcp.json` or Claude Desktop config:**
104
+
105
+ ```json
106
+ {
107
+ "mcpServers": {
108
+ "ssh": {
109
+ "command": "npx",
110
+ "args": ["-y", "mcp-ssh-terminal"]
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ (For a source checkout, use `"command": "node", "args": ["/path/to/mcp-ssh-terminal/dist/index.js"]` instead.)
117
+
118
+ The server speaks MCP over stdio. It writes nothing to stdout except protocol traffic (logs go to stderr), so it is safe to run under any stdio MCP host.
119
+
120
+ ## Docker (optional)
121
+
122
+ **Run it natively if you can.** The whole design delegates auth to *your* OpenSSH — config, keys, agent, known_hosts — and a container sees none of that unless you mount it in. Docker is the right choice when the consuming machine shouldn't need Node, or you want a pinned, hermetic runtime; it is the wrong choice if you rely on ssh-agent, FIDO keys, or VPN-only routes that exist on the host.
123
+
124
+ ```bash
125
+ docker build -t mcp-ssh-terminal .
126
+ ```
127
+
128
+ Register (mounting your ssh config/keys read-only):
129
+
130
+ ```bash
131
+ claude mcp add ssh -- docker run -i --rm --init -v "$HOME/.ssh:/home/node/.ssh:ro" mcp-ssh-terminal
132
+ ```
133
+
134
+ Notes for container mode:
135
+
136
+ - **`-i` is mandatory** (stdio transport) and `--init` is recommended (proper PID-1 signal handling; the server also handles SIGTERM itself).
137
+ - **ssh-agent:** Linux: add `-v "$SSH_AUTH_SOCK:/agent.sock" -e SSH_AUTH_SOCK=/agent.sock`. Docker Desktop for Mac: `-v /run/host-services/ssh-auth.sock:/agent.sock -e SSH_AUTH_SOCK=/agent.sock`. Without an agent, mounted key files still work (you'll type passphrases interactively via `ssh_send`).
138
+ - **known_hosts:** with a read-only mount, newly-accepted host keys can't be persisted — ssh warns and continues for that session. Mount `~/.ssh` read-write (or a dedicated known_hosts file) if you want them remembered.
139
+ - **File ownership:** the image runs as the `node` user (uid 1000). If your bind-mounted keys come through owned by a different uid and unreadable, add `--user root` (the container is ephemeral and has your keys mounted either way).
140
+ - **Networking:** the container has its own network namespace. Host-only routes (VPN tunnels, `localhost` targets) may need `--network host` (Linux only) or won't work as they do natively.
141
+
142
+ ## Tools
143
+
144
+ | Tool | Purpose |
145
+ | --- | --- |
146
+ | `ssh_connect` | Open a session to a host. Returns a session id and the initial screen (prompt, or an auth/host-key prompt to answer). |
147
+ | `ssh_send` | Send `text` and/or special `keys` (and optional Enter), wait for output to settle, return the updated screen. |
148
+ | `ssh_read` | Read the current screen without sending — poll long-running output. Supports `mode: "raw"` and blocking for new output via `waitMs`. |
149
+ | `ssh_interrupt` | Send Ctrl-C to the foreground program. |
150
+ | `ssh_resize` | Change terminal dimensions (affects wrapping and full-screen apps). |
151
+ | `ssh_list` | List active sessions with state, pid, age, destination. |
152
+ | `ssh_disconnect` | Close a session and terminate its `ssh` process. |
153
+ | `ssh_config_hosts` | List `Host` aliases from `~/.ssh/config` (best-effort, follows `Include`) for discovery. |
154
+
155
+ ### `ssh_connect` parameters
156
+
157
+ | Parameter | Type | Description |
158
+ | --- | --- | --- |
159
+ | `host` | string, required | ssh_config `Host` alias, hostname, or `user@hostname` |
160
+ | `user` | string | Username (prepended as `user@host`) |
161
+ | `port` | number | Port (`ssh -p`); usually unnecessary if set in config |
162
+ | `identityFile` | string | Private key path (`ssh -i`); `~` is expanded |
163
+ | `jump` | string | ProxyJump chain (`ssh -J`), e.g. `"bastion"` or `"userA@a,userB@b"` |
164
+ | `remoteCommand` | string | Run this command instead of an interactive login shell (still on a PTY) |
165
+ | `extraArgs` | string[] | Extra raw ssh arguments appended verbatim |
166
+ | `forceTty` | boolean | Force remote PTY allocation (`ssh -tt`). Default `true` |
167
+ | `cols`, `rows` | number | Terminal size (default 120×40) |
168
+ | `settleMs`, `timeoutMs` | number | Output-settle threshold / max wait (defaults 700 / 20000) |
169
+ | `maxLines` | number | Max screen lines returned (default 200) |
170
+
171
+ Every screen-returning tool accepts `settleMs`, `timeoutMs`, and `maxLines`, and appends a status footer:
172
+
173
+ ```text
174
+ [session=s1 | state=live | wait=idle | cursor=12,1 | screen=40x120 | shown=24/40]
175
+ ```
176
+
177
+ `state` becomes `exited code=N` when the ssh process ends; `wait` tells you whether output settled (`idle`), was still streaming (`timeout`), or nothing new arrived (`quiet`).
178
+
179
+ ## Special keys for `ssh_send`
180
+
181
+ Pass an ordered `keys` array. Recognised tokens (case-insensitive):
182
+
183
+ - **Control chords:** `C-c` (Ctrl-C), `C-x`, `Ctrl-d`, `^u`, `C-[` (Esc), `C-@` (NUL), `C-\`, `C-?` (DEL) — covers all of 0x00–0x1F and 0x7F.
184
+ - **Named keys:** `Enter`, `Tab`, `Backtab`, `Space`, `Escape`, `Backspace`, `Delete`, `Insert`, `Up`/`Down`/`Left`/`Right`, `Home`, `End`, `PageUp`, `PageDown`, `F1`–`F12`.
185
+ - **Alt/Meta:** `M-b`, `Alt-f`, `meta-.` → ESC-prefixed.
186
+ - **Raw bytes:** `hex:1b5b41` → arbitrary byte sequence.
187
+ - **Single characters:** `y`, `Q` → sent as-is.
188
+
189
+ Any other multi-character token is an **error** — a typo'd chord is rejected instead of being typed into the remote shell. Literal text belongs in `text`, not `keys`. Arrow/Home/End keys automatically switch to SS3 sequences when the remote app enables application cursor-keys mode (DECCKM), like a real terminal.
190
+
191
+ `text` is sent literally first, then `keys` in order, then Enter if `appendEnter: true`.
192
+
193
+ ## Usage walkthroughs
194
+
195
+ **Basic:**
196
+ 1. `ssh_connect { "host": "prod-web" }` → screen shows the shell prompt.
197
+ 2. `ssh_send { "session": "s1", "text": "uname -a", "appendEnter": true }` → screen shows the output.
198
+ 3. `ssh_disconnect { "session": "s1" }`.
199
+
200
+ **Mikrotik RouterOS:**
201
+ 1. `ssh_connect { "host": "admin@192.0.2.1" }` → RouterOS banner + `[admin@MikroTik] >` prompt.
202
+ 2. `ssh_send { "session": "s1", "text": "/interface print", "appendEnter": true }`.
203
+ 3. Tab-completion: `ssh_send { "session": "s1", "text": "/ip ad", "keys": ["Tab"] }`.
204
+ 4. Abort a running command: `ssh_interrupt { "session": "s1" }` (Ctrl-C), or send `Q` to quit a pager.
205
+
206
+ RouterOS console gotchas worth knowing:
207
+ - `?` and Tab are **live hotkeys** (inline help / completion) — they act the instant they arrive, so keep them out of `text` unless intended, and prefer single-line commands (multi-line pastes are mangled by auto-indent; use `/import` for scripts).
208
+ - Long `print` output stops at a pager line (`-- [Q quit|D dump|down]`): send `D` to dump the rest, or run `print without-paging`.
209
+ - `keys: ["C-x"]` toggles **Safe Mode** — enter it before config changes so they auto-revert if the session drops.
210
+
211
+ **ProxyJump A → B → C:** use a config alias that sets `ProxyJump`, and just `ssh_connect { "host": "internal-box" }`. Or set the chain inline: `ssh_connect { "host": "10.0.0.5", "jump": "bastionA,bastionB" }`.
212
+
213
+ **Answering auth / host-key prompts:** if the initial screen shows `Are you sure you want to continue connecting (yes/no)?`, reply `ssh_send { "session": "s1", "text": "yes", "appendEnter": true }`. For a password prompt, send the password the same way. The known_hosts check is never disabled.
214
+
215
+ **Long-running output:** `ssh_send` returns at the settle/timeout; if the footer says output is still arriving, call `ssh_read { "session": "s1", "waitMs": 2000 }` to poll for more. `ssh_read` blocks until *new* output arrives (footer `wait=idle`/`timeout`) or reports `wait=quiet` if nothing new came within `waitMs`.
216
+
217
+ ## Security notes
218
+
219
+ This server hands an AI agent a real shell with **your** SSH identity. Read this section before wiring it into anything.
220
+
221
+ - **The agent can do whatever your keys can do.** Every host reachable from your `~/.ssh/config` and agent is reachable by the model. Use your MCP client's permission prompts (don't blanket-allow `ssh_send`), and consider a dedicated restricted key or user for agent-driven work.
222
+ - **Remote output is untrusted input to the model.** Screen content from a remote host flows back into the agent's context; a compromised or malicious host could try prompt injection through banners, MOTDs, or command output. Treat sessions to untrusted hosts accordingly.
223
+ - **known_hosts verification is left on.** The server does not add `StrictHostKeyChecking=no` or similar; you decide, per host, by answering the fingerprint prompt.
224
+ - **Argument injection is blocked** for `host`/`user`/`jump`: values starting with `-` are rejected and the destination is passed after a `--` separator, so a hostile hostname can't smuggle in ssh options like `ProxyCommand`. `extraArgs` remains a deliberate raw passthrough — treat it as you would a shell: options like `-oProxyCommand=…` execute **local** commands as the user running the server. If your MCP client supports per-tool argument review, scrutinize `extraArgs`.
225
+ - **`ssh_config_hosts` reads the file you point it at.** `configPath` accepts any path readable by the server's user (it only echoes config-shaped lines, but still). Point it at ssh configs only.
226
+ - **Credentials are never handled by this server**; `ssh`/ssh-agent own them. Passwords you type via `ssh_send` go straight to the PTY (not logged to stdout) — but they **do transit the MCP transport and may end up in client-side conversation logs**. Prefer key-based auth over typing passwords.
227
+ - Run it as your normal user so it inherits your `~/.ssh` and agent.
228
+
229
+ ## Troubleshooting
230
+
231
+ - **`posix_spawnp failed` from node-pty on macOS:** the prebuilt `spawn-helper` lost its execute bit. Fix: `chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper`. `npm run build` is unaffected, but a fresh `npm install` may need this once.
232
+ - **`ssh: connect to host … Connection refused`:** faithfully reported from the real `ssh`; the destination isn't listening. Session exits with code 255.
233
+ - **`Connection timed out during banner exchange` on a ProxyJump chain:** OpenSSH's `ConnectTimeout` (default here: 30) also caps the destination's banner exchange, and that clock keeps running while you answer interactive prompts (host key, password) at the *jump* hop. Answer promptly, or raise it: `extraArgs: ["-o", "ConnectTimeout=120"]`. Note that `-o` options are not propagated to the jump hop itself — its host key is checked against your default known_hosts.
234
+ - **`channel 0: open failed: administratively prohibited` via a jump host:** the jump's sshd has `AllowTcpForwarding no` (common on hardened/minimal distros, e.g. Alpine's default). Enable it on the jump host; ProxyJump needs TCP forwarding there.
235
+ - **Output looks truncated:** increase `maxLines` on `ssh_send`/`ssh_read`, or poll with `ssh_read`. The emulator keeps 5000 lines of scrollback per session.
236
+ - **Session limit reached:** at most 32 concurrent sessions; exited ones are evicted automatically, live ones must be `ssh_disconnect`ed first.
237
+
238
+ ## Development
239
+
240
+ ```bash
241
+ npm run build # tsc → dist/
242
+ npm test # vitest: unit tests + live PTY integration tests
243
+ ```
244
+
245
+ ```
246
+ src/
247
+ index.ts entry point; stdio transport + graceful shutdown
248
+ server.ts MCP server + tool definitions
249
+ manager.ts session registry + ssh argv builder
250
+ session.ts PTY + headless xterm; idle-wait + screen rendering
251
+ keys.ts key-token → byte-sequence translation
252
+ sshConfig.ts best-effort ~/.ssh/config host lister (discovery only)
253
+ test/ vitest tests (keys, argv builder, ssh_config, live PTY sessions)
254
+ Dockerfile optional container packaging (see the Docker section)
255
+ ```
256
+
257
+ Contributions welcome — please run `npm test` before opening a PR.
258
+
259
+ ## License
260
+
261
+ [MIT](LICENSE)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { SessionManager } from "./manager.js";
4
+ import { createServer } from "./server.js";
5
+ async function main() {
6
+ const manager = new SessionManager();
7
+ const server = createServer(manager);
8
+ let shuttingDown = false;
9
+ const shutdown = () => {
10
+ if (shuttingDown)
11
+ return;
12
+ shuttingDown = true;
13
+ manager.closeAll(250);
14
+ // Exit only after the SIGKILL escalation inside closeAll has had a
15
+ // chance to run, so stubborn children aren't orphaned.
16
+ setTimeout(() => process.exit(0), 500);
17
+ };
18
+ process.on("SIGINT", shutdown);
19
+ process.on("SIGTERM", shutdown);
20
+ const transport = new StdioServerTransport();
21
+ await server.connect(transport);
22
+ // When the MCP client disconnects, shut down too — live PTY children would
23
+ // otherwise keep this process (and its ssh sessions) running forever. The
24
+ // SDK's stdio transport only watches stdin 'data'/'error' (onclose fires
25
+ // solely on explicit close), so watch stdin EOF ourselves as well.
26
+ server.server.onclose = shutdown;
27
+ process.stdin.on("end", shutdown);
28
+ process.stdin.on("close", shutdown);
29
+ // IMPORTANT: stdout is the MCP protocol channel. Never write logs there.
30
+ console.error("mcp-ssh server started (stdio).");
31
+ }
32
+ main().catch((err) => {
33
+ console.error("Fatal:", err);
34
+ process.exit(1);
35
+ });
36
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,KAAK,UAAU,IAAI;IACjB,MAAM,OAAO,GAAG,IAAI,cAAc,EAAE,CAAC;IACrC,MAAM,MAAM,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC;IAErC,IAAI,YAAY,GAAG,KAAK,CAAC;IACzB,MAAM,QAAQ,GAAG,GAAG,EAAE;QACpB,IAAI,YAAY;YAAE,OAAO;QACzB,YAAY,GAAG,IAAI,CAAC;QACpB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QACtB,mEAAmE;QACnE,uDAAuD;QACvD,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACzC,CAAC,CAAC;IACF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAEhC,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAEhC,2EAA2E;IAC3E,0EAA0E;IAC1E,yEAAyE;IACzE,mEAAmE;IACnE,MAAM,CAAC,MAAM,CAAC,OAAO,GAAG,QAAQ,CAAC;IACjC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IAClC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAEpC,yEAAyE;IACzE,OAAO,CAAC,KAAK,CAAC,iCAAiC,CAAC,CAAC;AACnD,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC7B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
package/dist/keys.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Translation from human-friendly key tokens to the exact byte sequences a
3
+ * terminal application receives. This is what lets a caller press Ctrl-X,
4
+ * arrow keys, function keys, Escape, Tab, etc. against an interactive remote
5
+ * shell (bash, vi, RouterOS CLI, ...) without knowing the raw escape codes.
6
+ *
7
+ * Tokens are case-insensitive. Recognised forms:
8
+ * - Named keys: "Enter", "Tab", "Escape", "Up", "F5", "Backspace", ...
9
+ * - Control chords: "C-c", "Ctrl-x", "^d", "control-[" -> a single 0x00..0x1f/0x7f byte
10
+ * - Alt/Meta chords: "M-b", "Alt-f", "meta-." -> ESC-prefixed byte
11
+ * - Raw hex: "hex:1b5b41" -> arbitrary bytes
12
+ * - A single character is sent as-is (e.g. "y", "Q").
13
+ *
14
+ * Any other multi-character token is an error: silently typing a typo'd
15
+ * chord (e.g. "ctrl+c") into a live remote shell is far worse than failing
16
+ * the call. Literal text belongs in `text`, not `keys`.
17
+ */
18
+ export interface KeyOptions {
19
+ /** Emit SS3 cursor-key sequences (remote app has DECCKM enabled). */
20
+ applicationCursorKeys?: boolean;
21
+ }
22
+ /**
23
+ * Resolve a single key token to its byte sequence. Throws on a malformed
24
+ * control/alt/hex chord — or any unrecognised multi-character token — so the
25
+ * caller gets a clear error instead of silently sending the wrong bytes.
26
+ */
27
+ export declare function resolveKey(token: string, opts?: KeyOptions): string;
28
+ /** Resolve an ordered list of key tokens to one concatenated byte string. */
29
+ export declare function resolveKeys(tokens: string[], opts?: KeyOptions): string;
package/dist/keys.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Translation from human-friendly key tokens to the exact byte sequences a
3
+ * terminal application receives. This is what lets a caller press Ctrl-X,
4
+ * arrow keys, function keys, Escape, Tab, etc. against an interactive remote
5
+ * shell (bash, vi, RouterOS CLI, ...) without knowing the raw escape codes.
6
+ *
7
+ * Tokens are case-insensitive. Recognised forms:
8
+ * - Named keys: "Enter", "Tab", "Escape", "Up", "F5", "Backspace", ...
9
+ * - Control chords: "C-c", "Ctrl-x", "^d", "control-[" -> a single 0x00..0x1f/0x7f byte
10
+ * - Alt/Meta chords: "M-b", "Alt-f", "meta-." -> ESC-prefixed byte
11
+ * - Raw hex: "hex:1b5b41" -> arbitrary bytes
12
+ * - A single character is sent as-is (e.g. "y", "Q").
13
+ *
14
+ * Any other multi-character token is an error: silently typing a typo'd
15
+ * chord (e.g. "ctrl+c") into a live remote shell is far worse than failing
16
+ * the call. Literal text belongs in `text`, not `keys`.
17
+ */
18
+ const ESC = "\x1b";
19
+ /** Named keys mapped to the byte sequence xterm-style terminals expect. */
20
+ const NAMED_KEYS = {
21
+ enter: "\r",
22
+ return: "\r",
23
+ ret: "\r",
24
+ newline: "\n",
25
+ lf: "\n",
26
+ cr: "\r",
27
+ tab: "\t",
28
+ backtab: `${ESC}[Z`,
29
+ space: " ",
30
+ escape: ESC,
31
+ esc: ESC,
32
+ backspace: "\x7f",
33
+ bs: "\x7f",
34
+ delete: `${ESC}[3~`,
35
+ del: `${ESC}[3~`,
36
+ insert: `${ESC}[2~`,
37
+ ins: `${ESC}[2~`,
38
+ up: `${ESC}[A`,
39
+ down: `${ESC}[B`,
40
+ right: `${ESC}[C`,
41
+ left: `${ESC}[D`,
42
+ home: `${ESC}[H`,
43
+ end: `${ESC}[F`,
44
+ pageup: `${ESC}[5~`,
45
+ pgup: `${ESC}[5~`,
46
+ pagedown: `${ESC}[6~`,
47
+ pgdn: `${ESC}[6~`,
48
+ f1: `${ESC}OP`,
49
+ f2: `${ESC}OQ`,
50
+ f3: `${ESC}OR`,
51
+ f4: `${ESC}OS`,
52
+ f5: `${ESC}[15~`,
53
+ f6: `${ESC}[17~`,
54
+ f7: `${ESC}[18~`,
55
+ f8: `${ESC}[19~`,
56
+ f9: `${ESC}[20~`,
57
+ f10: `${ESC}[21~`,
58
+ f11: `${ESC}[23~`,
59
+ f12: `${ESC}[24~`,
60
+ nul: "\x00",
61
+ null: "\x00",
62
+ };
63
+ /**
64
+ * Cursor/keypad keys switch to SS3 sequences when the remote app enables
65
+ * DECCKM (application cursor-keys mode), the way a real terminal does.
66
+ */
67
+ const APP_CURSOR_KEYS = {
68
+ up: `${ESC}OA`,
69
+ down: `${ESC}OB`,
70
+ right: `${ESC}OC`,
71
+ left: `${ESC}OD`,
72
+ home: `${ESC}OH`,
73
+ end: `${ESC}OF`,
74
+ };
75
+ /**
76
+ * Map a single control-chord target character to its control byte.
77
+ * Ctrl-A..Ctrl-Z -> 0x01..0x1a, plus the C0 punctuation chords.
78
+ */
79
+ function controlByte(ch) {
80
+ if (ch.length !== 1)
81
+ return undefined;
82
+ const c = ch.toLowerCase();
83
+ if (c >= "a" && c <= "z") {
84
+ return String.fromCharCode(c.charCodeAt(0) - 96); // 'a'->1 ... 'z'->26
85
+ }
86
+ // C0 control punctuation, matching what a real terminal produces.
87
+ const punct = {
88
+ "@": 0,
89
+ " ": 0,
90
+ "[": 27,
91
+ "\\": 28,
92
+ "]": 29,
93
+ "^": 30,
94
+ "_": 31,
95
+ "?": 127,
96
+ };
97
+ if (ch in punct)
98
+ return String.fromCharCode(punct[ch]);
99
+ return undefined;
100
+ }
101
+ function parseHex(spec) {
102
+ const hex = spec.replace(/[\s:]/g, "");
103
+ if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) {
104
+ throw new Error(`Invalid hex key token "hex:${spec}"`);
105
+ }
106
+ let out = "";
107
+ for (let i = 0; i < hex.length; i += 2) {
108
+ out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
109
+ }
110
+ return out;
111
+ }
112
+ /**
113
+ * Resolve a single key token to its byte sequence. Throws on a malformed
114
+ * control/alt/hex chord — or any unrecognised multi-character token — so the
115
+ * caller gets a clear error instead of silently sending the wrong bytes.
116
+ */
117
+ export function resolveKey(token, opts = {}) {
118
+ if (token.length === 0)
119
+ return "";
120
+ const lower = token.toLowerCase();
121
+ if (opts.applicationCursorKeys && lower in APP_CURSOR_KEYS)
122
+ return APP_CURSOR_KEYS[lower];
123
+ if (lower in NAMED_KEYS)
124
+ return NAMED_KEYS[lower];
125
+ if (lower.startsWith("hex:"))
126
+ return parseHex(token.slice(4));
127
+ // Control chord: "C-x", "Ctrl-x", "control-x", "^x"
128
+ const m = /^(?:c|ctrl|control)-(.+)$/i.exec(token);
129
+ const caretTarget = !m && token.length === 2 && token[0] === "^" ? token.slice(1) : undefined;
130
+ if (m || caretTarget !== undefined) {
131
+ const target = m ? m[1] : caretTarget;
132
+ // Allow a named key as the chord target (e.g. "C-Space").
133
+ const namedTarget = target.toLowerCase();
134
+ const base = namedTarget in NAMED_KEYS ? NAMED_KEYS[namedTarget] : target;
135
+ const cb = controlByte(base.length === 1 ? base : target);
136
+ if (cb === undefined)
137
+ throw new Error(`Unsupported control chord "${token}"`);
138
+ return cb;
139
+ }
140
+ // Alt / Meta chord: "M-b", "Alt-f", "meta-." -> ESC followed by the target.
141
+ const alt = /^(?:m|alt|meta)-(.+)$/i.exec(token);
142
+ if (alt) {
143
+ const target = alt[1];
144
+ const resolvedTarget = resolveKey(target, opts); // supports Alt+named/control targets
145
+ return ESC + resolvedTarget;
146
+ }
147
+ // A single character is a literal keypress. Count code points, not UTF-16
148
+ // units, so an astral-plane character (e.g. an emoji) still counts as one.
149
+ if ([...token].length === 1)
150
+ return token;
151
+ throw new Error(`Unrecognised key token "${token}". Use a named key (Enter, Tab, Up, F5, ...), ` +
152
+ `a chord ("C-x", "M-b"), "hex:..", a single character, or put literal text in \`text\`.`);
153
+ }
154
+ /** Resolve an ordered list of key tokens to one concatenated byte string. */
155
+ export function resolveKeys(tokens, opts = {}) {
156
+ return tokens.map((t) => resolveKey(t, opts)).join("");
157
+ }
158
+ //# sourceMappingURL=keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,45 @@
1
+ import { Session } from "./session.js";
2
+ export interface ConnectSpec {
3
+ /** Destination: an ssh_config Host alias, hostname, or user@hostname. */
4
+ host: string;
5
+ user?: string;
6
+ port?: number;
7
+ /** IdentityFile path (-i). ~ is expanded. */
8
+ identityFile?: string;
9
+ /** ProxyJump chain (-J), e.g. "bastion" or "userA@a,userB@b". */
10
+ jump?: string;
11
+ /** Raw command to run instead of an interactive login shell. */
12
+ remoteCommand?: string;
13
+ /** Extra raw ssh arguments appended verbatim. */
14
+ extraArgs?: string[];
15
+ /** Force PTY allocation on the remote side (-tt). Default true. */
16
+ forceTty?: boolean;
17
+ cols?: number;
18
+ rows?: number;
19
+ }
20
+ export interface BuiltCommand {
21
+ file: string;
22
+ args: string[];
23
+ label: string;
24
+ }
25
+ /**
26
+ * Build the argv for the OpenSSH client from a connect spec. Everything the
27
+ * ssh client natively understands — ~/.ssh/config, known_hosts, ssh-agent,
28
+ * ProxyJump — is left to ssh; we only translate the explicit overrides.
29
+ */
30
+ export declare function buildSshCommand(spec: ConnectSpec): BuiltCommand;
31
+ /** In-memory registry of live sessions. */
32
+ export declare class SessionManager {
33
+ private sessions;
34
+ private counter;
35
+ create(cmd: BuiltCommand, cols: number, rows: number): Session;
36
+ get(id: string): Session | undefined;
37
+ require(id: string): Session;
38
+ list(): Session[];
39
+ remove(id: string): boolean;
40
+ /**
41
+ * SIGHUP everything, escalating to SIGKILL after `escalateMs` for children
42
+ * that ignore it. Callers that exit afterwards must wait past `escalateMs`.
43
+ */
44
+ closeAll(escalateMs?: number): void;
45
+ }