sshmd 0.1.0__py3-none-any.whl

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.
sshm/state.py ADDED
@@ -0,0 +1,76 @@
1
+ """Runtime state files shared by the CLI and the daemon (~/.sshm)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import secrets
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def sshm_dir() -> Path:
12
+ d = Path.home() / ".sshm"
13
+ d.mkdir(parents=True, exist_ok=True)
14
+ return d
15
+
16
+
17
+ def pid_file() -> Path:
18
+ return sshm_dir() / "sshmd.pid"
19
+
20
+
21
+ def token_file() -> Path:
22
+ return sshm_dir() / "token"
23
+
24
+
25
+ def log_file() -> Path:
26
+ return sshm_dir() / "sshmd.log"
27
+
28
+
29
+ def port_file() -> Path:
30
+ return sshm_dir() / "port"
31
+
32
+
33
+ DEFAULT_PORT = 19222
34
+
35
+
36
+ def resolve_port() -> int:
37
+ """Effective IPC port: SSHM_PORT env > persisted ~/.sshm/port > default.
38
+
39
+ The env var wins for ad-hoc overrides; the persisted file lets an
40
+ autostarted daemon (systemd/launchd/Task Scheduler), which never sees the
41
+ interactive shell's environment, agree with the CLI on a non-default port.
42
+ """
43
+ env = os.environ.get("SSHM_PORT")
44
+ if env:
45
+ try:
46
+ return int(env)
47
+ except ValueError:
48
+ pass
49
+
50
+ pf = port_file()
51
+ try:
52
+ return int(pf.read_text(encoding="utf-8").strip())
53
+ except (ValueError, OSError):
54
+ return DEFAULT_PORT
55
+
56
+
57
+ def write_port(port: int) -> None:
58
+ port_file().write_text(str(port), encoding="utf-8")
59
+
60
+
61
+ def read_token() -> str | None:
62
+ tf = token_file()
63
+ if tf.exists():
64
+ return tf.read_text(encoding="utf-8").strip()
65
+ return None
66
+
67
+
68
+ def new_token() -> str:
69
+ return secrets.token_hex(32)
70
+
71
+
72
+ def write_token(token: str) -> None:
73
+ tf = token_file()
74
+ tf.write_text(token, encoding="utf-8")
75
+ if sys.platform != "win32":
76
+ os.chmod(tf, 0o600)
sshm/terminal.py ADDED
@@ -0,0 +1,199 @@
1
+ """Raw terminal I/O bridge between the local console and a daemon socket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import socket
7
+ import sys
8
+ import threading
9
+ from collections.abc import Callable
10
+
11
+ CHUNK = 4096
12
+
13
+ # Called with (cols, rows) when the local terminal is resized. Returns nothing;
14
+ # implementations should be non-blocking / best-effort.
15
+ ResizeCallback = Callable[[int, int], None]
16
+
17
+
18
+ def stream_bridge(
19
+ sock: socket.socket, initial: bytes = b"", on_resize: ResizeCallback | None = None
20
+ ) -> None:
21
+ """Pump bytes between the local terminal and the socket until either side closes.
22
+
23
+ ``initial`` is session output that arrived bundled with the attach response
24
+ and is written to the terminal before streaming begins. ``on_resize`` is
25
+ invoked with the new (cols, rows) whenever the local terminal is resized
26
+ (POSIX only; Windows keeps the size it attached with).
27
+ """
28
+ if sys.platform == "win32":
29
+ _bridge_windows(sock, initial)
30
+ else:
31
+ _bridge_unix(sock, initial, on_resize)
32
+
33
+
34
+ def _bridge_unix(
35
+ sock: socket.socket, initial: bytes = b"", on_resize: ResizeCallback | None = None
36
+ ) -> None:
37
+ import select
38
+ import signal
39
+ import termios
40
+ import tty
41
+
42
+ stdin_fd = sys.stdin.fileno()
43
+ # Raw mode only makes sense (and tcgetattr only works) on a real terminal.
44
+ # When stdin is redirected (a pipe, /dev/null) we still bridge, just non-raw.
45
+ is_tty = sys.stdin.isatty()
46
+ old_attrs = termios.tcgetattr(stdin_fd) if is_tty else None
47
+
48
+ # SIGWINCH self-pipe (set up inside the try so the finally always reclaims the
49
+ # fds and the handler). The handler does almost nothing — just writes a byte;
50
+ # the select loop does the signal-unsafe work of reading the size.
51
+ winch_r = winch_w = -1
52
+ old_winch = None
53
+ use_winch = is_tty and on_resize is not None and hasattr(signal, "SIGWINCH")
54
+
55
+ def _notify_resize() -> None:
56
+ try:
57
+ sz = os.get_terminal_size(stdin_fd)
58
+ on_resize(sz.columns, sz.lines)
59
+ except Exception:
60
+ pass # never let a resize hiccup break the bridge
61
+
62
+ try:
63
+ if use_winch:
64
+ winch_r, winch_w = os.pipe()
65
+ os.set_blocking(winch_r, False)
66
+ os.set_blocking(winch_w, False)
67
+
68
+ def _winch_handler(_signum, _frame):
69
+ try:
70
+ os.write(winch_w, b"\x01")
71
+ except OSError:
72
+ pass
73
+
74
+ old_winch = signal.signal(signal.SIGWINCH, _winch_handler)
75
+
76
+ if is_tty:
77
+ tty.setraw(stdin_fd)
78
+
79
+ if initial:
80
+ sys.stdout.buffer.write(initial)
81
+ sys.stdout.buffer.flush()
82
+
83
+ watch = [stdin_fd, sock]
84
+ if use_winch:
85
+ watch.append(winch_r)
86
+
87
+ while True:
88
+ ready, _, _ = select.select(watch, [], [], 1.0)
89
+
90
+ for fd in ready:
91
+ if fd is sock:
92
+ data = sock.recv(CHUNK)
93
+ if not data:
94
+ return
95
+ sys.stdout.buffer.write(data)
96
+ sys.stdout.buffer.flush()
97
+ elif use_winch and fd == winch_r:
98
+ while True: # fully drain coalesced signals (winch_r is non-blocking)
99
+ try:
100
+ if not os.read(winch_r, 4096):
101
+ break
102
+ except OSError:
103
+ break
104
+ _notify_resize()
105
+ elif fd == stdin_fd:
106
+ data = os.read(stdin_fd, CHUNK)
107
+ if not data:
108
+ return
109
+ sock.sendall(data)
110
+ except OSError:
111
+ pass
112
+ finally:
113
+ if old_winch is not None:
114
+ signal.signal(signal.SIGWINCH, old_winch)
115
+ if winch_r != -1:
116
+ os.close(winch_r)
117
+ if winch_w != -1:
118
+ os.close(winch_w)
119
+ if is_tty and old_attrs is not None:
120
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
121
+ sock.close()
122
+
123
+
124
+ def _bridge_windows(sock: socket.socket, initial: bytes = b"") -> None:
125
+ import ctypes
126
+ from ctypes import wintypes
127
+
128
+ kernel32 = ctypes.windll.kernel32
129
+ STD_INPUT_HANDLE = -10
130
+ STD_OUTPUT_HANDLE = -11
131
+
132
+ h_in = kernel32.GetStdHandle(STD_INPUT_HANDLE)
133
+ h_out = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
134
+
135
+ old_in_mode = wintypes.DWORD()
136
+ old_out_mode = wintypes.DWORD()
137
+ kernel32.GetConsoleMode(h_in, ctypes.byref(old_in_mode))
138
+ kernel32.GetConsoleMode(h_out, ctypes.byref(old_out_mode))
139
+
140
+ ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
141
+ kernel32.SetConsoleMode(h_in, ENABLE_VIRTUAL_TERMINAL_INPUT)
142
+
143
+ ENABLE_PROCESSED_OUTPUT = 0x0001
144
+ ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
145
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
146
+ kernel32.SetConsoleMode(
147
+ h_out,
148
+ ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING,
149
+ )
150
+
151
+ if initial:
152
+ written = wintypes.DWORD()
153
+ kernel32.WriteFile(h_out, initial, len(initial), ctypes.byref(written), None)
154
+
155
+ stop_event = threading.Event()
156
+
157
+ def _read_socket() -> None:
158
+ try:
159
+ written = wintypes.DWORD()
160
+ while not stop_event.is_set():
161
+ data = sock.recv(CHUNK)
162
+ if not data:
163
+ break
164
+ kernel32.WriteFile(h_out, data, len(data), ctypes.byref(written), None)
165
+ except OSError:
166
+ pass
167
+ finally:
168
+ stop_event.set()
169
+
170
+ def _read_stdin() -> None:
171
+ try:
172
+ buf = ctypes.create_string_buffer(CHUNK)
173
+ bytes_read = wintypes.DWORD()
174
+ while not stop_event.is_set():
175
+ success = kernel32.ReadFile(h_in, buf, CHUNK, ctypes.byref(bytes_read), None)
176
+ if success and bytes_read.value > 0:
177
+ sock.sendall(buf.raw[: bytes_read.value])
178
+ elif not success:
179
+ break
180
+ else:
181
+ sock.sendall(b"\x1a") # Ctrl-Z: signal EOF to the remote shell
182
+ except OSError:
183
+ pass
184
+ finally:
185
+ stop_event.set()
186
+
187
+ t_sock = threading.Thread(target=_read_socket, daemon=True)
188
+ t_stdin = threading.Thread(target=_read_stdin, daemon=True)
189
+ t_sock.start()
190
+ t_stdin.start()
191
+
192
+ try:
193
+ stop_event.wait()
194
+ finally:
195
+ kernel32.SetConsoleMode(h_in, old_in_mode.value)
196
+ kernel32.SetConsoleMode(h_out, old_out_mode.value)
197
+ sock.close()
198
+ t_sock.join(timeout=1)
199
+ t_stdin.join(timeout=1)
@@ -0,0 +1,309 @@
1
+ Metadata-Version: 2.4
2
+ Name: sshmd
3
+ Version: 0.1.0
4
+ Summary: Cross-platform SSH session manager with a background daemon
5
+ Keywords: ssh,session-manager,daemon,port-forwarding,socks,terminal
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: System Administrators
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: System :: Networking
13
+ Classifier: Topic :: Utilities
14
+ Requires-Dist: click>=8.0
15
+ Requires-Python: >=3.12
16
+ Project-URL: Homepage, https://github.com/revsearch/sshm
17
+ Project-URL: Repository, https://github.com/revsearch/sshm
18
+ Project-URL: Issues, https://github.com/revsearch/sshm/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sshm
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/sshmd)](https://pypi.org/project/sshmd/) [![Tests](https://github.com/revsearch/sshm/actions/workflows/tests.yml/badge.svg)](https://github.com/revsearch/sshm/actions/workflows/tests.yml)
24
+
25
+ An SSH session manager with a background daemon. Remote shells stay alive when you
26
+ close the terminal, reconnect on their own when the link drops, and reattach
27
+ instantly. State is kept in plain `~/.ssh/config`, so `ssh`, `scp`, and `rsync`
28
+ keep working alongside it.
29
+
30
+ ```bash
31
+ sshm add prod root@192.0.2.10 # keygen + copy key + write ~/.ssh/config
32
+ sshm prod # attach to a live shell (or start one)
33
+ # close the terminal — the shell keeps running; `sshm prod` reattaches it
34
+
35
+ ssh prod # plain ssh/scp/rsync work too — same alias & key
36
+ ```
37
+
38
+ ## Install
39
+
40
+ The package is published as **`sshmd`** and installs the `sshm` (and `sshmd`)
41
+ commands. With [uv](https://docs.astral.sh/uv/) or [pipx](https://pipx.pypa.io/):
42
+
43
+ ```bash
44
+ uv tool install sshmd
45
+ # or
46
+ pipx install sshmd
47
+ ```
48
+
49
+ Latest from git instead of PyPI:
50
+
51
+ ```bash
52
+ uv tool install git+https://github.com/revsearch/sshm
53
+ ```
54
+
55
+ From a checkout:
56
+
57
+ ```bash
58
+ git clone https://github.com/revsearch/sshm
59
+ cd sshm
60
+ uv tool install . # or: pip install .
61
+ ```
62
+
63
+ Needs Python 3.12+. The `sshm` and `sshmd` commands are installed to your tool bin
64
+ directory (`~/.local/bin`, or the Python Scripts dir on Windows) — make sure it's
65
+ on your PATH. Update with `uv tool upgrade sshm`.
66
+
67
+ ## Quick start
68
+
69
+ ```bash
70
+ # Add a server: generates an ed25519 key, copies it to the remote, writes config
71
+ sshm add myserver root@192.168.1.100
72
+
73
+ # Connect (interactive shell via the daemon)
74
+ sshm myserver
75
+
76
+ # Or explicitly
77
+ sshm c myserver
78
+ ```
79
+
80
+ ## Works with `ssh`, `scp`, `rsync`
81
+
82
+ `sshm add` writes a normal `Host` entry to `~/.ssh/config`, so the alias works with
83
+ any tool that reads it — not just `sshm`. The generated key and port are picked up
84
+ automatically, so no `-i`/`-p` flags are needed:
85
+
86
+ ```bash
87
+ ssh myserver # plain SSH, same alias
88
+ ssh myserver htop # run a one-off command
89
+ scp ./backup.tar.gz myserver:/srv/ # copy a file up
90
+ scp myserver:/var/log/app.log . # ...and back down
91
+ rsync -avz ./site/ myserver:/var/www/ # sync a directory
92
+ sftp myserver # sftp, git over ssh, etc. all work too
93
+ git clone myserver:/srv/repo.git # alias works as the ssh host anywhere
94
+ ```
95
+
96
+ The difference: `sshm myserver` attaches to a persistent, auto-reconnecting shell
97
+ via the daemon, while `ssh myserver` is a plain one-off connection — both to the
98
+ same host, using the same key and config.
99
+
100
+ ## Commands
101
+
102
+ Most commands have a short alias (shown first). `sshm <alias>` with no command is
103
+ shorthand for `connect`.
104
+
105
+ ### Sessions
106
+
107
+ ```bash
108
+ sshm <alias> # Connect (shorthand)
109
+ sshm c, connect <alias> [name] # Attach to a session or create a new one
110
+ sshm l, list # List all hosts
111
+ sshm l, list <alias> # List active sessions for a host
112
+ sshm a, add <alias> user@host # Add a server (keygen + copy key)
113
+ sshm r, remove <alias> # Remove a host and disconnect all sessions
114
+ sshm mv, rename <alias> <new> # Rename an alias (and its managed key)
115
+ ```
116
+
117
+ `add` takes `user@host`, `user@host:port`, or bracketed IPv6
118
+ (`user@[2001:db8::1]:22`).
119
+
120
+ ### Port forwarding and SOCKS
121
+
122
+ Forwards are written as native `LocalForward` / `RemoteForward` / `DynamicForward`
123
+ directives in `~/.ssh/config`, so any SSH client sees them. Direction is `-L` /
124
+ `-R` / `-D`, same as `ssh`:
125
+
126
+ ```bash
127
+ sshm p a, port add <alias> -L <local>:<host>:<remote> # Local forward
128
+ sshm p a, port add <alias> -R <remote>:<host>:<local> # Reverse forward
129
+ sshm p a, port add <alias> -D <port> # SOCKS proxy (ssh -D)
130
+ sshm p r, port remove <alias> -L <local>:<host>:<remote> # Remove a forward
131
+ sshm p r, port remove <alias> -D <port> # Remove a SOCKS proxy
132
+ ```
133
+
134
+ `-D <port>` is a dynamic forward — a SOCKS5 proxy on `127.0.0.1:<port>` tunnelled
135
+ through the host. Point a browser or any SOCKS-aware app at it.
136
+
137
+ ### Auto-connect
138
+
139
+ When enabled, the daemon keeps at least one shell alive and reconnects it on
140
+ failure, so attaching is instant.
141
+
142
+ ```bash
143
+ sshm e, enable <alias> # Keep a session alive, auto-reconnect
144
+ sshm d, disable <alias> # Stop auto-connect
145
+ ```
146
+
147
+ ### Import / export
148
+
149
+ Move hosts, including their keys, between machines as JSON.
150
+
151
+ ```bash
152
+ sshm export servers.json # Export all hosts
153
+ sshm export prod.json web db api # Export specific hosts
154
+ sshm l servers.json # Preview a JSON file
155
+ sshm import servers.json # Import (skip existing)
156
+ sshm import servers.json -o # Import (override existing)
157
+ sshm import servers.json web db # Import only specific hosts
158
+ sshm import servers.json web=prod # Import 'web' under the alias 'prod'
159
+ ```
160
+
161
+ ### Daemon
162
+
163
+ The daemon (`sshmd`) starts on first use.
164
+
165
+ ```bash
166
+ sshm status # Daemon status
167
+ sshm stop # Stop the daemon
168
+ sshm install # Autostart on login (systemd / launchd / Task Scheduler)
169
+ sshm uninstall # Remove autostart
170
+ ```
171
+
172
+ ## Shell completions
173
+
174
+ ### fish
175
+
176
+ `sshm` ships fish completions: subcommands, `port -L/-R/-D` flags, and host aliases
177
+ pulled live from `~/.ssh/config` (including the bare `sshm <alias>` shorthand).
178
+ `uv tool install` does **not** wire these up automatically, so install them once
179
+ (`exec fish` reloads the shell so they're active immediately):
180
+
181
+ ```fish
182
+ sshm completions fish > ~/.config/fish/completions/sshm.fish && exec fish
183
+ ```
184
+
185
+ fish autoloads from that directory — new sessions pick it up with no `source`. From
186
+ a checkout you can instead symlink the source file so edits are picked up live:
187
+
188
+ ```fish
189
+ ln -s (path resolve src/sshm/completions/sshm.fish) ~/.config/fish/completions/sshm.fish
190
+ ```
191
+
192
+ ## Session states
193
+
194
+ | Icon | State | Meaning |
195
+ |------|----------|-----------------------------------|
196
+ | `●` | ready | Shell running, waiting for attach |
197
+ | `◆` | attached | A client is connected |
198
+ | `○` | dead | Process exited |
199
+
200
+ ## How it works
201
+
202
+ ```
203
+ sshm (CLI) ── TCP localhost:19222 ──> sshmd (daemon)
204
+ ├── SSH processes (one shell per session)
205
+ ├── Reader threads (scrollback buffer)
206
+ ├── Watchdog (health, reconnect, keep-warm)
207
+ └── IPC server (JSON protocol + I/O bridge)
208
+ ```
209
+
210
+ - The daemon spawns `ssh -tt <alias>` under a real PTY (POSIX; pipes on Windows)
211
+ and holds the shell process.
212
+ - `connect` bridges your terminal I/O to that process over TCP and forwards your
213
+ terminal size (and resizes / `SIGWINCH`) so the remote shell matches your window.
214
+ - Detaching (closing the terminal, Ctrl-C) leaves the shell running; reattach later.
215
+ - Typing `exit` in the shell removes the session cleanly — no reconnect.
216
+ - A lost connection (SSH exit 255) triggers reconnect with exponential backoff.
217
+ - Config stays in `~/.ssh/config`. Every rewrite goes to a temp file and is
218
+ replaced atomically (a `config.bak` is kept), so a crash mid-write won't corrupt it.
219
+ - Runtime state lives in `~/.sshm/`: pid file, IPC token, `sshmd.log`.
220
+ - The IPC server binds `127.0.0.1` only and checks a random per-daemon token
221
+ (stored `0600` in `~/.sshm/token`) on every request.
222
+ - The IPC port defaults to `19222`; set `SSHM_PORT` to change it (e.g. to run sshm
223
+ in both Windows and WSL when mirrored networking shares localhost). `sshm install`
224
+ persists the port to `~/.sshm/port` so the autostarted daemon — which doesn't see
225
+ your shell env — uses the same one.
226
+
227
+ ## Platforms
228
+
229
+ - Linux — systemd user service for autostart.
230
+ - macOS — launchd LaunchAgent.
231
+ - Windows — Task Scheduler for autostart, Win32 console VT mode for terminal I/O.
232
+
233
+ On POSIX, ssh runs under a real PTY, so the remote shell tracks your window size
234
+ and resizes. Windows has no `pty` module, so there a session keeps the size it
235
+ attached with — full-screen apps (`vim`, `htop`) won't follow later resizes.
236
+
237
+ ## Development
238
+
239
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full branch / commit / PR workflow.
240
+
241
+ ```bash
242
+ uv sync # install dependencies (including the dev group)
243
+ uv run pytest # run the tests
244
+ ```
245
+
246
+ Module layout (`src/sshm/`):
247
+
248
+ | Module | Responsibility |
249
+ |----------------|-----------------------------------------------------------|
250
+ | `cli.py` | CLI commands (click), entry point `sshm` |
251
+ | `daemon.py` | `sshmd`: request dispatch, watchdog, entry `sshmd` |
252
+ | `process.py` | SSH sessions: PTY spawn, scrollback, reconnect, health |
253
+ | `ipc.py` | TCP IPC client/server on `127.0.0.1:19222` (token auth) |
254
+ | `protocol.py` | JSON message schema for IPC |
255
+ | `config.py` | `~/.ssh/config` parser/writer, port-forward rules |
256
+ | `terminal.py` | Raw terminal bridge (termios on Unix, Win32 console API) |
257
+ | `procutil.py` | Cross-platform process helpers (pid checks, Popen flags) |
258
+ | `state.py` | `~/.sshm` runtime files: pid, token, port, log |
259
+ | `autostart.py` | Task Scheduler / systemd / launchd integration |
260
+
261
+ ## Troubleshooting
262
+
263
+ ### Windows: `Bad owner or permissions on ~/.ssh/config`
264
+
265
+ OpenSSH on Windows refuses to read `~/.ssh/config` if the ACL contains extra
266
+ principals like `OWNER RIGHTS` (S-1-3-4). Symptom:
267
+
268
+ ```
269
+ Bad permissions. Try removing permissions for user: \\OWNER RIGHTS (S-1-3-4) on file C:/Users/<you>/.ssh/config.
270
+ Bad owner or permissions on C:\\Users\\<you>/.ssh/config
271
+ ```
272
+
273
+ Remove the offending principal:
274
+
275
+ ```powershell
276
+ icacls "$env:USERPROFILE\.ssh\config" /remove "OWNER RIGHTS"
277
+ ```
278
+
279
+ If other files in `.ssh` have the same issue (private keys, etc.):
280
+
281
+ ```powershell
282
+ icacls "$env:USERPROFILE\.ssh\*" /remove "OWNER RIGHTS"
283
+ ```
284
+
285
+ If that's not enough (inheritance can pull in other groups), reset the ACL so only
286
+ you have access:
287
+
288
+ ```powershell
289
+ $f = "$env:USERPROFILE\.ssh\config"
290
+ icacls $f /inheritance:r
291
+ icacls $f /grant:r "$($env:USERNAME):(F)"
292
+ ```
293
+
294
+ If `icacls` returns `Access is denied`, you don't own the file. Take ownership
295
+ first, then fix the ACL:
296
+
297
+ ```powershell
298
+ takeown /F "$env:USERPROFILE\.ssh\config"
299
+ icacls "$env:USERPROFILE\.ssh\config" /grant "$($env:USERNAME):F"
300
+ icacls "$env:USERPROFILE\.ssh\config" /inheritance:r
301
+ icacls "$env:USERPROFILE\.ssh\config" /grant:r "$($env:USERNAME):(F)"
302
+ ```
303
+
304
+ If `takeown` fails, run PowerShell as Administrator and repeat. Verify with
305
+ `icacls "$env:USERPROFILE\.ssh\config"` — only your user with `(F)` should remain.
306
+
307
+ ## License
308
+
309
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ sshm/__init__.py,sha256=Y08F-pbSNt9Qn4jKXwrI1RMqEfeUPQSOMR_-wbuTiLc,99
2
+ sshm/__main__.py,sha256=Qe_SH_wVLnIAdV1SGaNNsXuByZEMfJlf-XMZ0JTaZrE,69
3
+ sshm/autostart.py,sha256=Xd-55R6KfvRu5-_PmetiXDUPyTT5ACn9_FjeRDngcl0,4584
4
+ sshm/cli.py,sha256=4LKV44Ai6d7h_DIWRGXbCKfXPIe7TF7BrxizyQbIk4w,26222
5
+ sshm/completions/sshm.fish,sha256=9PNgf-W-bVQg0D61Ja1hTvSrfYOQEAADfkwCOtAIClA,5603
6
+ sshm/config.py,sha256=c5kPWcSyrVAz4m2PNFjTshjy3wiwjg-weAWVfKFG_MU,12329
7
+ sshm/daemon.py,sha256=0mcWHHsAMUx6_2lQg3S6eh-sKHcbkc8A8nF-bTt-Jug,10797
8
+ sshm/ipc.py,sha256=m-Ym_bE8s8QhgkFYfzr3qpiivM7dUA7ADJJtkcPE_J8,8137
9
+ sshm/process.py,sha256=rLtabhPGDS1Vh2gi4JXHbdly4Mro1g4UPAKRj_BXeo0,21101
10
+ sshm/procutil.py,sha256=nzfG0ukHYqDtjpPGCFVO1PZF1A2CyLrDkJqEcLID9PE,2033
11
+ sshm/protocol.py,sha256=axTnLqUC4zbTza4QX5x64zbVtrPhU2eWlAuQckfQhcc,1272
12
+ sshm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ sshm/state.py,sha256=myemTcAHgC7U4m-IBZkFKX9RkazXYCILz6XJtNYIpq8,1634
14
+ sshm/terminal.py,sha256=KM7GeBfNdC6CMK2UN0oSXpVf4xNW61qNSJ-uBjj2EUM,6598
15
+ sshmd-0.1.0.dist-info/licenses/LICENSE,sha256=DtR0oMTDZc6VZkhxNNyWp5U4b1uCYyTEAzyfP3eRQ_4,1066
16
+ sshmd-0.1.0.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
17
+ sshmd-0.1.0.dist-info/entry_points.txt,sha256=YiUEbn59iKt0OAuvYdNw_Bk2NThpg6Y7b1YaIieNs8I,65
18
+ sshmd-0.1.0.dist-info/METADATA,sha256=qnh82o19wA8NtBzgCIQyMu08Lwqc0NQALc3MIwb0l4Y,11687
19
+ sshmd-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.21
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ sshm = sshm.cli:main
3
+ sshmd = sshm.daemon:main
4
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 revsearch
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.