feat: add cli_path support to ClaudeAgentOptions (#235)
Some checks failed
Lint / lint (push) Has been cancelled
Test / test (macos-latest, 3.10) (push) Has been cancelled
Test / test (macos-latest, 3.11) (push) Has been cancelled
Test / test (macos-latest, 3.12) (push) Has been cancelled
Test / test (macos-latest, 3.13) (push) Has been cancelled
Test / test (ubuntu-latest, 3.10) (push) Has been cancelled
Test / test (ubuntu-latest, 3.11) (push) Has been cancelled
Test / test (ubuntu-latest, 3.12) (push) Has been cancelled
Test / test (ubuntu-latest, 3.13) (push) Has been cancelled
Test / test (windows-latest, 3.10) (push) Has been cancelled
Test / test (windows-latest, 3.11) (push) Has been cancelled
Test / test (windows-latest, 3.12) (push) Has been cancelled
Test / test (windows-latest, 3.13) (push) Has been cancelled
Test / test-examples (3.10) (push) Has been cancelled
Test / test-e2e (macos-latest, 3.10) (push) Has been cancelled
Test / test-e2e (macos-latest, 3.11) (push) Has been cancelled
Test / test-e2e (macos-latest, 3.12) (push) Has been cancelled
Test / test-e2e (macos-latest, 3.13) (push) Has been cancelled
Test / test-e2e (ubuntu-latest, 3.10) (push) Has been cancelled
Test / test-e2e (ubuntu-latest, 3.11) (push) Has been cancelled
Test / test-e2e (ubuntu-latest, 3.12) (push) Has been cancelled
Test / test-e2e (ubuntu-latest, 3.13) (push) Has been cancelled
Test / test-e2e (windows-latest, 3.10) (push) Has been cancelled
Test / test-e2e (windows-latest, 3.12) (push) Has been cancelled
Test / test-e2e (windows-latest, 3.13) (push) Has been cancelled
Test / test-examples (3.11) (push) Has been cancelled
Test / test-examples (3.12) (push) Has been cancelled
Test / test-examples (3.13) (push) Has been cancelled
Test / test-e2e (windows-latest, 3.11) (push) Has been cancelled

## 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 <ashwin@anthropic.com>
This commit is contained in:
Chase Naples 2025-10-13 02:19:53 -04:00 committed by GitHub
parent 20c1b89734
commit aebcf9d6a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 54 additions and 72 deletions

View file

@ -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

View file

@ -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]:

View file

@ -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)

View file

@ -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

View file

@ -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()