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
1 change: 1 addition & 0 deletions src/claude_agent_sdk/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async def process_query(
initialize_timeout=initialize_timeout,
agents=agents_dict,
exclude_dynamic_sections=exclude_dynamic_sections,
skills=configured_options.skills,
)

try:
Expand Down
10 changes: 9 additions & 1 deletion src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import os
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
from contextlib import suppress
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal

import anyio
from mcp.types import (
Expand Down Expand Up @@ -77,6 +77,7 @@ def __init__(
initialize_timeout: float = 60.0,
agents: dict[str, dict[str, Any]] | None = None,
exclude_dynamic_sections: bool | None = None,
skills: list[str] | Literal["all"] | None = None,
):
"""Initialize Query with transport and callbacks.
Expand All @@ -90,6 +91,8 @@ def __init__(
agents: Optional agent definitions to send via initialize
exclude_dynamic_sections: Optional preset-prompt flag to send via
initialize (see ``SystemPromptPreset``)
skills: Optional skill allowlist to send via initialize so the CLI
can filter which skills are loaded into the system prompt
"""
self._initialize_timeout = initialize_timeout
self.transport = transport
Expand All @@ -99,6 +102,7 @@ def __init__(
self.sdk_mcp_servers = sdk_mcp_servers or {}
self._agents = agents
self._exclude_dynamic_sections = exclude_dynamic_sections
self._skills = skills

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

# Use longer timeout for initialize since MCP servers may take time to start
response = await self._send_control_request(
Expand Down
50 changes: 46 additions & 4 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,44 @@ def _build_settings_value(self) -> str | None:

return json.dumps(settings_obj)

def _apply_skills_defaults(
self,
) -> tuple[list[str], list[str] | None]:
"""Compute effective allowed_tools and setting_sources for skills.

When ``options.skills`` is ``"all"``, injects the bare ``Skill`` tool;
when it is a list, injects ``Skill(name)`` for each entry. In either
case ``setting_sources`` defaults to ``["user", "project"]`` when
unset so the CLI discovers installed skills without the caller having
to wire up both options manually. ``None`` is a no-op.

Does not mutate the original options object.
"""
allowed_tools: list[str] = list(self._options.allowed_tools)
setting_sources: list[str] | None = (
list(self._options.setting_sources)
if self._options.setting_sources is not None
else None
)

skills = self._options.skills
if skills is None:
return allowed_tools, setting_sources

if skills == "all":
if "Skill" not in allowed_tools:
allowed_tools.append("Skill")
else:
for name in skills:
pattern = f"Skill({name})"
if pattern not in allowed_tools:
allowed_tools.append(pattern)

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

return allowed_tools, setting_sources

def _build_command(self) -> list[str]:
"""Build CLI command with arguments."""
if self._cli_path is None:
Expand Down Expand Up @@ -193,8 +231,12 @@ def _build_command(self) -> list[str]:
# Preset object - 'claude_code' preset maps to 'default'
cmd.extend(["--tools", "default"])

if self._options.allowed_tools:
cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])
effective_allowed_tools, effective_setting_sources = (
self._apply_skills_defaults()
)

if effective_allowed_tools:
cmd.extend(["--allowedTools", ",".join(effective_allowed_tools)])

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

if self._options.setting_sources:
cmd.extend(["--setting-sources", ",".join(self._options.setting_sources)])
if effective_setting_sources:
cmd.extend(["--setting-sources", ",".join(effective_setting_sources)])

# Add plugin directories
if self._options.plugins:
Expand Down
1 change: 1 addition & 0 deletions src/claude_agent_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ async def _empty_stream() -> AsyncIterator[dict[str, Any]]:
initialize_timeout=initialize_timeout,
agents=agents_dict,
exclude_dynamic_sections=exclude_dynamic_sections,
skills=self.options.skills,
)

