Skip to content

Commit 1c26bd3

Browse files
authored
feat(types): add top-level skills option to ClaudeAgentOptions (#804)
## Summary Adds `skills: list[str] | Literal["all"] | None` to `ClaudeAgentOptions` as the single place to enable skills for the main session, mirroring the existing `AgentDefinition.skills` field for subagents (#684). Today, enabling skills requires two non-obvious steps in unrelated fields: ```python options = ClaudeAgentOptions( allowed_tools=["Skill"], # easy to forget setting_sources=["user", "project"], ) ``` With this change: ```python ClaudeAgentOptions(skills="all") # every discovered skill ClaudeAgentOptions(skills=["pdf", "docx"]) # named subset only ClaudeAgentOptions() # default: no SDK auto-config (CLI defaults apply) ``` Users no longer put `"Skill"` in `allowed_tools`. The old way still works unchanged. ## Behavior When `skills` is set (not `None`): **1. CLI flags (works on all CLI versions):** the transport computes effective `allowed_tools` and `setting_sources` at command-build time: - `"all"` → appends bare `Skill` to `--allowedTools` - `[name, ...]` → appends `Skill(name)` for each entry - `setting_sources is None` → defaults to `["user", "project"]` - The caller's options object is never mutated; existing `allowed_tools` are preserved; explicit `setting_sources` take precedence; duplicate entries are not re-added. **2. `initialize` control request (forward-compatible):** when `skills` is a list, it is forwarded as `{"skills": [...]}` so a supporting CLI filters which skills are *loaded into the prompt* (not just permission-gated). `"all"` and `None` both omit the field — they are equivalent at the wire level. Older CLIs ignore unknown initialize fields, so this degrades to permission-layer gating only. `skills=None` (default) means the SDK does no automatic configuration. The CLI's own defaults still apply — which currently means all setting sources are loaded — so this is **not** "skills off." To suppress every skill from the listing, use `skills=[]`. ## Scope: context filter, not sandbox `skills=[...]` controls what the model **sees and can invoke**. Unlisted skills are hidden from the listing and rejected by the Skill tool, but their files remain on disk — a session with `Read` or `Bash` can still access `.claude/skills/**` directly. Bundled skills and installed-plugin skills are discovered regardless of `setting_sources`; the `skills` allowlist is the single mechanism that hides them from the model's listing. For filesystem-level isolation, point `cwd` at a directory with only the desired skills, or use permission deny rules. Do not store secrets in skill files. ## Related issues - Closes #528 (skills loading scope) - Partially addresses #582 (per-skill allow patterns) - Relates to #456 (explicit skill path config) - Picks up where #684 left off for the main session ## Test plan - `tests/test_transport.py` — 8 individual tests + 7-case `test_skills_option_matrix` parametrized table - `tests/test_query.py` — `skills` presence/absence in the `initialize` payload (list sent; `"all"`/`None` omitted) - 476 tests pass; `ruff` and `mypy` clean - 10/10 e2e behavior matrix with real API calls against a CLI built from #27911 — see comment below
1 parent b70e7e1 commit 1c26bd3

7 files changed

Lines changed: 274 additions & 5 deletions

File tree

src/claude_agent_sdk/_internal/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ async def process_query(
128128
initialize_timeout=initialize_timeout,
129129
agents=agents_dict,
130130
exclude_dynamic_sections=exclude_dynamic_sections,
131+
skills=configured_options.skills,
131132
)
132133

133134
try:

src/claude_agent_sdk/_internal/query.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
88
from contextlib import suppress
9-
from typing import TYPE_CHECKING, Any
9+
from typing import TYPE_CHECKING, Any, Literal
1010

1111
import anyio
1212
from mcp.types import (
@@ -77,6 +77,7 @@ def __init__(
7777
initialize_timeout: float = 60.0,
7878
agents: dict[str, dict[str, Any]] | None = None,
7979
exclude_dynamic_sections: bool | None = None,
80+
skills: list[str] | Literal["all"] | None = None,
8081
):
8182
"""Initialize Query with transport and callbacks.
8283
@@ -90,6 +91,8 @@ def __init__(
9091
agents: Optional agent definitions to send via initialize
9192
exclude_dynamic_sections: Optional preset-prompt flag to send via
9293
initialize (see ``SystemPromptPreset``)
94+
skills: Optional skill allowlist to send via initialize so the CLI
95+
can filter which skills are loaded into the system prompt
9396
"""
9497
self._initialize_timeout = initialize_timeout
9598
self.transport = transport
@@ -99,6 +102,7 @@ def __init__(
99102
self.sdk_mcp_servers = sdk_mcp_servers or {}
100103
self._agents = agents
101104
self._exclude_dynamic_sections = exclude_dynamic_sections
105+
self._skills = skills
102106

103107
# Control protocol state
104108
self.pending_control_responses: dict[str, anyio.Event] = {}
@@ -160,6 +164,10 @@ async def initialize(self) -> dict[str, Any] | None:
160164
request["agents"] = self._agents
161165
if self._exclude_dynamic_sections is not None:
162166
request["excludeDynamicSections"] = self._exclude_dynamic_sections
167+
# 'all' and omitted are equivalent at the wire level (no filter), so
168+
# only send the field when it's an explicit list.
169+
if isinstance(self._skills, list):
170+
request["skills"] = self._skills
163171

164172
# Use longer timeout for initialize since MCP servers may take time to start
165173
response = await self._send_control_request(

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,44 @@ def _build_settings_value(self) -> str | None:
162162

163163
return json.dumps(settings_obj)
164164

165+
def _apply_skills_defaults(
166+
self,
167+
) -> tuple[list[str], list[str] | None]:
168+
"""Compute effective allowed_tools and setting_sources for skills.
169+
170+
When ``options.skills`` is ``"all"``, injects the bare ``Skill`` tool;
171+
when it is a list, injects ``Skill(name)`` for each entry. In either
172+
case ``setting_sources`` defaults to ``["user", "project"]`` when
173+
unset so the CLI discovers installed skills without the caller having
174+
to wire up both options manually. ``None`` is a no-op.
175+
176+
Does not mutate the original options object.
177+
"""
178+
allowed_tools: list[str] = list(self._options.allowed_tools)
179+
setting_sources: list[str] | None = (
180+
list(self._options.setting_sources)
181+
if self._options.setting_sources is not None
182+
else None
183+
)
184+
185+
skills = self._options.skills
186+
if skills is None:
187+
return allowed_tools, setting_sources
188+
189+
if skills == "all":
190+
if "Skill" not in allowed_tools:
191+
allowed_tools.append("Skill")
192+
else:
193+
for name in skills:
194+
pattern = f"Skill({name})"
195+
if pattern not in allowed_tools:
196+
allowed_tools.append(pattern)
197+
198+
if setting_sources is None:
199+
setting_sources = ["user", "project"]
200+
201+
return allowed_tools, setting_sources
202+
165203
def _build_command(self) -> list[str]:
166204
"""Build CLI command with arguments."""
167205
if self._cli_path is None:
@@ -193,8 +231,12 @@ def _build_command(self) -> list[str]:
193231
# Preset object - 'claude_code' preset maps to 'default'
194232
cmd.extend(["--tools", "default"])
195233

196-
if self._options.allowed_tools:
197-
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
234+
effective_allowed_tools, effective_setting_sources = (
235+
self._apply_skills_defaults()
236+
)
237+
238+
if effective_allowed_tools:
239+
cmd.extend(["--allowedTools", ",".join(effective_allowed_tools)])
198240

199241
if self._options.max_turns:
200242
cmd.extend(["--max-turns", str(self._options.max_turns)])
@@ -280,8 +322,8 @@ def _build_command(self) -> list[str]:
280322
# Agents are always sent via initialize request (matching TypeScript SDK)
281323
# No --agents CLI flag needed
282324

283-
if self._options.setting_sources is not None:
284-
cmd.append(f"--setting-sources={','.join(self._options.setting_sources)}")
325+
if effective_setting_sources is not None:
326+
cmd.append(f"--setting-sources={','.join(effective_setting_sources)}")
285327

286328
# Add plugin directories
287329
if self._options.plugins:

src/claude_agent_sdk/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ async def _empty_stream() -> AsyncIterator[dict[str, Any]]:
186186
initialize_timeout=initialize_timeout,
187187
agents=agents_dict,
188188
exclude_dynamic_sections=exclude_dynamic_sections,
189+
skills=self.options.skills,
189190
)
190191

191192
# Start reading messages and initialize

src/claude_agent_sdk/types.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,6 +1222,33 @@ class ClaudeAgentOptions:
12221222
agents: dict[str, AgentDefinition] | None = None
12231223
# Setting sources to load (user, project, local)
12241224
setting_sources: list[SettingSource] | None = None
1225+
# Skills to enable for the main session. This is the one place to turn
1226+
# skills on; you do not need to add ``"Skill"`` to ``allowed_tools`` or
1227+
# set ``setting_sources`` yourself — the SDK does both when this is set.
1228+
# The value is also sent on the ``initialize`` control request so a
1229+
# supporting CLI can filter which skills are loaded into the system prompt
1230+
# (older CLIs ignore the field).
1231+
# * ``None`` (default): no SDK auto-configuration. The CLI's own
1232+
# defaults still apply, so this is **not** "skills off" — to suppress
1233+
# every skill from the listing, use ``[]``.
1234+
# * ``"all"``: enable every discovered skill.
1235+
# * ``[name, ...]``: enable only the listed skills. Names match the
1236+
# SKILL.md ``name`` / directory name, or ``plugin:skill`` for
1237+
# plugin-qualified skills.
1238+
#
1239+
# .. note::
1240+
# This is a **context filter**, not a sandbox. Unlisted skills are
1241+
# hidden from the model's skill listing and cannot be invoked via the
1242+
# Skill tool, but their files remain on disk — a session with ``Read``
1243+
# or ``Bash`` can still access ``.claude/skills/**`` directly. For
1244+
# hard isolation, point ``cwd`` at a directory whose
1245+
# ``.claude/skills/`` contains only the desired subset, or add
1246+
# permission deny rules for ``Read``/``Bash`` on skill paths. Note
1247+
# that bundled skills and installed-plugin skills are discovered
1248+
# regardless of ``setting_sources``; the ``skills`` allowlist is the
1249+
# single mechanism that hides them from the model's listing. Do not
1250+
# store secrets in skill files.
1251+
skills: list[str] | Literal["all"] | None = None
12251252
# Sandbox configuration for bash command isolation.
12261253
# Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch),
12271254
# not from these sandbox settings.

tests/test_query.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ def test_initialize_omits_exclude_dynamic_sections_when_unset():
5959
assert "excludeDynamicSections" not in sent
6060

6161

62+
def test_initialize_sends_skills_list():
63+
"""Query.initialize() includes skills only when it is a list."""
64+
sent = _capture_initialize_request(skills=["pdf", "docx"])
65+
assert sent["skills"] == ["pdf", "docx"]
66+
67+
sent_empty = _capture_initialize_request(skills=[])
68+
assert sent_empty["skills"] == []
69+
70+
71+
def test_initialize_omits_skills_for_none_and_all():
72+
"""'all' and None both omit skills from initialize (no filter at wire level)."""
73+
assert "skills" not in _capture_initialize_request()
74+
assert "skills" not in _capture_initialize_request(skills=None)
75+
assert "skills" not in _capture_initialize_request(skills="all")
76+
77+
6278
def _make_mock_transport(messages, control_requests=None):
6379
"""Create a mock transport that yields messages and optionally sends control requests.
6480

tests/test_transport.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from claude_agent_sdk.types import ClaudeAgentOptions
1313

1414
DEFAULT_CLI_PATH = "/usr/bin/claude"
15+
_ABSENT = object() # sentinel for "field not sent on the wire"
1516

1617

1718
def make_options(**kwargs: object) -> ClaudeAgentOptions:
@@ -454,6 +455,179 @@ def test_build_command_setting_sources_included_when_provided(self):
454455
cmd = transport._build_command()
455456
assert "--setting-sources=user,project" in cmd
456457

458+
def test_build_command_skills_none_leaves_options_untouched(self):
459+
"""When skills is None (default), neither allowed_tools nor setting_sources change."""
460+
transport = SubprocessCLITransport(
461+
prompt="test",
462+
options=make_options(),
463+
)
464+
cmd = transport._build_command()
465+
assert "--allowedTools" not in cmd
466+
assert not any(a.startswith("--setting-sources") for a in cmd)
467+
468+
def test_build_command_skills_all_enables_skill_tool(self):
469+
"""skills='all' enables the bare Skill tool and defaults setting_sources."""
470+
transport = SubprocessCLITransport(
471+
prompt="test",
472+
options=make_options(skills="all"),
473+
)
474+
cmd = transport._build_command()
475+
assert "--allowedTools" in cmd
476+
assert cmd[cmd.index("--allowedTools") + 1] == "Skill"
477+
assert "--setting-sources=user,project" in cmd
478+
479+
def test_build_command_skills_empty_list_adds_no_skill_entries(self):
480+
"""skills=[] is a degenerate subset: setting_sources defaults, no Skill entries."""
481+
transport = SubprocessCLITransport(
482+
prompt="test",
483+
options=make_options(skills=[]),
484+
)
485+
cmd = transport._build_command()
486+
assert "--allowedTools" not in cmd
487+
assert "--setting-sources=user,project" in cmd
488+
489+
def test_build_command_skills_named_list_uses_skill_patterns(self):
490+
"""Non-empty skills list adds Skill(name) entries and defaults setting_sources."""
491+
transport = SubprocessCLITransport(
492+
prompt="test",
493+
options=make_options(skills=["pdf", "docx"]),
494+
)
495+
cmd = transport._build_command()
496+
assert "--allowedTools" in cmd
497+
assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf),Skill(docx)"
498+
assert "--setting-sources=user,project" in cmd
499+
500+
def test_build_command_skills_merges_with_existing_allowed_tools(self):
501+
"""skills augment (not replace) an existing allowed_tools list."""
502+
transport = SubprocessCLITransport(
503+
prompt="test",
504+
options=make_options(
505+
allowed_tools=["Read", "Write"],
506+
skills=["pdf"],
507+
),
508+
)
509+
cmd = transport._build_command()
510+
assert cmd[cmd.index("--allowedTools") + 1] == "Read,Write,Skill(pdf)"
511+
512+
def test_build_command_skills_preserves_user_setting_sources(self):
513+
"""When setting_sources is explicitly provided, skills should not override it."""
514+
transport = SubprocessCLITransport(
515+
prompt="test",
516+
options=make_options(
517+
skills="all",
518+
setting_sources=["local"],
519+
),
520+
)
521+
cmd = transport._build_command()
522+
assert "--setting-sources=local" in cmd
523+
524+
def test_build_command_skills_does_not_mutate_options(self):
525+
"""Applying skills defaults must not mutate the caller's options object."""
526+
options = make_options(allowed_tools=["Read"], skills=["pdf"])
527+
transport = SubprocessCLITransport(prompt="test", options=options)
528+
transport._build_command()
529+
assert options.allowed_tools == ["Read"]
530+
assert options.setting_sources is None
531+
532+
def test_build_command_skills_does_not_duplicate_entries(self):
533+
"""Injecting Skill entries is idempotent when caller already listed them."""
534+
transport = SubprocessCLITransport(
535+
prompt="test",
536+
options=make_options(
537+
allowed_tools=["Skill(pdf)"],
538+
skills=["pdf"],
539+
),
540+
)
541+
cmd = transport._build_command()
542+
assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf)"
543+
544+
@pytest.mark.parametrize(
545+
("skills", "extra", "want_tools", "want_sources", "want_init_skills"),
546+
[
547+
# (1) default: no auto-config
548+
(None, {}, None, None, _ABSENT),
549+
# (2) old manual way still works (skills=None, user wires it)
550+
(
551+
None,
552+
{
553+
"allowed_tools": ["Skill", "Read"],
554+
"setting_sources": ["user", "project"],
555+
},
556+
"Skill,Read",
557+
"user,project",
558+
_ABSENT,
559+
),
560+
# (3) "all": bare Skill, default sources, no wire filter
561+
("all", {}, "Skill", "user,project", _ABSENT),
562+
# (4) named subset
563+
(
564+
["pdf", "docx"],
565+
{},
566+
"Skill(pdf),Skill(docx)",
567+
"user,project",
568+
["pdf", "docx"],
569+
),
570+
# (5) subset + explicit setting_sources (user wins)
571+
(
572+
["pdf"],
573+
{"setting_sources": ["project"]},
574+
"Skill(pdf)",
575+
"project",
576+
["pdf"],
577+
),
578+
# (6) subset merges into existing allowed_tools
579+
(
580+
["pdf"],
581+
{"allowed_tools": ["Read", "Bash"]},
582+
"Read,Bash,Skill(pdf)",
583+
"user,project",
584+
["pdf"],
585+
),
586+
# (7) empty list = degenerate subset (not "all")
587+
([], {}, None, "user,project", []),
588+
],
589+
ids=[
590+
"default-none",
591+
"old-manual",
592+
"all",
593+
"subset",
594+
"subset+explicit-sources",
595+
"subset+merge-tools",
596+
"empty-list",
597+
],
598+
)
599+
def test_skills_option_matrix(
600+
self, skills, extra, want_tools, want_sources, want_init_skills
601+
):
602+
"""Documented behavior table for ClaudeAgentOptions.skills.
603+
604+
Asserts the full (input) -> (allowedTools, setting_sources,
605+
initialize.skills) mapping in one place. See also
606+
test_query.py::test_initialize_* for the wire-level half.
607+
"""
608+
transport = SubprocessCLITransport(
609+
prompt="test",
610+
options=make_options(skills=skills, **extra),
611+
)
612+
cmd = transport._build_command()
613+
614+
if want_tools is None:
615+
assert "--allowedTools" not in cmd
616+
else:
617+
assert cmd[cmd.index("--allowedTools") + 1] == want_tools
618+
619+
if want_sources is None:
620+
assert not any(a.startswith("--setting-sources") for a in cmd)
621+
else:
622+
assert f"--setting-sources={want_sources}" in cmd
623+
624+
# Wire-level: what the Query layer would send on initialize.
625+
# 'all' and None both omit the field; only an explicit list is sent.
626+
if want_init_skills is _ABSENT:
627+
assert not isinstance(skills, list)
628+
else:
629+
assert skills == want_init_skills
630+
457631
def test_build_command_with_extra_args(self):
458632
"""Test building CLI command with extra_args for future flags."""
459633
transport = SubprocessCLITransport(

0 commit comments

Comments
 (0)