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 +2 -0
- aimux/__main__.py +4 -0
- aimux/aimux.conf +8 -0
- aimux/cli.py +607 -0
- aimux/constants.py +31 -0
- aimux/output.py +37 -0
- aimux/session.py +105 -0
- aimux/ssh.py +318 -0
- aimux/tmux.py +147 -0
- aimux/transfer.py +365 -0
- aimux/wait.py +43 -0
- aimux-0.1.1.dist-info/METADATA +63 -0
- aimux-0.1.1.dist-info/RECORD +15 -0
- aimux-0.1.1.dist-info/WHEEL +4 -0
- aimux-0.1.1.dist-info/entry_points.txt +2 -0
aimux/__init__.py
ADDED
aimux/__main__.py
ADDED
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)
|