remote-exec-grpc 0.2.1__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.
@@ -0,0 +1,10 @@
1
+ """Remote command execution over gRPC."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ # Single source of truth: the version in pyproject.toml (via installed metadata),
6
+ # shared by remote-exec-server, remote-exec-client and the GetServerInfo RPC.
7
+ try:
8
+ __version__ = version("remote-exec-grpc")
9
+ except PackageNotFoundError: # running from a source tree without installation
10
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,118 @@
1
+ ---
2
+ description: Run commands on remote Linux servers that have specialised tools or hardware (FPGA tools, GPUs, licensed software, datasets) using remote-exec-client. Use when a task needs a tool or resource that is not available locally but exists on a configured remote server.
3
+ argument-hint: "[task or SERVER COMMAND]"
4
+ ---
5
+
6
+ # Remote execution with `remote-exec-client`
7
+
8
+ `remote-exec-client` runs a shell command on a remote server and streams the
9
+ remote **stdout and stderr live**, like `ssh SERVER COMMAND`. Its exit status is
10
+ the remote command's exit status, so judge success exactly as for a local
11
+ command. You only ever need `remote-exec-client`; the servers are already set
12
+ up and running.
13
+
14
+ If this was invoked with arguments, the request is: $ARGUMENTS
15
+
16
+ ## 1. Find out which servers exist
17
+
18
+ ```bash
19
+ remote-exec-client --list # NAME HOST:PORT description (tools/resources)
20
+ remote-exec-client SERVER --ping # reachable? remote host/user, allowed and denied programs
21
+ ```
22
+
23
+ Each server may restrict which programs commands can run (its `tools.yaml`).
24
+ `--ping` shows the allowed and denied program lists; by default everything
25
+ except `ssh` is allowed. Plan commands that only use allowed programs.
26
+
27
+ Pick the server whose description lists the tool or resource you need. If
28
+ none fits or the description is unclear, check with a harmless probe such as
29
+ `remote-exec-client SERVER "command -v vivado nvcc python3; nvidia-smi -L"`,
30
+ or ask the user. Do not guess server names that `--list` does not show.
31
+
32
+ ## 2. When to run remotely
33
+
34
+ - The task needs a tool that is not installed locally (e.g. `vivado`, `vsim`,
35
+ `nvcc`, `nvidia-smi`) and a server provides it.
36
+ - The task needs remote hardware (GPU, FPGA board), licenses, or data that live
37
+ on a server.
38
+ - Do **not** use it for things that work locally (editing files, local `git`,
39
+ reading code). A server sees only its own filesystem: project files must
40
+ already exist there (git checkout, NFS, shared directory). If local changes
41
+ are needed remotely, sync them first (e.g. commit/push, then `git pull`
42
+ remotely) and tell the user you did so.
43
+
44
+ ## 3. Usage
45
+
46
+ ```bash
47
+ remote-exec-client SERVER [options] "COMMAND"
48
+ ```
49
+
50
+ - Pass the command as **one quoted argument**. It runs as `bash -c "COMMAND"`
51
+ on the server, so pipes, `&&`, globs and `$VARS` are evaluated remotely. Use
52
+ single quotes locally when `$VAR` must not expand on your machine.
53
+ - `--cwd DIR` sets the remote working directory (absolute, or `'~/path'`).
54
+ Prefer it over `cd DIR && ...`.
55
+ - `-e KEY=VALUE` sets a remote environment variable (repeatable); `-e KEY`
56
+ copies the local value.
57
+ - `--timeout SECONDS` kills the remote command **and all its child processes**
58
+ when exceeded. Always set one, sized to the job (e.g. `120` for a quick test,
59
+ `7200` for a full FPGA implementation run).
60
+ - `--verbose` prints a summary line (host, status, exit code, duration) to stderr.
61
+ - stdin is not forwarded: commands must be non-interactive.
62
+ - Programs that buffer output when not on a TTY (e.g. Python) stream better
63
+ with `-e PYTHONUNBUFFERED=1` or `python -u`.
64
+
65
+ Examples:
66
+
67
+ ```bash
68
+ remote-exec-client gpu01 --timeout 30 "nvidia-smi"
69
+ remote-exec-client fpga01 --cwd /home/user/project --timeout 7200 \
70
+ "vivado -mode batch -source build.tcl"
71
+ remote-exec-client fpga01 --cwd /home/user/project \
72
+ -e FPGA_PART=xcvu37p -e BUILD_MODE=release --timeout 3600 "./build.sh"
73
+ remote-exec-client gpu01 --cwd '~/ml' -e PYTHONUNBUFFERED=1 --timeout 600 \
74
+ "python train.py --epochs 1"
75
+ ```
76
+
77
+ ## 4. Interpreting the result
78
+
79
+ | exit status | meaning |
80
+ |-------------|---------|
81
+ | `0` | remote command succeeded |
82
+ | other `1..123`, `125..127` | remote command failed with that status; `127` = command not found on that server (wrong server or PATH) |
83
+ | `124` | `--timeout` exceeded; the remote process tree was killed. Output so far was printed. Increase the timeout only if the job was making progress |
84
+ | `126` | the server's tools policy blocked a program in the command; nothing was run. The message names the program |
85
+ | `128+N` | remote command killed by signal N (`137` = SIGKILL, often out of memory) |
86
+ | `130` / `143` | the client itself was interrupted; the remote command was cancelled |
87
+ | `255` | remote-exec problem, not the command: unreachable server, authentication failure, bad config, invalid `--cwd`. Read the message on stderr |
88
+
89
+ Lines starting with `remote-exec-client:` on stderr come from the client;
90
+ all other output comes from the remote command.
91
+
92
+ ## 5. Long-running jobs and large output
93
+
94
+ - Keep output small: filter **remotely** inside the quoted command, e.g.
95
+ `"make 2>&1 | tail -n 100"`, or write the full log to a remote file and
96
+ inspect it with follow-up commands:
97
+ `"vivado -mode batch -source build.tcl > build.log 2>&1; echo exit=$?; tail -n 100 build.log"`.
98
+ - If your own tool call has a time limit shorter than the job, set the remote
99
+ `--timeout` below it so the remote side is cleaned up deterministically.
100
+ For jobs longer than that, start them with output redirected to a log file
101
+ and a generous `--timeout`, run the call in the background, and check
102
+ progress with separate commands (`tail -n 50 build.log`).
103
+ - Interrupting or killing the client terminates the remote command.
104
+
105
+ ## 6. Safety
106
+
107
+ - Commands run with the remote service user's permissions on shared machines.
108
+ Do not run destructive commands (`rm -rf`, `git reset --hard`, `git clean`,
109
+ killing other users' processes, editing system files, rebooting, flashing
110
+ hardware) unless the user explicitly asked for exactly that. When in doubt,
111
+ ask first.
112
+ - Inspect before changing anything (`ls`, `cat`, `git status`).
113
+ - If a command is blocked by the server's tools policy (exit `126`), do not
114
+ try to work around it (wrappers, scripts, `python -c`, variables). The
115
+ restriction is deliberate: use an allowed alternative or ask the user. Do not
116
+ `ssh` from a remote server to other machines.
117
+ - Never put secrets (tokens, license keys, passwords) in commands or `-e`
118
+ values: command lines are recorded in the server's audit log.
remote_exec/client.py ADDED
@@ -0,0 +1,242 @@
1
+ """Async gRPC client library (no CLI concerns).
2
+
3
+ Typical use::
4
+
5
+ async with RemoteExecClient(endpoint) as client:
6
+ result = await client.run("make test", on_stdout=..., on_stderr=...)
7
+ print(result.exit_code)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ from collections.abc import AsyncIterator, Callable, Mapping
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+
18
+ import grpc
19
+
20
+ from .config import TOKEN_ENV_VAR, ServerEndpoint
21
+ from .security import auth_metadata, channel_credentials, read_secret_file
22
+ from .v1 import remote_exec_pb2 as pb
23
+ from .v1 import remote_exec_pb2_grpc as pb_grpc
24
+
25
+ DEFAULT_CONNECT_TIMEOUT = 10.0
26
+
27
+ # Shell/ssh-like exit codes for outcomes that are not a remote exit status.
28
+ EXIT_TIMEOUT = 124 # same as coreutils `timeout`
29
+ EXIT_REJECTED = 126 # same as the shell's "cannot execute": blocked by the server's tools policy
30
+ EXIT_INTERRUPTED = 130 # 128 + SIGINT
31
+ EXIT_CLIENT_ERROR = 255 # same as ssh: connection/auth/protocol failure
32
+
33
+ CLIENT_OPTIONS = [
34
+ ("grpc.keepalive_time_ms", 30_000),
35
+ ("grpc.keepalive_timeout_ms", 20_000),
36
+ ("grpc.keepalive_permit_without_calls", 1),
37
+ ("grpc.http2.max_pings_without_data", 0),
38
+ ("grpc.max_receive_message_length", 16 * 1024 * 1024),
39
+ ]
40
+
41
+
42
+ class RemoteExecError(Exception):
43
+ """Failure of the remote execution machinery (not of the remote command)."""
44
+
45
+
46
+ class ServerUnavailable(RemoteExecError):
47
+ pass
48
+
49
+
50
+ class CommandRejected(RemoteExecError):
51
+ """The server refused to run the command (tools policy)."""
52
+
53
+
54
+ @dataclass
55
+ class ExecutionResult:
56
+ status: str # "exited" | "timeout" | "error"
57
+ exit_code: int
58
+ signal: int
59
+ message: str
60
+ execution_id: str
61
+ hostname: str
62
+ start_time: datetime | None
63
+ end_time: datetime | None
64
+
65
+ @property
66
+ def ok(self) -> bool:
67
+ return self.status == "exited" and self.exit_code == 0
68
+
69
+ @property
70
+ def duration(self) -> float | None:
71
+ if self.start_time and self.end_time:
72
+ return (self.end_time - self.start_time).total_seconds()
73
+ return None
74
+
75
+ @property
76
+ def local_exit_code(self) -> int:
77
+ """Exit code a CLI wrapper should return for this result."""
78
+ if self.status == "exited":
79
+ return self.exit_code
80
+ if self.status == "timeout":
81
+ return EXIT_TIMEOUT
82
+ return EXIT_CLIENT_ERROR
83
+
84
+
85
+ _STATUS_NAMES = {
86
+ pb.Finished.EXITED: "exited",
87
+ pb.Finished.TIMEOUT: "timeout",
88
+ pb.Finished.ERROR: "error",
89
+ }
90
+
91
+
92
+ def resolve_token(endpoint: ServerEndpoint) -> str | None:
93
+ if endpoint.token:
94
+ return endpoint.token
95
+ if endpoint.token_file:
96
+ return read_secret_file(endpoint.token_file)
97
+ return os.environ.get(TOKEN_ENV_VAR) or None
98
+
99
+
100
+ def _describe_rpc_error(endpoint: ServerEndpoint, err: grpc.aio.AioRpcError) -> str:
101
+ code = err.code()
102
+ details = err.details() or ""
103
+ where = f"{endpoint.name} ({endpoint.address})" if endpoint.name != endpoint.address else endpoint.address
104
+ if code == grpc.StatusCode.UNAVAILABLE:
105
+ return f"cannot reach server {where}: {details}"
106
+ if code == grpc.StatusCode.UNAUTHENTICATED:
107
+ return f"authentication failed for {where}: {details}"
108
+ if code == grpc.StatusCode.PERMISSION_DENIED:
109
+ return f"{where} refused to run the command: {details}"
110
+ if code == grpc.StatusCode.INVALID_ARGUMENT:
111
+ return f"invalid request: {details}"
112
+ if code == grpc.StatusCode.RESOURCE_EXHAUSTED:
113
+ return f"server {where} is busy: {details}"
114
+ if code == grpc.StatusCode.CANCELLED:
115
+ return f"call to {where} was cancelled: {details}"
116
+ return f"{where}: {code.name}: {details}"
117
+
118
+
119
+ class RemoteExecClient:
120
+ def __init__(self, endpoint: ServerEndpoint, connect_timeout: float | None = None):
121
+ self.endpoint = endpoint
122
+ self.connect_timeout = connect_timeout or endpoint.connect_timeout or DEFAULT_CONNECT_TIMEOUT
123
+ self._metadata = auth_metadata(resolve_token(endpoint))
124
+ self._channel: grpc.aio.Channel | None = None
125
+ self._stub: pb_grpc.RemoteExecutorStub | None = None
126
+
127
+ def _make_channel(self) -> grpc.aio.Channel:
128
+ ep = self.endpoint
129
+ options = list(CLIENT_OPTIONS)
130
+ if ep.uses_tls:
131
+ if ep.tls_server_name:
132
+ options.append(("grpc.ssl_target_name_override", ep.tls_server_name))
133
+ creds = channel_credentials(ep.ca_cert, ep.client_cert, ep.client_key)
134
+ return grpc.aio.secure_channel(ep.address, creds, options=options)
135
+ return grpc.aio.insecure_channel(ep.address, options=options)
136
+
137
+ async def __aenter__(self) -> RemoteExecClient:
138
+ self._channel = self._make_channel()
139
+ self._stub = pb_grpc.RemoteExecutorStub(self._channel)
140
+ return self
141
+
142
+ async def __aexit__(self, *exc) -> None:
143
+ await self.close()
144
+
145
+ async def close(self) -> None:
146
+ if self._channel is not None:
147
+ await self._channel.close()
148
+ self._channel = None
149
+
150
+ async def server_info(self) -> pb.ServerInfo:
151
+ assert self._stub, "use 'async with RemoteExecClient(...)'"
152
+ try:
153
+ return await self._stub.GetServerInfo(
154
+ pb.GetServerInfoRequest(), metadata=self._metadata, timeout=self.connect_timeout
155
+ )
156
+ except grpc.aio.AioRpcError as err:
157
+ raise self._error(err) from None
158
+
159
+ def _error(self, err: grpc.aio.AioRpcError) -> RemoteExecError:
160
+ msg = _describe_rpc_error(self.endpoint, err)
161
+ if err.code() in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED):
162
+ return ServerUnavailable(msg)
163
+ if err.code() == grpc.StatusCode.PERMISSION_DENIED:
164
+ return CommandRejected(msg)
165
+ return RemoteExecError(msg)
166
+
167
+ async def execute(
168
+ self,
169
+ command: str,
170
+ cwd: str | None = None,
171
+ env: Mapping[str, str] | None = None,
172
+ timeout: float | None = None,
173
+ ) -> AsyncIterator[pb.ExecuteEvent]:
174
+ """Yield raw ExecuteEvent messages as they arrive.
175
+
176
+ Closing the iterator early (or cancelling the task) cancels the RPC,
177
+ which makes the server terminate the remote process group.
178
+ """
179
+ assert self._stub, "use 'async with RemoteExecClient(...)'"
180
+ request = pb.ExecuteRequest(
181
+ command=command, working_directory=cwd or "", environment=dict(env or {}),
182
+ timeout_seconds=float(timeout or 0),
183
+ )
184
+ call = self._stub.Execute(request, metadata=self._metadata, wait_for_ready=False)
185
+ try:
186
+ # The server answers with Started right after spawning, so the first
187
+ # message doubles as a connection check with a bounded wait.
188
+ try:
189
+ first = await asyncio.wait_for(call.read(), self.connect_timeout)
190
+ except asyncio.TimeoutError:
191
+ raise ServerUnavailable(
192
+ f"no response from {self.endpoint.address} within {self.connect_timeout:g}s"
193
+ ) from None
194
+ if first is grpc.aio.EOF:
195
+ raise RemoteExecError("server closed the stream without sending any event")
196
+ yield first
197
+ while (event := await call.read()) is not grpc.aio.EOF:
198
+ yield event
199
+ except grpc.aio.AioRpcError as err:
200
+ raise self._error(err) from None
201
+ finally:
202
+ if not call.done():
203
+ call.cancel()
204
+
205
+ async def run(
206
+ self,
207
+ command: str,
208
+ cwd: str | None = None,
209
+ env: Mapping[str, str] | None = None,
210
+ timeout: float | None = None,
211
+ on_stdout: Callable[[bytes], None] | None = None,
212
+ on_stderr: Callable[[bytes], None] | None = None,
213
+ ) -> ExecutionResult:
214
+ """Run a command, forwarding output chunks to the callbacks."""
215
+ hostname = ""
216
+ events = self.execute(command, cwd=cwd, env=env, timeout=timeout)
217
+ try:
218
+ async for ev in events:
219
+ kind = ev.WhichOneof("event")
220
+ if kind == "stdout":
221
+ if on_stdout:
222
+ on_stdout(ev.stdout.data)
223
+ elif kind == "stderr":
224
+ if on_stderr:
225
+ on_stderr(ev.stderr.data)
226
+ elif kind == "started":
227
+ hostname = ev.started.hostname
228
+ elif kind == "finished":
229
+ f = ev.finished
230
+ return ExecutionResult(
231
+ status=_STATUS_NAMES.get(f.status, "error"),
232
+ exit_code=f.exit_code,
233
+ signal=f.signal,
234
+ message=f.message,
235
+ execution_id=f.execution_id,
236
+ hostname=hostname,
237
+ start_time=f.start_time.ToDatetime() if f.HasField("start_time") else None,
238
+ end_time=f.end_time.ToDatetime() if f.HasField("end_time") else None,
239
+ )
240
+ finally:
241
+ await events.aclose()
242
+ raise RemoteExecError("stream ended without a final status (server crashed or connection lost)")
@@ -0,0 +1,226 @@
1
+ """``remote-exec-client`` command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import os
8
+ import signal
9
+ import sys
10
+
11
+ from . import __version__
12
+ from .client import (
13
+ EXIT_CLIENT_ERROR,
14
+ EXIT_INTERRUPTED,
15
+ EXIT_REJECTED,
16
+ CommandRejected,
17
+ RemoteExecClient,
18
+ RemoteExecError,
19
+ ServerUnavailable,
20
+ )
21
+ from .config import ClientConfig, ConfigError, default_client_config_path, load_client_config
22
+ from .security import SecurityError
23
+ from .skill import DEFAULT_SKILL_DIR, SKILL_NAME, SkillExistsError, install_skill
24
+
25
+ PROG = "remote-exec-client"
26
+
27
+ EPILOG = f"""\
28
+ exit status:
29
+ N the remote command's exit status (128+N if killed by signal N)
30
+ 124 the command timed out (--timeout)
31
+ 126 the server's tools policy does not allow a program in the command
32
+ (see the allow/deny lists shown by --ping)
33
+ 130 interrupted locally (Ctrl-C); the remote command is terminated
34
+ 255 remote-exec error: config, connection, authentication, or the command
35
+ could not be started (message on stderr)
36
+
37
+ default config: {default_client_config_path()} (override with --config or $REMOTE_EXEC_CONFIG)
38
+
39
+ examples:
40
+ {PROG} fpga01 hostname
41
+ {PROG} fpga01 --cwd /home/user/project "vivado -mode batch -source build.tcl"
42
+ {PROG} fpga01 -e FPGA_PART=xcvu37p -e BUILD_MODE=release ./build.sh
43
+ {PROG} gpu01 --timeout 60 -- nvidia-smi -L
44
+ {PROG} --list
45
+ {PROG} --install-skill # teach Claude Code to use this tool
46
+ """
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ p = argparse.ArgumentParser(
51
+ prog=PROG,
52
+ description="Run a shell command on a remote-exec-server and stream its stdout/stderr, "
53
+ "exiting with the remote exit status (like ssh).",
54
+ epilog=EPILOG,
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ )
57
+ p.add_argument("server", nargs="?", help="server name from the config, or HOST[:PORT] (default port 50051)")
58
+ p.add_argument("command", nargs="*", help="command line; multiple words are joined with spaces (like ssh)")
59
+ p.add_argument("-C", "--cwd", metavar="DIR", help="remote working directory (absolute, or ~/...)")
60
+ p.add_argument("-e", "--env", metavar="KEY=VALUE", action="append", default=[],
61
+ help="set a remote environment variable (repeatable); bare KEY copies the local value")
62
+ p.add_argument("-t", "--timeout", type=float, metavar="SECONDS",
63
+ help="kill the remote command (and its children) after SECONDS")
64
+ p.add_argument("-c", "--config", metavar="FILE", help="client config file")
65
+ p.add_argument("--connect-timeout", type=float, metavar="SECONDS",
66
+ help="max wait for the server to accept the command (default: 10)")
67
+ p.add_argument("--verbose", action="store_true", help="print execution summary to stderr")
68
+ p.add_argument("--list", action="store_true", help="list configured servers and exit")
69
+ p.add_argument("--ping", action="store_true", help="check that SERVER is reachable and show its info")
70
+ p.add_argument("--install-skill", nargs="?", const=str(DEFAULT_SKILL_DIR), metavar="DIR",
71
+ help=f"install the '{SKILL_NAME}' Claude Code skill as DIR/{SKILL_NAME}.md "
72
+ f"(default DIR: {DEFAULT_SKILL_DIR}) and exit")
73
+ p.add_argument("--force", action="store_true", help="with --install-skill: overwrite an existing, modified skill")
74
+ p.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
75
+ return p
76
+
77
+
78
+ def do_install_skill(directory: str, force: bool) -> int:
79
+ try:
80
+ path, status = install_skill(directory, force=force)
81
+ except SkillExistsError as exc:
82
+ err(f"error: {exc}")
83
+ return 1
84
+ except OSError as exc:
85
+ err(f"error: cannot install skill: {exc}")
86
+ return 1
87
+ if status == "unchanged":
88
+ print(f"skill already up to date: {path}")
89
+ else:
90
+ print(f"skill {status}: {path}")
91
+ print(f"Claude Code can now use it automatically, or invoke it with /{SKILL_NAME}")
92
+ return 0
93
+
94
+
95
+ def parse_env(items: list[str]) -> dict[str, str]:
96
+ env = {}
97
+ for item in items:
98
+ key, sep, value = item.partition("=")
99
+ if not key:
100
+ raise ConfigError(f"invalid --env {item!r}, expected KEY=VALUE")
101
+ if not sep:
102
+ if key not in os.environ:
103
+ raise ConfigError(f"--env {key}: not set in the local environment")
104
+ value = os.environ[key]
105
+ env[key] = value
106
+ return env
107
+
108
+
109
+ def err(msg: str) -> None:
110
+ print(f"{PROG}: {msg}", file=sys.stderr, flush=True)
111
+
112
+
113
+ def _write(stream):
114
+ def write(data: bytes) -> None:
115
+ stream.write(data)
116
+ stream.flush()
117
+ return write
118
+
119
+
120
+ def list_servers(config: ClientConfig) -> int:
121
+ if not config.servers:
122
+ where = config.path or default_client_config_path()
123
+ print(f"no servers configured (config: {where})")
124
+ return 0
125
+ width = max(len(n) for n in config.servers)
126
+ for name, ep in config.servers.items():
127
+ line = f"{name:<{width}} {ep.address}"
128
+ if ep.description:
129
+ line += f" {ep.description}"
130
+ print(line)
131
+ return 0
132
+
133
+
134
+ async def _ping(client: RemoteExecClient) -> int:
135
+ info = await client.server_info()
136
+ print(f"{client.endpoint.name}: ok host={info.hostname} user={info.user} version={info.version} shell={info.shell}")
137
+ print(f" allowed programs: {', '.join(info.allowed_tools) or '(none)'}")
138
+ print(f" denied programs: {', '.join(info.denied_tools) or '(none)'}")
139
+ return 0
140
+
141
+
142
+ async def _execute(client: RemoteExecClient, args, command: str, env: dict[str, str]) -> int:
143
+ result = await client.run(
144
+ command, cwd=args.cwd, env=env, timeout=args.timeout,
145
+ on_stdout=_write(sys.stdout.buffer), on_stderr=_write(sys.stderr.buffer),
146
+ )
147
+ name = client.endpoint.name
148
+ if result.status == "timeout":
149
+ err(f"{name}: {result.message or 'command timed out'}; remote process group terminated")
150
+ elif result.status == "error":
151
+ err(f"{name}: {result.message or 'remote execution failed'}")
152
+ elif result.signal:
153
+ err(f"{name}: remote command killed by signal {result.signal}")
154
+ if args.verbose:
155
+ dur = f"{result.duration:.3f}s" if result.duration is not None else "?"
156
+ err(f"{name}: host={result.hostname} status={result.status} exit_code={result.exit_code} "
157
+ f"duration={dur} id={result.execution_id}")
158
+ return result.local_exit_code
159
+
160
+
161
+ async def _main_async(args, endpoint, command: str | None, env: dict[str, str]) -> int:
162
+ task = asyncio.current_task()
163
+ loop = asyncio.get_running_loop()
164
+ # SIGTERM (e.g. from an agent's tool timeout) cancels the RPC cleanly so the
165
+ # server kills the remote command right away.
166
+ loop.add_signal_handler(signal.SIGTERM, task.cancel)
167
+ async with RemoteExecClient(endpoint, connect_timeout=args.connect_timeout) as client:
168
+ if args.ping:
169
+ return await _ping(client)
170
+ return await _execute(client, args, command, env)
171
+
172
+
173
+ def main(argv: list[str] | None = None) -> int:
174
+ parser = build_parser()
175
+ args = parser.parse_intermixed_args(argv)
176
+ if args.install_skill:
177
+ return do_install_skill(args.install_skill, args.force)
178
+
179
+ try:
180
+ config = load_client_config(args.config)
181
+ if args.list:
182
+ return list_servers(config)
183
+ if not args.server:
184
+ parser.error("missing SERVER")
185
+ command = " ".join(args.command)
186
+ if not args.ping and not command.strip():
187
+ parser.error("missing COMMAND")
188
+ if args.timeout is not None and args.timeout <= 0:
189
+ parser.error("--timeout must be > 0")
190
+ env = parse_env(args.env)
191
+ endpoint = config.resolve(args.server)
192
+ except (ConfigError, SecurityError) as exc:
193
+ err(f"error: {exc}")
194
+ return EXIT_CLIENT_ERROR
195
+
196
+ try:
197
+ return asyncio.run(_main_async(args, endpoint, command, env))
198
+ except KeyboardInterrupt:
199
+ err("interrupted; remote command cancelled")
200
+ return EXIT_INTERRUPTED
201
+ except asyncio.CancelledError:
202
+ err("terminated; remote command cancelled")
203
+ return 128 + signal.SIGTERM
204
+ except ServerUnavailable as exc:
205
+ hint = ""
206
+ if args.server not in config.servers and not any(c in args.server for c in ".:"):
207
+ hint = f" ('{args.server}' is not a configured server name; config: {config.path or default_client_config_path()})"
208
+ err(f"error: {exc}{hint}")
209
+ return EXIT_CLIENT_ERROR
210
+ except CommandRejected as exc:
211
+ err(f"error: {exc}")
212
+ return EXIT_REJECTED
213
+ except (RemoteExecError, SecurityError) as exc:
214
+ err(f"error: {exc}")
215
+ return EXIT_CLIENT_ERROR
216
+ except BrokenPipeError:
217
+ # Local stdout closed (e.g. piped into `head`); the RPC was cancelled.
218
+ try:
219
+ sys.stdout = open(os.devnull, "w")
220
+ except OSError:
221
+ pass
222
+ return 141
223
+
224
+
225
+ if __name__ == "__main__":
226
+ sys.exit(main())