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/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:]