# Start reading messages and initialize
Expand Down
22 changes: 22 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,28 @@ class ClaudeAgentOptions:
agents: dict[str, AgentDefinition] | None = None
# Setting sources to load (user, project, local)
setting_sources: list[SettingSource] | None = None
# Skills to enable for the main session. This is the one place to turn
# skills on; you do not need to add ``"Skill"`` to ``allowed_tools`` or
# set ``setting_sources`` yourself — the SDK does both when this is set.
# The value is also sent on the ``initialize`` control request so a
# supporting CLI can filter which skills are loaded into the system prompt
# (older CLIs ignore the field).
# * ``None`` (default): skills are off.
# * ``"all"``: enable every discovered skill.
# * ``[name, ...]``: enable only the listed skills. Names match the
# SKILL.md ``name`` / directory name, or ``plugin:skill`` for
# plugin-qualified skills.
#
# .. note::
# This is a **context filter**, not a sandbox. Unlisted skills are
# hidden from the model's skill listing and cannot be invoked via the
# Skill tool, but their files remain on disk — a session with ``Read``
# or ``Bash`` can still access ``.claude/skills/**`` directly. For
# hard isolation, either omit those files from the working directory,
# bundle the desired subset as a local plugin (``plugins=[...]`` with
# ``setting_sources=None``), or add explicit permission deny rules.
# Do not store secrets in skill files.
skills: list[str] | Literal["all"] | None = None
# Sandbox configuration for bash command isolation.
# Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch),
# not from these sandbox settings.
Expand Down
16 changes: 16 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ def test_initialize_omits_exclude_dynamic_sections_when_unset():
assert "excludeDynamicSections" not in sent


def test_initialize_sends_skills_list():
"""Query.initialize() includes skills only when it is a list."""
sent = _capture_initialize_request(skills=["pdf", "docx"])
assert sent["skills"] == ["pdf", "docx"]

sent_empty = _capture_initialize_request(skills=[])
assert sent_empty["skills"] == []


def test_initialize_omits_skills_for_none_and_all():
"""'all' and None both omit skills from initialize (no filter at wire level)."""
assert "skills" not in _capture_initialize_request()
assert "skills" not in _capture_initialize_request(skills=None)
assert "skills" not in _capture_initialize_request(skills="all")


def _make_mock_transport(messages, control_requests=None):
"""Create a mock transport that yields messages and optionally sends control requests.

Expand Down
89 changes: 89 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,95 @@ def test_build_command_setting_sources_included_when_provided(self):
idx = cmd.index("--setting-sources")
assert cmd[idx + 1] == "user,project"

def test_build_command_skills_none_leaves_options_untouched(self):
"""When skills is None (default), neither allowed_tools nor setting_sources change."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(),
)
cmd = transport._build_command()
assert "--allowedTools" not in cmd
assert "--setting-sources" not in cmd

def test_build_command_skills_all_enables_skill_tool(self):
"""skills='all' enables the bare Skill tool and defaults setting_sources."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(skills="all"),
)
cmd = transport._build_command()
assert "--allowedTools" in cmd
assert cmd[cmd.index("--allowedTools") + 1] == "Skill"
assert "--setting-sources" in cmd
assert cmd[cmd.index("--setting-sources") + 1] == "user,project"

def test_build_command_skills_empty_list_adds_no_skill_entries(self):
"""skills=[] is a degenerate subset: setting_sources defaults, no Skill entries."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(skills=[]),
)
cmd = transport._build_command()
assert "--allowedTools" not in cmd
assert "--setting-sources" in cmd
assert cmd[cmd.index("--setting-sources") + 1] == "user,project"

def test_build_command_skills_named_list_uses_skill_patterns(self):
"""Non-empty skills list adds Skill(name) entries and defaults setting_sources."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(skills=["pdf", "docx"]),
)
cmd = transport._build_command()
assert "--allowedTools" in cmd
assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf),Skill(docx)"
assert "--setting-sources" in cmd
assert cmd[cmd.index("--setting-sources") + 1] == "user,project"

def test_build_command_skills_merges_with_existing_allowed_tools(self):
"""skills augment (not replace) an existing allowed_tools list."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(
allowed_tools=["Read", "Write"],
skills=["pdf"],
),
)
cmd = transport._build_command()
assert cmd[cmd.index("--allowedTools") + 1] == "Read,Write,Skill(pdf)"

def test_build_command_skills_preserves_user_setting_sources(self):
"""When setting_sources is explicitly provided, skills should not override it."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(
skills="all",
setting_sources=["local"],
),
)
cmd = transport._build_command()
assert cmd[cmd.index("--setting-sources") + 1] == "local"

def test_build_command_skills_does_not_mutate_options(self):
"""Applying skills defaults must not mutate the caller's options object."""
options = make_options(allowed_tools=["Read"], skills=["pdf"])
transport = SubprocessCLITransport(prompt="test", options=options)
transport._build_command()
assert options.allowed_tools == ["Read"]
assert options.setting_sources is None

def test_build_command_skills_does_not_duplicate_entries(self):
"""Injecting Skill entries is idempotent when caller already listed them."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(
allowed_tools=["Skill(pdf)"],
skills=["pdf"],
),
)
cmd = transport._build_command()
assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf)"

def test_build_command_with_extra_args(self):
"""Test building CLI command with extra_args for future flags."""
transport = SubprocessCLITransport(
Expand Down
Loading