diff --git a/src/claude_agent_sdk/_internal/client.py b/src/claude_agent_sdk/_internal/client.py index 76323323..d6b787f4 100644 --- a/src/claude_agent_sdk/_internal/client.py +++ b/src/claude_agent_sdk/_internal/client.py @@ -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: diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 80b6d93c..30ad2257 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -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 ( @@ -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. @@ -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 @@ -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] = {} @@ -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( diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 4b47f115..6733cb4f 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -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: @@ -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)]) @@ -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: diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index 64f845fb..7d7911ac 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -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 diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index a82a8b9b..75bb9902 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -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. diff --git a/tests/test_query.py b/tests/test_query.py index 30e51c09..1fcb4dea 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -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. diff --git a/tests/test_transport.py b/tests/test_transport.py index b2c40923..f3a846e4 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -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(