claude-agent-sdk 0.0.23__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.
Potentially problematic release.
This version of claude-agent-sdk might be problematic. Click here for more details.
- claude_agent_sdk/__init__.py +325 -0
- claude_agent_sdk/_errors.py +56 -0
- claude_agent_sdk/_internal/__init__.py +1 -0
- claude_agent_sdk/_internal/client.py +121 -0
- claude_agent_sdk/_internal/message_parser.py +172 -0
- claude_agent_sdk/_internal/query.py +523 -0
- claude_agent_sdk/_internal/transport/__init__.py +68 -0
- claude_agent_sdk/_internal/transport/subprocess_cli.py +456 -0
- claude_agent_sdk/_version.py +3 -0
- claude_agent_sdk/client.py +325 -0
- claude_agent_sdk/py.typed +0 -0
- claude_agent_sdk/query.py +126 -0
- claude_agent_sdk/types.py +412 -0
- claude_agent_sdk-0.0.23.dist-info/METADATA +309 -0
- claude_agent_sdk-0.0.23.dist-info/RECORD +17 -0
- claude_agent_sdk-0.0.23.dist-info/WHEEL +4 -0
- claude_agent_sdk-0.0.23.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Subprocess transport implementation using Claude Code CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from collections.abc import AsyncIterable, AsyncIterator
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from subprocess import PIPE
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
import anyio.abc
|
|
16
|
+
from anyio.abc import Process
|
|
17
|
+
from anyio.streams.text import TextReceiveStream, TextSendStream
|
|
18
|
+
|
|
19
|
+
from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError
|
|
20
|
+
from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError
|
|
21
|
+
from ..._version import __version__
|
|
22
|
+
from ...types import ClaudeAgentOptions
|
|
23
|
+
from . import Transport
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SubprocessCLITransport(Transport):
|
|
31
|
+
"""Subprocess transport using Claude Code CLI."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
prompt: str | AsyncIterable[dict[str, Any]],
|
|
36
|
+
options: ClaudeAgentOptions,
|
|
37
|
+
cli_path: str | Path | None = None,
|
|
38
|
+
):
|
|
39
|
+
self._prompt = prompt
|
|
40
|
+
self._is_streaming = not isinstance(prompt, str)
|
|
41
|
+
self._options = options
|
|
42
|
+
self._cli_path = str(cli_path) if cli_path else self._find_cli()
|
|
43
|
+
self._cwd = str(options.cwd) if options.cwd else None
|
|
44
|
+
self._process: Process | None = None
|
|
45
|
+
self._stdout_stream: TextReceiveStream | None = None
|
|
46
|
+
self._stdin_stream: TextSendStream | None = None
|
|
47
|
+
self._stderr_stream: TextReceiveStream | None = None
|
|
48
|
+
self._stderr_task_group: anyio.abc.TaskGroup | None = None
|
|
49
|
+
self._ready = False
|
|
50
|
+
self._exit_error: Exception | None = None # Track process exit errors
|
|
51
|
+
|
|
52
|
+
def _find_cli(self) -> str:
|
|
53
|
+
"""Find Claude Code CLI binary."""
|
|
54
|
+
if cli := shutil.which("claude"):
|
|
55
|
+
return cli
|
|
56
|
+
|
|
57
|
+
locations = [
|
|
58
|
+
Path.home() / ".npm-global/bin/claude",
|
|
59
|
+
Path("/usr/local/bin/claude"),
|
|
60
|
+
Path.home() / ".local/bin/claude",
|
|
61
|
+
Path.home() / "node_modules/.bin/claude",
|
|
62
|
+
Path.home() / ".yarn/bin/claude",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
for path in locations:
|
|
66
|
+
if path.exists() and path.is_file():
|
|
67
|
+
return str(path)
|
|
68
|
+
|
|
69
|
+
node_installed = shutil.which("node") is not None
|
|
70
|
+
|
|
71
|
+
if not node_installed:
|
|
72
|
+
error_msg = "Claude Code requires Node.js, which is not installed.\n\n"
|
|
73
|
+
error_msg += "Install Node.js from: https://nodejs.org/\n"
|
|
74
|
+
error_msg += "\nAfter installing Node.js, install Claude Code:\n"
|
|
75
|
+
error_msg += " npm install -g @anthropic-ai/claude-code"
|
|
76
|
+
raise CLINotFoundError(error_msg)
|
|
77
|
+
|
|
78
|
+
raise CLINotFoundError(
|
|
79
|
+
"Claude Code not found. Install with:\n"
|
|
80
|
+
" npm install -g @anthropic-ai/claude-code\n"
|
|
81
|
+
"\nIf already installed locally, try:\n"
|
|
82
|
+
' export PATH="$HOME/node_modules/.bin:$PATH"\n'
|
|
83
|
+
"\nOr specify the path when creating transport:\n"
|
|
84
|
+
" SubprocessCLITransport(..., cli_path='/path/to/claude')"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _build_command(self) -> list[str]:
|
|
88
|
+
"""Build CLI command with arguments."""
|
|
89
|
+
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
|
|
90
|
+
|
|
91
|
+
if self._options.system_prompt is None:
|
|
92
|
+
pass
|
|
93
|
+
elif isinstance(self._options.system_prompt, str):
|
|
94
|
+
cmd.extend(["--system-prompt", self._options.system_prompt])
|
|
95
|
+
else:
|
|
96
|
+
if (
|
|
97
|
+
self._options.system_prompt.get("type") == "preset"
|
|
98
|
+
and "append" in self._options.system_prompt
|
|
99
|
+
):
|
|
100
|
+
cmd.extend(
|
|
101
|
+
["--append-system-prompt", self._options.system_prompt["append"]]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if self._options.allowed_tools:
|
|
105
|
+
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
|
|
106
|
+
|
|
107
|
+
if self._options.max_turns:
|
|
108
|
+
cmd.extend(["--max-turns", str(self._options.max_turns)])
|
|
109
|
+
|
|
110
|
+
if self._options.disallowed_tools:
|
|
111
|
+
cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])
|
|
112
|
+
|
|
113
|
+
if self._options.model:
|
|
114
|
+
cmd.extend(["--model", self._options.model])
|
|
115
|
+
|
|
116
|
+
if self._options.permission_prompt_tool_name:
|
|
117
|
+
cmd.extend(
|
|
118
|
+
["--permission-prompt-tool", self._options.permission_prompt_tool_name]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if self._options.permission_mode:
|
|
122
|
+
cmd.extend(["--permission-mode", self._options.permission_mode])
|
|
123
|
+
|
|
124
|
+
if self._options.continue_conversation:
|
|
125
|
+
cmd.append("--continue")
|
|
126
|
+
|
|
127
|
+
if self._options.resume:
|
|
128
|
+
cmd.extend(["--resume", self._options.resume])
|
|
129
|
+
|
|
130
|
+
if self._options.settings:
|
|
131
|
+
cmd.extend(["--settings", self._options.settings])
|
|
132
|
+
|
|
133
|
+
if self._options.add_dirs:
|
|
134
|
+
# Convert all paths to strings and add each directory
|
|
135
|
+
for directory in self._options.add_dirs:
|
|
136
|
+
cmd.extend(["--add-dir", str(directory)])
|
|
137
|
+
|
|
138
|
+
if self._options.mcp_servers:
|
|
139
|
+
if isinstance(self._options.mcp_servers, dict):
|
|
140
|
+
# Process all servers, stripping instance field from SDK servers
|
|
141
|
+
servers_for_cli: dict[str, Any] = {}
|
|
142
|
+
for name, config in self._options.mcp_servers.items():
|
|
143
|
+
if isinstance(config, dict) and config.get("type") == "sdk":
|
|
144
|
+
# For SDK servers, pass everything except the instance field
|
|
145
|
+
sdk_config: dict[str, object] = {
|
|
146
|
+
k: v for k, v in config.items() if k != "instance"
|
|
147
|
+
}
|
|
148
|
+
servers_for_cli[name] = sdk_config
|
|
149
|
+
else:
|
|
150
|
+
# For external servers, pass as-is
|
|
151
|
+
servers_for_cli[name] = config
|
|
152
|
+
|
|
153
|
+
# Pass all servers to CLI
|
|
154
|
+
if servers_for_cli:
|
|
155
|
+
cmd.extend(
|
|
156
|
+
[
|
|
157
|
+
"--mcp-config",
|
|
158
|
+
json.dumps({"mcpServers": servers_for_cli}),
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
# String or Path format: pass directly as file path or JSON string
|
|
163
|
+
cmd.extend(["--mcp-config", str(self._options.mcp_servers)])
|
|
164
|
+
|
|
165
|
+
if self._options.include_partial_messages:
|
|
166
|
+
cmd.append("--include-partial-messages")
|
|
167
|
+
|
|
168
|
+
if self._options.fork_session:
|
|
169
|
+
cmd.append("--fork-session")
|
|
170
|
+
|
|
171
|
+
if self._options.agents:
|
|
172
|
+
agents_dict = {
|
|
173
|
+
name: {k: v for k, v in asdict(agent_def).items() if v is not None}
|
|
174
|
+
for name, agent_def in self._options.agents.items()
|
|
175
|
+
}
|
|
176
|
+
cmd.extend(["--agents", json.dumps(agents_dict)])
|
|
177
|
+
|
|
178
|
+
sources_value = (
|
|
179
|
+
",".join(self._options.setting_sources)
|
|
180
|
+
if self._options.setting_sources is not None
|
|
181
|
+
else ""
|
|
182
|
+
)
|
|
183
|
+
cmd.extend(["--setting-sources", sources_value])
|
|
184
|
+
|
|
185
|
+
# Add extra args for future CLI flags
|
|
186
|
+
for flag, value in self._options.extra_args.items():
|
|
187
|
+
if value is None:
|
|
188
|
+
# Boolean flag without value
|
|
189
|
+
cmd.append(f"--{flag}")
|
|
190
|
+
else:
|
|
191
|
+
# Flag with value
|
|
192
|
+
cmd.extend([f"--{flag}", str(value)])
|
|
193
|
+
|
|
194
|
+
# Add prompt handling based on mode
|
|
195
|
+
if self._is_streaming:
|
|
196
|
+
# Streaming mode: use --input-format stream-json
|
|
197
|
+
cmd.extend(["--input-format", "stream-json"])
|
|
198
|
+
else:
|
|
199
|
+
# String mode: use --print with the prompt
|
|
200
|
+
cmd.extend(["--print", "--", str(self._prompt)])
|
|
201
|
+
|
|
202
|
+
return cmd
|
|
203
|
+
|
|
204
|
+
async def connect(self) -> None:
|
|
205
|
+
"""Start subprocess."""
|
|
206
|
+
if self._process:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
cmd = self._build_command()
|
|
210
|
+
try:
|
|
211
|
+
# Merge environment variables: system -> user -> SDK required
|
|
212
|
+
process_env = {
|
|
213
|
+
**os.environ,
|
|
214
|
+
**self._options.env, # User-provided env vars
|
|
215
|
+
"CLAUDE_CODE_ENTRYPOINT": "sdk-py",
|
|
216
|
+
"CLAUDE_AGENT_SDK_VERSION": __version__,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if self._cwd:
|
|
220
|
+
process_env["PWD"] = self._cwd
|
|
221
|
+
|
|
222
|
+
# Pipe stderr if we have a callback OR debug mode is enabled
|
|
223
|
+
should_pipe_stderr = (
|
|
224
|
+
self._options.stderr is not None
|
|
225
|
+
or "debug-to-stderr" in self._options.extra_args
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# For backward compat: use debug_stderr file object if no callback and debug is on
|
|
229
|
+
stderr_dest = PIPE if should_pipe_stderr else None
|
|
230
|
+
|
|
231
|
+
self._process = await anyio.open_process(
|
|
232
|
+
cmd,
|
|
233
|
+
stdin=PIPE,
|
|
234
|
+
stdout=PIPE,
|
|
235
|
+
stderr=stderr_dest,
|
|
236
|
+
cwd=self._cwd,
|
|
237
|
+
env=process_env,
|
|
238
|
+
user=self._options.user,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if self._process.stdout:
|
|
242
|
+
self._stdout_stream = TextReceiveStream(self._process.stdout)
|
|
243
|
+
|
|
244
|
+
# Setup stderr stream if piped
|
|
245
|
+
if should_pipe_stderr and self._process.stderr:
|
|
246
|
+
self._stderr_stream = TextReceiveStream(self._process.stderr)
|
|
247
|
+
# Start async task to read stderr
|
|
248
|
+
self._stderr_task_group = anyio.create_task_group()
|
|
249
|
+
await self._stderr_task_group.__aenter__()
|
|
250
|
+
self._stderr_task_group.start_soon(self._handle_stderr)
|
|
251
|
+
|
|
252
|
+
# Setup stdin for streaming mode
|
|
253
|
+
if self._is_streaming and self._process.stdin:
|
|
254
|
+
self._stdin_stream = TextSendStream(self._process.stdin)
|
|
255
|
+
elif not self._is_streaming and self._process.stdin:
|
|
256
|
+
# String mode: close stdin immediately
|
|
257
|
+
await self._process.stdin.aclose()
|
|
258
|
+
|
|
259
|
+
self._ready = True
|
|
260
|
+
|
|
261
|
+
except FileNotFoundError as e:
|
|
262
|
+
# Check if the error comes from the working directory or the CLI
|
|
263
|
+
if self._cwd and not Path(self._cwd).exists():
|
|
264
|
+
error = CLIConnectionError(
|
|
265
|
+
f"Working directory does not exist: {self._cwd}"
|
|
266
|
+
)
|
|
267
|
+
self._exit_error = error
|
|
268
|
+
raise error from e
|
|
269
|
+
error = CLINotFoundError(f"Claude Code not found at: {self._cli_path}")
|
|
270
|
+
self._exit_error = error
|
|
271
|
+
raise error from e
|
|
272
|
+
except Exception as e:
|
|
273
|
+
error = CLIConnectionError(f"Failed to start Claude Code: {e}")
|
|
274
|
+
self._exit_error = error
|
|
275
|
+
raise error from e
|
|
276
|
+
|
|
277
|
+
async def _handle_stderr(self) -> None:
|
|
278
|
+
"""Handle stderr stream - read and invoke callbacks."""
|
|
279
|
+
if not self._stderr_stream:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
async for line in self._stderr_stream:
|
|
284
|
+
line_str = line.rstrip()
|
|
285
|
+
if not line_str:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Call the stderr callback if provided
|
|
289
|
+
if self._options.stderr:
|
|
290
|
+
self._options.stderr(line_str)
|
|
291
|
+
|
|
292
|
+
# For backward compatibility: write to debug_stderr if in debug mode
|
|
293
|
+
elif (
|
|
294
|
+
"debug-to-stderr" in self._options.extra_args
|
|
295
|
+
and self._options.debug_stderr
|
|
296
|
+
):
|
|
297
|
+
self._options.debug_stderr.write(line_str + "\n")
|
|
298
|
+
if hasattr(self._options.debug_stderr, "flush"):
|
|
299
|
+
self._options.debug_stderr.flush()
|
|
300
|
+
except anyio.ClosedResourceError:
|
|
301
|
+
pass # Stream closed, exit normally
|
|
302
|
+
except Exception:
|
|
303
|
+
pass # Ignore other errors during stderr reading
|
|
304
|
+
|
|
305
|
+
async def close(self) -> None:
|
|
306
|
+
"""Close the transport and clean up resources."""
|
|
307
|
+
self._ready = False
|
|
308
|
+
|
|
309
|
+
if not self._process:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
# Close stderr task group if active
|
|
313
|
+
if self._stderr_task_group:
|
|
314
|
+
with suppress(Exception):
|
|
315
|
+
self._stderr_task_group.cancel_scope.cancel()
|
|
316
|
+
await self._stderr_task_group.__aexit__(None, None, None)
|
|
317
|
+
self._stderr_task_group = None
|
|
318
|
+
|
|
319
|
+
# Close streams
|
|
320
|
+
if self._stdin_stream:
|
|
321
|
+
with suppress(Exception):
|
|
322
|
+
await self._stdin_stream.aclose()
|
|
323
|
+
self._stdin_stream = None
|
|
324
|
+
|
|
325
|
+
if self._stderr_stream:
|
|
326
|
+
with suppress(Exception):
|
|
327
|
+
await self._stderr_stream.aclose()
|
|
328
|
+
self._stderr_stream = None
|
|
329
|
+
|
|
330
|
+
if self._process.stdin:
|
|
331
|
+
with suppress(Exception):
|
|
332
|
+
await self._process.stdin.aclose()
|
|
333
|
+
|
|
334
|
+
# Terminate and wait for process
|
|
335
|
+
if self._process.returncode is None:
|
|
336
|
+
with suppress(ProcessLookupError):
|
|
337
|
+
self._process.terminate()
|
|
338
|
+
# Wait for process to finish with timeout
|
|
339
|
+
with suppress(Exception):
|
|
340
|
+
# Just try to wait, but don't block if it fails
|
|
341
|
+
await self._process.wait()
|
|
342
|
+
|
|
343
|
+
self._process = None
|
|
344
|
+
self._stdout_stream = None
|
|
345
|
+
self._stdin_stream = None
|
|
346
|
+
self._stderr_stream = None
|
|
347
|
+
self._exit_error = None
|
|
348
|
+
|
|
349
|
+
async def write(self, data: str) -> None:
|
|
350
|
+
"""Write raw data to the transport."""
|
|
351
|
+
# Check if ready (like TypeScript)
|
|
352
|
+
if not self._ready or not self._stdin_stream:
|
|
353
|
+
raise CLIConnectionError("ProcessTransport is not ready for writing")
|
|
354
|
+
|
|
355
|
+
# Check if process is still alive (like TypeScript)
|
|
356
|
+
if self._process and self._process.returncode is not None:
|
|
357
|
+
raise CLIConnectionError(
|
|
358
|
+
f"Cannot write to terminated process (exit code: {self._process.returncode})"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Check for exit errors (like TypeScript)
|
|
362
|
+
if self._exit_error:
|
|
363
|
+
raise CLIConnectionError(
|
|
364
|
+
f"Cannot write to process that exited with error: {self._exit_error}"
|
|
365
|
+
) from self._exit_error
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
await self._stdin_stream.send(data)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
self._ready = False # Mark as not ready (like TypeScript)
|
|
371
|
+
self._exit_error = CLIConnectionError(
|
|
372
|
+
f"Failed to write to process stdin: {e}"
|
|
373
|
+
)
|
|
374
|
+
raise self._exit_error from e
|
|
375
|
+
|
|
376
|
+
async def end_input(self) -> None:
|
|
377
|
+
"""End the input stream (close stdin)."""
|
|
378
|
+
if self._stdin_stream:
|
|
379
|
+
with suppress(Exception):
|
|
380
|
+
await self._stdin_stream.aclose()
|
|
381
|
+
self._stdin_stream = None
|
|
382
|
+
|
|
383
|
+
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
|
384
|
+
"""Read and parse messages from the transport."""
|
|
385
|
+
return self._read_messages_impl()
|
|
386
|
+
|
|
387
|
+
async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]:
|
|
388
|
+
"""Internal implementation of read_messages."""
|
|
389
|
+
if not self._process or not self._stdout_stream:
|
|
390
|
+
raise CLIConnectionError("Not connected")
|
|
391
|
+
|
|
392
|
+
json_buffer = ""
|
|
393
|
+
|
|
394
|
+
# Process stdout messages
|
|
395
|
+
try:
|
|
396
|
+
async for line in self._stdout_stream:
|
|
397
|
+
line_str = line.strip()
|
|
398
|
+
if not line_str:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
# Accumulate partial JSON until we can parse it
|
|
402
|
+
# Note: TextReceiveStream can truncate long lines, so we need to buffer
|
|
403
|
+
# and speculatively parse until we get a complete JSON object
|
|
404
|
+
json_lines = line_str.split("\n")
|
|
405
|
+
|
|
406
|
+
for json_line in json_lines:
|
|
407
|
+
json_line = json_line.strip()
|
|
408
|
+
if not json_line:
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
# Keep accumulating partial JSON until we can parse it
|
|
412
|
+
json_buffer += json_line
|
|
413
|
+
|
|
414
|
+
if len(json_buffer) > _MAX_BUFFER_SIZE:
|
|
415
|
+
json_buffer = ""
|
|
416
|
+
raise SDKJSONDecodeError(
|
|
417
|
+
f"JSON message exceeded maximum buffer size of {_MAX_BUFFER_SIZE} bytes",
|
|
418
|
+
ValueError(
|
|
419
|
+
f"Buffer size {len(json_buffer)} exceeds limit {_MAX_BUFFER_SIZE}"
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
data = json.loads(json_buffer)
|
|
425
|
+
json_buffer = ""
|
|
426
|
+
yield data
|
|
427
|
+
except json.JSONDecodeError:
|
|
428
|
+
# We are speculatively decoding the buffer until we get
|
|
429
|
+
# a full JSON object. If there is an actual issue, we
|
|
430
|
+
# raise an error after _MAX_BUFFER_SIZE.
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
except anyio.ClosedResourceError:
|
|
434
|
+
pass
|
|
435
|
+
except GeneratorExit:
|
|
436
|
+
# Client disconnected
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
# Check process completion and handle errors
|
|
440
|
+
try:
|
|
441
|
+
returncode = await self._process.wait()
|
|
442
|
+
except Exception:
|
|
443
|
+
returncode = -1
|
|
444
|
+
|
|
445
|
+
# Use exit code for error detection
|
|
446
|
+
if returncode is not None and returncode != 0:
|
|
447
|
+
self._exit_error = ProcessError(
|
|
448
|
+
f"Command failed with exit code {returncode}",
|
|
449
|
+
exit_code=returncode,
|
|
450
|
+
stderr="Check stderr output for details",
|
|
451
|
+
)
|
|
452
|
+
raise self._exit_error
|
|
453
|
+
|
|
454
|
+
def is_ready(self) -> bool:
|
|
455
|
+
"""Check if transport is ready for communication."""
|
|
456
|
+
return self._ready
|