remote-cli 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.
- remote_cli/__init__.py +15 -0
- remote_cli/_version.py +24 -0
- remote_cli/cli.py +303 -0
- remote_cli/client.py +164 -0
- remote_cli/daemon.py +296 -0
- remote_cli/protocol.py +66 -0
- remote_cli/screen.py +68 -0
- remote_cli/session.py +339 -0
- remote_cli/terminal.py +137 -0
- remote_cli/utils.py +40 -0
- remote_cli-0.1.0.dist-info/METADATA +166 -0
- remote_cli-0.1.0.dist-info/RECORD +15 -0
- remote_cli-0.1.0.dist-info/WHEEL +4 -0
- remote_cli-0.1.0.dist-info/entry_points.txt +2 -0
- remote_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
remote_cli/session.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Session management, PTY allocation, and I/O multiplexing."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import datetime
|
|
5
|
+
import fcntl
|
|
6
|
+
import os
|
|
7
|
+
import pty
|
|
8
|
+
import re
|
|
9
|
+
import struct
|
|
10
|
+
import termios
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from .protocol import ExecResult, SessionInfo
|
|
14
|
+
from .screen import RingBuffer, VirtualScreen
|
|
15
|
+
from .utils import generate_session_id, strip_ansi
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExecWatcher:
|
|
19
|
+
"""Tracks command execution via unique sentinels in output stream."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, sentinel_id: str, raw_command: str):
|
|
22
|
+
self.sentinel_id = sentinel_id
|
|
23
|
+
self.raw_command = raw_command
|
|
24
|
+
self.start_marker = f"__REMOTE_CLI_START_{sentinel_id}__"
|
|
25
|
+
self.end_marker_prefix = f"__REMOTE_CLI_END_{sentinel_id}_"
|
|
26
|
+
self.end_marker_regex = re.compile(rf"{re.escape(self.end_marker_prefix)}(\d+)__")
|
|
27
|
+
self.future: asyncio.Future[ExecResult] = asyncio.get_event_loop().create_future()
|
|
28
|
+
self.buffer = ""
|
|
29
|
+
self.start_seen = False
|
|
30
|
+
self.start_time = asyncio.get_event_loop().time()
|
|
31
|
+
|
|
32
|
+
def feed(self, text: str) -> None:
|
|
33
|
+
if self.future.done():
|
|
34
|
+
return
|
|
35
|
+
self.buffer += text
|
|
36
|
+
|
|
37
|
+
# Check for end marker
|
|
38
|
+
match = self.end_marker_regex.search(self.buffer)
|
|
39
|
+
if match:
|
|
40
|
+
exit_code = int(match.group(1))
|
|
41
|
+
end_pos = match.start()
|
|
42
|
+
|
|
43
|
+
# Extract output between the executed start marker and end marker
|
|
44
|
+
content = self.buffer[:end_pos]
|
|
45
|
+
if self.start_marker in content:
|
|
46
|
+
_, _, after_start = content.rpartition(self.start_marker)
|
|
47
|
+
output = after_start
|
|
48
|
+
else:
|
|
49
|
+
output = content
|
|
50
|
+
|
|
51
|
+
# Clean output: strip ANSI codes, normalize CRLF to LF, and strip leading/trailing newlines
|
|
52
|
+
clean_output = strip_ansi(output).replace("\r\n", "\n").replace("\r", "\n").strip("\n")
|
|
53
|
+
duration = asyncio.get_event_loop().time() - self.start_time
|
|
54
|
+
result = ExecResult(
|
|
55
|
+
session_id="",
|
|
56
|
+
command=self.raw_command,
|
|
57
|
+
exit_code=exit_code,
|
|
58
|
+
output=clean_output,
|
|
59
|
+
duration=round(duration, 3),
|
|
60
|
+
timed_out=False,
|
|
61
|
+
)
|
|
62
|
+
self.future.set_result(result)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Session:
|
|
66
|
+
"""Represents a single interactive PTY session."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
session_id: str,
|
|
71
|
+
name: str,
|
|
72
|
+
command: list[str],
|
|
73
|
+
rows: int = 24,
|
|
74
|
+
cols: int = 80,
|
|
75
|
+
):
|
|
76
|
+
self.session_id = session_id
|
|
77
|
+
self.name = name or session_id
|
|
78
|
+
self.command = command
|
|
79
|
+
self.rows = rows
|
|
80
|
+
self.cols = cols
|
|
81
|
+
self.created_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
82
|
+
self.status = "active"
|
|
83
|
+
self.exit_code: int | None = None
|
|
84
|
+
|
|
85
|
+
self.master_fd: int | None = None
|
|
86
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
87
|
+
self.screen = VirtualScreen(cols=cols, rows=rows)
|
|
88
|
+
self.buffer = RingBuffer()
|
|
89
|
+
|
|
90
|
+
self.attached_writers: set[asyncio.StreamWriter] = set()
|
|
91
|
+
self.active_watcher: ExecWatcher | None = None
|
|
92
|
+
self.exec_lock = asyncio.Lock()
|
|
93
|
+
self.loop = asyncio.get_event_loop()
|
|
94
|
+
|
|
95
|
+
def start(self) -> None:
|
|
96
|
+
"""Allocates PTY and spawns subprocess."""
|
|
97
|
+
master_fd, slave_fd = pty.openpty()
|
|
98
|
+
self.master_fd = master_fd
|
|
99
|
+
|
|
100
|
+
# Set window size on PTY
|
|
101
|
+
self._set_pty_size(master_fd, self.rows, self.cols)
|
|
102
|
+
|
|
103
|
+
# Set non-blocking master
|
|
104
|
+
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
|
105
|
+
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
106
|
+
|
|
107
|
+
# Spawn subprocess
|
|
108
|
+
try:
|
|
109
|
+
self.process = asyncio.subprocess.Process(
|
|
110
|
+
transport=None, # Handled directly via PTY fd
|
|
111
|
+
protocol=None,
|
|
112
|
+
loop=self.loop,
|
|
113
|
+
)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Use os.fork or subprocess with slave_fd
|
|
118
|
+
import subprocess
|
|
119
|
+
|
|
120
|
+
# Set slave fd inheritable
|
|
121
|
+
os.set_inheritable(slave_fd, True)
|
|
122
|
+
|
|
123
|
+
proc = subprocess.Popen(
|
|
124
|
+
self.command,
|
|
125
|
+
stdin=slave_fd,
|
|
126
|
+
stdout=slave_fd,
|
|
127
|
+
stderr=slave_fd,
|
|
128
|
+
preexec_fn=os.setsid,
|
|
129
|
+
close_fds=True,
|
|
130
|
+
)
|
|
131
|
+
os.close(slave_fd)
|
|
132
|
+
self._proc = proc
|
|
133
|
+
|
|
134
|
+
# Add reader to event loop
|
|
135
|
+
self.loop.add_reader(self.master_fd, self._on_pty_readable)
|
|
136
|
+
|
|
137
|
+
def _set_pty_size(self, fd: int, rows: int, cols: int) -> None:
|
|
138
|
+
try:
|
|
139
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
140
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
def resize(self, rows: int, cols: int) -> None:
|
|
145
|
+
"""Resizes the PTY and virtual screen."""
|
|
146
|
+
self.rows = rows
|
|
147
|
+
self.cols = cols
|
|
148
|
+
self.screen.resize(rows, cols)
|
|
149
|
+
if self.master_fd is not None:
|
|
150
|
+
self._set_pty_size(self.master_fd, rows, cols)
|
|
151
|
+
|
|
152
|
+
def _on_pty_readable(self) -> None:
|
|
153
|
+
"""Callback invoked when PTY master has output to read."""
|
|
154
|
+
if self.master_fd is None:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
data = os.read(self.master_fd, 4096)
|
|
159
|
+
except (BlockingIOError, InterruptedError):
|
|
160
|
+
return
|
|
161
|
+
except OSError:
|
|
162
|
+
# PTY slave closed / child exited
|
|
163
|
+
data = b""
|
|
164
|
+
|
|
165
|
+
if not data:
|
|
166
|
+
self._on_process_exit()
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Feed screen and buffer
|
|
170
|
+
self.screen.feed(data)
|
|
171
|
+
self.buffer.append(data)
|
|
172
|
+
|
|
173
|
+
# Feed active watcher
|
|
174
|
+
if self.active_watcher:
|
|
175
|
+
text = data.decode("utf-8", errors="replace")
|
|
176
|
+
self.active_watcher.feed(text)
|
|
177
|
+
|
|
178
|
+
# Broadcast to all attached clients
|
|
179
|
+
dead_writers = set()
|
|
180
|
+
for writer in self.attached_writers:
|
|
181
|
+
try:
|
|
182
|
+
writer.write(data)
|
|
183
|
+
except Exception:
|
|
184
|
+
dead_writers.add(writer)
|
|
185
|
+
|
|
186
|
+
for writer in dead_writers:
|
|
187
|
+
self.attached_writers.discard(writer)
|
|
188
|
+
|
|
189
|
+
def _on_process_exit(self) -> None:
|
|
190
|
+
"""Handles PTY close and subprocess termination."""
|
|
191
|
+
if self.master_fd is not None:
|
|
192
|
+
try:
|
|
193
|
+
self.loop.remove_reader(self.master_fd)
|
|
194
|
+
os.close(self.master_fd)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
self.master_fd = None
|
|
198
|
+
|
|
199
|
+
self.status = "exited"
|
|
200
|
+
if hasattr(self, "_proc") and self._proc:
|
|
201
|
+
try:
|
|
202
|
+
self._proc.poll()
|
|
203
|
+
self.exit_code = self._proc.returncode
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
# Notify attached writers
|
|
208
|
+
for writer in list(self.attached_writers):
|
|
209
|
+
try:
|
|
210
|
+
writer.close()
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
self.attached_writers.clear()
|
|
214
|
+
|
|
215
|
+
# Fail any waiting exec
|
|
216
|
+
if self.active_watcher and not self.active_watcher.future.done():
|
|
217
|
+
self.active_watcher.future.set_exception(
|
|
218
|
+
RuntimeError(f"Session {self.session_id} exited with code {self.exit_code}")
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def write_input(self, data: bytes) -> None:
|
|
222
|
+
"""Writes raw input bytes to the PTY."""
|
|
223
|
+
if self.master_fd is None or self.status != "active":
|
|
224
|
+
raise RuntimeError(f"Session {self.session_id} is not active")
|
|
225
|
+
try:
|
|
226
|
+
os.write(self.master_fd, data)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
raise RuntimeError(f"Failed to write to session {self.session_id}: {e}") from e
|
|
229
|
+
|
|
230
|
+
def attach_client(self, writer: asyncio.StreamWriter) -> None:
|
|
231
|
+
"""Registers a client stream for live output broadcast."""
|
|
232
|
+
self.attached_writers.add(writer)
|
|
233
|
+
|
|
234
|
+
def detach_client(self, writer: asyncio.StreamWriter) -> None:
|
|
235
|
+
"""Unregisters a client stream."""
|
|
236
|
+
self.attached_writers.discard(writer)
|
|
237
|
+
|
|
238
|
+
async def execute_command(self, command: str, timeout: float = 30.0) -> ExecResult:
|
|
239
|
+
"""Executes a command and waits for completion sentinel."""
|
|
240
|
+
async with self.exec_lock:
|
|
241
|
+
if self.master_fd is None or self.status != "active":
|
|
242
|
+
raise RuntimeError(f"Session {self.session_id} is not active")
|
|
243
|
+
|
|
244
|
+
sentinel_id = uuid.uuid4().hex[:12]
|
|
245
|
+
watcher = ExecWatcher(sentinel_id, command)
|
|
246
|
+
self.active_watcher = watcher
|
|
247
|
+
|
|
248
|
+
# Format injection with POSIX printf for clean delimiter detection
|
|
249
|
+
# Command: printf "\n%s\n" "<START>"; <CMD>; printf "\n%s%d__\n" "<END_PREFIX>" "$?"
|
|
250
|
+
cmd_payload = (
|
|
251
|
+
f'printf "\\n%s\\n" "{watcher.start_marker}"; '
|
|
252
|
+
f"{command}; "
|
|
253
|
+
f'printf "\\n%s%d__\\n" "{watcher.end_marker_prefix}" "$?"\n'
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
self.write_input(cmd_payload.encode("utf-8"))
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
result = await asyncio.wait_for(watcher.future, timeout=timeout)
|
|
260
|
+
result.session_id = self.session_id
|
|
261
|
+
return result
|
|
262
|
+
except TimeoutError:
|
|
263
|
+
# Capture partial output
|
|
264
|
+
partial_output = strip_ansi(watcher.buffer).strip("\r\n")
|
|
265
|
+
duration = self.loop.time() - watcher.start_time
|
|
266
|
+
return ExecResult(
|
|
267
|
+
session_id=self.session_id,
|
|
268
|
+
command=command,
|
|
269
|
+
exit_code=None,
|
|
270
|
+
output=partial_output,
|
|
271
|
+
duration=round(duration, 3),
|
|
272
|
+
timed_out=True,
|
|
273
|
+
)
|
|
274
|
+
finally:
|
|
275
|
+
self.active_watcher = None
|
|
276
|
+
|
|
277
|
+
def close(self) -> None:
|
|
278
|
+
"""Terminates the session and kills process."""
|
|
279
|
+
if hasattr(self, "_proc") and self._proc:
|
|
280
|
+
try:
|
|
281
|
+
self._proc.terminate()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
self._on_process_exit()
|
|
285
|
+
|
|
286
|
+
def get_info(self) -> SessionInfo:
|
|
287
|
+
return SessionInfo(
|
|
288
|
+
session_id=self.session_id,
|
|
289
|
+
name=self.name,
|
|
290
|
+
command=self.command,
|
|
291
|
+
created_at=self.created_at,
|
|
292
|
+
status=self.status,
|
|
293
|
+
exit_code=self.exit_code,
|
|
294
|
+
attached_clients=len(self.attached_writers),
|
|
295
|
+
rows=self.rows,
|
|
296
|
+
cols=self.cols,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class SessionManager:
|
|
301
|
+
"""Manages all active sessions."""
|
|
302
|
+
|
|
303
|
+
def __init__(self):
|
|
304
|
+
self.sessions: dict[str, Session] = {}
|
|
305
|
+
|
|
306
|
+
def create_session(
|
|
307
|
+
self,
|
|
308
|
+
command: list[str],
|
|
309
|
+
name: str | None = None,
|
|
310
|
+
rows: int = 24,
|
|
311
|
+
cols: int = 80,
|
|
312
|
+
) -> Session:
|
|
313
|
+
session_id = generate_session_id()
|
|
314
|
+
session_name = name or f"session-{session_id}"
|
|
315
|
+
session = Session(
|
|
316
|
+
session_id=session_id,
|
|
317
|
+
name=session_name,
|
|
318
|
+
command=command,
|
|
319
|
+
rows=rows,
|
|
320
|
+
cols=cols,
|
|
321
|
+
)
|
|
322
|
+
session.start()
|
|
323
|
+
self.sessions[session_id] = session
|
|
324
|
+
return session
|
|
325
|
+
|
|
326
|
+
def get_session(self, session_id: str) -> Session | None:
|
|
327
|
+
return self.sessions.get(session_id)
|
|
328
|
+
|
|
329
|
+
def list_sessions(self) -> list[SessionInfo]:
|
|
330
|
+
# Cleanup exited sessions older than threshold if needed or return all
|
|
331
|
+
return [session.get_info() for session in self.sessions.values()]
|
|
332
|
+
|
|
333
|
+
def close_session(self, session_id: str) -> bool:
|
|
334
|
+
session = self.sessions.get(session_id)
|
|
335
|
+
if session:
|
|
336
|
+
session.close()
|
|
337
|
+
del self.sessions[session_id]
|
|
338
|
+
return True
|
|
339
|
+
return False
|
remote_cli/terminal.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Terminal raw mode handling, window resize, and interactive attach client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import select
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import termios
|
|
10
|
+
import tty
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .client import Client
|
|
14
|
+
from .protocol import ActionType, Request, Response
|
|
15
|
+
from .utils import get_socket_path
|
|
16
|
+
|
|
17
|
+
ESCAPE_KEY = b"\x1d" # Ctrl+]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_terminal_size() -> tuple[int, int]:
|
|
21
|
+
"""Returns (rows, cols) for the current terminal."""
|
|
22
|
+
try:
|
|
23
|
+
sz = os.get_terminal_size()
|
|
24
|
+
return sz.lines, sz.columns
|
|
25
|
+
except Exception:
|
|
26
|
+
return 24, 80
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def attach_session(session_id: str, socket_path: Path | None = None) -> None:
|
|
30
|
+
"""Attaches current terminal in raw mode to a remote-cli session."""
|
|
31
|
+
sock_path = socket_path or get_socket_path()
|
|
32
|
+
if not sock_path.exists():
|
|
33
|
+
print(f"Error: Daemon socket {sock_path} not found.", file=sys.stderr)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
rows, cols = get_terminal_size()
|
|
37
|
+
|
|
38
|
+
# Connect to daemon
|
|
39
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
40
|
+
try:
|
|
41
|
+
s.connect(str(sock_path))
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(f"Error connecting to daemon: {e}", file=sys.stderr)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Send ATTACH request
|
|
47
|
+
req = Request(
|
|
48
|
+
action=ActionType.ATTACH,
|
|
49
|
+
session_id=session_id,
|
|
50
|
+
rows=rows,
|
|
51
|
+
cols=cols,
|
|
52
|
+
)
|
|
53
|
+
s.sendall((req.model_dump_json() + "\n").encode("utf-8"))
|
|
54
|
+
|
|
55
|
+
# Read acknowledgment
|
|
56
|
+
resp_line = b""
|
|
57
|
+
while b"\n" not in resp_line:
|
|
58
|
+
chunk = s.recv(1024)
|
|
59
|
+
if not chunk:
|
|
60
|
+
break
|
|
61
|
+
resp_line += chunk
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
resp = Response.model_validate(json.loads(resp_line.decode("utf-8").strip()))
|
|
65
|
+
if not resp.success:
|
|
66
|
+
print(f"Failed to attach: {resp.error or resp.message}", file=sys.stderr)
|
|
67
|
+
s.close()
|
|
68
|
+
return
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"Invalid attach response from server: {e}", file=sys.stderr)
|
|
71
|
+
s.close()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# Save original terminal attributes
|
|
75
|
+
stdin_fd = sys.stdin.fileno()
|
|
76
|
+
is_tty = os.isatty(stdin_fd)
|
|
77
|
+
old_attrs = None
|
|
78
|
+
if is_tty:
|
|
79
|
+
old_attrs = termios.tcgetattr(stdin_fd)
|
|
80
|
+
|
|
81
|
+
client_helper = Client(socket_path=sock_path)
|
|
82
|
+
|
|
83
|
+
# SIGWINCH handler
|
|
84
|
+
def handle_sigwinch(signum, frame):
|
|
85
|
+
try:
|
|
86
|
+
r, c = get_terminal_size()
|
|
87
|
+
client_helper.resize(session_id, rows=r, cols=c)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
old_sigwinch = None
|
|
92
|
+
try:
|
|
93
|
+
old_sigwinch = signal.signal(signal.SIGWINCH, handle_sigwinch)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if is_tty:
|
|
99
|
+
tty.setraw(stdin_fd)
|
|
100
|
+
|
|
101
|
+
# Main select loop
|
|
102
|
+
running = True
|
|
103
|
+
sock_fd = s.fileno()
|
|
104
|
+
|
|
105
|
+
while running:
|
|
106
|
+
rlist, _, _ = select.select([stdin_fd, sock_fd], [], [])
|
|
107
|
+
|
|
108
|
+
if sock_fd in rlist:
|
|
109
|
+
data = s.recv(4096)
|
|
110
|
+
if not data:
|
|
111
|
+
# Session exited or socket closed
|
|
112
|
+
break
|
|
113
|
+
os.write(sys.stdout.fileno(), data)
|
|
114
|
+
|
|
115
|
+
if stdin_fd in rlist:
|
|
116
|
+
data = os.read(stdin_fd, 1024)
|
|
117
|
+
if not data:
|
|
118
|
+
break
|
|
119
|
+
# Check for detach escape key (Ctrl+])
|
|
120
|
+
if ESCAPE_KEY in data:
|
|
121
|
+
# Print detach notice after restoring terminal
|
|
122
|
+
break
|
|
123
|
+
s.sendall(data)
|
|
124
|
+
|
|
125
|
+
except (KeyboardInterrupt, BrokenPipeError, ConnectionResetError):
|
|
126
|
+
pass
|
|
127
|
+
finally:
|
|
128
|
+
# Restore terminal settings
|
|
129
|
+
if is_tty and old_attrs is not None:
|
|
130
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
|
|
131
|
+
if old_sigwinch is not None:
|
|
132
|
+
try:
|
|
133
|
+
signal.signal(signal.SIGWINCH, old_sigwinch)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
s.close()
|
|
137
|
+
print("\n[Detached from session]")
|
remote_cli/utils.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Utility functions for remote-cli."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_base_dir() -> Path:
|
|
12
|
+
"""Returns the base directory for remote-cli configuration and sockets."""
|
|
13
|
+
base = Path(os.environ.get("REMOTE_CLI_DIR", Path.home() / ".remote-cli"))
|
|
14
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
return base
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_socket_path() -> Path:
|
|
19
|
+
"""Returns the Unix domain socket path."""
|
|
20
|
+
return get_base_dir() / "remote-cli.sock"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_pid_path() -> Path:
|
|
24
|
+
"""Returns the daemon PID file path."""
|
|
25
|
+
return get_base_dir() / "remote-cli.pid"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_log_path() -> Path:
|
|
29
|
+
"""Returns the daemon log file path."""
|
|
30
|
+
return get_base_dir() / "daemon.log"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_session_id() -> str:
|
|
34
|
+
"""Generates a short, user-friendly session ID."""
|
|
35
|
+
return f"s_{uuid.uuid4().hex[:8]}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def strip_ansi(text: str) -> str:
|
|
39
|
+
"""Strips ANSI escape codes from string."""
|
|
40
|
+
return ANSI_ESCAPE_REGEX.sub("", text)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: remote-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A shared SSH CLI tool for AI Agent and human co-piloting
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: pydantic>=2.10.0
|
|
8
|
+
Requires-Dist: pyte>=0.8.2
|
|
9
|
+
Requires-Dist: rich>=13.9.0
|
|
10
|
+
Requires-Dist: typer>=0.15.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# remote-cli
|
|
14
|
+
|
|
15
|
+
> **Shared SSH & Terminal CLI tool for AI Agent and Human Co-piloting.**
|
|
16
|
+
|
|
17
|
+
`remote-cli` allows a human user to start an interactive SSH session (handling passwords, 2FA, bastion hosts, and SSH keys themselves) and share that session with an AI Agent via a unique `session-id`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Key Features
|
|
22
|
+
|
|
23
|
+
- 🤝 **Real-Time Human & Agent Co-piloting**:
|
|
24
|
+
- The human sees everything the Agent does in their original terminal window in real-time.
|
|
25
|
+
- The human can continue typing and operating in the same session at any time.
|
|
26
|
+
- ⚡ **Zero External Binary Dependencies**:
|
|
27
|
+
- Pure Python + POSIX PTY (`pty.openpty`, `termios`, `tty`).
|
|
28
|
+
- No need to install `tmux` or `screen` on local or remote servers.
|
|
29
|
+
- 🤖 **First-Class AI Agent Support**:
|
|
30
|
+
- `exec`: Structured execution of shell commands with stdout capture and return codes.
|
|
31
|
+
- `snapshot`: In-memory 2D virtual terminal rendering (`pyte`) for clean screen capture (even for ncurses / curses / colored prompts).
|
|
32
|
+
- `send`: Keystrokes and control signals (`Ctrl+C`, `Ctrl+D`, confirmation answers `y/n`).
|
|
33
|
+
- `logs`: Fast access to recent scrollback history.
|
|
34
|
+
- 🔌 **Seamless Background Daemon**:
|
|
35
|
+
- Communicates via Unix Domain Sockets (`~/.remote-cli/remote-cli.sock`).
|
|
36
|
+
- Transparently auto-starts in the background on demand.
|
|
37
|
+
- Safely detach (`Ctrl+]`) and re-attach (`remote-cli attach <session-id>`) anytime.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation & Setup
|
|
42
|
+
|
|
43
|
+
Using [`uv`](https://github.com/astral-sh/uv):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Clone the repository
|
|
47
|
+
git clone https://github.com/your-username/remote-cli.git
|
|
48
|
+
cd remote-cli
|
|
49
|
+
|
|
50
|
+
# Install dependencies and create venv
|
|
51
|
+
uv sync
|
|
52
|
+
|
|
53
|
+
# Run directly via uv
|
|
54
|
+
uv run remote-cli --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Quickstart & Workflow
|
|
60
|
+
|
|
61
|
+
### 1. Human Starts SSH Session
|
|
62
|
+
The user initiates the SSH connection to the remote machine. Once connected and authenticated, a `Session ID` is displayed:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv run remote-cli ssh user@server.example.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Output:
|
|
69
|
+
```text
|
|
70
|
+
╭────────────────────── remote-cli SSH Session ──────────────────────╮
|
|
71
|
+
│ Session Created: s_7f8a9b2c │
|
|
72
|
+
│ Agent Command: remote-cli exec s_7f8a9b2c "<command>" │
|
|
73
|
+
│ Press Ctrl+] to detach from session at any time. │
|
|
74
|
+
╰────────────────────────────────────────────────────────────────────╯
|
|
75
|
+
user@server.example.com's password: ***
|
|
76
|
+
user@server:~$ _
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> **Tip**: You can also create local shell sessions for testing:
|
|
80
|
+
> ```bash
|
|
81
|
+
> uv run remote-cli session create --name my-session -- /bin/bash
|
|
82
|
+
> ```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
### 2. Share `session-id` with AI Agent
|
|
87
|
+
|
|
88
|
+
Simply tell your AI Agent:
|
|
89
|
+
> *"I have logged into the server. The session ID is `s_7f8a9b2c`. Please check the disk space and restart Nginx."*
|
|
90
|
+
|
|
91
|
+
The Agent can now execute commands on the remote server via `remote-cli`:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Agent runs a command and captures return code and stdout
|
|
95
|
+
uv run remote-cli exec s_7f8a9b2c "df -h"
|
|
96
|
+
|
|
97
|
+
# Agent runs a command with JSON output
|
|
98
|
+
uv run remote-cli exec s_7f8a9b2c "systemctl status nginx" --json
|
|
99
|
+
|
|
100
|
+
# Agent inspects the current 2D screen state
|
|
101
|
+
uv run remote-cli snapshot s_7f8a9b2c
|
|
102
|
+
|
|
103
|
+
# Agent sends an interactive response (e.g. confirming a prompt)
|
|
104
|
+
uv run remote-cli send s_7f8a9b2c "y"
|
|
105
|
+
|
|
106
|
+
# Agent sends Ctrl+C to interrupt a long-running process
|
|
107
|
+
uv run remote-cli send s_7f8a9b2c --ctrl-c
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### 3. Human Observation & Intervention
|
|
113
|
+
|
|
114
|
+
While the Agent is executing commands, the human user sees all command text and outputs scrolling in real time in their terminal window. If needed, the human can type commands directly into that same terminal window.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## CLI Command Reference
|
|
119
|
+
|
|
120
|
+
| Command | Description |
|
|
121
|
+
| :--- | :--- |
|
|
122
|
+
| `remote-cli ssh [SSH_ARGS...]` | Start SSH session, print Session ID, and attach immediately |
|
|
123
|
+
| `remote-cli session create [-d] [-- <CMD...>]` | Create a new session (default: `/bin/bash`) |
|
|
124
|
+
| `remote-cli session list` (or `ls`) | List all active and recent sessions |
|
|
125
|
+
| `remote-cli session attach <ID>` (or `attach`) | Attach terminal in raw mode to existing session |
|
|
126
|
+
| `remote-cli session close <ID>` | Close and terminate a session |
|
|
127
|
+
| `remote-cli exec <ID> "<COMMAND>"` | Execute command in session, capture output & exit code |
|
|
128
|
+
| `remote-cli send <ID> [TEXT]` | Send raw keystrokes or control keys (`--ctrl-c`, `--ctrl-d`) |
|
|
129
|
+
| `remote-cli snapshot <ID>` | Capture 2D terminal screen state (ANSI-rendered) |
|
|
130
|
+
| `remote-cli logs <ID> [-n LINES]` | View recent output scrollback logs |
|
|
131
|
+
| `remote-cli daemon start / stop / status` | Manage background daemon lifecycle |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Architecture
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
┌─────────────────────────┐ ┌───────────────────────────┐
|
|
139
|
+
│ Human User Terminal │ │ AI Agent / Script │
|
|
140
|
+
│ (Raw Mode) │ │ (remote-cli exec/snapshot)│
|
|
141
|
+
└────────────┬────────────┘ └─────────────┬─────────────┘
|
|
142
|
+
│ │
|
|
143
|
+
│ Attach (Stdin/Stdout stream) │ JSON Request/Response
|
|
144
|
+
▼ ▼
|
|
145
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
146
|
+
│ remote-cli Daemon Process │
|
|
147
|
+
│ (Unix Domain Socket: ~/.remote-cli/remote-cli.sock) │
|
|
148
|
+
│ │
|
|
149
|
+
│ ┌────────────────────────────────────────────────────────┐ │
|
|
150
|
+
│ │ Session (e.g. s_7f8a9b2c) │ │
|
|
151
|
+
│ │ - Master/Slave PTY (`pty.openpty`) │ │
|
|
152
|
+
│ │ - Pyte Virtual Terminal Screen (`pyte.HistoryScreen`) │ │
|
|
153
|
+
│ │ - Scrollback Ring Buffer │ │
|
|
154
|
+
│ │ - Exec Sentinel Detection Engine │ │
|
|
155
|
+
│ │ - Process: `ssh user@remote-server` (or local shell) │ │
|
|
156
|
+
│ └────────────────────────────────────────────────────────┘ │
|
|
157
|
+
└─────────────────────────────────────────────────────────────┘
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Running Tests
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
uv run pytest -v
|
|
166
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
remote_cli/__init__.py,sha256=eF8390oxxMkXcnOMf8r3g6Qf1Vc3CI4pAIeHiZQMOqw,434
|
|
2
|
+
remote_cli/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
remote_cli/cli.py,sha256=YEAJGVdmLGKqo4vNzm0xtR-e6G_XZKBl7f5IpaG87QE,10103
|
|
4
|
+
remote_cli/client.py,sha256=7FwKDDHUbpfmBSC2d_XwtPdd6-sZGFXSy2h9fYuhEF4,5281
|
|
5
|
+
remote_cli/daemon.py,sha256=3YmFhX5BaTfcYteUCWz3M4z6e4dwpZU8A0LPeoxVkZE,10036
|
|
6
|
+
remote_cli/protocol.py,sha256=LnQcgWlkOZZZ6pQoRy-KW9pwfcNdGS-qwCVWYL-tu1I,1507
|
|
7
|
+
remote_cli/screen.py,sha256=Tj9L0XFnMhIMCKkoO3y4qBomcqkeOscevuMj-mRtK2c,2326
|
|
8
|
+
remote_cli/session.py,sha256=XbQq7VI9iUm-lWhOYnWV82eGxnRWM6FU9R8-hfS9GA4,11385
|
|
9
|
+
remote_cli/terminal.py,sha256=LNUaiENiMSOuOBDBVLNGFc9KJ5fXEmbDVOLvFnXzor0,3865
|
|
10
|
+
remote_cli/utils.py,sha256=W_6bLxi8ylVgyvRevLvieR8__Lj0r1xAHqtpcbI4ARs,1037
|
|
11
|
+
remote_cli-0.1.0.dist-info/METADATA,sha256=e4S1atGcBirjXulC3FEn2_8sQitragAAVbU1pOgNwzI,7264
|
|
12
|
+
remote_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
13
|
+
remote_cli-0.1.0.dist-info/entry_points.txt,sha256=7XpsEnHYQZF3EG7dsfxiVeP7ZFcpFZ0Q2ezW7n-ThaQ,50
|
|
14
|
+
remote_cli-0.1.0.dist-info/licenses/LICENSE,sha256=DWcolN0n9Am669uQA1E04dsdunFqNcqXk5YsOR8xS5o,1065
|
|
15
|
+
remote_cli-0.1.0.dist-info/RECORD,,
|