feat: add agents and setting sources support (#182)

## Summary
- Add support for custom agent definitions via `agents` option
- Add support for controlling setting sources via `setting_sources`
option
- Add `/commit` slash command to project
- Add examples demonstrating both features
- Add e2e tests for verification

## Changes

### Core Implementation
- Add `AgentDefinition` and `SettingSource` types to `types.py`
- Add `agents` and `setting_sources` fields to `ClaudeCodeOptions`
- Update subprocess CLI transport to pass `--agents` and
`--setting-sources` flags
- **Default behavior**: When `setting_sources` is not provided, pass
empty string (no settings loaded)
- Handle empty `setting_sources` array correctly (pass empty string to
CLI)

### Examples
- `examples/agents.py`: Demonstrates custom agent definitions with
different tools and models
- `examples/setting_sources.py`: Shows how setting sources control which
settings are loaded
  - Default behavior (no settings)
  - User-only settings
  - User + project settings

### Tests
- Add e2e tests verifying agents and setting_sources functionality
- Test default behavior (no settings loaded)
- Test filtering by setting source
- Use `output_style` checking to verify settings loaded/not loaded
- Tests use temporary directories for isolated testing

### Project Config
- Add `.claude/commands/commit.md` slash command for git commits

## Test Plan
- [x] E2E tests added for all new functionality
- [ ] CI tests pass
- [ ] Examples run successfully

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ashwin Bhat
2025-09-26 12:52:46 -07:00
committed by GitHub
parent 233cefa3e1
commit 507e22cb4a
7 changed files with 507 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Create a git commit
---
## Context
- Current git status: !`git status`
- Current git diff (staged and unstaged changes): !`git diff HEAD`
- Current branch: !`git branch --show-current`
- Recent commits: !`git log --oneline -10`
## Your task
Based on the above changes, create a single git commit.
You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.

View File

@@ -0,0 +1,155 @@
"""End-to-end tests for agents and setting sources with real Claude API calls."""
import tempfile
from pathlib import Path
import pytest
from claude_code_sdk import (
AgentDefinition,
ClaudeCodeOptions,
ClaudeSDKClient,
SystemMessage,
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_agent_definition():
"""Test that custom agent definitions work."""
options = ClaudeCodeOptions(
agents={
"test-agent": AgentDefinition(
description="A test agent for verification",
prompt="You are a test agent. Always respond with 'Test agent activated'",
tools=["Read"],
model="sonnet",
)
},
max_turns=1,
)
async with ClaudeSDKClient(options=options) as client:
await client.query("What is 2 + 2?")
# Check that agent is available in init message
async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init":
agents = message.data.get("agents", [])
assert isinstance(
agents, list
), f"agents should be a list of strings, got: {type(agents)}"
assert (
"test-agent" in agents
), f"test-agent should be available, got: {agents}"
break
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_setting_sources_default():
"""Test that default (no setting_sources) loads no settings."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a temporary project with local settings
project_dir = Path(tmpdir)
claude_dir = project_dir / ".claude"
claude_dir.mkdir(parents=True)
# Create local settings with custom outputStyle
settings_file = claude_dir / "settings.local.json"
settings_file.write_text('{"outputStyle": "local-test-style"}')
# Don't provide setting_sources - should default to no settings
options = ClaudeCodeOptions(
cwd=project_dir,
max_turns=1,
)
async with ClaudeSDKClient(options=options) as client:
await client.query("What is 2 + 2?")
# Check that settings were NOT loaded
async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init":
output_style = message.data.get("output_style")
assert (
output_style != "local-test-style"
), f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}"
assert (
output_style == "default"
), f"outputStyle should be 'default', got: {output_style}"
break
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_setting_sources_user_only():
"""Test that setting_sources=['user'] excludes project settings."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a temporary project with a slash command
project_dir = Path(tmpdir)
commands_dir = project_dir / ".claude" / "commands"
commands_dir.mkdir(parents=True)
test_command = commands_dir / "testcmd.md"
test_command.write_text(
"""---
description: Test command
---
This is a test command.
"""
)
# Use setting_sources=["user"] to exclude project settings
options = ClaudeCodeOptions(
setting_sources=["user"],
cwd=project_dir,
max_turns=1,
)
async with ClaudeSDKClient(options=options) as client:
await client.query("What is 2 + 2?")
# Check that project command is NOT available
async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init":
commands = message.data.get("slash_commands", [])
assert (
"testcmd" not in commands
), f"testcmd should NOT be available with user-only sources, got: {commands}"
break
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_setting_sources_project_included():
"""Test that setting_sources=['user', 'project'] includes project settings."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a temporary project with local settings
project_dir = Path(tmpdir)
claude_dir = project_dir / ".claude"
claude_dir.mkdir(parents=True)
# Create local settings with custom outputStyle
settings_file = claude_dir / "settings.local.json"
settings_file.write_text('{"outputStyle": "local-test-style"}')
# Use setting_sources=["user", "project", "local"] to include local settings
options = ClaudeCodeOptions(
setting_sources=["user", "project", "local"],
cwd=project_dir,
max_turns=1,
)
async with ClaudeSDKClient(options=options) as client:
await client.query("What is 2 + 2?")
# Check that settings WERE loaded
async for message in client.receive_response():
if isinstance(message, SystemMessage) and message.subtype == "init":
output_style = message.data.get("output_style")
assert (
output_style == "local-test-style"
), f"outputStyle should be from local settings, got: {output_style}"
break

124
examples/agents.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Example of using custom agents with Claude Code SDK.
This example demonstrates how to define and use custom agents with specific
tools, prompts, and models.
Usage:
./examples/agents.py - Run the example
"""
import anyio
from claude_code_sdk import (
AgentDefinition,
AssistantMessage,
ClaudeCodeOptions,
ResultMessage,
TextBlock,
query,
)
async def code_reviewer_example():
"""Example using a custom code reviewer agent."""
print("=== Code Reviewer Agent Example ===")
options = ClaudeCodeOptions(
agents={
"code-reviewer": AgentDefinition(
description="Reviews code for best practices and potential issues",
prompt="You are a code reviewer. Analyze code for bugs, performance issues, "
"security vulnerabilities, and adherence to best practices. "
"Provide constructive feedback.",
tools=["Read", "Grep"],
model="sonnet",
),
},
)
async for message in query(
prompt="Use the code-reviewer agent to review the code in src/claude_code_sdk/types.py",
options=options,
):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f"Claude: {block.text}")
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
print(f"\nCost: ${message.total_cost_usd:.4f}")
print()
async def documentation_writer_example():
"""Example using a documentation writer agent."""
print("=== Documentation Writer Agent Example ===")
options = ClaudeCodeOptions(
agents={
"doc-writer": AgentDefinition(
description="Writes comprehensive documentation",
prompt="You are a technical documentation expert. Write clear, comprehensive "
"documentation with examples. Focus on clarity and completeness.",
tools=["Read", "Write", "Edit"],
model="sonnet",
),
},
)
async for message in query(
prompt="Use the doc-writer agent to explain what AgentDefinition is used for",
options=options,
):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f"Claude: {block.text}")
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
print(f"\nCost: ${message.total_cost_usd:.4f}")
print()
async def multiple_agents_example():
"""Example with multiple custom agents."""
print("=== Multiple Agents Example ===")
options = ClaudeCodeOptions(
agents={
"analyzer": AgentDefinition(
description="Analyzes code structure and patterns",
prompt="You are a code analyzer. Examine code structure, patterns, and architecture.",
tools=["Read", "Grep", "Glob"],
),
"tester": AgentDefinition(
description="Creates and runs tests",
prompt="You are a testing expert. Write comprehensive tests and ensure code quality.",
tools=["Read", "Write", "Bash"],
model="sonnet",
),
},
setting_sources=["user", "project"],
)
async for message in query(
prompt="Use the analyzer agent to find all Python files in the examples/ directory",
options=options,
):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f"Claude: {block.text}")
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
print(f"\nCost: ${message.total_cost_usd:.4f}")
print()
async def main():
"""Run all agent examples."""
await code_reviewer_example()
await documentation_writer_example()
await multiple_agents_example()
if __name__ == "__main__":
anyio.run(main)

