aimux 0.1.1__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.
aimux/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """aimux — AI-agent-friendly tmux wrapper."""
2
+ __version__ = "0.1.1"
aimux/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from aimux.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
aimux/aimux.conf ADDED
@@ -0,0 +1,8 @@
1
+ # aimux server configuration — loaded once when the dedicated socket starts.
2
+ # Enforces the "1 session = 1 window = 1 pane" invariant by unbinding any
3
+ # default keys that would create new windows or split panes.
4
+
5
+ unbind-key c
6
+ unbind-key '"'
7
+ unbind-key %
8
+ unbind-key !
aimux/cli.py ADDED
@@ -0,0 +1,607 @@
1
+ """aimux CLI — typer-based entrypoint."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from typing import Annotated
12
+
13
+ import click
14
+ import typer
15
+
16
+ from . import session as session_mod
17
+ from . import ssh as ssh_mod
18
+ from . import tmux
19
+ from . import transfer
20
+ from . import wait as wait_mod
21
+ from .constants import (
22
+ EXIT_AUTH_REQUIRED,
23
+ EXIT_OK,
24
+ EXIT_SAFETY_DENIED,
25
+ EXIT_SSH_UNREACHABLE,
26
+ EXIT_TIMEOUT,
27
+ EXIT_USER_ERROR,
28
+ OPT_CREATED_AT,
29
+ OPT_CREATED_BY,
30
+ OPT_HOST,
31
+ OPT_LOCATION,
32
+ OPT_OUTPUT_SYNC_FILE,
33
+ )
34
+ from .output import emit_table, err, print_error_json, print_json
35
+
36
+ app = typer.Typer(
37
+ add_completion=False,
38
+ no_args_is_help=True,
39
+ help="aimux — AI-agent-friendly tmux wrapper.",
40
+ context_settings={"help_option_names": ["-h", "--help"]},
41
+ )
42
+
43
+ remote_app = typer.Typer(
44
+ add_completion=False,
45
+ no_args_is_help=True,
46
+ help="Remote-host management (reads/appends ~/.ssh/config).",
47
+ )
48
+ app.add_typer(remote_app, name="remote")
49
+
50
+
51
+ # ---------- helpers ----------
52
+
53
+ def _now_iso() -> str:
54
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+
56
+
57
+ def _actor() -> str:
58
+ return os.environ.get("AIMUX_ACTOR") or os.environ.get("USER") or "unknown"
59
+
60
+
61
+ def _tmux_target_gone(e: tmux.TmuxError) -> bool:
62
+ msg = e.stderr.lower()
63
+ return "no server running" in msg or "can't find session" in msg
64
+
65
+
66
+ def _resolve_or_die(spec: str, *, json_mode: bool = False) -> session_mod.Session:
67
+ try:
68
+ return session_mod.resolve(spec)
69
+ except session_mod.IdError as e:
70
+ if json_mode:
71
+ print_error_json(EXIT_USER_ERROR, "not_found", str(e))
72
+ else:
73
+ err(f"error: {e}")
74
+ raise typer.Exit(EXIT_USER_ERROR)
75
+
76
+
77
+ # ---------- ls ----------
78
+
79
+ @app.command("ls", help="List all aimux sessions.")
80
+ def cmd_ls(
81
+ json_out: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
82
+ remote_only: Annotated[bool, typer.Option("--remote-only", help="Only show remote sessions.")] = False,
83
+ local_only: Annotated[bool, typer.Option("--local-only", help="Only show local sessions.")] = False,
84
+ ):
85
+ sessions = session_mod.list_sessions()
86
+ if remote_only:
87
+ sessions = [s for s in sessions if s.location != "local"]
88
+ if local_only:
89
+ sessions = [s for s in sessions if s.location == "local"]
90
+
91
+ if json_out:
92
+ print_json([s.to_dict() for s in sessions])
93
+ return
94
+
95
+ rows = []
96
+ for s in sessions:
97
+ loc = s.location if s.managed else f"{s.location} (unmanaged)"
98
+ created = s.created_at or "-"
99
+ rows.append([loc, s.name, "attached" if s.attached else "detached", created])
100
+ emit_table(["LOCATION", "NAME", "STATUS", "CREATED"], rows)
101
+
102
+
103
+ # ---------- new ----------
104
+
105
+ @app.command("new", help="Create a new session.")
106
+ def cmd_new(
107
+ name: Annotated[str, typer.Option("--name", help="Session name.")],
108
+ remote: Annotated[Optional[str], typer.Option("--remote", help="SSH host name from ~/.ssh/config.")] = None,
109
+ cwd: Annotated[Optional[str], typer.Option("--cwd", help="Initial working directory.")] = None,
110
+ cmd: Annotated[Optional[str], typer.Option("--cmd", help="Initial command to run.")] = None,
111
+ output_sync_to_file: Annotated[
112
+ Optional[str],
113
+ typer.Option("--output-sync-to-file", help="Real-time mirror pane output to this file (append)."),
114
+ ] = None,
115
+ reuse: Annotated[bool, typer.Option("--reuse", help="Return success if session already exists.")] = False,
116
+ ):
117
+ if "/" in name:
118
+ err("error: --name must not contain '/'")
119
+ raise typer.Exit(EXIT_USER_ERROR)
120
+
121
+ if tmux.has_session(name):
122
+ if reuse:
123
+ return
124
+ err(f"error: session {name!r} already exists")
125
+ raise typer.Exit(EXIT_USER_ERROR)
126
+
127
+ if remote and not ssh_mod.host_exists(remote):
128
+ err(f"error: host {remote!r} not found in ~/.ssh/config")
129
+ raise typer.Exit(EXIT_USER_ERROR)
130
+
131
+ try:
132
+ if remote:
133
+ if not ssh_mod.is_resolvable(remote):
134
+ err(f"error: ssh host {remote!r} is not resolvable via ssh -G")
135
+ raise typer.Exit(EXIT_SSH_UNREACHABLE)
136
+ launch = ssh_mod.build_remote_launch(remote, cwd=cwd, cmd=cmd)
137
+ tmux.new_session(name, cwd=None, command=launch)
138
+ location = remote
139
+ host_val = remote
140
+ else:
141
+ tmux.new_session(name, cwd=cwd, command=cmd)
142
+ location = "local"
143
+ host_val = ""
144
+
145
+ tmux.set_option(name, OPT_LOCATION, location)
146
+ tmux.set_option(name, OPT_HOST, host_val)
147
+ tmux.set_option(name, OPT_CREATED_AT, _now_iso())
148
+ tmux.set_option(name, OPT_CREATED_BY, _actor())
149
+
150
+ if output_sync_to_file:
151
+ path = Path(output_sync_to_file).expanduser().resolve()
152
+ if not path.parent.exists():
153
+ err(f"error: parent directory does not exist: {path.parent}")
154
+ # session was created — keep it; user can kill if they want a clean slate
155
+ raise typer.Exit(EXIT_USER_ERROR)
156
+ # Use absolute path quoted via shlex for the pipe command.
157
+ import shlex
158
+ pipe_cmd = f"cat >> {shlex.quote(str(path))}"
159
+ tmux.pipe_pane(name, pipe_cmd)
160
+ tmux.set_option(name, OPT_OUTPUT_SYNC_FILE, str(path))
161
+ except tmux.TmuxError as e:
162
+ if remote and _tmux_target_gone(e):
163
+ err(
164
+ f"error: remote session {remote!r}/{name!r} exited before aimux could initialize it; "
165
+ f"run 'aimux remote test {remote}' to check SSH reachability"
166
+ )
167
+ raise typer.Exit(EXIT_SSH_UNREACHABLE)
168
+ if not remote and _tmux_target_gone(e):
169
+ err(f"error: session {name!r} exited before aimux could initialize it")
170
+ raise typer.Exit(EXIT_USER_ERROR)
171
+ raise
172
+
173
+
174
+ # ---------- attach ----------
175
+
176
+ @app.command("attach", help="Attach to a session. ⚠️ Human use only — agents should use send-keys + capture.")
177
+ def cmd_attach(
178
+ target: Annotated[str, typer.Argument(help="Session id (location/name or short name).")],
179
+ read_only: Annotated[bool, typer.Option("--read-only", help="Attach in read-only mode.")] = False,
180
+ ):
181
+ s = _resolve_or_die(target)
182
+ tmux.attach(s.name, read_only=read_only)
183
+
184
+
185
+ # ---------- send-keys ----------
186
+
187
+ def _parse_send_keys_args(rest: list[str]) -> tuple[list[str], list[str]]:
188
+ """Pull leading tmux flags out of `rest`; the remainder is keys.
189
+ Supports a literal '--' separator that ends flag parsing."""
190
+ flags: list[str] = []
191
+ i = 0
192
+ while i < len(rest):
193
+ a = rest[i]
194
+ if a == "--":
195
+ return flags, rest[i + 1:]
196
+ if a in ("-F", "-H", "-K", "-l", "-M", "-R", "-X"):
197
+ flags.append(a)
198
+ i += 1
199
+ continue
200
+ if a == "-N":
201
+ if i + 1 >= len(rest):
202
+ raise click.UsageError("-N requires a count")
203
+ flags += ["-N", rest[i + 1]]
204
+ i += 2
205
+ continue
206
+ return flags, rest[i:]
207
+ return flags, []
208
+
209
+
210
+ @app.command(
211
+ "send-keys",
212
+ help=(
213
+ "Send keys to a session. Behaves exactly like 'tmux send-keys': "
214
+ "supports -F/-H/-K/-l/-M/-R/-X/-N <count>. Pass 'Enter' as a key to submit a line."
215
+ ),
216
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "allow_interspersed_args": False},
217
+ )
218
+ def cmd_send_keys(
219
+ ctx: typer.Context,
220
+ target: Annotated[str, typer.Argument(help="Session id.")],
221
+ ):
222
+ flags, keys = _parse_send_keys_args(list(ctx.args))
223
+ if not keys:
224
+ err("error: send-keys requires at least one key argument")
225
+ raise typer.Exit(EXIT_USER_ERROR)
226
+ s = _resolve_or_die(target)
227
+ tmux.send_keys(s.name, keys, flags=flags)
228
+
229
+
230
+ # ---------- file transfers ----------
231
+
232
+ def _classify_and_exit_sftp(remote: str, action: str, e: transfer.SftpError) -> None:
233
+ status = ssh_mod._classify(e.returncode, "\n".join([e.stderr, e.stdout]))
234
+ err(f"error: sftp {action} to {remote!r} failed: {e}")
235
+ if status == "auth-required":
236
+ raise typer.Exit(EXIT_AUTH_REQUIRED)
237
+ if status == "unreachable":
238
+ raise typer.Exit(EXIT_SSH_UNREACHABLE)
239
+ raise typer.Exit(EXIT_USER_ERROR)
240
+
241
+
242
+ @app.command("send_files", help="Upload multiple local files or directories to an SSH host using sftp.")
243
+ def cmd_send_files(
244
+ remote: Annotated[str, typer.Argument(help="SSH host name from ~/.ssh/config.")],
245
+ remote_path: Annotated[str, typer.Argument(help="Destination directory on the remote host.")],
246
+ local_paths: Annotated[list[str], typer.Argument(help="Local files or directories to upload.")],
247
+ gitignore: Annotated[
248
+ bool,
249
+ typer.Option("--gitignore", help="Skip files ignored by git ignore rules when uploading."),
250
+ ] = False,
251
+ ):
252
+ if not ssh_mod.host_exists(remote):
253
+ err(f"error: host {remote!r} not found in ~/.ssh/config")
254
+ raise typer.Exit(EXIT_USER_ERROR)
255
+
256
+ try:
257
+ result = transfer.send_paths(remote, local_paths, remote_path, gitignore=gitignore)
258
+ except transfer.SftpUploadError as e:
259
+ _classify_and_exit_sftp(remote, "upload", e)
260
+ except transfer.TransferError as e:
261
+ err(f"error: {e}")
262
+ raise typer.Exit(EXIT_USER_ERROR)
263
+
264
+ print(f"uploaded {result.uploaded_files} file(s) to {remote}:{remote_path}")
265
+ if result.skipped_files:
266
+ print(f"skipped {result.skipped_files} gitignored file(s)")
267
+
268
+
269
+ # ---------- get_files ----------
270
+
271
+ @app.command("get_files", help="Download multiple remote files or directories from an SSH host using sftp.")
272
+ def cmd_get_files(
273
+ remote: Annotated[str, typer.Argument(help="SSH host name from ~/.ssh/config.")],
274
+ local_dir: Annotated[str, typer.Argument(help="Local destination directory.")],
275
+ remote_paths: Annotated[list[str], typer.Argument(help="Remote files or directories to download.")],
276
+ ):
277
+ if not ssh_mod.host_exists(remote):
278
+ err(f"error: host {remote!r} not found in ~/.ssh/config")
279
+ raise typer.Exit(EXIT_USER_ERROR)
280
+
281
+ try:
282
+ result = transfer.get_paths(remote, remote_paths, local_dir)
283
+ except transfer.SftpDownloadError as e:
284
+ _classify_and_exit_sftp(remote, "download", e)
285
+ except transfer.TransferError as e:
286
+ err(f"error: {e}")
287
+ raise typer.Exit(EXIT_USER_ERROR)
288
+
289
+ print(f"downloaded {result.downloaded_paths} path(s) from {remote} to {result.plan.local_dir}")
290
+
291
+
292
+ # ---------- capture ----------
293
+
294
+ @app.command("capture", help="Capture pane output (last N lines).")
295
+ def cmd_capture(
296
+ target: Annotated[str, typer.Argument(help="Session id.")],
297
+ lines: Annotated[int, typer.Option("--lines", help="Number of lines to capture (required, must be > 0).")] = 0,
298
+ ansi: Annotated[bool, typer.Option("--ansi", help="Preserve ANSI escape sequences.")] = False,
299
+ json_out: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
300
+ ):
301
+ if lines <= 0:
302
+ msg = "--lines must be a positive integer"
303
+ if json_out:
304
+ print_error_json(EXIT_USER_ERROR, "bad_argument", msg)
305
+ else:
306
+ err(f"error: {msg}")
307
+ raise typer.Exit(EXIT_USER_ERROR)
308
+ s = _resolve_or_die(target, json_mode=json_out)
309
+ content = tmux.capture_pane(s.name, lines=lines, ansi=ansi)
310
+ if json_out:
311
+ print_json({"id": s.id, "lines": lines, "content": content})
312
+ else:
313
+ sys.stdout.write(content)
314
+ if not content.endswith("\n"):
315
+ sys.stdout.write("\n")
316
+
317
+
318
+ # ---------- kill ----------
319
+
320
+ @app.command("kill", help="Destroy a single session. Does not accept wildcards or batch.")
321
+ def cmd_kill(
322
+ target: Annotated[str, typer.Argument(help="Session id.")],
323
+ ):
324
+ s = _resolve_or_die(target)
325
+ tmux.kill_session(s.name)
326
+
327
+
328
+ # ---------- wait-last-command-complete ----------
329
+
330
+ @app.command(
331
+ "wait-last-command-complete",
332
+ help="Block until the pane's foreground process is back at a shell. Local sessions only.",
333
+ )
334
+ def cmd_wait(
335
+ target: Annotated[str, typer.Argument(help="Session id.")],
336
+ timeout: Annotated[str, typer.Option("--timeout", help="Required. Format: <int>s|m|h, e.g. 30s, 5m, 2h.")],
337
+ print_lines: Annotated[
338
+ Optional[int],
339
+ typer.Option("--print-lines", help="After wait, print pane's last N lines on stdout."),
340
+ ] = None,
341
+ json_out: Annotated[bool, typer.Option("--json")] = False,
342
+ poll_interval: Annotated[str, typer.Option("--poll-interval", help="e.g. 2s")] = "2s",
343
+ ):
344
+ try:
345
+ timeout_s = wait_mod.parse_duration(timeout)
346
+ poll_s = wait_mod.parse_duration(poll_interval)
347
+ except ValueError as e:
348
+ if json_out:
349
+ print_error_json(EXIT_USER_ERROR, "bad_argument", str(e))
350
+ else:
351
+ err(f"error: {e}")
352
+ raise typer.Exit(EXIT_USER_ERROR)
353
+
354
+ if print_lines is not None and print_lines <= 0:
355
+ msg = "--print-lines must be a positive integer"
356
+ if json_out:
357
+ print_error_json(EXIT_USER_ERROR, "bad_argument", msg)
358
+ else:
359
+ err(f"error: {msg}")
360
+ raise typer.Exit(EXIT_USER_ERROR)
361
+
362
+ s = _resolve_or_die(target, json_mode=json_out)
363
+ if s.location != "local":
364
+ msg = (
365
+ "wait-last-command-complete only supports local sessions; "
366
+ "for remote sessions, send-keys an explicit completion marker and poll capture"
367
+ )
368
+ if json_out:
369
+ print_error_json(EXIT_SAFETY_DENIED, "remote_unsupported", msg)
370
+ else:
371
+ err(f"error: {msg}")
372
+ raise typer.Exit(EXIT_SAFETY_DENIED)
373
+
374
+ res = wait_mod.wait(s.name, timeout_s, poll_s)
375
+
376
+ content: Optional[str] = None
377
+ if print_lines is not None and not res.session_gone:
378
+ try:
379
+ content = tmux.capture_pane(s.name, lines=print_lines, ansi=False)
380
+ except tmux.TmuxError:
381
+ content = None
382
+
383
+ # Outputs:
384
+ if res.session_gone:
385
+ if json_out:
386
+ print_error_json(EXIT_USER_ERROR, "session_gone", f"session {s.id!r} disappeared", elapsed_ms=res.elapsed_ms)
387
+ else:
388
+ err(f"error: session {s.id!r} disappeared during wait")
389
+ raise typer.Exit(EXIT_USER_ERROR)
390
+
391
+ if json_out:
392
+ if res.completed:
393
+ payload = {"completed": True, "elapsed_ms": res.elapsed_ms}
394
+ if content is not None:
395
+ payload["content"] = content
396
+ print_json(payload)
397
+ raise typer.Exit(EXIT_OK)
398
+ # timeout
399
+ payload = {
400
+ "error": {
401
+ "code": EXIT_TIMEOUT,
402
+ "kind": "timeout",
403
+ "message": f"command did not complete within {timeout}",
404
+ "elapsed_ms": res.elapsed_ms,
405
+ }
406
+ }
407
+ if content is not None:
408
+ payload["content"] = content
409
+ print(__import__("json").dumps(payload, ensure_ascii=False))
410
+ raise typer.Exit(EXIT_TIMEOUT)
411
+
412
+ # Text mode
413
+ status_line = "status: completed" if res.completed else "status: timeout"
414
+ elapsed_line = f"elapsed: {res.elapsed_ms / 1000:.1f}s"
415
+ if content is not None:
416
+ # stdout = pane content, stderr = status
417
+ sys.stdout.write(content)
418
+ if not content.endswith("\n"):
419
+ sys.stdout.write("\n")
420
+ err(status_line)
421
+ err(elapsed_line)
422
+ else:
423
+ print(status_line)
424
+ print(elapsed_line)
425
+ raise typer.Exit(EXIT_OK if res.completed else EXIT_TIMEOUT)
426
+
427
+
428
+ # ---------- remote ls ----------
429
+
430
+ @remote_app.command("ls", help="List ssh-config hosts and probe reachability in parallel.")
431
+ def cmd_remote_ls(
432
+ json_out: Annotated[bool, typer.Option("--json")] = False,
433
+ timeout: Annotated[str, typer.Option("--timeout", help="Per-host probe timeout, e.g. 5s.")] = "5s",
434
+ ):
435
+ try:
436
+ timeout_s = int(wait_mod.parse_duration(timeout))
437
+ except ValueError as e:
438
+ err(f"error: {e}")
439
+ raise typer.Exit(EXIT_USER_ERROR)
440
+
441
+ hosts = ssh_mod.list_hosts()
442
+ results: dict[str, ssh_mod.ProbeResult] = {}
443
+ if hosts:
444
+ with ThreadPoolExecutor(max_workers=min(16, len(hosts))) as pool:
445
+ for h, r in zip(hosts, pool.map(lambda h: ssh_mod.probe(h.name, timeout_s), hosts)):
446
+ results[h.name] = r
447
+
448
+ if json_out:
449
+ out = []
450
+ for h in hosts:
451
+ r = results.get(h.name)
452
+ out.append(
453
+ {
454
+ "name": h.name,
455
+ "user": h.user,
456
+ "hostname": h.hostname,
457
+ "port": h.port,
458
+ "status": r.status if r else "unknown",
459
+ "rtt_ms": r.rtt_ms if r else None,
460
+ }
461
+ )
462
+ print_json(out)
463
+ return
464
+
465
+ rows = []
466
+ for h in hosts:
467
+ r = results.get(h.name)
468
+ rtt = f"{r.rtt_ms}ms" if r and r.rtt_ms is not None else "-"
469
+ status = r.status if r else "unknown"
470
+ rows.append([h.name, h.user or "-", h.hostname or h.name, h.port, status, rtt])
471
+ emit_table(["HOST", "USER", "HOSTNAME", "PORT", "STATUS", "RTT"], rows)
472
+
473
+
474
+ # ---------- remote test ----------
475
+
476
+ @remote_app.command("test", help="Probe a single ssh-config host.")
477
+ def cmd_remote_test(
478
+ name: Annotated[str, typer.Argument(help="Host name in ~/.ssh/config.")],
479
+ timeout: Annotated[str, typer.Option("--timeout")] = "5s",
480
+ json_out: Annotated[bool, typer.Option("--json")] = False,
481
+ ):
482
+ try:
483
+ timeout_s = int(wait_mod.parse_duration(timeout))
484
+ except ValueError as e:
485
+ err(f"error: {e}")
486
+ raise typer.Exit(EXIT_USER_ERROR)
487
+
488
+ if not ssh_mod.host_exists(name):
489
+ msg = f"host {name!r} not found in ~/.ssh/config"
490
+ if json_out:
491
+ print_error_json(EXIT_USER_ERROR, "host_not_found", msg)
492
+ else:
493
+ err(f"error: {msg}")
494
+ raise typer.Exit(EXIT_USER_ERROR)
495
+
496
+ r = ssh_mod.probe(name, timeout_s)
497
+ if json_out:
498
+ print_json({"name": name, "status": r.status, "rtt_ms": r.rtt_ms})
499
+ else:
500
+ rtt = f"{r.rtt_ms}ms" if r.rtt_ms is not None else "-"
501
+ print(f"{name}\t{r.status}\trtt={rtt}")
502
+ if r.status not in ("reachable", "auth-required") and r.stderr:
503
+ err(r.stderr.strip())
504
+
505
+ if r.status == "reachable":
506
+ raise typer.Exit(EXIT_OK)
507
+ if r.status == "auth-required":
508
+ raise typer.Exit(EXIT_AUTH_REQUIRED)
509
+ if r.status == "unreachable":
510
+ raise typer.Exit(EXIT_SSH_UNREACHABLE)
511
+ raise typer.Exit(EXIT_USER_ERROR)
512
+
513
+
514
+ # ---------- remote hardware ----------
515
+
516
+ @remote_app.command("hardware", help="Probe a host for GPU count/model, CPU count, and memory.")
517
+ def cmd_remote_hardware(
518
+ name: Annotated[str, typer.Argument(help="Host name in ~/.ssh/config.")],
519
+ timeout: Annotated[str, typer.Option("--timeout")] = "10s",
520
+ json_out: Annotated[bool, typer.Option("--json")] = False,
521
+ ):
522
+ try:
523
+ timeout_s = int(wait_mod.parse_duration(timeout))
524
+ except ValueError as e:
525
+ err(f"error: {e}")
526
+ raise typer.Exit(EXIT_USER_ERROR)
527
+
528
+ if not ssh_mod.host_exists(name):
529
+ msg = f"host {name!r} not found in ~/.ssh/config"
530
+ if json_out:
531
+ print_error_json(EXIT_USER_ERROR, "host_not_found", msg)
532
+ else:
533
+ err(f"error: {msg}")
534
+ raise typer.Exit(EXIT_USER_ERROR)
535
+
536
+ try:
537
+ info = ssh_mod.hardware_info(name, timeout_s)
538
+ except ssh_mod.HardwareError as e:
539
+ msg = f"failed to gather hardware info from {name}: {e}"
540
+ if json_out:
541
+ print_error_json(EXIT_SSH_UNREACHABLE, "ssh_failed", msg)
542
+ else:
543
+ err(f"error: {msg}")
544
+ raise typer.Exit(EXIT_SSH_UNREACHABLE)
545
+
546
+ mem_gb: float | None = None
547
+ if info.mem_total_kb is not None:
548
+ mem_gb = round(info.mem_total_kb / 1024 / 1024, 2)
549
+
550
+ if json_out:
551
+ print_json(
552
+ {
553
+ "name": name,
554
+ "gpu_count": info.gpu_count,
555
+ "gpu_model": info.gpu_model,
556
+ "cpu_count": info.cpu_count,
557
+ "mem_total_kb": info.mem_total_kb,
558
+ "mem_total_gb": mem_gb,
559
+ }
560
+ )
561
+ return
562
+
563
+ mem_str = f"{mem_gb} GB" if mem_gb is not None else "-"
564
+ emit_table(
565
+ ["HOST", "GPU_COUNT", "GPU_MODEL", "CPU_COUNT", "MEMORY"],
566
+ [[name, info.gpu_count, info.gpu_model or "-", info.cpu_count if info.cpu_count is not None else "-", mem_str]],
567
+ )
568
+
569
+
570
+ # ---------- remote add ----------
571
+
572
+ @remote_app.command("add", help="Probe a host; if reachable, append a Host block to ~/.ssh/config.")
573
+ def cmd_remote_add(
574
+ host: Annotated[str, typer.Option("--host", help="HostName (IP or DNS).")],
575
+ user: Annotated[str, typer.Option("--user", help="SSH user.")],
576
+ port: Annotated[int, typer.Option("--port", help="SSH port.")] = 22,
577
+ name: Annotated[Optional[str], typer.Option("--name", help="Alias to use in ssh config; defaults to --host.")] = None,
578
+ identity: Annotated[Optional[str], typer.Option("--identity", help="Path to private key file.")] = None,
579
+ timeout: Annotated[str, typer.Option("--timeout")] = "5s",
580
+ ):
581
+ try:
582
+ timeout_s = int(wait_mod.parse_duration(timeout))
583
+ except ValueError as e:
584
+ err(f"error: {e}")
585
+ raise typer.Exit(EXIT_USER_ERROR)
586
+
587
+ alias = name or host
588
+ if ssh_mod.host_exists(alias):
589
+ err(f"error: host {alias!r} already exists in ssh config; pick another --name")
590
+ raise typer.Exit(EXIT_USER_ERROR)
591
+
592
+ r = ssh_mod.probe_raw(host, port, user, identity, timeout_s)
593
+ if r.status == "auth-required":
594
+ err(f"error: probe to {user}@{host}:{port} got auth-required (network OK but no key auth)")
595
+ if r.stderr:
596
+ err(r.stderr.strip())
597
+ raise typer.Exit(EXIT_AUTH_REQUIRED)
598
+ if r.status != "reachable":
599
+ err(f"error: probe to {user}@{host}:{port} -> {r.status}")
600
+ if r.stderr:
601
+ err(r.stderr.strip())
602
+ raise typer.Exit(EXIT_SSH_UNREACHABLE)
603
+
604
+ backup = ssh_mod.append_host(name=alias, host=host, port=port, user=user, identity=identity)
605
+ rtt = f"{r.rtt_ms}ms" if r.rtt_ms is not None else "-"
606
+ print(f"{alias}\treachable\trtt={rtt}")
607
+ print(f"appended to ~/.ssh/config (backup: {backup})")
aimux/constants.py ADDED
@@ -0,0 +1,31 @@
1
+ """Hardcoded constants. aimux does not read a config file."""
2
+ from __future__ import annotations
3
+
4
+ import importlib.resources
5
+ from pathlib import Path
6
+
7
+ SOCKET_NAME = "aimux"
8
+
9
+ # user-option keys
10
+ OPT_LOCATION = "@aimux-location"
11
+ OPT_HOST = "@aimux-host"
12
+ OPT_CREATED_AT = "@aimux-created-at"
13
+ OPT_CREATED_BY = "@aimux-created-by"
14
+ OPT_OUTPUT_SYNC_FILE = "@aimux-output-sync-file"
15
+
16
+ # Known shell names (case-insensitive). Used by wait-last-command-complete
17
+ # to detect "pane is back at a shell prompt".
18
+ SHELLS = {"bash", "zsh", "sh", "fish", "csh", "tcsh", "ksh", "dash", "ash"}
19
+
20
+ # Exit codes (semantic; see design.md §5)
21
+ EXIT_OK = 0
22
+ EXIT_USER_ERROR = 1
23
+ EXIT_SSH_UNREACHABLE = 2
24
+ EXIT_SAFETY_DENIED = 3
25
+ EXIT_AUTH_REQUIRED = 4
26
+ EXIT_TIMEOUT = 124
27
+
28
+
29
+ def aimux_conf_path() -> Path:
30
+ """Absolute path to the bundled aimux.conf resource."""
31
+ return Path(str(importlib.resources.files("aimux") / "aimux.conf"))
aimux/output.py ADDED
@@ -0,0 +1,37 @@
1
+ """Output helpers: text-vs-JSON, table-vs-quiet."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from typing import Any, Iterable, Sequence
7
+
8
+
9
+ def print_json(obj: Any) -> None:
10
+ print(json.dumps(obj, ensure_ascii=False, sort_keys=False))
11
+
12
+
13
+ def print_error_json(code: int, kind: str, message: str, **extra: Any) -> None:
14
+ payload: dict[str, Any] = {"error": {"code": code, "kind": kind, "message": message}}
15
+ payload.update(extra)
16
+ print(json.dumps(payload, ensure_ascii=False))
17
+
18
+
19
+ def emit_table(headers: Sequence[str], rows: Iterable[Sequence[Any]]) -> None:
20
+ rows = list(rows)
21
+ cols = [str(h) for h in headers]
22
+ widths = [len(c) for c in cols]
23
+ str_rows: list[list[str]] = []
24
+ for row in rows:
25
+ srow = ["" if v is None else str(v) for v in row]
26
+ str_rows.append(srow)
27
+ for i, cell in enumerate(srow):
28
+ if i < len(widths):
29
+ widths[i] = max(widths[i], len(cell))
30
+ line = " ".join(c.ljust(widths[i]) for i, c in enumerate(cols))
31
+ print(line)
32
+ for srow in str_rows:
33
+ print(" ".join(c.ljust(widths[i]) for i, c in enumerate(srow)))
34
+
35
+
36
+ def err(msg: str) -> None:
37
+ print(msg, file=sys.stderr)