Skip to content

Commit 180d648

Browse files
dicksontsaiclaude
andauthored
feat: add stderr callback to capture CLI debug output (#170)
## Summary - Add stderr callback option to ClaudeCodeOptions to capture CLI subprocess stderr output - Matches TypeScript SDK's stderr callback behavior for feature parity - Useful for debugging and monitoring CLI operations ## Changes - Added `stderr: Callable[[str], None] | None` field to `ClaudeCodeOptions` - Updated `SubprocessCLITransport` to handle stderr streaming with async task - Added example demonstrating stderr callback usage - Added e2e tests to verify functionality ## Test plan - [x] Run e2e tests: `python -m pytest e2e-tests/test_stderr_callback.py -v` - [x] Run example: `python examples/stderr_callback_example.py` - [x] Verify backward compatibility with existing `debug_stderr` field - [x] All linting and type checks pass 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <[email protected]>
1 parent 62289d2 commit 180d648

File tree

4 files changed

+154
-7
lines changed

4 files changed

+154
-7
lines changed

e2e-tests/test_stderr_callback.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""End-to-end test for stderr callback functionality."""
2+
3+
import pytest
4+
5+
from claude_code_sdk import ClaudeCodeOptions, query
6+
7+
8+
@pytest.mark.e2e
9+
@pytest.mark.asyncio
10+
async def test_stderr_callback_captures_debug_output():
11+
"""Test that stderr callback receives debug output when enabled."""
12+
stderr_lines = []
13+
14+
def capture_stderr(line: str):
15+
stderr_lines.append(line)
16+
17+
# Enable debug mode to generate stderr output
18+
options = ClaudeCodeOptions(
19+
stderr=capture_stderr,
20+
extra_args={"debug-to-stderr": None}
21+
)
22+
23+
# Run a simple query
24+
async for _ in query(prompt="What is 1+1?", options=options):
25+
pass # Just consume messages
26+
27+
# Verify we captured debug output
28+
assert len(stderr_lines) > 0, "Should capture stderr output with debug enabled"
29+
assert any("[DEBUG]" in line for line in stderr_lines), "Should contain DEBUG messages"
30+
31+
32+
@pytest.mark.e2e
33+
@pytest.mark.asyncio
34+
async def test_stderr_callback_without_debug():
35+
"""Test that stderr callback works but receives no output without debug mode."""
36+
stderr_lines = []
37+
38+
def capture_stderr(line: str):
39+
stderr_lines.append(line)
40+
41+
# No debug mode enabled
42+
options = ClaudeCodeOptions(stderr=capture_stderr)
43+
44+
# Run a simple query
45+
async for _ in query(prompt="What is 1+1?", options=options):
46+
pass # Just consume messages
47+
48+
# Should work but capture minimal/no output without debug
49+
assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Simple example demonstrating stderr callback for capturing CLI debug output."""
2+
3+
import asyncio
4+
5+
from claude_code_sdk import ClaudeCodeOptions, query
6+
7+
8+
async def main():
9+
"""Capture stderr output from the CLI using a callback."""
10+
11+
# Collect stderr messages
12+
stderr_messages = []
13+
14+
def stderr_callback(message: str):
15+
"""Callback that receives each line of stderr output."""
16+
stderr_messages.append(message)
17+
# Optionally print specific messages
18+
if "[ERROR]" in message:
19+
print(f"Error detected: {message}")
20+
21+
# Create options with stderr callback and enable debug mode
22+
options = ClaudeCodeOptions(
23+
stderr=stderr_callback,
24+
extra_args={"debug-to-stderr": None} # Enable debug output
25+
)
26+
27+
# Run a query
28+
print("Running query with stderr capture...")
29+
async for message in query(
30+
prompt="What is 2+2?",
31+
options=options
32+
):
33+
if hasattr(message, 'content'):
34+
if isinstance(message.content, str):
35+
print(f"Response: {message.content}")
36+
37+
# Show what we captured
38+
print(f"\nCaptured {len(stderr_messages)} stderr lines")
39+
if stderr_messages:
40+
print("First stderr line:", stderr_messages[0][:100])
41+
42+
43+
if __name__ == "__main__":
44+
asyncio.run(main())

src/claude_code_sdk/_internal/transport/subprocess_cli.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Any
1313

1414
import anyio
15+
import anyio.abc
1516
from anyio.abc import Process
1617
from anyio.streams.text import TextReceiveStream, TextSendStream
1718

@@ -43,6 +44,8 @@ def __init__(
4344
self._process: Process | None = None
4445
self._stdout_stream: TextReceiveStream | None = None
4546
self._stdin_stream: TextSendStream | None = None
47+
self._stderr_stream: TextReceiveStream | None = None
48+
self._stderr_task_group: anyio.abc.TaskGroup | None = None
4649
self._ready = False
4750
self._exit_error: Exception | None = None # Track process exit errors
4851

@@ -216,14 +219,15 @@ async def connect(self) -> None:
216219
if self._cwd:
217220
process_env["PWD"] = self._cwd
218221

219-
# Only output stderr if customer explicitly requested debug output and provided a file object
220-
stderr_dest = (
221-
self._options.debug_stderr
222-
if "debug-to-stderr" in self._options.extra_args
223-
and self._options.debug_stderr
224-
else None
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
225226
)
226227

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+
227231
self._process = await anyio.open_process(
228232
cmd,
229233
stdin=PIPE,
@@ -237,6 +241,14 @@ async def connect(self) -> None:
237241
if self._process.stdout:
238242
self._stdout_stream = TextReceiveStream(self._process.stdout)
239243

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+
240252
# Setup stdin for streaming mode
241253
if self._is_streaming and self._process.stdin:
242254
self._stdin_stream = TextSendStream(self._process.stdin)
@@ -262,19 +274,59 @@ async def connect(self) -> None:
262274
self._exit_error = error
263275
raise error from e
264276

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+
265305
async def close(self) -> None:
266306
"""Close the transport and clean up resources."""
267307
self._ready = False
268308

269309
if not self._process:
270310
return
271311

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+
272319
# Close streams
273320
if self._stdin_stream:
274321
with suppress(Exception):
275322
await self._stdin_stream.aclose()
276323
self._stdin_stream = None
277324

325+
if self._stderr_stream:
326+
with suppress(Exception):
327+
await self._stderr_stream.aclose()
328+
self._stderr_stream = None
329+
278330
if self._process.stdin:
279331
with suppress(Exception):
280332
await self._process.stdin.aclose()
@@ -291,6 +343,7 @@ async def close(self) -> None:
291343
self._process = None
292344
self._stdout_stream = None
293345
self._stdin_stream = None
346+
self._stderr_stream = None
294347
self._exit_error = None
295348

296349
async def write(self, data: str) -> None:

src/claude_code_sdk/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ class ClaudeAgentOptions:
322322
) # Pass arbitrary CLI flags
323323
debug_stderr: Any = (
324324
sys.stderr
325-
) # File-like object for debug output when debug-to-stderr is set
325+
) # Deprecated: File-like object for debug output. Use stderr callback instead.
326+
stderr: Callable[[str], None] | None = None # Callback for stderr output from CLI
326327

327328
# Tool permission callback
328329
can_use_tool: CanUseTool | None = None

0 commit comments

Comments
 (0)