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/cli.py ADDED
@@ -0,0 +1,740 @@
1
+ """sshm — SSH session manager CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import ClassVar
12
+
13
+ import click
14
+
15
+ from . import protocol
16
+ from .ipc import DaemonNotRunning, connect_streaming, ensure_daemon, send_request
17
+ from .terminal import stream_bridge
18
+
19
+
20
+ def _send(cmd: str, **kwargs) -> dict:
21
+ try:
22
+ ensure_daemon()
23
+ resp = send_request(cmd, **kwargs)
24
+ except DaemonNotRunning as e:
25
+ click.echo(f"Error: {e}", err=True)
26
+ sys.exit(1)
27
+
28
+ if not resp.get("ok"):
29
+ click.echo(f"Error: {resp.get('error', 'Unknown error')}", err=True)
30
+ sys.exit(1)
31
+
32
+ return resp.get("data", {})
33
+
34
+
35
+ class AliasedGroup(click.Group):
36
+ """Click group with short aliases and custom help formatting."""
37
+
38
+ COMMAND_ALIASES: ClassVar[dict[str, str]] = {
39
+ "l": "list",
40
+ "c": "connect",
41
+ "a": "add",
42
+ "r": "remove",
43
+ "mv": "rename",
44
+ "e": "enable",
45
+ "d": "disable",
46
+ }
47
+
48
+ def get_command(self, ctx, cmd_name):
49
+ cmd_name = self.COMMAND_ALIASES.get(cmd_name, cmd_name)
50
+ return super().get_command(ctx, cmd_name)
51
+
52
+ # Prefix groups with flexible add/remove syntax:
53
+ # sshm p a <alias> -L ... → port-add
54
+ # sshm p <alias> a -L ... → port-add <alias> ...
55
+ # sshm p a <alias> -D <port> → port-add (SOCKS proxy)
56
+ PREFIX_COMMANDS: ClassVar[dict[tuple[str, ...], tuple[str, str]]] = {
57
+ ("port", "po", "p"): ("port-add", "port-remove"),
58
+ }
59
+
60
+ def resolve_command(self, ctx, args):
61
+ if args:
62
+ for prefixes, (add_name, remove_name) in self.PREFIX_COMMANDS.items():
63
+ if args[0] not in prefixes:
64
+ continue
65
+ rest = list(args[1:])
66
+ for i in range(min(2, len(rest))):
67
+ if rest[i] in ("add", "a"):
68
+ rest.pop(i)
69
+ return super().resolve_command(ctx, [add_name] + rest)
70
+ elif rest[i] in ("remove", "r", "rm"):
71
+ rest.pop(i)
72
+ return super().resolve_command(ctx, [remove_name] + rest)
73
+ # A prefix command (port) with no add/remove action: fail with a
74
+ # usage hint instead of silently falling through to `connect`,
75
+ # which would report a baffling "Unknown alias: port".
76
+ if not ctx.resilient_parsing:
77
+ ctx.fail(
78
+ f"'{args[0]}' needs an action: a|add or r|remove "
79
+ f"(e.g. sshm port <alias> a -L 8080:localhost:80)"
80
+ )
81
+ break
82
+
83
+ # "sshm <alias>" → "sshm connect <alias>" if not a known command
84
+ if args and args[0] not in self.COMMAND_ALIASES and args[0] not in self.list_commands(ctx):
85
+ args = ["connect"] + list(args)
86
+
87
+ return super().resolve_command(ctx, args)
88
+
89
+ def format_help(self, ctx, formatter):
90
+ formatter.write("sshm — SSH Session Manager\n\n")
91
+
92
+ formatter.write("Usage:\n")
93
+ formatter.write(" sshm <alias> Connect (shorthand)\n")
94
+ formatter.write(" sshm <command> [args]\n\n")
95
+
96
+ formatter.write("Sessions:\n")
97
+ formatter.write(" l, list [alias] List hosts or sessions\n")
98
+ formatter.write(" c, connect <alias> [name] Attach to session\n")
99
+ formatter.write(" a, add <alias> user@host[:port] Add server\n")
100
+ formatter.write(" r, remove <alias> Remove host\n")
101
+ formatter.write(" mv, rename <alias> <new-alias> Rename host alias\n")
102
+ formatter.write("\n")
103
+
104
+ formatter.write("Forwarding:\n")
105
+ formatter.write(" p, port <alias> a|r -L|-R <local>:<host>:<remote> Port forward\n")
106
+ formatter.write(" p, port <alias> a|r -D <port> SOCKS proxy (ssh -D)\n")
107
+ formatter.write("\n")
108
+
109
+ formatter.write("Auto-connect:\n")
110
+ formatter.write(" e, enable <alias> Keep session alive\n")
111
+ formatter.write(" d, disable <alias> Stop auto-connect\n")
112
+ formatter.write("\n")
113
+
114
+ formatter.write("Import/Export:\n")
115
+ formatter.write(" export <file> [names] Export hosts + keys\n")
116
+ formatter.write(" import <file> [-o] [name|name=new] Import hosts + keys\n")
117
+ formatter.write(" l, list <file.json> Preview JSON file\n")
118
+ formatter.write("\n")
119
+
120
+ formatter.write("Daemon:\n")
121
+ formatter.write(" status Daemon status\n")
122
+ formatter.write(" stop Stop daemon\n")
123
+ formatter.write(" install Autostart on login\n")
124
+ formatter.write(" uninstall Remove autostart\n")
125
+ formatter.write(" completions [fish] Print shell completion script\n")
126
+
127
+
128
+ @click.group(cls=AliasedGroup)
129
+ def main():
130
+ pass
131
+
132
+
133
+ # --- list ---
134
+
135
+ @main.command("list")
136
+ @click.argument("alias", required=False)
137
+ def list_cmd(alias: str | None):
138
+ """List hosts or sessions for alias, or contents of a JSON file."""
139
+ if alias and alias.endswith(".json"):
140
+ _list_json(alias)
141
+ return
142
+
143
+ data = _send(protocol.CMD_LIST, alias=alias)
144
+
145
+ if alias:
146
+ _print_sessions(alias, data)
147
+ else:
148
+ _print_hosts(data)
149
+
150
+
151
+ def _print_sessions(alias: str, sessions: list[dict]) -> None:
152
+ if not sessions:
153
+ click.echo(f"No active connections for '{alias}'")
154
+ return
155
+ click.echo(f"{'NAME':<20} {'PID':<8} {'STATE':<12} {'UPTIME':<12} {'FORWARDS'}")
156
+ click.echo("-" * 70)
157
+ for s in sessions:
158
+ uptime = _format_uptime(s.get("uptime", 0))
159
+ fwds = ", ".join(s.get("port_forwards", []))
160
+ if not s.get("alive", False):
161
+ state, icon = "dead", "○"
162
+ elif s.get("attached", False):
163
+ state, icon = "attached", "◆"
164
+ else:
165
+ state, icon = "ready", "●"
166
+ click.echo(f"{icon} {s['name']:<18} {s.get('pid', '-'):<8} {state:<12} {uptime:<12} {fwds}")
167
+
168
+
169
+ def _print_hosts(hosts: list[dict]) -> None:
170
+ if not hosts:
171
+ click.echo("No hosts configured. Use 'sshm add' to add one.")
172
+ return
173
+ click.echo(f"{'ALIAS':<16} {'HOST':<20} {'USER':<12} {'SESS':<6} {'ENABLED':<8} {'FORWARDS'}")
174
+ click.echo("-" * 80)
175
+ for e in hosts:
176
+ fwds = ", ".join(e.get("port_forwards", []))
177
+ enabled = "yes" if e.get("enabled") else "-"
178
+ host_port = e["hostname"]
179
+ if e.get("port", 22) != 22:
180
+ host_port += f":{e['port']}"
181
+ conns = e.get("connections", 0)
182
+ attached = e.get("attached", 0)
183
+ sess = f"{attached}/{conns}" if conns else "0"
184
+ click.echo(
185
+ f"{e['alias']:<16} {host_port:<20} {e.get('user', '-'):<12} "
186
+ f"{sess:<6} {enabled:<8} {fwds}"
187
+ )
188
+
189
+
190
+ def _list_json(filepath: str) -> None:
191
+ hosts = _read_hosts_file(filepath)
192
+ if not hosts:
193
+ click.echo("No hosts in file.")
194
+ return
195
+ click.echo(f"{'ALIAS':<16} {'HOST':<20} {'USER':<12} {'PORT':<6} {'ENABLED':<8} {'FORWARDS'}")
196
+ click.echo("-" * 80)
197
+ for h in hosts:
198
+ fwds = ", ".join(h.get("port_forwards", []))
199
+ enabled = "yes" if h.get("enabled") else "-"
200
+ host_port = h.get("hostname", "")
201
+ port = h.get("port", 22)
202
+ if port != 22:
203
+ host_port += f":{port}"
204
+ click.echo(
205
+ f"{h['alias']:<16} {host_port:<20} {h.get('user', '-'):<12} "
206
+ f"{port:<6} {enabled:<8} {fwds}"
207
+ )
208
+
209
+
210
+ def _read_hosts_file(filepath: str) -> list[dict]:
211
+ p = Path(filepath)
212
+ if not p.exists():
213
+ click.echo(f"Error: file not found: {filepath}", err=True)
214
+ sys.exit(1)
215
+ try:
216
+ data = json.loads(p.read_text(encoding="utf-8"))
217
+ except (OSError, ValueError) as e:
218
+ click.echo(f"Error: cannot read {filepath}: {e}", err=True)
219
+ sys.exit(1)
220
+ if not isinstance(data, dict) or not isinstance(data.get("hosts"), list):
221
+ click.echo(f"Error: {filepath} is not a valid sshm export (no 'hosts' list)", err=True)
222
+ sys.exit(1)
223
+ # Keep only well-formed host objects that at least carry an alias.
224
+ return [h for h in data["hosts"] if isinstance(h, dict) and h.get("alias")]
225
+
226
+
227
+ # --- connect (attach) ---
228
+
229
+ def _terminal_size() -> tuple[int, int]:
230
+ """Current (cols, rows) of the controlling terminal, with a sane fallback."""
231
+ try:
232
+ sz = os.get_terminal_size()
233
+ return sz.columns, sz.lines
234
+ except OSError:
235
+ return 80, 24
236
+
237
+
238
+ @main.command("connect")
239
+ @click.argument("alias")
240
+ @click.argument("name", required=False)
241
+ def connect_cmd(alias: str, name: str | None):
242
+ """Attach to session (or create new)."""
243
+ cols, rows = _terminal_size()
244
+ try:
245
+ ensure_daemon()
246
+ sock, resp, initial = connect_streaming(
247
+ protocol.CMD_ATTACH, alias=alias, name=name, cli_pid=os.getpid(),
248
+ cols=cols, rows=rows,
249
+ )
250
+ except DaemonNotRunning as e:
251
+ click.echo(f"Error: {e}", err=True)
252
+ sys.exit(1)
253
+
254
+ if not resp.get("ok"):
255
+ click.echo(f"Error: {resp.get('error', 'Unknown error')}", err=True)
256
+ sys.exit(1)
257
+
258
+ session_name = resp["data"]["name"]
259
+ click.echo(f"Attached to {session_name}")
260
+
261
+ # Forward later terminal resizes to the daemon on a separate connection (the
262
+ # bridge socket is a raw byte stream). Fire-and-forget so a slow/again-busy
263
+ # daemon never stalls the interactive session.
264
+ def on_resize(new_cols: int, new_rows: int) -> None:
265
+ def _send() -> None:
266
+ try:
267
+ send_request(
268
+ protocol.CMD_RESIZE, alias=alias, name=session_name,
269
+ cols=new_cols, rows=new_rows,
270
+ )
271
+ except Exception:
272
+ pass
273
+ threading.Thread(target=_send, daemon=True).start()
274
+
275
+ stream_bridge(sock, initial, on_resize=on_resize)
276
+
277
+ click.echo(f"\nDetached from {session_name}")
278
+
279
+
280
+ # --- add ---
281
+
282
+ def _parse_port(port_str: str) -> int:
283
+ try:
284
+ port = int(port_str)
285
+ except ValueError:
286
+ click.echo(f"Error: invalid port '{port_str}'", err=True)
287
+ sys.exit(1)
288
+ if not 1 <= port <= 65535:
289
+ click.echo(f"Error: port out of range: {port}", err=True)
290
+ sys.exit(1)
291
+ return port
292
+
293
+
294
+ def _parse_target(target: str) -> tuple[str, str, int]:
295
+ """Split 'user@host[:port]' into (user, hostname, port).
296
+
297
+ Supports bracketed IPv6 literals: user@[::1] or user@[::1]:2222.
298
+ """
299
+ if "@" not in target:
300
+ click.echo("Error: target must be user@host[:port]", err=True)
301
+ sys.exit(1)
302
+
303
+ user, host_part = target.split("@", 1)
304
+
305
+ def _ok(hostname: str, port: int) -> tuple[str, str, int]:
306
+ if not user or not hostname:
307
+ click.echo(f"Error: target must be user@host[:port], got '{target}'", err=True)
308
+ sys.exit(1)
309
+ return user, hostname, port
310
+
311
+ if host_part.startswith("["): # bracketed IPv6, optional :port after the ]
312
+ end = host_part.find("]")
313
+ if end == -1:
314
+ click.echo("Error: unterminated '[' in IPv6 address", err=True)
315
+ sys.exit(1)
316
+ hostname = host_part[1:end]
317
+ rest = host_part[end + 1:]
318
+ if rest.startswith(":"):
319
+ return _ok(hostname, _parse_port(rest[1:]))
320
+ if rest == "":
321
+ return _ok(hostname, 22)
322
+ click.echo(f"Error: unexpected '{rest}' after IPv6 address", err=True)
323
+ sys.exit(1)
324
+
325
+ # A bare IPv6 literal has multiple colons and no port; everything else uses
326
+ # the last colon as the host/port separator.
327
+ if host_part.count(":") == 1:
328
+ hostname, port_str = host_part.rsplit(":", 1)
329
+ return _ok(hostname, _parse_port(port_str))
330
+ return _ok(host_part, 22)
331
+
332
+
333
+ def _ensure_key(alias: str) -> Path:
334
+ """Generate an ed25519 key for the alias if it doesn't exist yet."""
335
+ ssh_dir = Path.home() / ".ssh"
336
+ ssh_dir.mkdir(mode=0o700, exist_ok=True)
337
+ key_path = ssh_dir / f"sshm_{alias}"
338
+
339
+ if key_path.exists():
340
+ click.echo(f"Using existing key: {key_path}")
341
+ return key_path
342
+
343
+ click.echo(f"Generating SSH key: {key_path}")
344
+ result = subprocess.run(
345
+ ["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", "", "-C", f"sshm_{alias}"],
346
+ capture_output=True, text=True,
347
+ )
348
+ if result.returncode != 0:
349
+ click.echo(f"ssh-keygen failed: {result.stderr}", err=True)
350
+ sys.exit(1)
351
+ return key_path
352
+
353
+
354
+ def _copy_key_to_remote(key_path: Path, user: str, hostname: str, port: int) -> None:
355
+ click.echo(f"Copying key to {user}@{hostname}...")
356
+ pub_key = key_path.with_suffix(".pub").read_text(encoding="utf-8").strip()
357
+
358
+ # Install the key idempotently and fix up everything StrictModes cares about:
359
+ # - tighten $HOME perms (group/other-writable home makes sshd ignore the key)
360
+ # - create ~/.ssh and authorized_keys with correct modes
361
+ # - skip the append if the key is already present (no duplicates)
362
+ # - the leading printf newline guards against a file with no trailing newline,
363
+ # which would otherwise glue our key onto the previous one
364
+ remote = (
365
+ 'chmod go-w ~ 2>/dev/null; '
366
+ 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && '
367
+ 'touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && '
368
+ f'grep -qxF "{pub_key}" ~/.ssh/authorized_keys || '
369
+ f'printf \'\\n%s\\n\' "{pub_key}" >> ~/.ssh/authorized_keys'
370
+ )
371
+ cmd = ["ssh"]
372
+ if port != 22:
373
+ cmd.extend(["-p", str(port)])
374
+ cmd.extend([f"{user}@{hostname}", remote])
375
+
376
+ if subprocess.run(cmd).returncode != 0:
377
+ click.echo("Warning: failed to copy key. You may need to copy it manually.", err=True)
378
+
379
+
380
+ @main.command("add")
381
+ @click.argument("alias")
382
+ @click.argument("target")
383
+ def add_cmd(alias: str, target: str):
384
+ """Add server (keygen + copy key)."""
385
+ from .config import add_host, find_entry, ssh_config_path
386
+
387
+ user, hostname, port = _parse_target(target)
388
+
389
+ if find_entry(alias):
390
+ click.echo(f"Error: alias '{alias}' already exists", err=True)
391
+ sys.exit(1)
392
+
393
+ key_path = _ensure_key(alias)
394
+ _copy_key_to_remote(key_path, user, hostname, port)
395
+
396
+ add_host(
397
+ alias, hostname, user, port, f"~/.ssh/sshm_{alias}", ssh_config_path(),
398
+ # IdentitiesOnly stops ssh from offering agent/default keys first and
399
+ # exhausting MaxAuthTries before it ever tries this host's key.
400
+ extra_options=[("IdentitiesOnly", "yes")],
401
+ )
402
+ click.echo(f"Added '{alias}' -> {user}@{hostname}:{port}")
403
+
404
+ click.echo("Testing connection...")
405
+ test = subprocess.run(
406
+ ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
407
+ "-o", "IdentitiesOnly=yes",
408
+ "-i", str(key_path), "-p", str(port),
409
+ f"{user}@{hostname}", "echo ok"],
410
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
411
+ )
412
+ if test.returncode == 0:
413
+ click.echo("Connection successful!")
414
+ else:
415
+ click.echo("Warning: test connection failed — key auth is not working yet.")
416
+ err = (test.stderr or "").strip()
417
+ if err:
418
+ click.echo(err, err=True)
419
+
420
+
421
+ # --- remove ---
422
+
423
+ @main.command("remove")
424
+ @click.argument("alias")
425
+ def remove_cmd(alias: str):
426
+ """Remove host and disconnect."""
427
+ _send(protocol.CMD_REMOVE, alias=alias)
428
+ click.echo(f"Removed '{alias}'")
429
+
430
+
431
+ # --- rename ---
432
+
433
+ def _managed_key_paths(alias: str) -> tuple[Path, Path]:
434
+ ssh_dir = Path.home() / ".ssh"
435
+ key = ssh_dir / f"sshm_{alias}"
436
+ return key, key.with_suffix(".pub")
437
+
438
+
439
+ def _rename_key_files(old_alias: str, new_alias: str) -> None:
440
+ """Rename the sshm-managed key pair to match a renamed alias."""
441
+ old_key, old_pub = _managed_key_paths(old_alias)
442
+ new_key, new_pub = _managed_key_paths(new_alias)
443
+ for src, dst in ((old_key, new_key), (old_pub, new_pub)):
444
+ if src.exists():
445
+ src.rename(dst)
446
+
447
+
448
+ @main.command("rename")
449
+ @click.argument("alias")
450
+ @click.argument("new_alias")
451
+ def rename_cmd(alias: str, new_alias: str):
452
+ """Rename a host alias."""
453
+ from .config import find_entry
454
+
455
+ # Capture the identity file before the rename to decide whether the managed
456
+ # key pair should follow along (the daemon updates the config reference).
457
+ entry = find_entry(alias)
458
+ managed_key = entry is not None and entry.identity_file == f"~/.ssh/sshm_{alias}"
459
+
460
+ # Pre-flight: refuse if the destination key files already exist, BEFORE the
461
+ # daemon rewrites the config. Otherwise the rename could point IdentityFile
462
+ # at a key we then fail to create, leaving a dangling reference.
463
+ if managed_key:
464
+ for dst in _managed_key_paths(new_alias):
465
+ if dst.exists():
466
+ click.echo(
467
+ f"Error: {dst} already exists; remove or rename it first", err=True
468
+ )
469
+ sys.exit(1)
470
+
471
+ _send(protocol.CMD_RENAME, alias=alias, new_alias=new_alias)
472
+
473
+ if managed_key:
474
+ _rename_key_files(alias, new_alias)
475
+
476
+ click.echo(f"Renamed '{alias}' -> '{new_alias}'")
477
+
478
+
479
+ # --- port add / port remove (forwards + SOCKS proxy via -D) ---
480
+
481
+ def _parse_port_args(args: tuple[str, ...]) -> tuple[str, str]:
482
+ """Parse raw forward args into (direction, rule).
483
+
484
+ -L/-R take a <local>:<host>:<remote> rule; -D takes a single <port> and
485
+ declares a SOCKS proxy (DynamicForward).
486
+ """
487
+ if len(args) == 2 and args[0] in ("-L", "-R", "-D"):
488
+ return args[0][1], args[1] # "L" / "R" / "D", rule
489
+ click.echo(
490
+ "Usage: sshm port <alias> a|r -L|-R <local>:<host>:<remote>\n"
491
+ " sshm port <alias> a|r -D <port>",
492
+ err=True,
493
+ )
494
+ sys.exit(1)
495
+
496
+
497
+ @main.command("port-add", context_settings=dict(ignore_unknown_options=True))
498
+ @click.argument("alias")
499
+ @click.argument("args", nargs=-1, type=click.UNPROCESSED)
500
+ def port_add_cmd(alias: str, args: tuple[str, ...]):
501
+ """Add a port forward (-L/-R) or SOCKS proxy (-D)."""
502
+ direction, rule = _parse_port_args(args)
503
+ data = _send(protocol.CMD_PORT_ADD, alias=alias, direction=direction, rule=rule)
504
+ added = data.get("added", rule)
505
+ if direction == "D":
506
+ click.echo(f"Added SOCKS proxy: {added} (socks5://127.0.0.1:{rule})")
507
+ else:
508
+ click.echo(f"Added port forward: {added}")
509
+
510
+
511
+ @main.command("port-remove", context_settings=dict(ignore_unknown_options=True))
512
+ @click.argument("alias")
513
+ @click.argument("args", nargs=-1, type=click.UNPROCESSED)
514
+ def port_remove_cmd(alias: str, args: tuple[str, ...]):
515
+ """Remove a port forward (-L/-R) or SOCKS proxy (-D)."""
516
+ from .config import PortForward
517
+
518
+ direction, rule = _parse_port_args(args)
519
+ try:
520
+ if direction == "D":
521
+ rule_str = PortForward.socks(int(rule)).to_str()
522
+ else:
523
+ rule_str = PortForward.parse_rule(rule, direction).to_str()
524
+ except ValueError:
525
+ click.echo(f"Error: invalid rule format '{rule}'", err=True)
526
+ sys.exit(1)
527
+ _send(protocol.CMD_PORT_REMOVE, alias=alias, rule=rule_str)
528
+ label = "SOCKS proxy" if direction == "D" else "port forward"
529
+ click.echo(f"Removed {label}: {rule_str}")
530
+
531
+
532
+ # --- enable / disable ---
533
+
534
+ @main.command("enable")
535
+ @click.argument("alias")
536
+ def enable_cmd(alias: str):
537
+ """Keep session alive automatically."""
538
+ _send(protocol.CMD_ENABLE, alias=alias)
539
+ click.echo(f"Enabled auto-connect for '{alias}'")
540
+
541
+
542
+ @main.command("disable")
543
+ @click.argument("alias")
544
+ def disable_cmd(alias: str):
545
+ """Stop auto-connect."""
546
+ _send(protocol.CMD_DISABLE, alias=alias)
547
+ click.echo(f"Disabled auto-connect for '{alias}'")
548
+
549
+
550
+ # --- export / import ---
551
+
552
+ @main.command("export")
553
+ @click.argument("filepath")
554
+ @click.argument("names", nargs=-1)
555
+ def export_cmd(filepath: str, names: tuple[str, ...]):
556
+ """Export hosts with SSH keys to JSON."""
557
+ from .config import load_entries
558
+
559
+ entries = [e for e in load_entries() if e.alias not in ("*", "")]
560
+ if names:
561
+ entries = [e for e in entries if e.alias in names]
562
+
563
+ hosts = []
564
+ for e in entries:
565
+ h: dict = {
566
+ "alias": e.alias,
567
+ "hostname": e.hostname,
568
+ "user": e.user,
569
+ "port": e.port,
570
+ "port_forwards": [pf.to_str() for pf in e.port_forwards],
571
+ }
572
+ if e.identity_file:
573
+ h["identity_file"] = e.identity_file
574
+ key_path = Path(e.identity_file).expanduser()
575
+ if key_path.exists():
576
+ h["private_key"] = key_path.read_text(encoding="utf-8")
577
+ pub_path = key_path.with_suffix(".pub")
578
+ if pub_path.exists():
579
+ h["public_key"] = pub_path.read_text(encoding="utf-8")
580
+ hosts.append(h)
581
+
582
+ try:
583
+ Path(filepath).write_text(
584
+ json.dumps({"hosts": hosts}, indent=2, ensure_ascii=False),
585
+ encoding="utf-8",
586
+ )
587
+ except OSError as e:
588
+ click.echo(f"Error: cannot write {filepath}: {e}", err=True)
589
+ sys.exit(1)
590
+ click.echo(f"Exported {len(hosts)} host(s) to {filepath}")
591
+
592
+
593
+ @main.command("import")
594
+ @click.argument("filepath")
595
+ @click.option("-o", "--override", is_flag=True, help="Override existing hosts")
596
+ @click.argument("names", nargs=-1)
597
+ def import_cmd(filepath: str, override: bool, names: tuple[str, ...]):
598
+ """Import hosts from JSON. A name may be `<json-alias>=<new-alias>` to rename."""
599
+ from .config import PortForward, add_host, add_port_forward, find_entry, remove_host, ssh_config_path
600
+
601
+ rename = _parse_import_names(names) # {json_alias: target_alias}; empty → import all
602
+ hosts = _read_hosts_file(filepath)
603
+ if rename:
604
+ hosts = [h for h in hosts if h["alias"] in rename]
605
+
606
+ imported = 0
607
+ skipped = 0
608
+ for h in hosts:
609
+ src_alias = h["alias"]
610
+ alias = rename.get(src_alias, src_alias) # target alias (renamed or as-is)
611
+ existing = find_entry(alias)
612
+ label = f"{src_alias} -> {alias}" if alias != src_alias else alias
613
+
614
+ if existing and not override:
615
+ click.echo(f" skip {label} (already exists, use -o to override)")
616
+ skipped += 1
617
+ continue
618
+
619
+ if existing:
620
+ remove_host(alias)
621
+
622
+ # If the export's key is the managed ~/.ssh/sshm_<src>, retarget it to the
623
+ # new alias so the renamed host gets its own sshm_<new> key (like rename).
624
+ identity = h.get("identity_file")
625
+ if alias != src_alias and identity == f"~/.ssh/sshm_{src_alias}":
626
+ identity = f"~/.ssh/sshm_{alias}"
627
+ if identity and h.get("private_key"):
628
+ _write_key_files(identity, h, override)
629
+
630
+ add_host(
631
+ alias=alias,
632
+ hostname=h.get("hostname", ""),
633
+ user=h.get("user", "root"),
634
+ port=h.get("port", 22),
635
+ identity_file=identity,
636
+ path=ssh_config_path(),
637
+ )
638
+
639
+ for pf_str in h.get("port_forwards", []):
640
+ try:
641
+ add_port_forward(alias, PortForward.from_str(pf_str))
642
+ except Exception:
643
+ pass
644
+
645
+ click.echo(f" {'update' if existing else 'add':>6} {label}")
646
+ imported += 1
647
+
648
+ click.echo(f"\nImported {imported}, skipped {skipped}")
649
+
650
+
651
+ def _parse_import_names(names: tuple[str, ...]) -> dict[str, str]:
652
+ """Parse import selectors into {json_alias: target_alias}.
653
+
654
+ Each name is either `<alias>` (import as-is) or `<alias>=<new-alias>` (import
655
+ that host under a new alias). An empty tuple means "import everything".
656
+ """
657
+ mapping: dict[str, str] = {}
658
+ for n in names:
659
+ src, sep, dst = n.partition("=")
660
+ if sep and not (src and dst):
661
+ click.echo(f"Error: invalid selector '{n}', expected <name> or <name>=<new-name>", err=True)
662
+ sys.exit(1)
663
+ mapping[src] = dst if sep else src
664
+ return mapping
665
+
666
+
667
+ def _write_key_files(identity: str, host: dict, override: bool) -> None:
668
+ key_path = Path(identity).expanduser()
669
+ key_path.parent.mkdir(mode=0o700, exist_ok=True)
670
+ if not key_path.exists() or override:
671
+ key_path.write_text(host["private_key"], encoding="utf-8")
672
+ if sys.platform != "win32":
673
+ os.chmod(key_path, 0o600)
674
+ pub_path = key_path.with_suffix(".pub")
675
+ if host.get("public_key") and (not pub_path.exists() or override):
676
+ pub_path.write_text(host["public_key"], encoding="utf-8")
677
+
678
+
679
+ # --- daemon control ---
680
+
681
+ @main.command("stop")
682
+ def stop_cmd():
683
+ """Stop daemon."""
684
+ _send(protocol.CMD_SHUTDOWN)
685
+ click.echo("Daemon stopping...")
686
+
687
+
688
+ @main.command("status")
689
+ def status_cmd():
690
+ """Show daemon status."""
691
+ data = _send(protocol.CMD_STATUS)
692
+ click.echo(f"Daemon: {data.get('status', 'unknown')}, sessions: {data.get('sessions', 0)}")
693
+
694
+
695
+ @main.command("install")
696
+ def install_cmd():
697
+ """Autostart daemon on login."""
698
+ from .autostart import install_autostart
699
+ click.echo(install_autostart())
700
+
701
+
702
+ @main.command("uninstall")
703
+ def uninstall_cmd():
704
+ """Remove autostart."""
705
+ from .autostart import uninstall_autostart
706
+ click.echo(uninstall_autostart())
707
+
708
+
709
+ # --- shell completions ---
710
+
711
+ @main.command("completions")
712
+ @click.argument("shell", type=click.Choice(["fish"]), default="fish", required=False)
713
+ def completions_cmd(shell: str):
714
+ """Print the shell completion script (currently: fish).
715
+
716
+ Install with:
717
+ sshm completions fish > ~/.config/fish/completions/sshm.fish && exec fish
718
+ """
719
+ from importlib import resources
720
+
721
+ script = resources.files("sshm").joinpath(f"completions/sshm.{shell}").read_text(encoding="utf-8")
722
+ click.echo(script, nl=False)
723
+
724
+
725
+ # --- helpers ---
726
+
727
+ def _format_uptime(seconds: float) -> str:
728
+ s = int(seconds)
729
+ if s < 60:
730
+ return f"{s}s"
731
+ elif s < 3600:
732
+ return f"{s // 60}m{s % 60}s"
733
+ elif s < 86400:
734
+ return f"{s // 3600}h{(s % 3600) // 60}m"
735
+ else:
736
+ return f"{s // 86400}d{(s % 86400) // 3600}h"
737
+
738
+
739
+ if __name__ == "__main__":
740
+ main()