174
examples/setting_sources.py Normal file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Example demonstrating setting sources control.
This example shows how to use the setting_sources option to control which
settings are loaded, including custom slash commands, agents, and other
configurations.
Setting sources determine where Claude Code loads configurations from:
- "user": Global user settings (~/.claude/)
- "project": Project-level settings (.claude/ in project)
- "local": Local gitignored settings (.claude-local/)
IMPORTANT: When setting_sources is not provided (None), NO settings are loaded
by default. This creates an isolated environment. To load settings, explicitly
specify which sources to use.
By controlling which sources are loaded, you can:
- Create isolated environments with no custom settings (default)
- Load only user settings, excluding project-specific configurations
- Combine multiple sources as needed
Usage:
./examples/setting_sources.py - List the examples
./examples/setting_sources.py all - Run all examples
./examples/setting_sources.py default - Run a specific example
"""
import asyncio
import sys
from pathlib import Path
from claude_code_sdk import (
ClaudeCodeOptions,
ClaudeSDKClient,
SystemMessage,
)
def extract_slash_commands(msg: SystemMessage) -> list[str]:
"""Extract slash command names from system message."""
if msg.subtype == "init":
commands = msg.data.get("slash_commands", [])
return commands
return []
async def example_default():
"""Default behavior - no settings loaded."""
print("=== Default Behavior Example ===")
print("Setting sources: None (default)")
print("Expected: No custom slash commands will be available\n")
sdk_dir = Path(__file__).parent.parent
options = ClaudeCodeOptions(
cwd=sdk_dir,
)
async with ClaudeSDKClient(options=options) as client:
await client.query("What is 2 + 2?")
async for msg in client.receive_response():
if isinstance(msg, SystemMessage) and msg.subtype == "init":
commands = extract_slash_commands(msg)
print(f"Available slash commands: {commands}")
if "commit" in commands:
print("❌ /commit is available (unexpected)")
else:
print("✓ /commit is NOT available (expected - no settings loaded)")
break
print()
async def example_user_only():
"""Load only user-level settings, excluding project settings."""
print("=== User Settings Only Example ===")
print("Setting sources: ['user']")
print("Expected: Project slash commands (like /commit) will NOT be available\n")
# Use the SDK repo directory which has .claude/commands/commit.md
sdk_dir = Path(__file__).parent.parent
options = ClaudeCodeOptions(
setting_sources=["user"],
cwd=sdk_dir,
)
async with ClaudeSDKClient(options=options) as client:
# Send a simple query
await client.query("What is 2 + 2?")
# Check the initialize message for available commands
async for msg in client.receive_response():
if isinstance(msg, SystemMessage) and msg.subtype == "init":
commands = extract_slash_commands(msg)
print(f"Available slash commands: {commands}")
if "commit" in commands:
print("❌ /commit is available (unexpected)")
else:
print("✓ /commit is NOT available (expected)")
break
print()
async def example_project_and_user():
"""Load both project and user settings."""
print("=== Project + User Settings Example ===")
print("Setting sources: ['user', 'project']")
print("Expected: Project slash commands (like /commit) WILL be available\n")
sdk_dir = Path(__file__).parent.parent
options = ClaudeCodeOptions(
setting_sources=["user", "project"],
cwd=sdk_dir,
)
async with ClaudeSDKClient(options=options) as client:
await client.query("What is 2 + 2?")
async for msg in client.receive_response():
if isinstance(msg, SystemMessage) and msg.subtype == "init":
commands = extract_slash_commands(msg)
print(f"Available slash commands: {commands}")
if "commit" in commands:
print("✓ /commit is available (expected)")
else:
print("❌ /commit is NOT available (unexpected)")
break
print()
async def main():
"""Run all examples or a specific example based on command line argument."""
examples = {
"default": example_default,
"user_only": example_user_only,
"project_and_user": example_project_and_user,
}
if len(sys.argv) < 2:
print("Usage: python setting_sources.py <example_name>")
print("\nAvailable examples:")
print(" all - Run all examples")
for name in examples:
print(f" {name}")
sys.exit(0)
example_name = sys.argv[1]
if example_name == "all":
for example in examples.values():
await example()
print("-" * 50 + "\n")
elif example_name in examples:
await examples[example_name]()
else:
print(f"Error: Unknown example '{example_name}'")
print("\nAvailable examples:")
print(" all - Run all examples")
for name in examples:
print(f" {name}")
sys.exit(1)
if __name__ == "__main__":
print("Starting Claude SDK Setting Sources Examples...")
print("=" * 50 + "\n")
asyncio.run(main())

View File

@@ -16,6 +16,7 @@ from ._version import __version__
from .client import ClaudeSDKClient
from .query import query
from .types import (
AgentDefinition,
AssistantMessage,
CanUseTool,
ClaudeCodeOptions,
@@ -32,6 +33,7 @@ from .types import (
PermissionResultDeny,
PermissionUpdate,
ResultMessage,
SettingSource,
SystemMessage,
TextBlock,
ThinkingBlock,
@@ -307,6 +309,9 @@ __all__ = [
"HookCallback",
"HookContext",
"HookMatcher",
# Agent support
"AgentDefinition",
"SettingSource",
# MCP Server Support
"create_sdk_mcp_server",
"tool",

View File

@@ -6,6 +6,7 @@ import os
import shutil
from collections.abc import AsyncIterable, AsyncIterator
from contextlib import suppress
from dataclasses import asdict
from pathlib import Path
from subprocess import PIPE
from typing import Any
@@ -157,6 +158,20 @@ class SubprocessCLITransport(Transport):
if self._options.fork_session:
cmd.append("--fork-session")
if self._options.agents:
agents_dict = {
name: {k: v for k, v in asdict(agent_def).items() if v is not None}
for name, agent_def in self._options.agents.items()
}
cmd.extend(["--agents", json.dumps(agents_dict)])
sources_value = (
",".join(self._options.setting_sources)
if self._options.setting_sources is not None
else ""
)
cmd.extend(["--setting-sources", sources_value])
# Add extra args for future CLI flags
for flag, value in self._options.extra_args.items():
if value is None:

View File

@@ -14,6 +14,19 @@ if TYPE_CHECKING:
# Permission modes
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
# Agent definitions
SettingSource = Literal["user", "project", "local"]
@dataclass
class AgentDefinition:
"""Agent definition configuration."""
description: str
prompt: str
tools: list[str] | None = None
model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None
# Permission Update types (matching TypeScript SDK)
PermissionUpdateDestination = Literal[
@@ -317,6 +330,10 @@ class ClaudeCodeOptions:
# When true resumed sessions will fork to a new session ID rather than
# continuing the previous session.
fork_session: bool = False
# Agent definitions for custom agents
agents: dict[str, AgentDefinition] | None = None
# Setting sources to load (user, project, local)
setting_sources: list[SettingSource] | None = None
# SDK Control Protocol