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 ADDED
@@ -0,0 +1,15 @@
1
+ """remote-cli: Shared SSH CLI tool for AI Agent and human co-piloting."""
2
+
3
+ try:
4
+ from ._version import __version__, __version_tuple__
5
+ except ImportError:
6
+ try:
7
+ from importlib.metadata import version
8
+
9
+ __version__ = version("remote-cli")
10
+ __version_tuple__ = (0, 1, 0)
11
+ except Exception:
12
+ __version__ = "0.1.0"
13
+ __version_tuple__ = (0, 1, 0)
14
+
15
+ __all__ = ["__version__", "__version_tuple__"]
remote_cli/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
remote_cli/cli.py ADDED
@@ -0,0 +1,303 @@
1
+ """Main CLI entrypoint for remote-cli."""
2
+
3
+ import json
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+
10
+ from .client import Client
11
+ from .daemon import ensure_daemon_running, stop_daemon
12
+ from .terminal import attach_session, get_terminal_size
13
+ from .utils import get_base_dir, get_log_path, get_pid_path
14
+
15
+ app = typer.Typer(
16
+ name="remote-cli",
17
+ help="Shared SSH & terminal CLI tool for AI Agent and human co-piloting.",
18
+ no_args_is_help=True,
19
+ )
20
+ session_app = typer.Typer(help="Manage terminal sessions.")
21
+ daemon_app = typer.Typer(help="Manage the remote-cli background daemon.")
22
+
23
+ app.add_typer(session_app, name="session")
24
+ app.add_typer(daemon_app, name="daemon")
25
+
26
+ console = Console()
27
+ err_console = Console(stderr=True)
28
+
29
+
30
+ @app.command(
31
+ "ssh",
32
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
33
+ help="Start an SSH session, get a Session ID for your Agent, and attach immediately.",
34
+ )
35
+ def ssh_command(
36
+ ctx: typer.Context,
37
+ name: str | None = typer.Option(None, "--name", "-n", help="Optional name for this session"),
38
+ ):
39
+ """Start an interactive SSH session shared with your AI Agent."""
40
+ ssh_args = ctx.args
41
+ if not ssh_args:
42
+ err_console.print(
43
+ "[bold red]Error:[/bold red] Please provide SSH arguments (e.g. `remote-cli ssh user@host`)"
44
+ )
45
+ raise typer.Exit(1)
46
+
47
+ cmd = ["ssh"] + ssh_args
48
+ ensure_daemon_running()
49
+ client = Client()
50
+
51
+ rows, cols = get_terminal_size()
52
+ session = client.create_session(command=cmd, name=name, rows=rows, cols=cols)
53
+
54
+ console.print(
55
+ Panel.fit(
56
+ f"[bold green]Session Created:[/bold green] [bold yellow]{session.session_id}[/bold yellow]\n"
57
+ f'[cyan]Agent Command:[/cyan] [white]remote-cli exec {session.session_id} "<command>"[/white]\n'
58
+ f"[dim]Press [bold]Ctrl+][/bold] to detach from session at any time.[/dim]",
59
+ title="[bold]remote-cli SSH Session[/bold]",
60
+ border_style="green",
61
+ )
62
+ )
63
+
64
+ attach_session(session.session_id)
65
+
66
+
67
+ @session_app.command(
68
+ "create",
69
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
70
+ )
71
+ def session_create(
72
+ ctx: typer.Context,
73
+ name: str | None = typer.Option(None, "--name", "-n", help="Optional session name"),
74
+ detach: bool = typer.Option(False, "--detach", "-d", help="Do not attach immediately"),
75
+ ):
76
+ """Create a new shell session (default: /bin/bash or custom command)."""
77
+ command = ctx.args if ctx.args else ["/bin/bash"]
78
+ ensure_daemon_running()
79
+ client = Client()
80
+
81
+ rows, cols = get_terminal_size()
82
+ session = client.create_session(command=command, name=name, rows=rows, cols=cols)
83
+
84
+ if detach:
85
+ console.print(f"[green]Created session:[/green] [bold]{session.session_id}[/bold]")
86
+ console.print(f"Attach with: [cyan]remote-cli session attach {session.session_id}[/cyan]")
87
+ else:
88
+ console.print(
89
+ Panel.fit(
90
+ f"[bold green]Session Created:[/bold green] [bold yellow]{session.session_id}[/bold yellow]\n"
91
+ f'[cyan]Agent Command:[/cyan] [white]remote-cli exec {session.session_id} "<command>"[/white]\n'
92
+ f"[dim]Press [bold]Ctrl+][/bold] to detach.[/dim]",
93
+ title="[bold]remote-cli Session[/bold]",
94
+ border_style="green",
95
+ )
96
+ )
97
+ attach_session(session.session_id)
98
+
99
+
100
+ @session_app.command("list")
101
+ def session_list():
102
+ """List all active and recent sessions."""
103
+ ensure_daemon_running()
104
+ client = Client()
105
+ sessions = client.list_sessions()
106
+
107
+ if not sessions:
108
+ console.print("[dim]No active sessions found.[/dim]")
109
+ return
110
+
111
+ table = Table(title="remote-cli Sessions")
112
+ table.add_column("Session ID", style="bold yellow")
113
+ table.add_column("Name", style="cyan")
114
+ table.add_column("Command", style="white")
115
+ table.add_column("Status", style="bold")
116
+ table.add_column("Clients", justify="center")
117
+ table.add_column("Created At", style="dim")
118
+
119
+ for s in sessions:
120
+ status_str = (
121
+ f"[green]{s.status}[/green]" if s.status == "active" else f"[red]{s.status}[/red]"
122
+ )
123
+ cmd_str = " ".join(s.command)
124
+ table.add_row(
125
+ s.session_id,
126
+ s.name,
127
+ cmd_str,
128
+ status_str,
129
+ str(s.attached_clients),
130
+ s.created_at,
131
+ )
132
+
133
+ console.print(table)
134
+
135
+
136
+ @session_app.command("attach")
137
+ def session_attach(session_id: str):
138
+ """Attach terminal to an existing session."""
139
+ ensure_daemon_running()
140
+ client = Client()
141
+ session = client.get_session(session_id)
142
+ if not session:
143
+ err_console.print(f"[red]Error:[/red] Session {session_id} not found.")
144
+ raise typer.Exit(1)
145
+ if session.status != "active":
146
+ err_console.print(f"[red]Error:[/red] Session {session_id} has already exited.")
147
+ raise typer.Exit(1)
148
+
149
+ console.print(f"[dim]Attaching to {session_id}... (Press Ctrl+] to detach)[/dim]")
150
+ attach_session(session_id)
151
+
152
+
153
+ @session_app.command("close")
154
+ def session_close(session_id: str):
155
+ """Close and terminate a session."""
156
+ ensure_daemon_running()
157
+ client = Client()
158
+ if client.close_session(session_id):
159
+ console.print(f"[green]Session {session_id} closed.[/green]")
160
+ else:
161
+ err_console.print(f"[red]Error:[/red] Session {session_id} not found.")
162
+ raise typer.Exit(1)
163
+
164
+
165
+ # Shortcut commands at root level
166
+ @app.command("ls")
167
+ def ls_shortcut():
168
+ """Shortcut for `session list`."""
169
+ session_list()
170
+
171
+
172
+ @app.command("attach")
173
+ def attach_shortcut(session_id: str):
174
+ """Shortcut for `session attach`."""
175
+ session_attach(session_id)
176
+
177
+
178
+ @app.command("exec")
179
+ def exec_command(
180
+ session_id: str = typer.Argument(..., help="Target session ID"),
181
+ command: str = typer.Argument(..., help="Shell command to execute"),
182
+ timeout: float = typer.Option(30.0, "--timeout", "-t", help="Timeout in seconds"),
183
+ json_output: bool = typer.Option(False, "--json", help="Output results in JSON format"),
184
+ ):
185
+ """Execute a command in the session and capture output and return code."""
186
+ ensure_daemon_running()
187
+ client = Client()
188
+ try:
189
+ res = client.exec_command(session_id, command, timeout=timeout)
190
+ except Exception as e:
191
+ if json_output:
192
+ console.print(json.dumps({"success": False, "error": str(e)}))
193
+ else:
194
+ err_console.print(f"[red]Execution error:[/red] {e}")
195
+ raise typer.Exit(1) from None
196
+
197
+ if json_output:
198
+ print(res.model_dump_json(indent=2))
199
+ else:
200
+ if res.output:
201
+ print(res.output)
202
+ if res.timed_out:
203
+ err_console.print(f"[bold red]Command timed out after {timeout}s[/bold red]")
204
+ raise typer.Exit(124)
205
+ if res.exit_code is not None and res.exit_code != 0:
206
+ raise typer.Exit(res.exit_code)
207
+
208
+
209
+ @app.command("send")
210
+ def send_command(
211
+ session_id: str = typer.Argument(..., help="Target session ID"),
212
+ text: str | None = typer.Argument(None, help="Text to send"),
213
+ no_newline: bool = typer.Option(False, "--no-newline", "-n", help="Do not append newline"),
214
+ ctrl_c: bool = typer.Option(False, "--ctrl-c", help="Send Ctrl+C interrupt"),
215
+ ctrl_d: bool = typer.Option(False, "--ctrl-d", help="Send Ctrl+D EOF"),
216
+ ):
217
+ """Send raw text, keys, or control signals (Ctrl+C, Ctrl+D) to the session."""
218
+ ensure_daemon_running()
219
+ client = Client()
220
+ try:
221
+ success = client.send_input(
222
+ session_id=session_id,
223
+ text=text,
224
+ no_newline=no_newline,
225
+ ctrl_c=ctrl_c,
226
+ ctrl_d=ctrl_d,
227
+ )
228
+ if not success:
229
+ err_console.print("[red]Failed to send input.[/red]")
230
+ raise typer.Exit(1)
231
+ except Exception as e:
232
+ err_console.print(f"[red]Error:[/red] {e}")
233
+ raise typer.Exit(1) from None
234
+
235
+
236
+ @app.command("snapshot")
237
+ def snapshot_command(
238
+ session_id: str = typer.Argument(..., help="Target session ID"),
239
+ raw: bool = typer.Option(False, "--raw", help="Keep trailing empty lines"),
240
+ ):
241
+ """Capture the 2D terminal screen state."""
242
+ ensure_daemon_running()
243
+ client = Client()
244
+ try:
245
+ screen_text = client.snapshot(session_id, clean=not raw)
246
+ print(screen_text)
247
+ except Exception as e:
248
+ err_console.print(f"[red]Error:[/red] {e}")
249
+ raise typer.Exit(1) from None
250
+
251
+
252
+ @app.command("logs")
253
+ def logs_command(
254
+ session_id: str = typer.Argument(..., help="Target session ID"),
255
+ lines: int = typer.Option(100, "--lines", "-n", help="Number of lines to retrieve"),
256
+ ):
257
+ """View recent output scrollback logs."""
258
+ ensure_daemon_running()
259
+ client = Client()
260
+ try:
261
+ log_lines = client.logs(session_id, lines=lines)
262
+ for line in log_lines:
263
+ print(line)
264
+ except Exception as e:
265
+ err_console.print(f"[red]Error:[/red] {e}")
266
+ raise typer.Exit(1) from None
267
+
268
+
269
+ @daemon_app.command("start")
270
+ def daemon_start():
271
+ """Start the daemon process explicitly in foreground or background."""
272
+ ensure_daemon_running()
273
+ console.print(f"[green]remote-cli daemon is running.[/green] Log: {get_log_path()}")
274
+
275
+
276
+ @daemon_app.command("stop")
277
+ def daemon_stop():
278
+ """Stop the running daemon process."""
279
+ if stop_daemon():
280
+ console.print("[green]remote-cli daemon stopped.[/green]")
281
+ else:
282
+ console.print("[yellow]remote-cli daemon is not running.[/yellow]")
283
+
284
+
285
+ @daemon_app.command("status")
286
+ def daemon_status():
287
+ """Check the status of the remote-cli daemon."""
288
+ client = Client()
289
+ if client.ping():
290
+ pid = get_pid_path().read_text().strip() if get_pid_path().exists() else "unknown"
291
+ console.print(f"[green]Daemon is running[/green] (PID: {pid})")
292
+ console.print(f"Base Directory: {get_base_dir()}")
293
+ console.print(f"Log File: {get_log_path()}")
294
+ else:
295
+ console.print("[yellow]Daemon is stopped.[/yellow]")
296
+
297
+
298
+ def main():
299
+ app()
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()
remote_cli/client.py ADDED
@@ -0,0 +1,164 @@
1
+ """Client communication layer for remote-cli."""
2
+
3
+ import json
4
+ import socket
5
+ from pathlib import Path
6
+
7
+ from .protocol import (
8
+ ActionType,
9
+ ExecResult,
10
+ Request,
11
+ Response,
12
+ SessionInfo,
13
+ )
14
+ from .utils import get_socket_path
15
+
16
+
17
+ class Client:
18
+ """Synchronous socket client for sending requests to remote-cli daemon."""
19
+
20
+ def __init__(self, socket_path: Path | None = None):
21
+ self.socket_path = socket_path or get_socket_path()
22
+
23
+ def _send_request(self, req: Request, timeout: float | None = 35.0) -> Response:
24
+ if not self.socket_path.exists():
25
+ raise ConnectionError(
26
+ f"Daemon socket {self.socket_path} does not exist. Is daemon running?"
27
+ )
28
+
29
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
30
+ if timeout:
31
+ s.settimeout(timeout)
32
+
33
+ try:
34
+ s.connect(str(self.socket_path))
35
+ payload = (req.model_dump_json() + "\n").encode("utf-8")
36
+ s.sendall(payload)
37
+
38
+ # Read response until newline
39
+ chunks = []
40
+ while True:
41
+ chunk = s.recv(4096)
42
+ if not chunk:
43
+ break
44
+ chunks.append(chunk)
45
+ if b"\n" in chunk:
46
+ break
47
+
48
+ raw = b"".join(chunks).decode("utf-8").strip()
49
+ if not raw:
50
+ raise RuntimeError("Empty response received from daemon")
51
+
52
+ resp_data = json.loads(raw)
53
+ return Response.model_validate(resp_data)
54
+ except Exception as e:
55
+ raise RuntimeError(f"IPC communication error: {e}") from e
56
+ finally:
57
+ s.close()
58
+
59
+ def ping(self) -> bool:
60
+ """Checks if daemon is reachable and responding."""
61
+ try:
62
+ resp = self._send_request(Request(action=ActionType.PING), timeout=1.0)
63
+ return resp.success and resp.message == "pong"
64
+ except Exception:
65
+ return False
66
+
67
+ def create_session(
68
+ self,
69
+ command: list[str],
70
+ name: str | None = None,
71
+ rows: int = 24,
72
+ cols: int = 80,
73
+ ) -> SessionInfo:
74
+ req = Request(
75
+ action=ActionType.CREATE_SESSION,
76
+ command=command,
77
+ name=name,
78
+ rows=rows,
79
+ cols=cols,
80
+ )
81
+ resp = self._send_request(req)
82
+ if not resp.success:
83
+ raise RuntimeError(resp.error or resp.message)
84
+ return SessionInfo.model_validate(resp.data)
85
+
86
+ def list_sessions(self) -> list[SessionInfo]:
87
+ req = Request(action=ActionType.LIST_SESSIONS)
88
+ resp = self._send_request(req)
89
+ if not resp.success:
90
+ raise RuntimeError(resp.error or resp.message)
91
+ return [SessionInfo.model_validate(item) for item in (resp.data or [])]
92
+
93
+ def get_session(self, session_id: str) -> SessionInfo | None:
94
+ req = Request(action=ActionType.GET_SESSION, session_id=session_id)
95
+ resp = self._send_request(req)
96
+ if not resp.success:
97
+ return None
98
+ return SessionInfo.model_validate(resp.data)
99
+
100
+ def close_session(self, session_id: str) -> bool:
101
+ req = Request(action=ActionType.CLOSE_SESSION, session_id=session_id)
102
+ resp = self._send_request(req)
103
+ return resp.success
104
+
105
+ def exec_command(
106
+ self,
107
+ session_id: str,
108
+ command: str,
109
+ timeout: float = 30.0,
110
+ ) -> ExecResult:
111
+ req = Request(
112
+ action=ActionType.EXEC_COMMAND,
113
+ session_id=session_id,
114
+ command_str=command,
115
+ timeout=timeout,
116
+ )
117
+ # Give extra buffer to IPC socket timeout
118
+ resp = self._send_request(req, timeout=timeout + 5.0)
119
+ if not resp.success:
120
+ raise RuntimeError(resp.error or resp.message)
121
+ return ExecResult.model_validate(resp.data)
122
+
123
+ def send_input(
124
+ self,
125
+ session_id: str,
126
+ text: str | None = None,
127
+ no_newline: bool = False,
128
+ ctrl_c: bool = False,
129
+ ctrl_d: bool = False,
130
+ ) -> bool:
131
+ req = Request(
132
+ action=ActionType.SEND_INPUT,
133
+ session_id=session_id,
134
+ text=text,
135
+ no_newline=no_newline,
136
+ ctrl_c=ctrl_c,
137
+ ctrl_d=ctrl_d,
138
+ )
139
+ resp = self._send_request(req)
140
+ return resp.success
141
+
142
+ def snapshot(self, session_id: str, clean: bool = True) -> str:
143
+ req = Request(action=ActionType.SNAPSHOT, session_id=session_id, clean=clean)
144
+ resp = self._send_request(req)
145
+ if not resp.success:
146
+ raise RuntimeError(resp.error or resp.message)
147
+ return resp.data.get("snapshot", "")
148
+
149
+ def logs(self, session_id: str, lines: int = 100) -> list[str]:
150
+ req = Request(action=ActionType.LOGS, session_id=session_id, lines=lines)
151
+ resp = self._send_request(req)
152
+ if not resp.success:
153
+ raise RuntimeError(resp.error or resp.message)
154
+ return resp.data.get("lines", [])
155
+
156
+ def resize(self, session_id: str, rows: int, cols: int) -> bool:
157
+ req = Request(
158
+ action=ActionType.RESIZE,
159
+ session_id=session_id,
160
+ rows=rows,
161
+ cols=cols,
162
+ )
163
+ resp = self._send_request(req)
164
+ return resp.success