Skip to content

Commit 3bdeac2

Browse files
Zeffutclaude
andcommitted
fix(skills): inject Skill into --tools when explicit tools= is set (#977)
When users pass an explicit tools=[...] list alongside skills="all", the CLI's --tools argument did not include Skill, so the Skill tool was authorized but never loaded. Centralizes the injection inside _apply_skills_defaults via an effective_tools return value used by _build_command. Idempotent, no-op when skills is unset. Adds 5 regression tests. mypy + ruff clean. 744 passed. Alternative approach to #979. Closes #977. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3471a9f commit 3bdeac2

2 files changed

Lines changed: 96 additions & 12 deletions

File tree

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,24 @@ def _build_settings_value(self) -> str | None:
182182

183183
def _apply_skills_defaults(
184184
self,
185-
) -> tuple[list[str], list[str] | None]:
186-
"""Compute effective allowed_tools and setting_sources for skills.
185+
) -> tuple[list[str], list[str] | None, list[str] | None]:
186+
"""Compute effective allowed_tools, setting_sources, and tools for skills.
187187
188188
When ``options.skills`` is ``"all"``, injects the bare ``Skill`` tool;
189189
when it is a list, injects ``Skill(name)`` for each entry. In either
190190
case ``setting_sources`` defaults to ``["user", "project"]`` when
191191
unset so the CLI discovers installed skills without the caller having
192192
to wire up both options manually. ``None`` is a no-op.
193193
194+
When the caller provides an explicit ``tools`` list (the base set of
195+
tools the CLI is allowed to load), the bare ``Skill`` tool is also
196+
appended to that list when missing. Without this, ``skills="all"``
197+
would add ``Skill`` to ``allowed_tools`` but ``Skill`` would still be
198+
absent from the base tool set, so the model could not invoke it
199+
(issue #977). When ``tools`` is ``None`` or a preset object, no
200+
injection into ``tools`` is needed because the default tool set
201+
already includes ``Skill``.
202+
194203
Does not mutate the original options object.
195204
"""
196205
allowed_tools: list[str] = list(self._options.allowed_tools)
@@ -199,24 +208,38 @@ def _apply_skills_defaults(
199208
if self._options.setting_sources is not None
200209
else None
201210
)
211+
# Only mirror an explicit list-form ``tools`` option here. Preset
212+
# objects and ``None`` are returned as ``None`` so ``_build_command``
213+
# uses its existing path for them.
214+
tools: list[str] | None = (
215+
list(self._options.tools)
216+
if isinstance(self._options.tools, list)
217+
else None
218+
)
202219

203220
skills = self._options.skills
204221
if skills is None:
205-
return allowed_tools, setting_sources
222+
return allowed_tools, setting_sources, tools
206223

207224
if skills == "all":
208225
if "Skill" not in allowed_tools:
209226
allowed_tools.append("Skill")
227+
if tools is not None and "Skill" not in tools:
228+
tools.append("Skill")
210229
else:
211230
for name in skills:
212231
pattern = f"Skill({name})"
213232
if pattern not in allowed_tools:
214233
allowed_tools.append(pattern)
234+
# The CLI's --tools flag matches the bare tool name; allowed_tools
235+
# supplies the per-skill filtering. Ensure Skill is loadable.
236+
if tools is not None and "Skill" not in tools:
237+
tools.append("Skill")
215238

216239
if setting_sources is None:
217240
setting_sources = ["user", "project"]
218241

219-
return allowed_tools, setting_sources
242+
return allowed_tools, setting_sources, tools
220243

221244
def _build_command(self) -> list[str]:
222245
"""Build CLI command with arguments."""
@@ -237,22 +260,22 @@ def _build_command(self) -> list[str]:
237260
["--append-system-prompt", cast(SystemPromptPreset, sp)["append"]]
238261
)
239262

263+
effective_allowed_tools, effective_setting_sources, effective_tools = (
264+
self._apply_skills_defaults()
265+
)
266+
240267
# Handle tools option (base set of tools)
241268
if self._options.tools is not None:
242-
tools = self._options.tools
243-
if isinstance(tools, list):
244-
if len(tools) == 0:
269+
if effective_tools is not None:
270+
# List-form tools, possibly with Skill injected by skills option.
271+
if len(effective_tools) == 0:
245272
cmd.extend(["--tools", ""])
246273
else:
247-
cmd.extend(["--tools", ",".join(tools)])
274+
cmd.extend(["--tools", ",".join(effective_tools)])
248275
else:
249276
# Preset object - 'claude_code' preset maps to 'default'
250277
cmd.extend(["--tools", "default"])
251278

252-
effective_allowed_tools, effective_setting_sources = (
253-
self._apply_skills_defaults()
254-
)
255-
256279
if effective_allowed_tools:
257280
cmd.extend(["--allowedTools", ",".join(effective_allowed_tools)])
258281

tests/test_transport.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,67 @@ def test_build_command_skills_does_not_duplicate_entries(self):
627627
cmd = transport._build_command()
628628
assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf)"
629629

630+
def test_build_command_skills_all_injects_skill_into_explicit_tools(self):
631+
"""Regression for #977: skills='all' alongside an explicit tools list must
632+
add Skill to --tools so the model can actually invoke it."""
633+
transport = SubprocessCLITransport(
634+
prompt="test",
635+
options=make_options(
636+
tools=["WebSearch", "WebFetch", "Read"],
637+
skills="all",
638+
),
639+
)
640+
cmd = transport._build_command()
641+
assert "--tools" in cmd
642+
assert cmd[cmd.index("--tools") + 1] == "WebSearch,WebFetch,Read,Skill"
643+
# And Skill is in allowedTools too.
644+
assert cmd[cmd.index("--allowedTools") + 1] == "Skill"
645+
646+
def test_build_command_skills_list_injects_skill_into_explicit_tools(self):
647+
"""Named skills list must also add the bare Skill tool to --tools so the
648+
per-name patterns in allowedTools can take effect."""
649+
transport = SubprocessCLITransport(
650+
prompt="test",
651+
options=make_options(
652+
tools=["Read"],
653+
skills=["pdf"],
654+
),
655+
)
656+
cmd = transport._build_command()
657+
assert cmd[cmd.index("--tools") + 1] == "Read,Skill"
658+
assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf)"
659+
660+
def test_build_command_skills_tools_injection_is_idempotent(self):
661+
"""If the caller already put Skill in tools, do not duplicate it."""
662+
transport = SubprocessCLITransport(
663+
prompt="test",
664+
options=make_options(
665+
tools=["Read", "Skill"],
666+
skills="all",
667+
),
668+
)
669+
cmd = transport._build_command()
670+
assert cmd[cmd.index("--tools") + 1] == "Read,Skill"
671+
672+
def test_build_command_skills_none_does_not_inject_into_tools(self):
673+
"""Without skills set, an explicit tools list must be passed through unchanged."""
674+
transport = SubprocessCLITransport(
675+
prompt="test",
676+
options=make_options(tools=["Read"]),
677+
)
678+
cmd = transport._build_command()
679+
assert cmd[cmd.index("--tools") + 1] == "Read"
680+
681+
def test_build_command_skills_tools_injection_does_not_mutate_options(self):
682+
"""Tools injection must not leak back into the caller's options object."""
683+
options = make_options(
684+
tools=["Read"],
685+
skills="all",
686+
)
687+
transport = SubprocessCLITransport(prompt="test", options=options)
688+
transport._build_command()
689+
assert options.tools == ["Read"]
690+
630691
@pytest.mark.parametrize(
631692
("skills", "extra", "want_tools", "want_sources", "want_init_skills"),
632693
[

0 commit comments

Comments
 (0)