feat(types): add top-level skills option to ClaudeAgentOptions#804
Open
jsham042 wants to merge 11 commits intoanthropics:mainfrom
Open
feat(types): add top-level skills option to ClaudeAgentOptions#804jsham042 wants to merge 11 commits intoanthropics:mainfrom
skills option to ClaudeAgentOptions#804jsham042 wants to merge 11 commits intoanthropics:mainfrom
Conversation
Adds a `skills: list[str] | None` field to ClaudeAgentOptions that mirrors the existing field on AgentDefinition. When set, the SDK automatically: - Adds `Skill` (or `Skill(name)` patterns for specific names) to the `--allowedTools` CLI flag. - Defaults `setting_sources` to `["user", "project"]` when not already configured, so installed SKILL.md files are discovered. Previously, enabling skills required both "Skill" in allowed_tools and an explicit setting_sources list — a footgun the SDK can easily remove. The existing `allowed_tools` and `setting_sources` fields are unchanged and still take precedence when the caller sets them explicitly. The options object itself is never mutated.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #804 +/- ##
=======================================
Coverage ? 84.40%
=======================================
Files ? 14
Lines ? 2648
Branches ? 0
=======================================
Hits ? 2235
Misses ? 413
Partials ? 0 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
In addition to translating `skills` into `Skill(name)` allowedTools entries, also forward the list on the SDK `initialize` control request. A supporting CLI can use this to filter which skills are loaded into the system prompt (not just permission-gated). Older CLIs ignore unknown initialize fields, so this is forward-compatible.
Unlisted skills are hidden from the listing and blocked at the Skill tool, but their files remain readable via Read/Bash. Document the boundary and the alternatives (local plugin, deny rules) for users who need hard isolation.
API design refinement: skills is now the one place to enable skills
(users should not put 'Skill' in allowed_tools directly).
None - skills off (default)
'all' - every discovered skill
[name,...] - named subset only
[] - degenerate subset; setting_sources still defaults but no
Skill entries are added (natural list semantics)
Type widened to list[str] | Literal['all'] | None. Transport, query
init, docstring, and tests updated.
'all' and omitted both mean 'no filter' at the wire level, so only send the field when it is an explicit list. Keeps the CLI control schema as a plain string[] (which the zod-to-proto pipeline can represent) while the 'all' sentinel remains at the Python API surface for ergonomics.
|
My current feature requires this PR |
IgorTavcar
added a commit
to IgorTavcar/claude-agent-sdk-python
that referenced
this pull request
Apr 10, 2026
- anthropics#806: setting_sources=[] truthiness fix - anthropics#803: betas=[]/plugins=[] truthiness fix - anthropics#786: ThinkingBlock missing signature crash fix - anthropics#790: suppress ProcessError when result already received - anthropics#658: capture real stderr in ProcessError - anthropics#791: suppress stale task notifications between turns - anthropics#763: guard malformed CLAUDE_CODE_STREAM_CLOSE_TIMEOUT env var - anthropics#805: delete_session() cascades subagent transcript dir - anthropics#804: top-level skills option on ClaudeAgentOptions - anthropics#691: PostCompact hook event type support 479 tests passing, mypy clean, ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single table-driven test documenting the (skills value) -> (allowedTools, setting_sources, initialize-wire) mapping for all seven permutations: default, old-manual, 'all', subset, subset+explicit sources, subset+merge-tools, empty list. Serves as regression for the documented behavior table.
setting_sources=None falls through to the CLI default (all sources enabled), so the local-plugin isolation pattern leaked project/user skills. Correct it to setting_sources=[].
Bundled and installed-plugin skills are discovered regardless of setting_sources, and the transport's truthy check means setting_sources=[] is not sent anyway, so the previous recipe did not work. State the honest boundary: skills=[...] is the single mechanism for hiding skills from the listing; filesystem access is a separate concern (cwd or permission rules).
Author
E2E matrix: 10/10 passedRan a 10-case behavior matrix with real API calls against a CLI built from this branch + the Python SDK from #804. Project skills Test harness"""Manual matrix test for ClaudeAgentOptions.skills (PRs feat/sdk-skills-load-filter +
feat/top-level-skills-option).
Project skills on disk: alpha, beta, gamma (./.claude/skills/)
Plugin skill on disk: delta (./plugins/isolated/)
Each case asserts which of {alpha, beta, gamma, delta} the model can see.
Built-in skills (commit, review, ...) are tracked but not asserted on, since
they are baked into the CLI binary and not gated by setting_sources.
"""
import asyncio
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from claude_agent_sdk import AgentDefinition, ClaudeAgentOptions, ResultMessage, query
CLI = str(
Path.home()
/ "code/claude-cli-internal/build-ant-native/@anthropic-ai/claude-cli-native-darwin-arm64/cli"
)
REPO = Path(__file__).parent
PLUGIN = str(REPO / "plugins" / "isolated")
TRACKED = {"alpha", "beta", "gamma", "delta"}
PROMPT = (
"List EVERY skill name available to you in your system prompt, one per line, "
"lowercase, no commentary. If there are none, output exactly: NONE"
)
@dataclass
class Case:
name: str
opts: dict[str, Any]
expect_present: set[str]
expect_absent: set[str] = field(default_factory=set)
note: str = ""
def hermetic_env() -> dict[str, str]:
"""Scrub env vars leaked from a parent Claude Code session.
We do NOT override CLAUDE_CONFIG_DIR because the CLI needs the user's
auth credentials from ~/.claude. This means user-level plugins/MCP and
built-in skills still load; assertions therefore only check the tracked
project/plugin skills (alpha, beta, gamma, delta).
"""
return {
"CLAUDE_CODE_SESSION_ID": "",
"CLAUDE_CODE_SSE_PORT": "",
"CLAUDE_CODE_MESSAGING_SOCKET": "",
"CLAUDE_CODE_EXECPATH": "",
"CLAUDECODE": "",
}
def parse_skills(text: str | None) -> set[str]:
if text is None:
return set()
found: set[str] = set()
for line in text.splitlines():
token = line.strip().strip("`*- ").lower()
if not token or token == "none":
continue
# accept "plugin:name" or bare "name"
m = re.fullmatch(r"[a-z0-9_:\-]+", token)
if m:
found.add(token.split(":")[-1])
return found
async def run_case(case: Case, env: dict[str, str]) -> bool:
opts = ClaudeAgentOptions(
cwd=str(REPO),
cli_path=CLI,
max_turns=3,
env=env,
**case.opts,
)
result_text: str | None = None
try:
async with asyncio.timeout(120):
async for m in query(prompt=PROMPT, options=opts):
if isinstance(m, ResultMessage):
result_text = m.result
except Exception as e: # noqa: BLE001
print(f" ERROR: {type(e).__name__}: {e}")
return False
seen = parse_skills(result_text)
tracked_seen = seen & TRACKED
builtins_seen = seen - TRACKED
missing = case.expect_present - seen
leaked = case.expect_absent & seen
ok = not missing and not leaked
status = "PASS" if ok else "FAIL"
print(f" [{status}] tracked={sorted(tracked_seen)} builtins={len(builtins_seen)}")
if missing:
print(f" missing (expected present): {sorted(missing)}")
if leaked:
print(f" leaked (expected absent): {sorted(leaked)}")
if case.note:
print(f" note: {case.note}")
if not ok:
print(f" raw model output:\n{indent(result_text)}")
return ok
def indent(s: str | None) -> str:
if s is None:
return " <None>"
return "\n".join(f" {line}" for line in s.splitlines())
def build_cases() -> list[Case]:
return [
Case(
name="1. nothing set",
opts=dict(),
expect_present={"alpha", "beta", "gamma"},
expect_absent={"delta"},
note="CLI default: all sources discovered, SDK adds nothing",
),
Case(
name="2. old manual way",
opts=dict(
allowed_tools=["Skill", "Read"],
setting_sources=["user", "project"],
),
expect_present={"alpha", "beta", "gamma"},
expect_absent={"delta"},
),
Case(
name="3. skills='all' auto-wired",
opts=dict(skills="all", allowed_tools=["Read", "Bash"]),
expect_present={"alpha", "beta", "gamma"},
expect_absent={"delta"},
note="should match #2 without writing Skill or setting_sources",
),
Case(
name="4. subset only",
opts=dict(skills=["alpha", "gamma"], allowed_tools=["Read", "Bash"]),
expect_present={"alpha", "gamma"},
expect_absent={"beta", "delta"},
),
Case(
name="5. subset + setting_sources override",
opts=dict(skills=["alpha"], setting_sources=["project"]),
expect_present={"alpha"},
expect_absent={"beta", "gamma", "delta"},
),
Case(
name="6. anti-pattern: subset + bare Skill",
opts=dict(skills=["alpha"], allowed_tools=["Skill", "Read"]),
expect_present={"alpha"},
expect_absent={"beta", "gamma", "delta"},
note="listing should still be filtered; bare Skill only widens invocation perms",
),
Case(
name="7. plugin skill via allowlist",
opts=dict(
plugins=[{"type": "local", "path": PLUGIN}],
skills=["isolated:delta"],
allowed_tools=["Read"],
),
expect_present={"delta"},
expect_absent={"alpha", "beta", "gamma"},
note="allowlist matches plugin-qualified names; same filter mechanism as case 4",
),
Case(
name="4b. empty list (degenerate)",
opts=dict(skills=[], allowed_tools=["Read"]),
expect_present=set(),
expect_absent=TRACKED,
),
Case(
name="4c. nonexistent skill name",
opts=dict(skills=["nonexistent"], allowed_tools=["Read"]),
expect_present=set(),
expect_absent=TRACKED,
note="filter to a name that isn't on disk → nothing advertised",
),
Case(
name="8. subagent skills (AgentDefinition)",
opts=dict(
skills="all",
agents={
"reporter": AgentDefinition(
description="reporter",
prompt="You are reporter.",
skills=["alpha", "gamma"],
)
},
),
expect_present={"alpha", "beta", "gamma"},
expect_absent={"delta"},
note="main session unfiltered; subagent filter not exercised by this prompt",
),
]
async def main() -> None:
env = hermetic_env()
cases = build_cases()
passed = 0
for case in cases:
print(f"\n=== {case.name} ===")
print(f" opts: {case.opts}")
if await run_case(case, env):
passed += 1
print(f"\n{'=' * 50}")
print(f"RESULT: {passed}/{len(cases)} passed")
if __name__ == "__main__":
os.environ["CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"] = "1"
asyncio.run(main()) |
The CLI's own defaults still apply when the SDK omits --setting-sources, so None is 'no auto-configuration' rather than 'disabled'. Point users at [] if they want every skill suppressed from the listing.
…-option # Conflicts: # src/claude_agent_sdk/_internal/transport/subprocess_cli.py
qing-ant
approved these changes
Apr 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
skills: list[str] | Literal["all"] | NonetoClaudeAgentOptionsas the single place to enable skills for the main session, mirroring the existingAgentDefinition.skillsfield for subagents (#684).Today, enabling skills requires two non-obvious steps in unrelated fields:
With this change:
Users no longer put
"Skill"inallowed_tools. The old way still works unchanged.Behavior
When
skillsis set (notNone):1. CLI flags (works on all CLI versions): the transport computes effective
allowed_toolsandsetting_sourcesat command-build time:"all"→ appends bareSkillto--allowedTools[name, ...]→ appendsSkill(name)for each entrysetting_sources is None→ defaults to["user", "project"]allowed_toolsare preserved; explicitsetting_sourcestake precedence; duplicate entries are not re-added.2.
initializecontrol request (forward-compatible): whenskillsis a list, it is forwarded as{"skills": [...]}so a supporting CLI filters which skills are loaded into the prompt (not just permission-gated)."all"andNoneboth 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, useskills=[].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 withReadorBashcan still access.claude/skills/**directly. Bundled skills and installed-plugin skills are discovered regardless ofsetting_sources; theskillsallowlist is the single mechanism that hides them from the model's listing. For filesystem-level isolation, pointcwdat a directory with only the desired skills, or use permission deny rules. Do not store secrets in skill files.Related issues
Test plan
tests/test_transport.py— 8 individual tests + 7-casetest_skills_option_matrixparametrized tabletests/test_query.py—skillspresence/absence in theinitializepayload (list sent;"all"/Noneomitted)ruffandmypyclean