diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..31ef079 --- /dev/null +++ b/.claude/commands/commit.md @@ -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. diff --git a/e2e-tests/test_agents_and_settings.py b/e2e-tests/test_agents_and_settings.py new file mode 100644 index 0000000..121e17b --- /dev/null +++ b/e2e-tests/test_agents_and_settings.py @@ -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 \ No newline at end of file diff --git a/examples/agents.py b/examples/agents.py new file mode 100644 index 0000000..6d909f4 --- /dev/null +++ b/examples/agents.py @@ -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) diff --git a/examples/setting_sources.py b/examples/setting_sources.py new file mode 100644 index 0000000..0b04418 --- /dev/null +++ b/examples/setting_sources.py @@ -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 ") + 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()) \ No newline at end of file diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 639c2c5..227b476 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -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", diff --git a/src/claude_code_sdk/_internal/transport/subprocess_cli.py b/src/claude_code_sdk/_internal/transport/subprocess_cli.py index c701bb3..885fc11 100644 --- a/src/claude_code_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_code_sdk/_internal/transport/subprocess_cli.py @@ -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: diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index 18dedd6..711e0e8 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -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