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.
@@ -0,0 +1,137 @@
1
+ # Fish completions for sshm — https://github.com/revsearch/sshm
2
+ #
3
+ # Install: sshm completions fish > ~/.config/fish/completions/sshm.fish && exec fish
4
+
5
+ function __sshm_aliases --description 'Host aliases from ~/.ssh/config'
6
+ set -l cfg "$HOME/.ssh/config"
7
+ test -r "$cfg"
8
+ and awk 'tolower($1) == "host" { for (i = 2; i <= NF; i++) if ($i !~ /[*?]/) print $i }' "$cfg"
9
+ end
10
+
11
+ function __sshm_wants_alias --description 'True when the previous token expects a host alias'
12
+ set -l toks (commandline -opc)
13
+ contains -- "$toks[-1]" connect c remove r rename mv enable e disable d
14
+ end
15
+
16
+ # port has a flexible order: `port <alias> a|r ...` or `port a|r <alias> ...`.
17
+ # These predicates scan the tokens after the `port` keyword so each piece is only
18
+ # offered while it's still missing.
19
+ function __sshm_port_post_tokens
20
+ set -l toks (commandline -opc)
21
+ set -l i (contains -i -- port $toks; or contains -i -- po $toks; or contains -i -- p $toks)
22
+ test -n "$i"; or return
23
+ set -l rest $toks[(math $i + 1)..-1]
24
+ test (count $rest) -gt 0; and printf '%s\n' $rest
25
+ end
26
+
27
+ function __sshm_port_wants_alias --description 'port subcommand still needs its host alias'
28
+ __fish_seen_subcommand_from port po p; or return 1
29
+ for t in (__sshm_port_post_tokens)
30
+ switch $t
31
+ case add a remove r rm '-*'
32
+ case '*'
33
+ return 1 # a bare token already given — that's the alias
34
+ end
35
+ end
36
+ return 0
37
+ end
38
+
39
+ function __sshm_port_wants_action --description 'port subcommand has no add/remove yet'
40
+ __fish_seen_subcommand_from port po p; or return 1
41
+ for t in (__sshm_port_post_tokens)
42
+ contains -- $t add a remove r rm; and return 1
43
+ end
44
+ return 0
45
+ end
46
+
47
+ function __sshm_port_has_action --description 'port subcommand already has add/remove'
48
+ __fish_seen_subcommand_from port po p; and not __sshm_port_wants_action
49
+ end
50
+
51
+ function __sshm_port_wants_flag --description 'port has an action but no -L/-R/-D yet'
52
+ __sshm_port_has_action; or return 1
53
+ for t in (__sshm_port_post_tokens)
54
+ contains -- $t -L -R -D; and return 1
55
+ end
56
+ return 0
57
+ end
58
+
59
+ # Positional (non-flag) tokens after the first of the given subcommand keyword(s),
60
+ # used to offer an argument only while its slot is still empty.
61
+ function __sshm_post_tokens
62
+ set -l toks (commandline -opc)
63
+ set -l i
64
+ for kw in $argv
65
+ set i (contains -i -- $kw $toks)
66
+ test -n "$i"; and break
67
+ end
68
+ test -n "$i"; or return
69
+ for t in $toks[(math $i + 1)..-1]
70
+ string match -q -- '-*' $t; or echo $t
71
+ end
72
+ end
73
+
74
+ function __sshm_list_wants_arg
75
+ __fish_seen_subcommand_from list l; or return 1
76
+ set -l p (__sshm_post_tokens list l)
77
+ test (count $p) -eq 0
78
+ end
79
+
80
+ function __sshm_export_wants_file
81
+ __fish_seen_subcommand_from export; or return 1
82
+ set -l p (__sshm_post_tokens export)
83
+ test (count $p) -eq 0
84
+ end
85
+
86
+ function __sshm_export_wants_alias
87
+ __fish_seen_subcommand_from export; or return 1
88
+ set -l p (__sshm_post_tokens export)
89
+ test (count $p) -ge 1
90
+ end
91
+
92
+ function __sshm_import_wants_file
93
+ __fish_seen_subcommand_from import; or return 1
94
+ set -l p (__sshm_post_tokens import)
95
+ test (count $p) -eq 0
96
+ end
97
+
98
+ # Don't fall back to file completion unless a rule opts in (-F).
99
+ complete -c sshm -f
100
+ complete -c sshm -n __fish_use_subcommand -l help -d 'Show help'
101
+
102
+ # --- top level: subcommands + host aliases (bare `sshm <alias>` connects) ---
103
+ complete -c sshm -n __fish_use_subcommand -a '(__sshm_aliases)' -d 'Connect to host'
104
+ complete -c sshm -n __fish_use_subcommand -a list -d 'List hosts or sessions'
105
+ complete -c sshm -n __fish_use_subcommand -a connect -d 'Attach to a session'
106
+ complete -c sshm -n __fish_use_subcommand -a add -d 'Add a server'
107
+ complete -c sshm -n __fish_use_subcommand -a remove -d 'Remove a host'
108
+ complete -c sshm -n __fish_use_subcommand -a rename -d 'Rename an alias'
109
+ complete -c sshm -n __fish_use_subcommand -a port -d 'Port forward / SOCKS proxy'
110
+ complete -c sshm -n __fish_use_subcommand -a enable -d 'Keep session alive'
111
+ complete -c sshm -n __fish_use_subcommand -a disable -d 'Stop auto-connect'
112
+ complete -c sshm -n __fish_use_subcommand -a export -d 'Export hosts to JSON'
113
+ complete -c sshm -n __fish_use_subcommand -a import -d 'Import hosts from JSON'
114
+ complete -c sshm -n __fish_use_subcommand -a status -d 'Daemon status'
115
+ complete -c sshm -n __fish_use_subcommand -a stop -d 'Stop the daemon'
116
+ complete -c sshm -n __fish_use_subcommand -a install -d 'Autostart on login'
117
+ complete -c sshm -n __fish_use_subcommand -a uninstall -d 'Remove autostart'
118
+
119
+ # --- host alias as the argument to alias-taking commands ---
120
+ complete -c sshm -n __sshm_wants_alias -a '(__sshm_aliases)' -d host
121
+
122
+ # --- port: host alias (either order), action while missing, flag after the action ---
123
+ complete -c sshm -n __sshm_port_wants_alias -a '(__sshm_aliases)' -d host
124
+ complete -c sshm -n __sshm_port_wants_action -a 'add remove' -d action
125
+ complete -c sshm -n __sshm_port_wants_flag -a '-L -R -D' -d 'forward direction'
126
+
127
+ # --- list: a single arg — host alias or a .json file ---
128
+ complete -c sshm -n __sshm_list_wants_arg -a '(__sshm_aliases)' -d host
129
+ complete -c sshm -n __sshm_list_wants_arg -F
130
+
131
+ # --- export: <file> then host names ---
132
+ complete -c sshm -n __sshm_export_wants_file -F
133
+ complete -c sshm -n __sshm_export_wants_alias -a '(__sshm_aliases)' -d host
134
+
135
+ # --- import: <file>, then -o ---
136
+ complete -c sshm -n __sshm_import_wants_file -F
137
+ complete -c sshm -n '__fish_seen_subcommand_from import' -s o -l override -d 'Override existing hosts'
sshm/config.py ADDED
@@ -0,0 +1,344 @@
1
+ """SSH config parser/writer with sshm metadata in structured comments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import tempfile
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass
14
+ class PortForward:
15
+ direction: str # "L"/"R" (tunnel) or "D" (SOCKS dynamic proxy)
16
+ local_port: int
17
+ remote_host: str = ""
18
+ remote_port: int = 0
19
+
20
+ def to_str(self) -> str:
21
+ if self.direction == "D":
22
+ return f"D:{self.local_port}"
23
+ return f"{self.direction}:{self.local_port}:{self.remote_host}:{self.remote_port}"
24
+
25
+ def to_config_line(self) -> str:
26
+ if self.direction == "D":
27
+ return f" DynamicForward {self.local_port}"
28
+ keyword = "LocalForward" if self.direction == "L" else "RemoteForward"
29
+ return f" {keyword} {self.local_port} {self.remote_host}:{self.remote_port}"
30
+
31
+ @classmethod
32
+ def socks(cls, port: int) -> PortForward:
33
+ """SOCKS proxy through the host (ssh -D / DynamicForward)."""
34
+ return cls("D", port)
35
+
36
+ @classmethod
37
+ def parse_rule(cls, rule: str, direction: str) -> PortForward:
38
+ """Parse a CLI rule like '8080:80' or '8080:host:80'."""
39
+ parts = rule.split(":")
40
+ if len(parts) == 2:
41
+ return cls(direction, int(parts[0]), "localhost", int(parts[1]))
42
+ if len(parts) == 3:
43
+ return cls(direction, int(parts[0]), parts[1], int(parts[2]))
44
+ raise ValueError(f"Invalid port rule: {rule}")
45
+
46
+ @classmethod
47
+ def from_str(cls, s: str) -> PortForward:
48
+ """Parse the serialized form 'L:8080:host:80' or 'D:1080'."""
49
+ direction, _, rest = s.partition(":")
50
+ if direction == "D":
51
+ if not rest or ":" in rest:
52
+ raise ValueError(f"Invalid port forward: {s}")
53
+ return cls.socks(int(rest))
54
+ if direction not in ("L", "R") or not rest:
55
+ raise ValueError(f"Invalid port forward: {s}")
56
+ return cls.parse_rule(rest, direction)
57
+
58
+ @classmethod
59
+ def from_config(cls, direction: str, value: str) -> PortForward:
60
+ """Parse from an SSH config value like '8080 localhost:80' or '1080'."""
61
+ if direction == "D":
62
+ return cls.socks(int(value.strip()))
63
+ parts = value.strip().split()
64
+ if len(parts) == 2 and ":" in parts[1]:
65
+ remote_host, remote_port = parts[1].rsplit(":", 1)
66
+ return cls(direction, int(parts[0]), remote_host, int(remote_port))
67
+ raise ValueError(f"Invalid forward: {value}")
68
+
69
+
70
+ @dataclass
71
+ class HostEntry:
72
+ alias: str
73
+ hostname: str = ""
74
+ user: str = ""
75
+ port: int = 22
76
+ identity_file: str | None = None
77
+ enabled: bool = False
78
+ port_forwards: list[PortForward] = field(default_factory=list)
79
+ extra_options: list[tuple[str, str]] = field(default_factory=list)
80
+ _raw_lines: list[str] = field(default_factory=list, repr=False)
81
+
82
+
83
+ _META_RE = re.compile(r"^# sshm:(\w+)=(.*)$")
84
+ _HOST_RE = re.compile(r"^Host\s+(\S+)\s*$", re.IGNORECASE)
85
+ _OPTION_RE = re.compile(r"^\s+(\S+)\s+(.+)$")
86
+
87
+
88
+ def ssh_config_path() -> Path:
89
+ return Path.home() / ".ssh" / "config"
90
+
91
+
92
+ def _apply_option(entry: HostEntry, key: str, val: str) -> None:
93
+ key_lower = key.lower()
94
+ if key_lower == "hostname":
95
+ entry.hostname = val
96
+ elif key_lower == "user":
97
+ entry.user = val
98
+ elif key_lower == "port":
99
+ try:
100
+ entry.port = int(val)
101
+ except ValueError:
102
+ entry.extra_options.append((key, val)) # malformed Port → keep verbatim, don't crash the parse
103
+ elif key_lower == "identityfile":
104
+ # Keep the first IdentityFile as the primary (what sshm reads/writes);
105
+ # preserve any extras as raw options so regeneration doesn't drop them.
106
+ if entry.identity_file is None:
107
+ entry.identity_file = val
108
+ else:
109
+ entry.extra_options.append((key, val))
110
+ elif key_lower in ("localforward", "remoteforward", "dynamicforward"):
111
+ direction = {"localforward": "L", "remoteforward": "R", "dynamicforward": "D"}[key_lower]
112
+ try:
113
+ entry.port_forwards.append(PortForward.from_config(direction, val))
114
+ except ValueError:
115
+ entry.extra_options.append((key, val))
116
+ else:
117
+ entry.extra_options.append((key, val))
118
+
119
+
120
+ def parse_ssh_config(path: Path | None = None) -> tuple[list[str], list[HostEntry]]:
121
+ path = path or ssh_config_path()
122
+ if not path.exists():
123
+ return [], []
124
+
125
+ # surrogateescape: a config with non-UTF-8 bytes (a comment in another
126
+ # encoding, etc.) must not crash the parse or the watchdog that calls it; the
127
+ # bytes round-trip losslessly because write_ssh_config uses it too.
128
+ lines = path.read_text(encoding="utf-8", errors="surrogateescape").splitlines(keepends=True)
129
+
130
+ preamble: list[str] = []
131
+ entries: list[HostEntry] = []
132
+ pending_meta: dict[str, str] = {}
133
+ pending_meta_lines: list[str] = []
134
+ current: HostEntry | None = None
135
+ in_preamble = True
136
+
137
+ for line in lines:
138
+ stripped = line.rstrip("\n\r")
139
+
140
+ meta_match = _META_RE.match(stripped)
141
+ if meta_match:
142
+ pending_meta[meta_match.group(1)] = meta_match.group(2)
143
+ pending_meta_lines.append(line)
144
+ continue
145
+
146
+ host_match = _HOST_RE.match(stripped)
147
+ if host_match:
148
+ if current:
149
+ entries.append(current)
150
+ in_preamble = False
151
+
152
+ current = HostEntry(alias=host_match.group(1), _raw_lines=[line])
153
+ if "enabled" in pending_meta:
154
+ current.enabled = pending_meta["enabled"].lower() == "true"
155
+
156
+ pending_meta.clear()
157
+ pending_meta_lines.clear()
158
+ continue
159
+
160
+ if current:
161
+ current._raw_lines.append(line)
162
+ opt_match = _OPTION_RE.match(stripped)
163
+ if opt_match:
164
+ _apply_option(current, opt_match.group(1), opt_match.group(2))
165
+ elif in_preamble:
166
+ if pending_meta_lines:
167
+ preamble.extend(pending_meta_lines)
168
+ pending_meta_lines.clear()
169
+ pending_meta.clear()
170
+ preamble.append(line)
171
+
172
+ if current:
173
+ entries.append(current)
174
+
175
+ return preamble, entries
176
+
177
+
178
+ def write_ssh_config(
179
+ preamble: list[str],
180
+ entries: list[HostEntry],
181
+ path: Path | None = None,
182
+ ) -> None:
183
+ path = path or ssh_config_path()
184
+ path.parent.mkdir(parents=True, exist_ok=True)
185
+
186
+ # If ~/.ssh/config is a symlink (dotfile managers point it at a tracked file),
187
+ # write through to the real target so os.replace swaps the file rather than
188
+ # replacing the link with a regular file and orphaning the target.
189
+ target = path.resolve() if path.is_symlink() else path
190
+ target.parent.mkdir(parents=True, exist_ok=True)
191
+
192
+ if target.exists():
193
+ shutil.copy2(target, target.with_name(target.name + ".bak"))
194
+
195
+ # Write to a temp file in the same directory, then atomically replace, so a
196
+ # crash mid-write can never leave a truncated ~/.ssh/config behind.
197
+ fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=target.name + ".", suffix=".tmp")
198
+ try:
199
+ with os.fdopen(fd, "w", encoding="utf-8", newline="", errors="surrogateescape") as f:
200
+ for line in preamble:
201
+ f.write(line if line.endswith("\n") else line + "\n")
202
+
203
+ for entry in entries:
204
+ # sshm metadata comment (only the enabled flag; forwards are native directives)
205
+ if entry.enabled:
206
+ f.write("# sshm:enabled=true\n")
207
+
208
+ if entry._raw_lines:
209
+ for line in entry._raw_lines:
210
+ f.write(line if line.endswith("\n") else line + "\n")
211
+ else:
212
+ f.write(f"Host {entry.alias}\n")
213
+ if entry.hostname:
214
+ f.write(f" HostName {entry.hostname}\n")
215
+ if entry.user:
216
+ f.write(f" User {entry.user}\n")
217
+ if entry.port != 22:
218
+ f.write(f" Port {entry.port}\n")
219
+ if entry.identity_file:
220
+ f.write(f" IdentityFile {entry.identity_file}\n")
221
+ for key, val in entry.extra_options:
222
+ f.write(f" {key} {val}\n")
223
+ for pf in entry.port_forwards:
224
+ f.write(pf.to_config_line() + "\n")
225
+ f.write("\n")
226
+ f.flush()
227
+ os.fsync(f.fileno())
228
+ except BaseException:
229
+ os.unlink(tmp_name)
230
+ raise
231
+
232
+ if target.exists():
233
+ shutil.copymode(target, tmp_name)
234
+ os.replace(tmp_name, target)
235
+
236
+
237
+ def load_entries(path: Path | None = None) -> list[HostEntry]:
238
+ _, entries = parse_ssh_config(path)
239
+ return entries
240
+
241
+
242
+ def find_entry(alias: str, path: Path | None = None) -> HostEntry | None:
243
+ for entry in load_entries(path):
244
+ if entry.alias == alias:
245
+ return entry
246
+ return None
247
+
248
+
249
+ def _update_entry(alias: str, mutate, path: Path | None = None) -> None:
250
+ """Apply `mutate(entry)` to the matching host and rewrite the config."""
251
+ preamble, entries = parse_ssh_config(path)
252
+ for e in entries:
253
+ if e.alias == alias:
254
+ mutate(e)
255
+ break
256
+ else:
257
+ raise ValueError(f"Host '{alias}' not found")
258
+ write_ssh_config(preamble, entries, path)
259
+
260
+
261
+ def add_host(
262
+ alias: str,
263
+ hostname: str,
264
+ user: str,
265
+ port: int = 22,
266
+ identity_file: str | None = None,
267
+ path: Path | None = None,
268
+ extra_options: list[tuple[str, str]] | None = None,
269
+ ) -> None:
270
+ preamble, entries = parse_ssh_config(path)
271
+
272
+ if any(e.alias == alias for e in entries):
273
+ raise ValueError(f"Host '{alias}' already exists in SSH config")
274
+
275
+ entries.append(
276
+ HostEntry(
277
+ alias=alias,
278
+ hostname=hostname,
279
+ user=user,
280
+ port=port,
281
+ identity_file=identity_file,
282
+ extra_options=extra_options or [],
283
+ )
284
+ )
285
+ write_ssh_config(preamble, entries, path)
286
+
287
+
288
+ def remove_host(alias: str, path: Path | None = None) -> None:
289
+ preamble, entries = parse_ssh_config(path)
290
+ entries = [e for e in entries if e.alias != alias]
291
+ write_ssh_config(preamble, entries, path)
292
+
293
+
294
+ def rename_host(old_alias: str, new_alias: str, path: Path | None = None) -> None:
295
+ """Rename a host's alias, keeping its options, forwards and enabled flag.
296
+
297
+ If the IdentityFile is the sshm-managed key for the old alias
298
+ (`~/.ssh/sshm_<old>`), its reference is updated to `~/.ssh/sshm_<new>` to
299
+ match — the caller is responsible for renaming the key file on disk.
300
+ """
301
+ if old_alias == new_alias:
302
+ raise ValueError("New alias is the same as the old one")
303
+
304
+ preamble, entries = parse_ssh_config(path)
305
+
306
+ if any(e.alias == new_alias for e in entries):
307
+ raise ValueError(f"Host '{new_alias}' already exists in SSH config")
308
+
309
+ for e in entries:
310
+ if e.alias == old_alias:
311
+ break
312
+ else:
313
+ raise ValueError(f"Host '{old_alias}' not found")
314
+
315
+ e.alias = new_alias
316
+ if e.identity_file == f"~/.ssh/sshm_{old_alias}":
317
+ e.identity_file = f"~/.ssh/sshm_{new_alias}"
318
+ e._raw_lines = [] # force regeneration with the new Host line / identity
319
+ write_ssh_config(preamble, entries, path)
320
+
321
+
322
+ def set_enabled(alias: str, enabled: bool, path: Path | None = None) -> None:
323
+ def mutate(e: HostEntry) -> None:
324
+ e.enabled = enabled
325
+
326
+ _update_entry(alias, mutate, path)
327
+
328
+
329
+ def add_port_forward(alias: str, forward: PortForward, path: Path | None = None) -> None:
330
+ def mutate(e: HostEntry) -> None:
331
+ if forward.to_str() in (pf.to_str() for pf in e.port_forwards):
332
+ raise ValueError(f"Port forward {forward.to_str()} already exists")
333
+ e.port_forwards.append(forward)
334
+ e._raw_lines = [] # force regeneration with the new forward
335
+
336
+ _update_entry(alias, mutate, path)
337
+
338
+
339
+ def remove_port_forward(alias: str, rule_str: str, path: Path | None = None) -> None:
340
+ def mutate(e: HostEntry) -> None:
341
+ e.port_forwards = [pf for pf in e.port_forwards if pf.to_str() != rule_str]
342
+ e._raw_lines = [] # force regeneration without the forward
343
+
344
+ _update_entry(alias, mutate, path)