akashcli 4.6.1__tar.gz → 4.6.3__tar.gz

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.
Files changed (32) hide show
  1. {akashcli-4.6.1 → akashcli-4.6.3}/PKG-INFO +1 -1
  2. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/relay.py +522 -129
  3. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli.egg-info/PKG-INFO +1 -1
  4. {akashcli-4.6.1 → akashcli-4.6.3}/pyproject.toml +1 -1
  5. {akashcli-4.6.1 → akashcli-4.6.3}/README.md +0 -0
  6. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/__init__.py +0 -0
  7. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/_net.py +0 -0
  8. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/auth.py +0 -0
  9. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/cli.py +0 -0
  10. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/client.py +0 -0
  11. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/config.py +0 -0
  12. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/crypto.py +0 -0
  13. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/env_manager.py +0 -0
  14. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/exceptions.py +0 -0
  15. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/exec.py +0 -0
  16. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/integrity.py +0 -0
  17. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/logger.py +0 -0
  18. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/models.py +0 -0
  19. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/p2p.py +0 -0
  20. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/packages.py +0 -0
  21. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/restore.py +0 -0
  22. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/snapshot_crypto.py +0 -0
  23. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/storage.py +0 -0
  24. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/terminal.py +0 -0
  25. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/uploader.py +0 -0
  26. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli/utils.py +0 -0
  27. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli.egg-info/SOURCES.txt +0 -0
  28. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli.egg-info/dependency_links.txt +0 -0
  29. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli.egg-info/entry_points.txt +0 -0
  30. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli.egg-info/requires.txt +0 -0
  31. {akashcli-4.6.1 → akashcli-4.6.3}/akashcli.egg-info/top_level.txt +0 -0
  32. {akashcli-4.6.1 → akashcli-4.6.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.6.1
3
+ Version: 4.6.3
4
4
  Summary: Akash CLI — secure remote shell relay, file vault, and device management
5
5
  License: MIT
6
6
  Keywords: akash,cli,relay,remote-shell,vault
@@ -164,30 +164,68 @@ def _run_relay_device(ws, label: str) -> None:
164
164
 
165
165
 
166
166
  def _handle_run(ws, frame: dict) -> None:
167
- """Execute a non-interactive command and stream output back."""
167
+ """Execute a non-interactive command, stream output in real-time, send cwd."""
168
168
  import subprocess as _sp
169
+ import select as _sel
170
+
171
+ command = frame.get("command", "")
172
+ cwd = frame.get("cwd", os.path.expanduser("~"))
173
+ env = {**os.environ, "TERM": "dumb"}
169
174
 
170
- command = frame.get("command", "")
171
175
  try:
172
176
  proc = _sp.Popen(
173
177
  command, shell=True,
174
178
  stdout=_sp.PIPE, stderr=_sp.STDOUT,
175
- cwd=os.path.expanduser("~"),
179
+ cwd=cwd,
180
+ env=env,
176
181
  )
182
+ fd = proc.stdout.fileno()
183
+ # Non-blocking read: send chunks as soon as they arrive
177
184
  while True:
178
- chunk = proc.stdout.read(4096)
179
- if not chunk:
185
+ r, _, _ = _sel.select([fd], [], [], 0.02)
186
+ if r:
187
+ chunk = os.read(fd, 16384)
188
+ if not chunk:
189
+ break
190
+ ws.send(json.dumps({
191
+ "type": "stdout",
192
+ "data": base64.b64encode(chunk).decode(),
193
+ }))
194
+ elif proc.poll() is not None:
195
+ # Drain any remaining bytes
196
+ try:
197
+ while True:
198
+ r2, _, _ = _sel.select([fd], [], [], 0)
199
+ if not r2:
200
+ break
201
+ chunk = os.read(fd, 16384)
202
+ if not chunk:
203
+ break
204
+ ws.send(json.dumps({
205
+ "type": "stdout",
206
+ "data": base64.b64encode(chunk).decode(),
207
+ }))
208
+ except Exception:
209
+ pass
180
210
  break
181
- ws.send(json.dumps({
182
- "type": "stdout",
183
- "data": base64.b64encode(chunk).decode(),
184
- }))
185
211
  proc.wait()
186
- ws.send(json.dumps({"type": "exit", "code": proc.returncode or 0}))
212
+ # Resolve final cwd: try to read it from the shell
213
+ try:
214
+ result = _sp.run(
215
+ ["sh", "-c", f"cd {json.dumps(cwd)} 2>/dev/null; true"],
216
+ capture_output=True, timeout=1,
217
+ )
218
+ except Exception:
219
+ pass
220
+ ws.send(json.dumps({
221
+ "type": "exit",
222
+ "code": proc.returncode or 0,
223
+ "cwd": cwd,
224
+ }))
187
225
  except Exception as e:
188
226
  try:
189
227
  ws.send(json.dumps({"type": "error", "message": str(e)}))
190
- ws.send(json.dumps({"type": "exit", "code": 1}))
228
+ ws.send(json.dumps({"type": "exit", "code": 1, "cwd": cwd}))
191
229
  except Exception:
192
230
  pass
193
231
 
@@ -211,30 +249,51 @@ def _handle_pty(ws, frame: dict) -> None:
211
249
 
212
250
  def _child_setup():
213
251
  """Run inside the child process before exec.
214
- start_new_session=True already calls setsid(); we just need to claim
215
- the slave PTY as the controlling terminal via TIOCSCTTY."""
252
+ start_new_session=True already called setsid(); claim the slave PTY
253
+ as controlling terminal so bash gets full job control when possible."""
216
254
  try:
217
255
  fcntl.ioctl(0, termios.TIOCSCTTY, 0)
218
256
  except Exception:
219
257
  pass
220
258
 
221
259
  import subprocess as _sp
260
+ import tempfile as _tmp
261
+
222
262
  shell = command or os.environ.get("SHELL", "/bin/bash")
223
263
 
224
- # Use start_new_session=True so the child is a proper session leader
225
- # (equivalent to setsid()), then TIOCSCTTY in preexec_fn attaches the
226
- # slave PTY as the controlling terminal → bash gets full job control.
264
+ # On Android non-root, TIOCSCTTY is blocked by SELinux so bash prints:
265
+ # "cannot set terminal process group … no job control"
266
+ # Fix: launch bash with --rcfile pointing to a temp file that sources
267
+ # ~/.bashrc then runs `set +m` (disable job-control monitoring).
268
+ # This silences the warning while keeping the shell fully interactive.
269
+ _env = {**os.environ,
270
+ "TERM": "xterm-256color",
271
+ "COLUMNS": str(cols), "LINES": str(rows)}
272
+ _bash_env_file = None
273
+ _shell_args = [shell]
274
+ if "bash" in shell.lower():
275
+ try:
276
+ _fd, _bash_env_file = _tmp.mkstemp(prefix=".akash_rc_", suffix=".sh")
277
+ # Source ~/.bashrc if it exists, then disable job-control monitoring
278
+ _rc = (
279
+ b'[ -f ~/.bashrc ] && . ~/.bashrc\n'
280
+ b'set +m\n'
281
+ )
282
+ os.write(_fd, _rc)
283
+ os.close(_fd)
284
+ _shell_args = [shell, "--rcfile", _bash_env_file]
285
+ except Exception:
286
+ _bash_env_file = None
287
+
227
288
  try:
228
289
  proc = _sp.Popen(
229
- [shell],
290
+ _shell_args,
230
291
  stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
231
292
  close_fds=True,
232
293
  start_new_session=True,
233
294
  preexec_fn=_child_setup,
234
295
  cwd=os.path.expanduser("~"),
235
- env={**os.environ,
236
- "TERM": "xterm-256color",
237
- "COLUMNS": str(cols), "LINES": str(rows)},
296
+ env=_env,
238
297
  )
239
298
  except Exception as e:
240
299
  try:
@@ -326,6 +385,12 @@ def _handle_pty(ws, frame: dict) -> None:
326
385
  os.close(master_fd)
327
386
  except Exception:
328
387
  pass
388
+ # Clean up the BASH_ENV temp file if we created one
389
+ if _bash_env_file:
390
+ try:
391
+ os.unlink(_bash_env_file)
392
+ except Exception:
393
+ pass
329
394
  try:
330
395
  ws.send(json.dumps({"type": "exit", "code": proc.returncode or 0}))
331
396
  except Exception:
@@ -769,15 +834,19 @@ def _sh_help() -> None:
769
834
  print()
770
835
  print(f" {_SH_GR}╭─ AKASH SHELL COMMANDS{_SH_RS}")
771
836
  cmds = [
772
- (":h", "session history"),
773
- (":r", "re-run last command"),
774
- (":clear", "clear screen"),
775
- (":status", "show session info"),
776
- (":upload F", "upload local file F to vault"),
777
- (":exit", "disconnect"),
837
+ (":help / :h", "this help"),
838
+ (":hist", "command history"),
839
+ (":clear", "clear screen"),
840
+ (":status", "session info (code, cwd)"),
841
+ (":pty [cmd]", "run cmd in full PTY mode (for nano, vim, htop…)"),
842
+ (":upload F", "upload local file F to vault"),
843
+ (":exit", "disconnect"),
844
+ ("exit / Ctrl-D", "disconnect"),
778
845
  ]
779
846
  for cmd, desc in cmds:
780
- print(f" {_SH_GR}│{_SH_RS} {_SH_C}{cmd:<12}{_SH_RS} {_SH_DM}{desc}{_SH_RS}")
847
+ print(f" {_SH_GR}│{_SH_RS} {_SH_C}{cmd:<18}{_SH_RS} {_SH_DM}{desc}{_SH_RS}")
848
+ print(f" {_SH_GR}│{_SH_RS}")
849
+ print(f" {_SH_GR}│{_SH_RS} {_SH_DM}TUI apps (nano, vim, htop, python3…) auto-switch to PTY{_SH_RS}")
781
850
  print(f" {_SH_GR}╰─{_SH_RS}")
782
851
  print()
783
852
 
@@ -804,21 +873,96 @@ def _sh_history(history: list) -> None:
804
873
  print()
805
874
 
806
875
 
876
+ # ── TUI apps that always get PTY mode ────────────────────────────────────────
877
+ _PTY_APPS = frozenset({
878
+ "nano", "vim", "vi", "nvim", "emacs", "pico",
879
+ "htop", "top", "btop", "less", "more", "man",
880
+ "python", "python3", "ipython", "node", "irb",
881
+ "mysql", "psql", "sqlite3", "mongo",
882
+ "bash", "sh", "zsh", "fish", "ksh",
883
+ "watch", "screen", "tmux",
884
+ "fzf", "ranger", "mc", "nnn",
885
+ "ssh", "telnet",
886
+ })
887
+
888
+
889
+ def _is_pty_cmd(cmd: str) -> bool:
890
+ """Return True if cmd should run in PTY mode (TUI / interactive app)."""
891
+ first = cmd.strip().lstrip("\\").split()[0] if cmd.strip() else ""
892
+ # strip path — /usr/bin/vim → vim
893
+ first = first.split("/")[-1]
894
+ return first in _PTY_APPS
895
+
896
+
897
+ def _term_cols() -> int:
898
+ try:
899
+ import shutil as _s
900
+ return _s.get_terminal_size(fallback=(80, 24)).columns
901
+ except Exception:
902
+ return 80
903
+
904
+
905
+ def _term_size() -> tuple:
906
+ try:
907
+ import shutil as _s
908
+ ts = _s.get_terminal_size(fallback=(80, 24))
909
+ return ts.lines, ts.columns
910
+ except Exception:
911
+ return 24, 80
912
+
913
+
914
+ def _strip_ansi(text: str) -> str:
915
+ import re
916
+ text = re.sub(r'\x1b\[[0-9;]*[mGKHFJA-Za-z]', '', text)
917
+ text = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', text)
918
+ return text
919
+
920
+
921
+ def _print_output(lines: list, cols: int) -> None:
922
+ """Print command output with inset │ borders, wrapping long lines."""
923
+ GR, RS = _SH_GR, _SH_RS
924
+ # wrap at cols - 6 (accounts for " │ " prefix)
925
+ wrap = max(cols - 6, 40)
926
+ printed = 0
927
+ MAX = 200
928
+ for raw_line in lines:
929
+ # wrap long lines
930
+ while len(raw_line) > wrap:
931
+ sys.stdout.write(f" {GR}│{RS} {raw_line[:wrap]}\n")
932
+ raw_line = raw_line[wrap:]
933
+ printed += 1
934
+ if printed >= MAX:
935
+ break
936
+ if printed >= MAX:
937
+ remaining = len(lines) - lines.index(raw_line)
938
+ sys.stdout.write(f" {GR}│{RS} {_SH_DM}… {remaining} more lines{RS}\n")
939
+ break
940
+ sys.stdout.write(f" {GR}│{RS} {raw_line}\n")
941
+ printed += 1
942
+ sys.stdout.flush()
943
+
944
+
807
945
  def guest_connect_shell(base_url: str, code: str) -> int:
808
946
  """
809
947
  Connect to a guest host via pairing code.
810
- Opens a real PTY tunnel to the host's shell — identical to connect_shell()
811
- but uses the guest relay (no API key). Full interactive terminal: nano,
812
- vim, htop, etc. all work. DevOS banner is printed before entering raw mode.
813
- Returns the remote exit code.
948
+
949
+ Dual-mode interactive shell:
950
+ REPL mode — DevOS prompt, commands run via `run` frames, real-time
951
+ streaming output with │ borders. Fast, clean, readable.
952
+ PTY mode — auto-switched for TUI apps (nano/vim/htop/etc.) or on
953
+ demand with :pty. Terminal goes raw, full escape codes.
954
+
955
+ Returns the last remote exit code.
814
956
  """
957
+ import queue as _q
815
958
  import select as _sel
816
959
  import signal
960
+ import shutil as _sh
817
961
 
818
962
  code = code.strip().upper()
819
963
  ws_url = _guest_join_url(base_url, code)
820
964
 
821
- # ── Connect & handle approval (before entering raw mode) ─────────────────
965
+ # ── 1. Connect ────────────────────────────────────────────────────────────
822
966
  print(f"\n {_C}◈{_RS} {_DM}connecting…{_RS}", flush=True)
823
967
  try:
824
968
  ws = _ws_connect(ws_url, open_timeout=15, close_timeout=10)
@@ -853,145 +997,394 @@ def guest_connect_shell(base_url: str, code: str) -> int:
853
997
  ws.close()
854
998
  raise RuntimeError(first.get("message", "unexpected server response"))
855
999
 
856
- # ── Print DevOS banner (before raw mode) ──────────────────────────────────
857
- identity = os.environ.get("USER") or os.environ.get("LOGNAME") or "akash"
858
- print(f"\n {_G}◉{_RS} {_C}akash shell{_RS} "
859
- f"{_DM}code {code} · Ctrl-D or `exit` to quit{_RS}\n", flush=True)
1000
+ # ── 2. Session state ──────────────────────────────────────────────────────
1001
+ identity = os.environ.get("USER") or os.environ.get("LOGNAME") or "akash"
1002
+ cwd = "~" # updated from exit frames
1003
+ history : list[str] = []
1004
+ exit_code = 0
860
1005
 
861
- # ── Get terminal size ─────────────────────────────────────────────────────
1006
+ # readline history
1007
+ _AKASH_DIR.mkdir(parents=True, exist_ok=True)
1008
+ hist_file = _AKASH_DIR / "shell_history"
862
1009
  try:
863
- import shutil as _sh
864
- cols, rows = _sh.get_terminal_size(fallback=(80, 24))
1010
+ import readline as _rl
1011
+ _rl.read_history_file(str(hist_file))
1012
+ _rl.set_history_length(500)
865
1013
  except Exception:
866
- rows, cols = 24, 80
1014
+ _rl = None # type: ignore
867
1015
 
868
- # ── Enter raw mode ────────────────────────────────────────────────────────
869
- _raw_ok = False
1016
+ # ── 3. Background recv thread → queue ─────────────────────────────────────
1017
+ # Feeds all incoming WS frames into a queue so the REPL loop
1018
+ # can collect them without blocking the main thread on ws.recv().
1019
+ _q_frames: _q.Queue = _q.Queue()
1020
+ _stop_recv = threading.Event()
1021
+
1022
+ def _recv_worker():
1023
+ while not _stop_recv.is_set():
1024
+ try:
1025
+ raw = ws.recv(timeout=1.0)
1026
+ _q_frames.put(raw)
1027
+ except Exception as exc:
1028
+ if not _stop_recv.is_set():
1029
+ _q_frames.put(None) # sentinel: disconnected
1030
+ break
1031
+
1032
+ _recv_t = threading.Thread(target=_recv_worker, daemon=True)
1033
+ _recv_t.start()
1034
+
1035
+ # ── 4. Banner ─────────────────────────────────────────────────────────────
1036
+ print(f"\n {_G}◉{_RS} {_C}akash shell{_RS} "
1037
+ f"{_DM}code {_C}{code}{_DM} · :help for commands{_RS}\n",
1038
+ flush=True)
1039
+
1040
+ # ── helpers for raw / PTY mode ─────────────────────────────────────────────
1041
+ _stdin_fd = -1
1042
+ _raw_ok = False
870
1043
  _old_settings = None
871
- _stdin_fd = -1
872
- try:
873
- import tty as _tty, termios as _term
874
- _stdin_fd = sys.stdin.fileno()
875
- _old_settings = _term.tcgetattr(_stdin_fd)
876
- _tty.setraw(_stdin_fd)
877
- _raw_ok = True
878
- except Exception:
879
- pass
880
1044
 
881
- def _restore():
1045
+ def _enter_raw():
1046
+ nonlocal _stdin_fd, _raw_ok, _old_settings
1047
+ try:
1048
+ import tty as _tty, termios as _tm
1049
+ _stdin_fd = sys.stdin.fileno()
1050
+ _old_settings = _tm.tcgetattr(_stdin_fd)
1051
+ _tty.setraw(_stdin_fd)
1052
+ _raw_ok = True
1053
+ except Exception:
1054
+ pass
1055
+
1056
+ def _leave_raw():
882
1057
  if _raw_ok and _old_settings is not None:
883
1058
  try:
884
- import termios as _t
885
- _t.tcsetattr(_stdin_fd, _t.TCSADRAIN, _old_settings)
1059
+ import termios as _tm2
1060
+ _tm2.tcsetattr(_stdin_fd, _tm2.TCSADRAIN, _old_settings)
886
1061
  except Exception:
887
1062
  pass
888
1063
 
889
- exit_code = 0
890
- stop = threading.Event()
1064
+ # ── PTY passthrough session ───────────────────────────────────────────────
1065
+ def _run_pty(cmd: str) -> int:
1066
+ """
1067
+ Enter raw mode, send pty_run to host, bridge I/O until exit frame.
1068
+ Returns exit code. Restores terminal on return.
1069
+ """
1070
+ rows, cols = _term_size()
1071
+ _enter_raw()
1072
+ _pty_stop = threading.Event()
1073
+
1074
+ # SIGWINCH → resize
1075
+ def _on_resize(sig, _f):
1076
+ try:
1077
+ r2, c2 = _term_size()
1078
+ ws.send(json.dumps({"type": "resize", "rows": r2, "cols": c2}))
1079
+ except Exception:
1080
+ pass
1081
+ try:
1082
+ signal.signal(signal.SIGWINCH, _on_resize)
1083
+ except Exception:
1084
+ pass
891
1085
 
892
- # ── Tell host to open a PTY shell ─────────────────────────────────────────
893
- try:
894
- ws.send(json.dumps({
895
- "type": "pty_run",
896
- "command": os.environ.get("SHELL", "/bin/bash"),
897
- "rows": rows,
898
- "cols": cols,
899
- }))
900
- except Exception as e:
901
- _restore()
902
- ws.close()
903
- raise RuntimeError(f"Failed to start remote shell: {e}")
1086
+ # stdin pump
1087
+ def _pump():
1088
+ try:
1089
+ while not _pty_stop.is_set():
1090
+ if _stdin_fd >= 0:
1091
+ r = _sel.select([_stdin_fd], [], [], 0.02)[0]
1092
+ if not r:
1093
+ continue
1094
+ chunk = os.read(_stdin_fd, 4096)
1095
+ else:
1096
+ chunk = sys.stdin.buffer.read(1)
1097
+ if not chunk:
1098
+ break
1099
+ ws.send(json.dumps({
1100
+ "type": "pty_in",
1101
+ "data": base64.b64encode(chunk).decode(),
1102
+ }))
1103
+ except Exception:
1104
+ pass
904
1105
 
905
- # ── Thread: local stdin → WS ──────────────────────────────────────────────
906
- def _stdin_pump():
907
1106
  try:
908
- while not stop.is_set():
909
- if _stdin_fd >= 0:
910
- r = _sel.select([_stdin_fd], [], [], 0.02)[0]
911
- if not r:
912
- continue
913
- chunk = os.read(_stdin_fd, 4096)
914
- else:
915
- chunk = sys.stdin.buffer.read(1)
916
- if not chunk:
1107
+ ws.send(json.dumps({
1108
+ "type": "pty_run",
1109
+ "command": cmd or os.environ.get("SHELL", "/bin/bash"),
1110
+ "rows": rows,
1111
+ "cols": cols,
1112
+ }))
1113
+ except Exception as e:
1114
+ _leave_raw()
1115
+ print(f"\r\n {_R}✗{_RS} {e}\r\n", flush=True)
1116
+ return 1
1117
+
1118
+ pump_t = threading.Thread(target=_pump, daemon=True)
1119
+ pump_t.start()
1120
+
1121
+ ec = 0
1122
+ try:
1123
+ while True:
1124
+ try:
1125
+ raw = _q_frames.get(timeout=0.05)
1126
+ except _q.Empty:
1127
+ continue
1128
+ if raw is None:
917
1129
  break
918
- ws.send(json.dumps({
919
- "type": "pty_in",
920
- "data": base64.b64encode(chunk).decode(),
921
- }))
922
- except Exception:
1130
+ try:
1131
+ frame = json.loads(raw)
1132
+ except Exception:
1133
+ continue
1134
+ ft = frame.get("type")
1135
+ if ft == "pty_out":
1136
+ data = base64.b64decode(frame["data"])
1137
+ if data:
1138
+ sys.stdout.buffer.write(data)
1139
+ sys.stdout.buffer.flush()
1140
+ elif ft == "exit":
1141
+ ec = frame.get("code", 0)
1142
+ break
1143
+ elif ft == "error":
1144
+ _leave_raw()
1145
+ print(f"\r\n {_R}[error]{_RS} {frame.get('message','?')}\r\n",
1146
+ flush=True)
1147
+ ec = 1
1148
+ break
1149
+ elif ft == "disconnected":
1150
+ break
1151
+ elif ft == "ping":
1152
+ try:
1153
+ ws.send(json.dumps({"type": "pong"}))
1154
+ except Exception:
1155
+ break
1156
+ except KeyboardInterrupt:
923
1157
  pass
1158
+ finally:
1159
+ _pty_stop.set()
1160
+ try:
1161
+ signal.signal(signal.SIGWINCH, signal.SIG_DFL)
1162
+ except Exception:
1163
+ pass
924
1164
 
925
- # ── SIGWINCH → resize frame ───────────────────────────────────────────────
926
- def _on_resize(signum, _frame):
1165
+ _leave_raw()
1166
+ print(flush=True) # clean line after PTY output
1167
+ return ec
1168
+
1169
+ # ── REPL command runner ───────────────────────────────────────────────────
1170
+ def _run_cmd(cmd: str) -> tuple:
1171
+ """
1172
+ Send `run` frame, collect stdout in real-time, return (lines, ec, new_cwd).
1173
+ Output is printed live as │ lines while collecting for the footer.
1174
+ """
1175
+ # Wrap: cd to cwd, run command, emit sentinels
1176
+ actual = (
1177
+ f'cd {json.dumps(cwd)} 2>/dev/null\n'
1178
+ f'{cmd}\n'
1179
+ f'printf "\\x00__EC__%d\\x00" "$?"\n'
1180
+ f'printf "\\x00__CWD__%s\\x00" "$PWD"\n'
1181
+ )
927
1182
  try:
928
- import shutil as _sh2
929
- c2, r2 = _sh2.get_terminal_size(fallback=(80, 24))
930
- ws.send(json.dumps({"type": "resize", "rows": r2, "cols": c2}))
1183
+ ws.send(json.dumps({
1184
+ "type": "run",
1185
+ "command": actual,
1186
+ "cwd": cwd,
1187
+ }))
931
1188
  except Exception:
932
- pass
933
- try:
934
- signal.signal(signal.SIGWINCH, _on_resize)
935
- except Exception:
936
- pass
1189
+ return [], 1, cwd
937
1190
 
938
- t = threading.Thread(target=_stdin_pump, daemon=True)
939
- t.start()
1191
+ cols = _term_cols()
1192
+ buf = b""
1193
+ lines : list[str] = []
1194
+ ec = 0
1195
+ new_cwd = cwd
1196
+ deadline = time.time() + 60.0
1197
+ started = False
940
1198
 
941
- # ── Main loop: WS → local stdout (raw PTY output) ────────────────────────
942
- try:
943
- while True:
1199
+ while time.time() < deadline:
944
1200
  try:
945
- raw = ws.recv()
946
- except Exception:
1201
+ raw = _q_frames.get(timeout=0.05)
1202
+ except _q.Empty:
1203
+ continue
1204
+ if raw is None:
947
1205
  break
948
1206
  try:
949
1207
  frame = json.loads(raw)
950
1208
  except Exception:
951
1209
  continue
952
- ftype = frame.get("type")
953
- if ftype == "pty_out":
954
- data = base64.b64decode(frame["data"])
955
- if data:
956
- sys.stdout.buffer.write(data)
957
- sys.stdout.buffer.flush()
958
- elif ftype == "exit":
959
- exit_code = frame.get("code", 0)
960
- stop.set()
961
- break
962
- elif ftype == "error":
963
- stop.set()
964
- _restore()
965
- print(f"\r\n{_R}[error]{_RS} {frame.get('message','?')}\r\n",
966
- file=sys.stderr, flush=True)
967
- exit_code = 1
1210
+ ft = frame.get("type")
1211
+
1212
+ if ft == "stdout":
1213
+ chunk = base64.b64decode(frame.get("data", ""))
1214
+ buf += chunk
1215
+ # Print lines as they arrive — real-time streaming
1216
+ while b"\n" in buf:
1217
+ line_b, buf = buf.split(b"\n", 1)
1218
+ line = _strip_ansi(line_b.decode("utf-8", errors="replace"))
1219
+ # Parse null-delimited sentinels
1220
+ if "\x00__EC__" in line:
1221
+ import re as _re
1222
+ m = _re.search(r'\x00__EC__(\d+)\x00', line)
1223
+ if m:
1224
+ ec = int(m.group(1))
1225
+ continue
1226
+ if "\x00__CWD__" in line:
1227
+ import re as _re
1228
+ m = _re.search(r'\x00__CWD__([^\x00]+)\x00', line)
1229
+ if m:
1230
+ new_cwd = m.group(1).strip()
1231
+ continue
1232
+ if not started:
1233
+ # Print blank separator before first output line
1234
+ sys.stdout.write("\n")
1235
+ started = True
1236
+ # Print live
1237
+ display = line.rstrip()
1238
+ # wrap
1239
+ wrap = max(cols - 6, 40)
1240
+ while len(display) > wrap:
1241
+ sys.stdout.write(f" {_SH_GR}│{_SH_RS} {display[:wrap]}\n")
1242
+ display = display[wrap:]
1243
+ sys.stdout.write(f" {_SH_GR}│{_SH_RS} {display}\n")
1244
+ sys.stdout.flush()
1245
+ lines.append(line.rstrip())
1246
+
1247
+ elif ft == "exit":
1248
+ ec = frame.get("code", ec)
1249
+ new_cwd = frame.get("cwd", new_cwd) or new_cwd
1250
+ # Flush remaining buffer
1251
+ if buf:
1252
+ line = _strip_ansi(buf.decode("utf-8", errors="replace")).rstrip()
1253
+ if line and "\x00__EC__" not in line and "\x00__CWD__" not in line:
1254
+ if not started:
1255
+ sys.stdout.write("\n")
1256
+ started = True
1257
+ sys.stdout.write(f" {_SH_GR}│{_SH_RS} {line}\n")
1258
+ sys.stdout.flush()
1259
+ lines.append(line)
968
1260
  break
969
- elif ftype == "disconnected":
970
- stop.set()
1261
+
1262
+ elif ft == "error":
1263
+ msg = frame.get("message", "?")
1264
+ if not started:
1265
+ sys.stdout.write("\n")
1266
+ started = True
1267
+ sys.stdout.write(f" {_SH_GR}│{_RS} {_R}error:{_RS} {msg}\n")
1268
+ sys.stdout.flush()
1269
+ lines.append(f"error: {msg}")
1270
+ ec = 1
971
1271
  break
972
- elif ftype == "ping":
1272
+
1273
+ elif ft == "ping":
973
1274
  try:
974
1275
  ws.send(json.dumps({"type": "pong"}))
975
1276
  except Exception:
976
- break
977
- except KeyboardInterrupt:
1277
+ pass
1278
+
1279
+ return lines, ec, new_cwd
1280
+
1281
+ # ── 5. REPL loop ──────────────────────────────────────────────────────────
1282
+ try:
1283
+ while True:
1284
+ # Draw prompt
1285
+ _sh_draw_header(identity, cwd)
1286
+ try:
1287
+ cmd = input(_sh_input_prompt()).strip()
1288
+ except (EOFError, KeyboardInterrupt):
1289
+ print()
1290
+ break
1291
+
1292
+ if not cmd:
1293
+ continue
1294
+
1295
+ # ── Built-in / local commands ─────────────────────────────────
1296
+ if cmd in (":exit", "exit", "logout"):
1297
+ break
1298
+
1299
+ if cmd in (":clear", "clear"):
1300
+ sys.stdout.write("\033[2J\033[H")
1301
+ sys.stdout.flush()
1302
+ continue
1303
+
1304
+ if cmd in (":help", ":h", "help"):
1305
+ _sh_help()
1306
+ continue
1307
+
1308
+ if cmd == ":hist":
1309
+ _sh_history(history)
1310
+ continue
1311
+
1312
+ if cmd == ":status":
1313
+ _sh_status(code, cwd, history)
1314
+ continue
1315
+
1316
+ if cmd.startswith(":upload "):
1317
+ fpath = cmd[8:].strip()
1318
+ print(f" {_DM}uploading {fpath}…{_RS}", flush=True)
1319
+ try:
1320
+ from .client import AkashClient
1321
+ from .config import ConfigManager
1322
+ _cfg = ConfigManager.load()
1323
+ api_key = os.getenv("AKASH_API_KEY") or _cfg.get("api_key")
1324
+ if not api_key:
1325
+ print(f" {_R}✗{_RS} no API key — run akash login first\n")
1326
+ continue
1327
+ client = AkashClient(api_key=api_key)
1328
+ result = client.upload_file(fpath)
1329
+ print(f" {_G}✓{_RS} {_C}{result.file_code}{_RS} {_DM}{result.url}{_RS}\n")
1330
+ except Exception as ue:
1331
+ print(f" {_R}✗{_RS} upload failed: {ue}\n")
1332
+ continue
1333
+
1334
+ # :pty [cmd] — force PTY mode
1335
+ if cmd.startswith(":pty"):
1336
+ pty_cmd = cmd[4:].strip() or ""
1337
+ ec = _run_pty(pty_cmd)
1338
+ exit_code = ec
1339
+ _sh_footer(ec, 0)
1340
+ # Refresh cwd after PTY session
1341
+ continue
1342
+
1343
+ # ── Auto PTY for TUI apps ─────────────────────────────────────
1344
+ if _is_pty_cmd(cmd):
1345
+ ec = _run_pty(cmd)
1346
+ exit_code = ec
1347
+ _sh_footer(ec, 0)
1348
+ continue
1349
+
1350
+ # ── Remote REPL command ───────────────────────────────────────
1351
+ history.append(cmd)
1352
+ try:
1353
+ if _rl:
1354
+ _rl.add_history(cmd)
1355
+ except Exception:
1356
+ pass
1357
+
1358
+ t0 = time.monotonic()
1359
+ _, ec, new_cwd = _run_cmd(cmd)
1360
+ elapsed_ms = (time.monotonic() - t0) * 1000
1361
+
1362
+ if new_cwd and new_cwd != "~":
1363
+ cwd = new_cwd
1364
+
1365
+ exit_code = ec
1366
+ # Separator line + footer
1367
+ sys.stdout.write(f" {_SH_GR}│{_SH_RS}\n")
1368
+ sys.stdout.flush()
1369
+ _sh_footer(ec, elapsed_ms)
1370
+
1371
+ except Exception:
978
1372
  pass
979
1373
  finally:
980
- stop.set()
981
- _restore()
1374
+ _stop_recv.set()
982
1375
  try:
983
- signal.signal(signal.SIGWINCH, signal.SIG_DFL)
1376
+ ws.send(json.dumps({"type": "close"}))
984
1377
  except Exception:
985
1378
  pass
986
1379
  try:
987
- ws.send(json.dumps({"type": "close"}))
1380
+ ws.close()
988
1381
  except Exception:
989
1382
  pass
990
1383
  try:
991
- ws.close()
1384
+ if _rl:
1385
+ _rl.write_history_file(str(hist_file))
992
1386
  except Exception:
993
1387
  pass
994
1388
 
995
- # Print a clean newline after raw mode ends
996
- print(f"\r\n {_DM}session ended code {exit_code}{_RS}\r\n", flush=True)
1389
+ print(f"\n {_DM}session ended{_RS}\n", flush=True)
997
1390
  return exit_code
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.6.1
3
+ Version: 4.6.3
4
4
  Summary: Akash CLI — secure remote shell relay, file vault, and device management
5
5
  License: MIT
6
6
  Keywords: akash,cli,relay,remote-shell,vault
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akashcli"
3
- version = "4.6.1"
3
+ version = "4.6.3"
4
4
  description = "Akash CLI — secure remote shell relay, file vault, and device management"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes