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