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/daemon.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Background daemon server for remote-cli."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .protocol import ActionType, Request, Response
|
|
13
|
+
from .session import SessionManager
|
|
14
|
+
from .utils import get_log_path, get_pid_path, get_socket_path
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("remote_cli.daemon")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DaemonServer:
|
|
20
|
+
"""Unix Domain Socket server managing sessions."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, socket_path: Path | None = None):
|
|
23
|
+
self.socket_path = socket_path or get_socket_path()
|
|
24
|
+
self.session_manager = SessionManager()
|
|
25
|
+
self.server: asyncio.Server | None = None
|
|
26
|
+
self.running = False
|
|
27
|
+
|
|
28
|
+
async def handle_client(
|
|
29
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
30
|
+
) -> None:
|
|
31
|
+
try:
|
|
32
|
+
line = await reader.readline()
|
|
33
|
+
if not line:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
req_data = json.loads(line.decode("utf-8"))
|
|
37
|
+
req = Request.model_validate(req_data)
|
|
38
|
+
|
|
39
|
+
if req.action == ActionType.ATTACH:
|
|
40
|
+
await self._handle_attach(req, reader, writer)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
response = await self._dispatch_request(req)
|
|
44
|
+
resp_bytes = (response.model_dump_json() + "\n").encode("utf-8")
|
|
45
|
+
writer.write(resp_bytes)
|
|
46
|
+
await writer.drain()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.exception("Error handling client request")
|
|
49
|
+
err_resp = Response(success=False, error=str(e))
|
|
50
|
+
try:
|
|
51
|
+
writer.write((err_resp.model_dump_json() + "\n").encode("utf-8"))
|
|
52
|
+
await writer.drain()
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
finally:
|
|
56
|
+
try:
|
|
57
|
+
writer.close()
|
|
58
|
+
await writer.wait_closed()
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
async def _handle_attach(
|
|
63
|
+
self, req: Request, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
64
|
+
) -> None:
|
|
65
|
+
session = self.session_manager.get_session(req.session_id or "")
|
|
66
|
+
if not session or session.status != "active":
|
|
67
|
+
resp = Response(
|
|
68
|
+
success=False,
|
|
69
|
+
error=f"Session {req.session_id} not found or not active",
|
|
70
|
+
)
|
|
71
|
+
writer.write((resp.model_dump_json() + "\n").encode("utf-8"))
|
|
72
|
+
await writer.drain()
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Send OK acknowledgment
|
|
76
|
+
resp = Response(success=True, message="ATTACH_STREAM_START")
|
|
77
|
+
writer.write((resp.model_dump_json() + "\n").encode("utf-8"))
|
|
78
|
+
await writer.drain()
|
|
79
|
+
|
|
80
|
+
# If client passed initial window size, apply it
|
|
81
|
+
if req.rows and req.cols:
|
|
82
|
+
session.resize(req.rows, req.cols)
|
|
83
|
+
|
|
84
|
+
# Attach writer to session
|
|
85
|
+
session.attach_client(writer)
|
|
86
|
+
try:
|
|
87
|
+
while True:
|
|
88
|
+
data = await reader.read(4096)
|
|
89
|
+
if not data:
|
|
90
|
+
break
|
|
91
|
+
session.write_input(data)
|
|
92
|
+
except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError):
|
|
93
|
+
pass
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.debug(f"Attach stream read exception: {e}")
|
|
96
|
+
finally:
|
|
97
|
+
session.detach_client(writer)
|
|
98
|
+
|
|
99
|
+
async def _dispatch_request(self, req: Request) -> Response:
|
|
100
|
+
if req.action == ActionType.PING:
|
|
101
|
+
return Response(success=True, message="pong")
|
|
102
|
+
|
|
103
|
+
elif req.action == ActionType.CREATE_SESSION:
|
|
104
|
+
command = req.command or ["/bin/bash"]
|
|
105
|
+
rows = req.rows or 24
|
|
106
|
+
cols = req.cols or 80
|
|
107
|
+
session = self.session_manager.create_session(
|
|
108
|
+
command=command,
|
|
109
|
+
name=req.name,
|
|
110
|
+
rows=rows,
|
|
111
|
+
cols=cols,
|
|
112
|
+
)
|
|
113
|
+
return Response(
|
|
114
|
+
success=True,
|
|
115
|
+
message=f"Session {session.session_id} created",
|
|
116
|
+
data=session.get_info().model_dump(),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
elif req.action == ActionType.LIST_SESSIONS:
|
|
120
|
+
sessions = [s.model_dump() for s in self.session_manager.list_sessions()]
|
|
121
|
+
return Response(success=True, data=sessions)
|
|
122
|
+
|
|
123
|
+
elif req.action == ActionType.GET_SESSION:
|
|
124
|
+
session = self.session_manager.get_session(req.session_id or "")
|
|
125
|
+
if not session:
|
|
126
|
+
return Response(success=False, error="Session not found")
|
|
127
|
+
return Response(success=True, data=session.get_info().model_dump())
|
|
128
|
+
|
|
129
|
+
elif req.action == ActionType.CLOSE_SESSION:
|
|
130
|
+
closed = self.session_manager.close_session(req.session_id or "")
|
|
131
|
+
if not closed:
|
|
132
|
+
return Response(success=False, error="Session not found")
|
|
133
|
+
return Response(success=True, message=f"Session {req.session_id} closed")
|
|
134
|
+
|
|
135
|
+
elif req.action == ActionType.EXEC_COMMAND:
|
|
136
|
+
session = self.session_manager.get_session(req.session_id or "")
|
|
137
|
+
if not session:
|
|
138
|
+
return Response(success=False, error="Session not found")
|
|
139
|
+
if not req.command_str:
|
|
140
|
+
return Response(success=False, error="No command provided to exec")
|
|
141
|
+
result = await session.execute_command(req.command_str, timeout=req.timeout or 30.0)
|
|
142
|
+
return Response(success=True, data=result.model_dump())
|
|
143
|
+
|
|
144
|
+
elif req.action == ActionType.SEND_INPUT:
|
|
145
|
+
session = self.session_manager.get_session(req.session_id or "")
|
|
146
|
+
if not session:
|
|
147
|
+
return Response(success=False, error="Session not found")
|
|
148
|
+
|
|
149
|
+
payload = b""
|
|
150
|
+
if req.ctrl_c:
|
|
151
|
+
payload = b"\x03"
|
|
152
|
+
elif req.ctrl_d:
|
|
153
|
+
payload = b"\x04"
|
|
154
|
+
elif req.text is not None:
|
|
155
|
+
text = req.text
|
|
156
|
+
if not req.no_newline:
|
|
157
|
+
text += "\n"
|
|
158
|
+
payload = text.encode("utf-8")
|
|
159
|
+
|
|
160
|
+
session.write_input(payload)
|
|
161
|
+
return Response(success=True, message="Input sent")
|
|
162
|
+
|
|
163
|
+
elif req.action == ActionType.SNAPSHOT:
|
|
164
|
+
session = self.session_manager.get_session(req.session_id or "")
|
|
165
|
+
if not session:
|
|
166
|
+
return Response(success=False, error="Session not found")
|
|
167
|
+
text = session.screen.snapshot(clean=req.clean)
|
|
168
|
+
return Response(success=True, data={"snapshot": text})
|
|
169
|
+
|
|
170
|
+
elif req.action == ActionType.LOGS:
|
|
171
|
+
session = self.session_manager.get_session(req.session_id or "")
|
|
172
|
+
if not session:
|
|
173
|
+
return Response(success=False, error="Session not found")
|
|
174
|
+
lines = session.buffer.get_lines(req.lines or 100)
|
|
175
|
+
return Response(success=True, data={"lines": lines})
|
|
176
|
+
|
|
177
|
+
elif req.action == ActionType.RESIZE:
|
|
178
|
+
session = self.session_manager.get_session(req.session_id or "")
|
|
179
|
+
if not session:
|
|
180
|
+
return Response(success=False, error="Session not found")
|
|
181
|
+
if req.rows and req.cols:
|
|
182
|
+
session.resize(req.rows, req.cols)
|
|
183
|
+
return Response(success=True, message="Resized")
|
|
184
|
+
|
|
185
|
+
return Response(success=False, error=f"Unknown action: {req.action}")
|
|
186
|
+
|
|
187
|
+
async def start(self) -> None:
|
|
188
|
+
"""Starts the Unix Domain Socket server."""
|
|
189
|
+
if self.socket_path.exists():
|
|
190
|
+
try:
|
|
191
|
+
self.socket_path.unlink()
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
self.server = await asyncio.start_unix_server(
|
|
197
|
+
self.handle_client, path=str(self.socket_path)
|
|
198
|
+
)
|
|
199
|
+
self.running = True
|
|
200
|
+
|
|
201
|
+
# Write PID file
|
|
202
|
+
pid_path = get_pid_path()
|
|
203
|
+
pid_path.write_text(str(os.getpid()))
|
|
204
|
+
|
|
205
|
+
logger.info(f"remote-cli daemon started at {self.socket_path} (pid: {os.getpid()})")
|
|
206
|
+
async with self.server:
|
|
207
|
+
await self.server.serve_forever()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run_daemon() -> None:
|
|
211
|
+
"""Entry point for daemon process."""
|
|
212
|
+
log_file = get_log_path()
|
|
213
|
+
logging.basicConfig(
|
|
214
|
+
filename=str(log_file),
|
|
215
|
+
level=logging.INFO,
|
|
216
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
217
|
+
)
|
|
218
|
+
server = DaemonServer()
|
|
219
|
+
|
|
220
|
+
loop = asyncio.new_event_loop()
|
|
221
|
+
asyncio.set_event_loop(loop)
|
|
222
|
+
|
|
223
|
+
def handle_signal():
|
|
224
|
+
for task in asyncio.all_tasks(loop):
|
|
225
|
+
task.cancel()
|
|
226
|
+
loop.stop()
|
|
227
|
+
|
|
228
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
229
|
+
loop.add_signal_handler(sig, handle_signal)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
loop.run_until_complete(server.start())
|
|
233
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
234
|
+
pass
|
|
235
|
+
finally:
|
|
236
|
+
pid_path = get_pid_path()
|
|
237
|
+
if pid_path.exists():
|
|
238
|
+
pid_path.unlink()
|
|
239
|
+
sock_path = get_socket_path()
|
|
240
|
+
if sock_path.exists():
|
|
241
|
+
sock_path.unlink()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def ensure_daemon_running() -> None:
|
|
245
|
+
"""Checks if daemon is running; if not, spawns it in the background."""
|
|
246
|
+
from .client import Client
|
|
247
|
+
|
|
248
|
+
client = Client()
|
|
249
|
+
if client.ping():
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Clean up stale socket/pid
|
|
253
|
+
sock_path = get_socket_path()
|
|
254
|
+
if sock_path.exists():
|
|
255
|
+
try:
|
|
256
|
+
sock_path.unlink()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
import subprocess
|
|
261
|
+
|
|
262
|
+
cmd = [sys.executable, "-m", "remote_cli.daemon"]
|
|
263
|
+
subprocess.Popen(
|
|
264
|
+
cmd,
|
|
265
|
+
stdin=subprocess.DEVNULL,
|
|
266
|
+
stdout=subprocess.DEVNULL,
|
|
267
|
+
stderr=subprocess.DEVNULL,
|
|
268
|
+
start_new_session=True,
|
|
269
|
+
close_fds=True,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Wait for daemon to become ready
|
|
273
|
+
for _ in range(30):
|
|
274
|
+
time.sleep(0.1)
|
|
275
|
+
if client.ping():
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
raise RuntimeError("Failed to start background remote-cli daemon")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def stop_daemon() -> bool:
|
|
282
|
+
"""Stops the running daemon process."""
|
|
283
|
+
pid_path = get_pid_path()
|
|
284
|
+
if not pid_path.exists():
|
|
285
|
+
return False
|
|
286
|
+
try:
|
|
287
|
+
pid = int(pid_path.read_text().strip())
|
|
288
|
+
os.kill(pid, signal.SIGTERM)
|
|
289
|
+
time.sleep(0.3)
|
|
290
|
+
return True
|
|
291
|
+
except Exception:
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
run_daemon()
|
remote_cli/protocol.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""IPC protocol definitions and models for remote-cli."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActionType(StrEnum):
|
|
10
|
+
PING = "ping"
|
|
11
|
+
CREATE_SESSION = "create_session"
|
|
12
|
+
LIST_SESSIONS = "list_sessions"
|
|
13
|
+
GET_SESSION = "get_session"
|
|
14
|
+
CLOSE_SESSION = "close_session"
|
|
15
|
+
EXEC_COMMAND = "exec_command"
|
|
16
|
+
SEND_INPUT = "send_input"
|
|
17
|
+
SNAPSHOT = "snapshot"
|
|
18
|
+
LOGS = "logs"
|
|
19
|
+
RESIZE = "resize"
|
|
20
|
+
ATTACH = "attach"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SessionInfo(BaseModel):
|
|
24
|
+
session_id: str
|
|
25
|
+
name: str
|
|
26
|
+
command: list[str]
|
|
27
|
+
created_at: str
|
|
28
|
+
status: str # "active", "exited"
|
|
29
|
+
exit_code: int | None = None
|
|
30
|
+
attached_clients: int = 0
|
|
31
|
+
rows: int = 24
|
|
32
|
+
cols: int = 80
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Request(BaseModel):
|
|
36
|
+
action: ActionType
|
|
37
|
+
session_id: str | None = None
|
|
38
|
+
command: list[str] | None = None
|
|
39
|
+
command_str: str | None = None
|
|
40
|
+
name: str | None = None
|
|
41
|
+
rows: int | None = None
|
|
42
|
+
cols: int | None = None
|
|
43
|
+
text: str | None = None
|
|
44
|
+
no_newline: bool = False
|
|
45
|
+
ctrl_c: bool = False
|
|
46
|
+
ctrl_d: bool = False
|
|
47
|
+
timeout: float | None = 30.0
|
|
48
|
+
lines: int | None = 100
|
|
49
|
+
clean: bool = True
|
|
50
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Response(BaseModel):
|
|
54
|
+
success: bool
|
|
55
|
+
message: str = ""
|
|
56
|
+
data: Any = None
|
|
57
|
+
error: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ExecResult(BaseModel):
|
|
61
|
+
session_id: str
|
|
62
|
+
command: str
|
|
63
|
+
exit_code: int | None = None
|
|
64
|
+
output: str
|
|
65
|
+
duration: float
|
|
66
|
+
timed_out: bool = False
|
remote_cli/screen.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Terminal screen state and ring buffer management."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
|
|
5
|
+
import pyte
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VirtualScreen:
|
|
9
|
+
"""Maintains a 2D virtual terminal screen state using pyte."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, cols: int = 80, rows: int = 24, history: int = 1000):
|
|
12
|
+
self.cols = cols
|
|
13
|
+
self.rows = rows
|
|
14
|
+
self.screen = pyte.HistoryScreen(cols, rows, history=history)
|
|
15
|
+
self.stream = pyte.Stream(self.screen)
|
|
16
|
+
|
|
17
|
+
def feed(self, data: bytes) -> None:
|
|
18
|
+
"""Feeds raw byte output from PTY into the terminal emulator."""
|
|
19
|
+
try:
|
|
20
|
+
text = data.decode("utf-8", errors="replace")
|
|
21
|
+
self.stream.feed(text)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def resize(self, rows: int, cols: int) -> None:
|
|
26
|
+
"""Resizes the virtual screen dimensions."""
|
|
27
|
+
self.rows = rows
|
|
28
|
+
self.cols = cols
|
|
29
|
+
self.screen.resize(lines=rows, columns=cols)
|
|
30
|
+
|
|
31
|
+
def snapshot(self, clean: bool = True) -> str:
|
|
32
|
+
"""Returns the current rendered screen lines as a single string."""
|
|
33
|
+
lines = [line for line in self.screen.display]
|
|
34
|
+
if clean:
|
|
35
|
+
# Strip trailing spaces on each line
|
|
36
|
+
lines = [line.rstrip() for line in lines]
|
|
37
|
+
# Strip trailing blank lines
|
|
38
|
+
while lines and not lines[-1]:
|
|
39
|
+
lines.pop()
|
|
40
|
+
return "\n".join(lines)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RingBuffer:
|
|
44
|
+
"""Thread-safe and memory-bounded ring buffer for recent log output."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, max_lines: int = 10000, max_bytes: int = 5 * 1024 * 1024):
|
|
47
|
+
self.max_lines = max_lines
|
|
48
|
+
self.max_bytes = max_bytes
|
|
49
|
+
self._raw_chunks = deque()
|
|
50
|
+
self._total_bytes = 0
|
|
51
|
+
|
|
52
|
+
def append(self, data: bytes) -> None:
|
|
53
|
+
self._raw_chunks.append(data)
|
|
54
|
+
self._total_bytes += len(data)
|
|
55
|
+
while self._total_bytes > self.max_bytes and len(self._raw_chunks) > 1:
|
|
56
|
+
popped = self._raw_chunks.popleft()
|
|
57
|
+
self._total_bytes -= len(popped)
|
|
58
|
+
|
|
59
|
+
def get_raw_bytes(self) -> bytes:
|
|
60
|
+
return b"".join(self._raw_chunks)
|
|
61
|
+
|
|
62
|
+
def get_lines(self, num_lines: int = 100) -> list[str]:
|
|
63
|
+
raw = self.get_raw_bytes()
|
|
64
|
+
text = raw.decode("utf-8", errors="replace")
|
|
65
|
+
all_lines = text.splitlines()
|
|
66
|
+
if num_lines <= 0:
|
|
67
|
+
return all_lines
|
|
68
|
+
return all_lines[-num_lines:]
|