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.

@@ -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
@@ -0,0 +1,3 @@
1
+ """Version information for claude-agent-sdk."""
2
+
3
+ __version__ = "0.0.23"