From aebcf9d6a43d7611a68a17b3e46a2b46220fac4b Mon Sep 17 00:00:00 2001 From: Chase Naples Date: Mon, 13 Oct 2025 02:19:53 -0400 Subject: [PATCH] feat: add cli_path support to ClaudeAgentOptions (#235) ## Summary Adds support for passing custom Claude Code CLI paths through `ClaudeAgentOptions`, allowing organizations with non-standard installation locations to specify the CLI path explicitly. ## Motivation As noted in #214, organizations may install Claude Code CLI (or wrapped versions) at custom locations and prefer to provide those paths instead of relying on the SDK's default search logic. The transport layer already supported `cli_path`, but it was never exposed through the public API. ## Changes 1. **types.py**: Added `cli_path: str | Path | None = None` parameter to `ClaudeAgentOptions` dataclass 2. **_internal/client.py**: Pass `cli_path` from `configured_options.cli_path` to `SubprocessCLITransport` 3. **client.py**: Pass `cli_path` from `options.cli_path` to `SubprocessCLITransport` ## Implementation Details The `SubprocessCLITransport` constructor already accepted a `cli_path` parameter (line 40 of subprocess_cli.py), but it was never passed from the client layers. This PR completes the wiring by: - Adding the option to the public `ClaudeAgentOptions` interface - Extracting and passing it through both client implementations (`InternalClient.process_query` and `ClaudeSDKClient.connect`) ## Usage Example ```python from claude_agent_sdk import query, ClaudeAgentOptions # Specify custom CLI path options = ClaudeAgentOptions( cli_path="/custom/path/to/claude" ) result = await query("Hello!", options=options) ``` ## Testing - No new tests added as this is a straightforward parameter pass-through - Existing tests should continue to work (default behavior unchanged) - CI will validate the changes don't break existing functionality Fixes #214 --------- Co-authored-by: Ashwin Bhat --- src/claude_agent_sdk/_internal/client.py | 3 +- .../_internal/transport/subprocess_cli.py | 9 ++- src/claude_agent_sdk/types.py | 1 + tests/test_subprocess_buffering.py | 40 +++++----- tests/test_transport.py | 73 ++++++++----------- 5 files changed, 54 insertions(+), 72 deletions(-) diff --git a/src/claude_agent_sdk/_internal/client.py b/src/claude_agent_sdk/_internal/client.py index dbb6d19..6dbc877 100644 --- a/src/claude_agent_sdk/_internal/client.py +++ b/src/claude_agent_sdk/_internal/client.py @@ -71,7 +71,8 @@ class InternalClient: chosen_transport = transport else: chosen_transport = SubprocessCLITransport( - prompt=prompt, options=configured_options + prompt=prompt, + options=configured_options, ) # Connect transport diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index bd9fc59..8cdff4e 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -37,12 +37,13 @@ class SubprocessCLITransport(Transport): self, prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeAgentOptions, - cli_path: str | Path | None = None, ): self._prompt = prompt self._is_streaming = not isinstance(prompt, str) self._options = options - self._cli_path = str(cli_path) if cli_path else self._find_cli() + self._cli_path = ( + str(options.cli_path) if options.cli_path is not None else self._find_cli() + ) self._cwd = str(options.cwd) if options.cwd else None self._process: Process | None = None self._stdout_stream: TextReceiveStream | None = None @@ -79,8 +80,8 @@ class SubprocessCLITransport(Transport): " npm install -g @anthropic-ai/claude-code\n" "\nIf already installed locally, try:\n" ' export PATH="$HOME/node_modules/.bin:$PATH"\n' - "\nOr specify the path when creating transport:\n" - " SubprocessCLITransport(..., cli_path='/path/to/claude')" + "\nOr provide the path via ClaudeAgentOptions:\n" + " ClaudeAgentOptions(cli_path='/path/to/claude')" ) def _build_command(self) -> list[str]: diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 82a57ad..be1cb99 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -512,6 +512,7 @@ class ClaudeAgentOptions: model: str | None = None permission_prompt_tool_name: str | None = None cwd: str | Path | None = None + cli_path: str | Path | None = None settings: str | None = None add_dirs: list[str | Path] = field(default_factory=list) env: dict[str, str] = field(default_factory=dict) diff --git a/tests/test_subprocess_buffering.py b/tests/test_subprocess_buffering.py index 9437e02..0371074 100644 --- a/tests/test_subprocess_buffering.py +++ b/tests/test_subprocess_buffering.py @@ -15,6 +15,15 @@ from claude_agent_sdk._internal.transport.subprocess_cli import ( ) from claude_agent_sdk.types import ClaudeAgentOptions +DEFAULT_CLI_PATH = "/usr/bin/claude" + + +def make_options(**kwargs: object) -> ClaudeAgentOptions: + """Construct ClaudeAgentOptions with a default CLI path for tests.""" + + cli_path = kwargs.pop("cli_path", DEFAULT_CLI_PATH) + return ClaudeAgentOptions(cli_path=cli_path, **kwargs) + class MockTextReceiveStream: """Mock TextReceiveStream for testing.""" @@ -50,9 +59,7 @@ class TestSubprocessBuffering: buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) mock_process = MagicMock() mock_process.returncode = None @@ -85,9 +92,7 @@ class TestSubprocessBuffering: buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) mock_process = MagicMock() mock_process.returncode = None @@ -115,9 +120,7 @@ class TestSubprocessBuffering: buffered_line = json.dumps(json_obj1) + "\n\n\n" + json.dumps(json_obj2) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) mock_process = MagicMock() mock_process.returncode = None @@ -161,9 +164,7 @@ class TestSubprocessBuffering: part2 = complete_json[100:250] part3 = complete_json[250:] - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) mock_process = MagicMock() mock_process.returncode = None @@ -209,9 +210,7 @@ class TestSubprocessBuffering: for i in range(0, len(complete_json), chunk_size) ] - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) mock_process = MagicMock() mock_process.returncode = None @@ -239,9 +238,7 @@ class TestSubprocessBuffering: async def _test() -> None: huge_incomplete = '{"data": "' + "x" * (_DEFAULT_MAX_BUFFER_SIZE + 1000) - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) mock_process = MagicMock() mock_process.returncode = None @@ -269,8 +266,7 @@ class TestSubprocessBuffering: transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(max_buffer_size=custom_limit), - cli_path="/usr/bin/claude", + options=make_options(max_buffer_size=custom_limit), ) mock_process = MagicMock() @@ -309,9 +305,7 @@ class TestSubprocessBuffering: large_json[3000:] + "\n" + msg3, ] - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) mock_process = MagicMock() mock_process.returncode = None diff --git a/tests/test_transport.py b/tests/test_transport.py index 93538f4..f23dcbf 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -10,6 +10,15 @@ import pytest from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport from claude_agent_sdk.types import ClaudeAgentOptions +DEFAULT_CLI_PATH = "/usr/bin/claude" + + +def make_options(**kwargs: object) -> ClaudeAgentOptions: + """Construct options using the standard CLI path unless overridden.""" + + cli_path = kwargs.pop("cli_path", DEFAULT_CLI_PATH) + return ClaudeAgentOptions(cli_path=cli_path, **kwargs) + class TestSubprocessCLITransport: """Test subprocess transport implementation.""" @@ -29,9 +38,7 @@ class TestSubprocessCLITransport: def test_build_command_basic(self): """Test building basic CLI command.""" - transport = SubprocessCLITransport( - prompt="Hello", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="Hello", options=make_options()) cmd = transport._build_command() assert cmd[0] == "/usr/bin/claude" @@ -47,8 +54,7 @@ class TestSubprocessCLITransport: path = Path("/usr/bin/claude") transport = SubprocessCLITransport( prompt="Hello", - options=ClaudeAgentOptions(), - cli_path=path, + options=ClaudeAgentOptions(cli_path=path), ) # Path object is converted to string, compare with str(path) @@ -58,10 +64,9 @@ class TestSubprocessCLITransport: """Test building CLI command with system prompt as string.""" transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions( + options=make_options( system_prompt="Be helpful", ), - cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -72,10 +77,9 @@ class TestSubprocessCLITransport: """Test building CLI command with system prompt preset.""" transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions( + options=make_options( system_prompt={"type": "preset", "preset": "claude_code"}, ), - cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -86,14 +90,13 @@ class TestSubprocessCLITransport: """Test building CLI command with system prompt preset and append.""" transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions( + options=make_options( system_prompt={ "type": "preset", "preset": "claude_code", "append": "Be concise.", }, ), - cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -105,14 +108,13 @@ class TestSubprocessCLITransport: """Test building CLI command with options.""" transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions( + options=make_options( allowed_tools=["Read", "Write"], disallowed_tools=["Bash"], model="claude-sonnet-4-5", permission_mode="acceptEdits", max_turns=5, ), - cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -135,8 +137,7 @@ class TestSubprocessCLITransport: dir2 = Path("/path/to/dir2") transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(add_dirs=[dir1, dir2]), - cli_path="/usr/bin/claude", + options=make_options(add_dirs=[dir1, dir2]), ) cmd = transport._build_command() @@ -155,10 +156,7 @@ class TestSubprocessCLITransport: """Test session continuation options.""" transport = SubprocessCLITransport( prompt="Continue from before", - options=ClaudeAgentOptions( - continue_conversation=True, resume="session-123" - ), - cli_path="/usr/bin/claude", + options=make_options(continue_conversation=True, resume="session-123"), ) cmd = transport._build_command() @@ -198,8 +196,7 @@ class TestSubprocessCLITransport: transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(), - cli_path="/usr/bin/claude", + options=make_options(), ) await transport.connect() @@ -215,9 +212,7 @@ class TestSubprocessCLITransport: """Test reading messages from CLI output.""" # This test is simplified to just test the transport creation # The full async stream handling is tested in integration tests - transport = SubprocessCLITransport( - prompt="test", options=ClaudeAgentOptions(), cli_path="/usr/bin/claude" - ) + transport = SubprocessCLITransport(prompt="test", options=make_options()) # The transport now just provides raw message reading via read_messages() # So we just verify the transport can be created and basic structure is correct @@ -231,8 +226,7 @@ class TestSubprocessCLITransport: async def _test(): transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(cwd="/this/directory/does/not/exist"), - cli_path="/usr/bin/claude", + options=make_options(cwd="/this/directory/does/not/exist"), ) with pytest.raises(CLIConnectionError) as exc_info: @@ -246,8 +240,7 @@ class TestSubprocessCLITransport: """Test building CLI command with settings as file path.""" transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(settings="/path/to/settings.json"), - cli_path="/usr/bin/claude", + options=make_options(settings="/path/to/settings.json"), ) cmd = transport._build_command() @@ -259,8 +252,7 @@ class TestSubprocessCLITransport: settings_json = '{"permissions": {"allow": ["Bash(ls:*)"]}}' transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(settings=settings_json), - cli_path="/usr/bin/claude", + options=make_options(settings=settings_json), ) cmd = transport._build_command() @@ -271,14 +263,13 @@ class TestSubprocessCLITransport: """Test building CLI command with extra_args for future flags.""" transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions( + options=make_options( extra_args={ "new-flag": "value", "boolean-flag": None, "another-option": "test-value", } ), - cli_path="/usr/bin/claude", ) cmd = transport._build_command() @@ -309,8 +300,7 @@ class TestSubprocessCLITransport: transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(mcp_servers=mcp_servers), - cli_path="/usr/bin/claude", + options=make_options(mcp_servers=mcp_servers), ) cmd = transport._build_command() @@ -333,8 +323,7 @@ class TestSubprocessCLITransport: string_path = "/path/to/mcp-config.json" transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(mcp_servers=string_path), - cli_path="/usr/bin/claude", + options=make_options(mcp_servers=string_path), ) cmd = transport._build_command() @@ -346,8 +335,7 @@ class TestSubprocessCLITransport: path_obj = Path("/path/to/mcp-config.json") transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(mcp_servers=path_obj), - cli_path="/usr/bin/claude", + options=make_options(mcp_servers=path_obj), ) cmd = transport._build_command() @@ -361,8 +349,7 @@ class TestSubprocessCLITransport: json_config = '{"mcpServers": {"server": {"type": "stdio", "command": "test"}}}' transport = SubprocessCLITransport( prompt="test", - options=ClaudeAgentOptions(mcp_servers=json_config), - cli_path="/usr/bin/claude", + options=make_options(mcp_servers=json_config), ) cmd = transport._build_command() @@ -379,7 +366,7 @@ class TestSubprocessCLITransport: "MY_TEST_VAR": test_value, } - options = ClaudeAgentOptions(env=custom_env) + options = make_options(env=custom_env) # Mock the subprocess to capture the env argument with patch( @@ -408,7 +395,6 @@ class TestSubprocessCLITransport: transport = SubprocessCLITransport( prompt="test", options=options, - cli_path="/usr/bin/claude", ) await transport.connect() @@ -440,7 +426,7 @@ class TestSubprocessCLITransport: async def _test(): custom_user = "claude" - options = ClaudeAgentOptions(user=custom_user) + options = make_options(user=custom_user) # Mock the subprocess to capture the env argument with patch( @@ -469,7 +455,6 @@ class TestSubprocessCLITransport: transport = SubprocessCLITransport( prompt="test", options=options, - cli_path="/usr/bin/claude", ) await transport.connect()