Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"

# Valid values for the ``effort`` option. Validated in ``_build_command`` so
# callers receive a clear ValueError before a subprocess is ever spawned.
_VALID_EFFORT_VALUES: frozenset[str] = frozenset({"low", "medium", "high", "xhigh", "max"})

# Track live CLI subprocesses so we can terminate them when the parent Python
# process exits. This mirrors the TypeScript SDK's parent-exit cleanup and
# prevents orphaned `claude` processes from leaking when callers crash or exit
Expand Down Expand Up @@ -241,10 +245,15 @@ def _build_command(self) -> list[str]:
if self._options.tools is not None:
tools = self._options.tools
if isinstance(tools, list):
if len(tools) == 0:
tools_list = list(tools)
# When skills are enabled, ensure the Skill runner is in the base
# tool set so it isn't shadowed by an explicit --tools override.
if self._options.skills is not None and "Skill" not in tools_list:
tools_list.append("Skill")
if len(tools_list) == 0:
cmd.extend(["--tools", ""])
else:
cmd.extend(["--tools", ",".join(tools)])
cmd.extend(["--tools", ",".join(tools_list)])
else:
# Preset object - 'claude_code' preset maps to 'default'
cmd.extend(["--tools", "default"])
Expand Down Expand Up @@ -390,7 +399,15 @@ def _build_command(self) -> list[str]:
)

if self._options.effort is not None:
cmd.extend(["--effort", self._options.effort])
if (
isinstance(self._options.effort, str)
and self._options.effort not in _VALID_EFFORT_VALUES
):
raise ValueError(
f"Invalid effort value {self._options.effort!r}. "
f"Valid values: {', '.join(sorted(_VALID_EFFORT_VALUES))}"
)
cmd.extend(["--effort", str(self._options.effort)])

# Extract schema from output_format structure if provided
# Expected: {"type": "json_schema", "schema": {...}}
Expand Down
51 changes: 51 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,57 @@ def test_build_command_without_tools(self):
cmd = transport._build_command()
assert "--tools" not in cmd

def test_build_command_skills_all_with_explicit_tools_injects_skill(self):
"""skills='all' + explicit tools list → --tools contains Skill and --allowedTools has Skill."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(tools=["Bash"], skills="all"),
)
cmd = transport._build_command()

# --tools must include both the user-supplied tool and the injected Skill
assert "--tools" in cmd
tools_val = cmd[cmd.index("--tools") + 1]
assert "Bash" in tools_val.split(",")
assert "Skill" in tools_val.split(",")

# --allowedTools must also carry Skill (from _apply_skills_defaults)
assert "--allowedTools" in cmd
allowed_val = cmd[cmd.index("--allowedTools") + 1]
assert "Skill" in allowed_val.split(",")

def test_build_command_skills_all_without_explicit_tools_no_tools_flag(self):
"""skills='all' + no explicit tools → --tools is absent, --allowedTools still has Skill."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(skills="all"),
)
cmd = transport._build_command()

# No explicit tools list was given, so --tools should not appear
assert "--tools" not in cmd

# --allowedTools must carry Skill
assert "--allowedTools" in cmd
assert cmd[cmd.index("--allowedTools") + 1] == "Skill"

def test_build_command_skills_named_with_explicit_tools_injects_skill(self):
"""skills=['myskill'] + explicit tools list → --tools contains Skill."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(tools=["Bash"], skills=["myskill"]),
)
cmd = transport._build_command()

assert "--tools" in cmd
tools_val = cmd[cmd.index("--tools") + 1]
assert "Bash" in tools_val.split(",")
assert "Skill" in tools_val.split(",")

# --allowedTools carries the named pattern
assert "--allowedTools" in cmd
assert "Skill(myskill)" in cmd[cmd.index("--allowedTools") + 1]

def test_concurrent_writes_are_serialized(self):
"""Test that concurrent write() calls are serialized by the lock.

Expand Down