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/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,,