diff --git a/.gitignore b/.gitignore index 0023a53..b9678ab 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +Example/ClaudeCodeSDKExample/build/ diff --git a/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/ChatView.swift b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/ChatView.swift index be74df1..32c692c 100644 --- a/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/ChatView.swift +++ b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/ChatView.swift @@ -14,12 +14,33 @@ struct ChatView: View { @State var viewModel = ChatViewModel(claudeClient: ClaudeCodeClient(debug: true)) @State private var messageText: String = "" @FocusState private var isTextFieldFocused: Bool + @State private var showingSessions = false + @State private var showingMCPConfig = false var body: some View { VStack { // Top button bar HStack { + // List sessions button + Button(action: { + viewModel.listSessions() + showingSessions = true + }) { + Image(systemName: "list.bullet.rectangle") + .font(.title2) + } + + // MCP Config button + Button(action: { + showingMCPConfig = true + }) { + Image(systemName: "gearshape") + .font(.title2) + .foregroundColor(viewModel.isMCPEnabled ? .green : .primary) + } + Spacer() + Button(action: { clearChat() }) { @@ -100,6 +121,18 @@ struct ChatView: View { } } .navigationTitle("Claude Code Chat") + .sheet(isPresented: $showingSessions) { + SessionsListView(sessions: viewModel.sessions, isPresented: $showingSessions) + .frame(minWidth: 500, minHeight: 500) + } + .sheet(isPresented: $showingMCPConfig) { + MCPConfigView( + isMCPEnabled: $viewModel.isMCPEnabled, + mcpConfigPath: $viewModel.mcpConfigPath, + isPresented: $showingMCPConfig + ) + .frame(minWidth: 500, minHeight: 500) + } } private func sendMessage() { diff --git a/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/MCPConfigView.swift b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/MCPConfigView.swift new file mode 100644 index 0000000..3ccd1d6 --- /dev/null +++ b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/MCPConfigView.swift @@ -0,0 +1,115 @@ +// +// MCPConfigView.swift +// ClaudeCodeSDKExample +// +// Created by Assistant on 6/17/25. +// + +import SwiftUI + +struct MCPConfigView: View { + @Binding var isMCPEnabled: Bool + @Binding var mcpConfigPath: String + @Binding var isPresented: Bool + + var body: some View { + Form { + Section(header: Text("MCP Configuration")) { + Toggle("Enable MCP", isOn: $isMCPEnabled) + + if isMCPEnabled { + VStack(alignment: .leading, spacing: 8) { + Text("Config File Path") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + TextField("e.g., /path/to/mcp-config.json", text: $mcpConfigPath) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .disableAutocorrection(true) + + Button("Load Example") { + // Get the absolute path to the example file + let absolutePath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Example/ClaudeCodeSDKExample/mcp-config-example.json" + + // Check if file exists at the absolute path + if FileManager.default.fileExists(atPath: absolutePath) { + mcpConfigPath = absolutePath + } else { + // Try to find it relative to the app bundle (for when running from Xcode) + if let bundlePath = Bundle.main.resourcePath { + let bundleExamplePath = "\(bundlePath)/mcp-config-example.json" + if FileManager.default.fileExists(atPath: bundleExamplePath) { + mcpConfigPath = bundleExamplePath + } else { + // Show an error or use a placeholder + mcpConfigPath = "Error: Could not find mcp-config-example.json" + } + } + } + } + .buttonStyle(.bordered) + } + } + } + } + + if isMCPEnabled { + Section(header: Text("Example Configuration")) { + Text(exampleConfig) + .font(.system(.caption, design: .monospaced)) + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + + Section(header: Text("Notes")) { + Text("• MCP tools must be explicitly allowed using allowedTools") + .font(.caption) + Text("• MCP tool names follow the pattern: mcp____") + .font(.caption) + Text("• Use mcp__ to allow all tools from a server") + .font(.caption) + } + + Section(header: Text("XcodeBuildMCP Features")) { + Text("The example XcodeBuildMCP server provides tools for:") + .font(.caption) + .fontWeight(.semibold) + Text("• Xcode project management") + .font(.caption) + Text("• iOS/macOS simulator management") + .font(.caption) + Text("• Building and running apps") + .font(.caption) + Text("• Managing provisioning profiles") + .font(.caption) + } + } + } + .navigationTitle("MCP Settings") + .toolbar { + ToolbarItem(placement: .automatic) { + Button("Done") { + isPresented = false + } + } + } + } + + private var exampleConfig: String { + """ + { + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": [ + "-y", + "xcodebuildmcp@latest" + ] + } + } + } + """ + } +} diff --git a/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/SessionsListView.swift b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/SessionsListView.swift new file mode 100644 index 0000000..fed317a --- /dev/null +++ b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/SessionsListView.swift @@ -0,0 +1,84 @@ +// +// SessionsListView.swift +// ClaudeCodeSDKExample +// +// Created by Assistant on 6/17/25. +// + +import SwiftUI +import ClaudeCodeSDK + +struct SessionsListView: View { + let sessions: [SessionInfo] + @Binding var isPresented: Bool + + var body: some View { + NavigationView { + VStack { + if sessions.isEmpty { + Text("No sessions found") + .foregroundColor(.secondary) + .padding() + } else { + List(sessions, id: \.id) { session in + VStack(alignment: .leading, spacing: 4) { + Text(session.id) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + + if let created = session.created { + Text("Created: \(formattedDate(created))") + .font(.caption) + } + + HStack { + if let lastActive = session.lastActive { + Text("Last active: \(formattedDate(lastActive))") + .font(.caption) + } + + Spacer() + + if let totalCost = session.totalCostUsd { + Text("$\(String(format: "%.4f", totalCost))") + .font(.caption) + .foregroundColor(.blue) + } + } + + if let project = session.project { + Text("Project: \(project)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } + } + } + .navigationTitle("Sessions") + .toolbar { + ToolbarItem(placement: .automatic) { + Button("Done") { + isPresented = false + } + } + } + } + } + + private func formattedDate(_ dateString: String) -> String { + // Try to parse ISO8601 date + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + if let date = formatter.date(from: dateString) { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .short + displayFormatter.timeStyle = .short + return displayFormatter.string(from: date) + } + + return dateString + } +} diff --git a/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Model/ChatViewModel.swift b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Model/ChatViewModel.swift index 70ceb70..787ca74 100644 --- a/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Model/ChatViewModel.swift +++ b/Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Model/ChatViewModel.swift @@ -33,6 +33,15 @@ public class ChatViewModel { /// Error state public var error: Error? + /// Sessions list + var sessions: [SessionInfo] = [] + + /// MCP configuration enabled + var isMCPEnabled: Bool = false + + /// MCP config path + var mcpConfigPath: String = "" + // MARK: - Initialization @@ -99,6 +108,24 @@ public class ChatViewModel { isLoading = false } + /// Lists all available sessions + public func listSessions() { + Task { + do { + let fetchedSessions = try await claudeClient.listSessions() + await MainActor.run { + self.sessions = fetchedSessions + logger.info("Fetched \(fetchedSessions.count) sessions") + } + } catch { + await MainActor.run { + self.error = error + logger.error("Failed to list sessions: \(error)") + } + } + } + } + // MARK: - Private Methods private func startNewConversation(prompt: String, messageId: UUID) async throws { @@ -106,6 +133,23 @@ public class ChatViewModel { options.allowedTools = allowedTools options.verbose = true + // Add MCP config if enabled + if self.isMCPEnabled && !self.mcpConfigPath.isEmpty { + options.mcpConfigPath = self.mcpConfigPath + // Add MCP tools to allowed tools if using MCP + var updatedAllowedTools = self.allowedTools + + // Generate MCP tool patterns from the configuration file + let mcpToolPatterns = MCPToolFormatter.generateAllowedToolPatterns(fromConfigPath: self.mcpConfigPath) + updatedAllowedTools.append(contentsOf: mcpToolPatterns) + + options.allowedTools = updatedAllowedTools + + self.logger.info("MCP enabled with config: \(self.mcpConfigPath)") + self.logger.info("MCP servers found: \(MCPToolFormatter.extractServerNames(fromConfigPath: self.mcpConfigPath))") + self.logger.info("Allowed tools: \(updatedAllowedTools)") + } + let result = try await claudeClient.runSinglePrompt( prompt: prompt, outputFormat: .streamJson, @@ -120,6 +164,19 @@ public class ChatViewModel { options.allowedTools = allowedTools options.verbose = true + // Add MCP config if enabled + if isMCPEnabled && !mcpConfigPath.isEmpty { + options.mcpConfigPath = mcpConfigPath + // Add MCP tools to allowed tools if using MCP + var updatedAllowedTools = allowedTools + + // Generate MCP tool patterns from the configuration file + let mcpToolPatterns = MCPToolFormatter.generateAllowedToolPatterns(fromConfigPath: mcpConfigPath) + updatedAllowedTools.append(contentsOf: mcpToolPatterns) + + options.allowedTools = updatedAllowedTools + } + let result = try await claudeClient.resumeConversation( sessionId: sessionId, prompt: prompt, @@ -158,12 +215,12 @@ public class ChatViewModel { switch chunk { case .initSystem(let initMessage): - + if currentSessionId == nil { // Only update if not already in a conversation - self.currentSessionId = initMessage.sessionId - logger.debug("Started new session: \(initMessage.sessionId)") + self.currentSessionId = initMessage.sessionId + logger.debug("Started new session: \(initMessage.sessionId)") } else { - logger.debug("Continuing with new session ID: \(initMessage.sessionId)") + logger.debug("Continuing with new session ID: \(initMessage.sessionId)") } case .assistant(let message): @@ -365,3 +422,4 @@ extension ContentItem { return result } } + diff --git a/Example/ClaudeCodeSDKExample/README.md b/Example/ClaudeCodeSDKExample/README.md new file mode 100644 index 0000000..2873908 --- /dev/null +++ b/Example/ClaudeCodeSDKExample/README.md @@ -0,0 +1,93 @@ +# ClaudeCodeSDK Example App + +This example demonstrates how to use the ClaudeCodeSDK with MCP (Model Context Protocol) support. + +## Features + +- **Chat Interface**: Interactive chat with Claude Code +- **Session Management**: View and manage previous Claude Code sessions +- **MCP Configuration**: Enable and configure MCP servers + +## Using MCP Configuration + +1. Click the gear icon (⚙️) in the toolbar +2. Toggle "Enable MCP" to ON +3. Either: + - Click "Load Example" to use the included `mcp-config-example.json` + - Or provide your own path to an MCP configuration file + +### Example MCP Config + +The included `mcp-config-example.json` demonstrates integration with XcodeBuildMCP: + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": [ + "-y", + "xcodebuildmcp@latest" + ] + } + } +} +``` + +This configuration enables Claude Code to interact with Xcode build tools. + +### Creating Your Own MCP Config + +You can create custom MCP configurations for different servers: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/path/to/allowed/files" + ] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "your-github-token" + } + } + } +} +``` + +## MCP Tool Naming Convention + +When MCP is enabled, tools from MCP servers follow a specific naming pattern: +- `mcp____` + +For example, with the XcodeBuildMCP server, tools are available as: +- `mcp__XcodeBuildMCP__build` +- `mcp__XcodeBuildMCP__test` +- `mcp__XcodeBuildMCP__clean` + +The example app automatically: +1. Reads your MCP configuration file +2. Extracts all server names +3. Generates wildcard patterns like `mcp__XcodeBuildMCP__*` to allow all tools from each server +4. Adds these patterns to the allowed tools list + +## Important Notes + +- MCP tools must be explicitly allowed using the correct naming convention +- Use wildcards like `mcp____*` to allow all tools from a server +- The SDK handles the tool naming automatically when you provide an MCP configuration + +## Session Management + +Click the list icon (📋) to view all your Claude Code sessions, including: +- Session IDs +- Creation and last active dates +- Total cost per session +- Associated project names \ No newline at end of file diff --git a/Example/ClaudeCodeSDKExample/mcp-config-example.json b/Example/ClaudeCodeSDKExample/mcp-config-example.json new file mode 100644 index 0000000..29e6027 --- /dev/null +++ b/Example/ClaudeCodeSDKExample/mcp-config-example.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": [ + "-y", + "xcodebuildmcp@latest" + ] + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index d64a996..a5b226e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ [Beta] A Swift SDK for seamlessly integrating Claude Code into your iOS and macOS applications. Interact with Anthropic's Claude Code programmatically for AI-powered coding assistance. +## ✨ What's New + +* **Full TypeScript SDK API Parity** - All options and features from the TypeScript SDK are now available +* **Enhanced Error Handling** - Detailed error types with retry hints and classification +* **Built-in Retry Logic** - Automatic retry with exponential backoff for transient failures +* **Rate Limiting** - Token bucket rate limiter to respect API limits +* **Timeout Support** - Configurable timeouts for all operations +* **Cancellation** - AbortController support for canceling long-running operations +* **New Configuration Options** - Model selection, permission modes, executable configuration, and more + ## Overview ClaudeCodeSDK allows you to integrate Claude Code's capabilities directly into your Swift applications. The SDK provides a simple interface to run Claude Code as a subprocess, enabling multi-turn conversations, custom system prompts, and various output formats. @@ -149,14 +159,23 @@ client.configuration.workingDirectory = "/new/path" ### Customization Options -Fine-tune Claude Code's behavior: +Fine-tune Claude Code's behavior with comprehensive options: ```swift var options = ClaudeCodeOptions() options.verbose = true options.maxTurns = 5 -options.systemPrompt = "You are a senior backend engineer specializing in Swift." +options.customSystemPrompt = "You are a senior backend engineer specializing in Swift." options.appendSystemPrompt = "After writing code, add comprehensive comments." +options.timeout = 300 // 5 minute timeout +options.model = "claude-3-sonnet-20240229" +options.permissionMode = .acceptEdits +options.executable = .node // or .bun, .deno +options.maxThinkingTokens = 10000 + +// Tool configuration +options.allowedTools = ["Read", "Write", "Bash"] +options.disallowedTools = ["Delete"] let result = try await client.runSinglePrompt( prompt: "Create a REST API in Swift", @@ -165,6 +184,207 @@ let result = try await client.runSinglePrompt( ) ``` +### MCP Configuration + +The Model Context Protocol (MCP) allows you to extend Claude Code with additional tools and resources from external servers. ClaudeCodeSDK provides full support for MCP integration. + +#### Using MCP with Configuration File + +Create a JSON configuration file with your MCP servers: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/path/to/allowed/files" + ] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "your-github-token" + } + } + } +} +``` + +Use the configuration in your Swift code: + +```swift +var options = ClaudeCodeOptions() +options.mcpConfigPath = "/path/to/mcp-config.json" + +// MCP tools are automatically added with the format: mcp__serverName__toolName +// The SDK will automatically allow tools like: +// - mcp__filesystem__read_file +// - mcp__filesystem__list_directory +// - mcp__github__* + +let result = try await client.runSinglePrompt( + prompt: "List all files in the project", + outputFormat: .text, + options: options +) +``` + +#### Programmatic MCP Configuration + +You can also configure MCP servers programmatically: + +```swift +var options = ClaudeCodeOptions() + +// Define MCP servers in code +options.mcpServers = [ + "XcodeBuildMCP": .stdio(McpStdioServerConfig( + command: "npx", + args: ["-y", "xcodebuildmcp@latest"] + )), + "filesystem": .stdio(McpStdioServerConfig( + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"] + )) +] + +// The SDK creates a temporary configuration file automatically +let result = try await client.runSinglePrompt( + prompt: "Build the iOS app", + outputFormat: .streamJson, + options: options +) +``` + +#### MCP Tool Naming Convention + +MCP tools follow a specific naming pattern: `mcp____` + +```swift +// Explicitly allow specific MCP tools +options.allowedTools = [ + "mcp__filesystem__read_file", + "mcp__filesystem__write_file", + "mcp__github__search_repositories" +] + +// Or use wildcards to allow all tools from a server +options.allowedTools = ["mcp__filesystem__*", "mcp__github__*"] +``` + +#### Using MCP with Permission Prompts + +For non-interactive mode with MCP servers that require permissions: + +```swift +var options = ClaudeCodeOptions() +options.mcpConfigPath = "/path/to/mcp-config.json" +options.permissionMode = .auto +options.permissionPromptToolName = "mcp__permissions__approve" +``` + +### Error Handling & Resilience + +The SDK provides robust error handling with detailed error types and recovery options: + +```swift +// Enhanced error handling +do { + let result = try await client.runSinglePrompt( + prompt: "Complex task", + outputFormat: .json, + options: options + ) +} catch let error as ClaudeCodeError { + if error.isRetryable { + // Error can be retried + if let delay = error.suggestedRetryDelay { + // Wait and retry + } + } else if error.isRateLimitError { + print("Rate limited") + } else if error.isTimeoutError { + print("Request timed out") + } else if error.isPermissionError { + print("Permission denied") + } +} +``` + +### Retry Logic + +Built-in retry support with exponential backoff: + +```swift +// Simple retry with default policy +let result = try await client.runSinglePromptWithRetry( + prompt: "Generate code", + outputFormat: .json, + retryPolicy: .default // 3 attempts with exponential backoff +) + +// Custom retry policy +let conservativePolicy = RetryPolicy( + maxAttempts: 5, + initialDelay: 5.0, + maxDelay: 300.0, + backoffMultiplier: 2.0, + useJitter: true +) + +let result = try await client.runSinglePromptWithRetry( + prompt: "Complex analysis", + outputFormat: .json, + retryPolicy: conservativePolicy +) +``` + +### Rate Limiting + +Protect against API rate limits with built-in rate limiting: + +```swift +// Create a rate-limited client +let rateLimitedClient = RateLimitedClaudeCode( + wrapped: client, + requestsPerMinute: 10, + burstCapacity: 3 // Allow 3 requests in burst +) + +// All requests are automatically rate-limited +let result = try await rateLimitedClient.runSinglePrompt( + prompt: "Task", + outputFormat: .json, + options: nil +) +``` + +### Cancellation Support + +Cancel long-running operations with AbortController: + +```swift +var options = ClaudeCodeOptions() +let abortController = AbortController() +options.abortController = abortController + +// Start operation +Task { + let result = try await client.runSinglePrompt( + prompt: "Long running task", + outputFormat: .streamJson, + options: options + ) +} + +// Cancel when needed +abortController.abort() +``` + ## Example Project The repository includes a complete example project demonstrating how to integrate and use the SDK in a real application. You can find it in the `Example/ClaudeCodeSDKExample` directory. @@ -187,6 +407,7 @@ The example showcases: The SDK is built with a protocol-based architecture for maximum flexibility: +### Core Components * **`ClaudeCode`**: Protocol defining the interface * **`ClaudeCodeClient`**: Concrete implementation that runs Claude Code CLI as a subprocess * **`ClaudeCodeOptions`**: Configuration options for Claude Code execution @@ -194,6 +415,23 @@ The SDK is built with a protocol-based architecture for maximum flexibility: * **`ClaudeCodeResult`**: Result types returned by the SDK * **`ResponseChunk`**: Individual chunks in streaming responses +### Type System +* **`ApiKeySource`**: Source of API key (user/project/org/temporary) +* **`ConfigScope`**: Configuration scope levels (local/user/project) +* **`PermissionMode`**: Permission handling modes (default/acceptEdits/bypassPermissions/plan) +* **`ExecutableType`**: JavaScript runtime types (node/bun/deno) +* **`McpServerConfig`**: MCP server configurations (stdio/sse) + +### Error Handling +* **`ClaudeCodeError`**: Comprehensive error types with retry hints +* **`RetryPolicy`**: Configurable retry strategies +* **`RetryHandler`**: Automatic retry with exponential backoff + +### Utilities +* **`RateLimiter`**: Token bucket rate limiting +* **`AbortController`**: Cancellation support +* **`RateLimitedClaudeCode`**: Rate-limited wrapper + ## License ClaudeCodeSDK is available under the MIT license. See the `LICENSE` file for more info. diff --git a/Sources/ClaudeCodeSDK/API/AbortController.swift b/Sources/ClaudeCodeSDK/API/AbortController.swift new file mode 100644 index 0000000..af8a86e --- /dev/null +++ b/Sources/ClaudeCodeSDK/API/AbortController.swift @@ -0,0 +1,56 @@ +// +// AbortController.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation + +/// Controller for aborting operations +/// Similar to the web AbortController API +public final class AbortController: Sendable { + /// Signal that can be used to check if operation was aborted + public let signal: AbortSignal + + public init() { + self.signal = AbortSignal() + } + + /// Abort the operation + public func abort() { + signal.abort() + } +} + +/// Signal that indicates if an operation was aborted +public final class AbortSignal: @unchecked Sendable { + private var _aborted = false + private var callbacks: [() -> Void] = [] + private let queue = DispatchQueue(label: "com.claudecode.abortsignal") + + /// Whether the operation has been aborted + public var aborted: Bool { + queue.sync { _aborted } + } + + /// Add a callback to be called when aborted + public func onAbort(_ callback: @escaping () -> Void) { + queue.sync { + if _aborted { + callback() + } else { + callbacks.append(callback) + } + } + } + + internal func abort() { + queue.sync { + guard !_aborted else { return } + _aborted = true + callbacks.forEach { $0() } + callbacks.removeAll() + } + } +} diff --git a/Sources/ClaudeCodeSDK/API/ApiKeySource.swift b/Sources/ClaudeCodeSDK/API/ApiKeySource.swift new file mode 100644 index 0000000..22fe151 --- /dev/null +++ b/Sources/ClaudeCodeSDK/API/ApiKeySource.swift @@ -0,0 +1,23 @@ +// +// ApiKeySource.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation + +/// Represents the source of an API key used for authentication +public enum ApiKeySource: String, Codable { + /// API key from user configuration + case user = "user" + + /// API key from project configuration + case project = "project" + + /// API key from organization configuration + case org = "org" + + /// Temporary API key + case temporary = "temporary" +} diff --git a/Sources/ClaudeCodeSDK/API/ClaudeCodeConfiguration.swift b/Sources/ClaudeCodeSDK/API/ClaudeCodeConfiguration.swift index 141394e..51dd444 100644 --- a/Sources/ClaudeCodeSDK/API/ClaudeCodeConfiguration.swift +++ b/Sources/ClaudeCodeSDK/API/ClaudeCodeConfiguration.swift @@ -48,4 +48,4 @@ public struct ClaudeCodeConfiguration { self.enableDebugLogging = enableDebugLogging self.additionalPaths = additionalPaths } -} \ No newline at end of file +} diff --git a/Sources/ClaudeCodeSDK/API/ClaudeCodeOptions.swift b/Sources/ClaudeCodeSDK/API/ClaudeCodeOptions.swift index 3c1d03c..ca57df4 100644 --- a/Sources/ClaudeCodeSDK/API/ClaudeCodeOptions.swift +++ b/Sources/ClaudeCodeSDK/API/ClaudeCodeOptions.swift @@ -10,40 +10,75 @@ import Foundation // MARK: - ClaudeCodeOptions /// Configuration options for Claude Code execution +/// Matches the TypeScript SDK Options interface public struct ClaudeCodeOptions { - /// Run in non-interactive mode (--print/-p flag) - /// This should be true for all SDK operations - public var printMode: Bool = true - - /// Enable verbose logging - public var verbose: Bool = false - - /// Maximum number of turns allowed (for non-interactive mode) - public var maxTurns: Int? + /// Abort controller for cancellation support + public var abortController: AbortController? /// List of tools allowed for Claude to use public var allowedTools: [String]? + /// Text to append to system prompt + public var appendSystemPrompt: String? + + /// Custom system prompt + public var customSystemPrompt: String? + + /// Working directory (cwd) + public var cwd: String? + /// List of tools denied for Claude to use public var disallowedTools: [String]? + /// JavaScript runtime executable type + public var executable: ExecutableType? + + /// Arguments for the executable + public var executableArgs: [String]? + + /// Maximum thinking tokens + public var maxThinkingTokens: Int? + + /// Maximum number of turns allowed + public var maxTurns: Int? + + /// MCP server configurations + public var mcpServers: [String: McpServerConfiguration]? + + /// Path to Claude Code executable + public var pathToClaudeCodeExecutable: String? + + /// Permission mode for operations + public var permissionMode: PermissionMode? + /// Tool for handling permission prompts in non-interactive mode - public var permissionPromptTool: String? + public var permissionPromptToolName: String? - /// Custom system prompt - public var systemPrompt: String? + /// Continue flag for conversation continuation + public var `continue`: Bool? - /// Text to append to system prompt - public var appendSystemPrompt: String? + /// Resume session ID + public var resume: String? + + /// Model to use + public var model: String? + + /// Timeout in seconds for command execution + public var timeout: TimeInterval? /// Path to MCP configuration file + /// Alternative to mcpServers for file-based configuration public var mcpConfigPath: String? - /// Working directory for file operations - public var workingDirectory: String? + // Internal properties maintained for compatibility + /// Run in non-interactive mode (--print/-p flag) + internal var printMode: Bool = true - public init(printMode: Bool = true) { - self.printMode = printMode + /// Enable verbose logging + public var verbose: Bool = false + + public init() { + // Default initialization } /// Convert options to command line arguments @@ -64,24 +99,33 @@ public struct ClaudeCodeOptions { args.append("\(maxTurns)") } + if let maxThinkingTokens = maxThinkingTokens { + args.append("--max-thinking-tokens") + args.append("\(maxThinkingTokens)") + } + if let allowedTools = allowedTools, !allowedTools.isEmpty { args.append("--allowedTools") - args.append(allowedTools.joined(separator: ",")) + // Escape the joined string in quotes to prevent shell expansion + let toolsList = allowedTools.joined(separator: ",") + args.append("\"\(toolsList)\"") } if let disallowedTools = disallowedTools, !disallowedTools.isEmpty { args.append("--disallowedTools") - args.append(disallowedTools.joined(separator: ",")) + // Escape the joined string in quotes to prevent shell expansion + let toolsList = disallowedTools.joined(separator: ",") + args.append("\"\(toolsList)\"") } - if let permissionPromptTool = permissionPromptTool { + if let permissionPromptToolName = permissionPromptToolName { args.append("--permission-prompt-tool") - args.append(permissionPromptTool) + args.append(permissionPromptToolName) } - if let systemPrompt = systemPrompt { - args.append("--system-prompt") - args.append(systemPrompt) + if let customSystemPrompt = customSystemPrompt { + args.append("--custom-system-prompt") + args.append(customSystemPrompt) } if let appendSystemPrompt = appendSystemPrompt { @@ -89,9 +133,52 @@ public struct ClaudeCodeOptions { args.append(appendSystemPrompt) } + if let cwd = cwd { + args.append("--cwd") + args.append(cwd) + } + + if let executable = executable { + args.append("--executable") + args.append(executable.rawValue) + } + + if let executableArgs = executableArgs, !executableArgs.isEmpty { + args.append("--executable-args") + args.append(executableArgs.joined(separator: " ")) + } + + if let pathToClaudeCodeExecutable = pathToClaudeCodeExecutable { + args.append("--path-to-claude-code-executable") + args.append(pathToClaudeCodeExecutable) + } + + if let permissionMode = permissionMode { + args.append("--permission-mode") + args.append(permissionMode.rawValue) + } + + if let model = model { + args.append("--model") + args.append(model) + } + + // Handle MCP configuration if let mcpConfigPath = mcpConfigPath { + // Use file-based configuration args.append("--mcp-config") args.append(mcpConfigPath) + } else if let mcpServers = mcpServers, !mcpServers.isEmpty { + // Create temporary file with MCP configuration + let tempDir = FileManager.default.temporaryDirectory + let configFile = tempDir.appendingPathComponent("mcp-config-\(UUID().uuidString).json") + + let config = ["mcpServers": mcpServers] + if let jsonData = try? JSONEncoder().encode(config), + (try? jsonData.write(to: configFile)) != nil { + args.append("--mcp-config") + args.append(configFile.path) + } } return args diff --git a/Sources/ClaudeCodeSDK/API/ClaudeCodeResult.swift b/Sources/ClaudeCodeSDK/API/ClaudeCodeResult.swift index 5eb5e69..1f38002 100644 --- a/Sources/ClaudeCodeSDK/API/ClaudeCodeResult.swift +++ b/Sources/ClaudeCodeSDK/API/ClaudeCodeResult.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - ClaudeCodeResult /// Represents the different types of results that can be returned by Claude Code. -public enum ClaudeCodeResult { +@frozen public enum ClaudeCodeResult { /// Plain text result case text(String) diff --git a/Sources/ClaudeCodeSDK/API/ConfigScope.swift b/Sources/ClaudeCodeSDK/API/ConfigScope.swift new file mode 100644 index 0000000..a205a94 --- /dev/null +++ b/Sources/ClaudeCodeSDK/API/ConfigScope.swift @@ -0,0 +1,20 @@ +// +// ConfigScope.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation + +/// Represents the scope of configuration settings +public enum ConfigScope: String, Codable { + /// Local configuration (current directory) + case local = "local" + + /// User-level configuration + case user = "user" + + /// Project-level configuration + case project = "project" +} diff --git a/Sources/ClaudeCodeSDK/API/ExecutableType.swift b/Sources/ClaudeCodeSDK/API/ExecutableType.swift new file mode 100644 index 0000000..0d0a841 --- /dev/null +++ b/Sources/ClaudeCodeSDK/API/ExecutableType.swift @@ -0,0 +1,15 @@ +// +// ExecutableType.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation + +/// Type of JavaScript runtime executable +public enum ExecutableType: String, Codable, Sendable { + case bun = "bun" + case deno = "deno" + case node = "node" +} diff --git a/Sources/ClaudeCodeSDK/API/InitSystemMessage.swift b/Sources/ClaudeCodeSDK/API/InitSystemMessage.swift index 0c8cdf0..d42b19a 100644 --- a/Sources/ClaudeCodeSDK/API/InitSystemMessage.swift +++ b/Sources/ClaudeCodeSDK/API/InitSystemMessage.swift @@ -22,8 +22,8 @@ public struct InitSystemMessage: Codable { /// Represents system message subtypes public enum SystemSubtype: String, Codable { - case `init` - case success - case errorMaxTurns = "error_max_turns" - // Add other error types as needed + case `init` + case success + case errorMaxTurns = "error_max_turns" + // Add other error types as needed } diff --git a/Sources/ClaudeCodeSDK/API/McpServerConfig.swift b/Sources/ClaudeCodeSDK/API/McpServerConfig.swift new file mode 100644 index 0000000..406d508 --- /dev/null +++ b/Sources/ClaudeCodeSDK/API/McpServerConfig.swift @@ -0,0 +1,117 @@ +// +// McpServerConfig.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation + +/// Base protocol for MCP server configurations +public protocol McpServerConfig: Codable { + var type: McpServerType? { get } +} + +/// Type of MCP server +public enum McpServerType: String, Codable, Sendable { + case stdio = "stdio" + case sse = "sse" +} + +/// Configuration for stdio-based MCP servers +public struct McpStdioServerConfig: McpServerConfig, Sendable { + /// Type of server (optional for backwards compatibility) + public let type: McpServerType? + + /// Command to execute + public let command: String + + /// Arguments to pass to the command + public let args: [String]? + + /// Environment variables for the command + public let env: [String: String]? + + public init( + type: McpServerType? = .stdio, + command: String, + args: [String]? = nil, + env: [String: String]? = nil + ) { + self.type = type + self.command = command + self.args = args + self.env = env + } +} + +/// Configuration for SSE-based MCP servers +public struct McpSSEServerConfig: McpServerConfig, Sendable { + /// Type of server (required for SSE) + public let type: McpServerType? + + /// URL of the SSE server + public let url: String + + /// Headers to include in requests + public let headers: [String: String]? + + public init(type: McpServerType? = .sse, url: String, headers: [String: String]? = nil) { + self.type = type + self.url = url + self.headers = headers + } +} + +/// Container for MCP server configuration that handles both types +public enum McpServerConfiguration: Codable, Sendable { + case stdio(McpStdioServerConfig) + case sse(McpSSEServerConfig) + + private enum CodingKeys: String, CodingKey { + case type + case command + case args + case env + case url + case headers + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Try to decode type, but it's optional for stdio + let type = try container.decodeIfPresent(McpServerType.self, forKey: .type) + + // If type is SSE or we have a URL, decode as SSE + if type == .sse || container.contains(.url) { + let url = try container.decode(String.self, forKey: .url) + let headers = try container.decodeIfPresent([String: String].self, forKey: .headers) + self = .sse(McpSSEServerConfig(type: type, url: url, headers: headers)) + } else { + // Otherwise decode as stdio + let command = try container.decode(String.self, forKey: .command) + let args = try container.decodeIfPresent([String].self, forKey: .args) + let env = try container.decodeIfPresent([String: String].self, forKey: .env) + self = .stdio(McpStdioServerConfig(type: type, command: command, args: args, env: env)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .stdio(let config): + try container.encodeIfPresent(config.type, forKey: .type) + try container.encode(config.command, forKey: .command) + try container.encodeIfPresent(config.args, forKey: .args) + try container.encodeIfPresent(config.env, forKey: .env) + + case .sse(let config): + try container.encode(McpServerType.sse, forKey: .type) + try container.encode(config.url, forKey: .url) + try container.encodeIfPresent(config.headers, forKey: .headers) + } + } +} + diff --git a/Sources/ClaudeCodeSDK/API/PermissionMode.swift b/Sources/ClaudeCodeSDK/API/PermissionMode.swift new file mode 100644 index 0000000..a383301 --- /dev/null +++ b/Sources/ClaudeCodeSDK/API/PermissionMode.swift @@ -0,0 +1,23 @@ +// +// PermissionMode.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation + +/// Represents the permission mode for Claude Code operations +public enum PermissionMode: String, Codable, Sendable { + /// Default permission mode - asks for user confirmation + case `default` = "default" + + /// Automatically accepts edit operations + case acceptEdits = "acceptEdits" + + /// Bypasses all permission checks (use with caution) + case bypassPermissions = "bypassPermissions" + + /// Plan mode - creates a plan before executing + case plan = "plan" +} diff --git a/Sources/ClaudeCodeSDK/API/SessionInfo.swift b/Sources/ClaudeCodeSDK/API/SessionInfo.swift index b7c022b..6e4c3ad 100644 --- a/Sources/ClaudeCodeSDK/API/SessionInfo.swift +++ b/Sources/ClaudeCodeSDK/API/SessionInfo.swift @@ -8,9 +8,18 @@ import Foundation /// Information about a Claude Code session -public struct SessionInfo: Codable { - public let sessionId: String - public let createdAt: Date - public let lastUpdatedAt: Date - public let title: String? +public struct SessionInfo: Codable, Identifiable { + public let id: String + public let created: String? + public let lastActive: String? + public let totalCostUsd: Double? + public let project: String? + + private enum CodingKeys: String, CodingKey { + case id + case created + case lastActive = "last_active" + case totalCostUsd = "total_cost_usd" + case project + } } diff --git a/Sources/ClaudeCodeSDK/API/UserMessage.swift b/Sources/ClaudeCodeSDK/API/UserMessage.swift index 1948765..98334f0 100644 --- a/Sources/ClaudeCodeSDK/API/UserMessage.swift +++ b/Sources/ClaudeCodeSDK/API/UserMessage.swift @@ -18,3 +18,4 @@ public struct UserMessage: Decodable { public let content: [MessageResponse.Content] } } + diff --git a/Sources/ClaudeCodeSDK/Client/ClaudeCodeClient.swift b/Sources/ClaudeCodeSDK/Client/ClaudeCodeClient.swift index 7cb39b8..4f42890 100644 --- a/Sources/ClaudeCodeSDK/Client/ClaudeCodeClient.swift +++ b/Sources/ClaudeCodeSDK/Client/ClaudeCodeClient.swift @@ -7,7 +7,7 @@ import Foundation import os.log /// Concrete implementation of ClaudeCodeSDK that uses the Claude Code CLI -public class ClaudeCodeClient: ClaudeCode { +public final class ClaudeCodeClient: ClaudeCode, @unchecked Sendable { private var task: Process? private var cancellables = Set() private var logger: Logger? @@ -81,6 +81,11 @@ public class ClaudeCodeClient: ClaudeCode { opts.verbose = true } + // Use cwd from options if not set, fallback to configuration workingDirectory + if opts.cwd == nil, let workingDir = configuration.workingDirectory { + opts.cwd = workingDir + } + let args = opts.toCommandArgs() let argsString = args.joined(separator: " ") let commandString = "\(configuration.command) \(argsString)" @@ -88,7 +93,9 @@ public class ClaudeCodeClient: ClaudeCode { return try await executeClaudeCommand( command: commandString, outputFormat: outputFormat, - stdinContent: stdinContent + stdinContent: stdinContent, + abortController: opts.abortController, + timeout: opts.timeout ) } @@ -105,6 +112,11 @@ public class ClaudeCodeClient: ClaudeCode { opts.verbose = true } + // Use cwd from options if not set, fallback to configuration workingDirectory + if opts.cwd == nil, let workingDir = configuration.workingDirectory { + opts.cwd = workingDir + } + var args = opts.toCommandArgs() args.append(outputFormat.commandArgument) @@ -115,7 +127,9 @@ public class ClaudeCodeClient: ClaudeCode { return try await executeClaudeCommand( command: commandString, outputFormat: outputFormat, - stdinContent: prompt + stdinContent: prompt, + abortController: opts.abortController, + timeout: opts.timeout ) } @@ -132,6 +146,11 @@ public class ClaudeCodeClient: ClaudeCode { opts.verbose = true } + // Use cwd from options if not set, fallback to configuration workingDirectory + if opts.cwd == nil, let workingDir = configuration.workingDirectory { + opts.cwd = workingDir + } + var args = opts.toCommandArgs() args.append("--continue") args.append(outputFormat.commandArgument) @@ -143,7 +162,9 @@ public class ClaudeCodeClient: ClaudeCode { return try await executeClaudeCommand( command: commandString, outputFormat: outputFormat, - stdinContent: prompt + stdinContent: prompt, + abortController: opts.abortController, + timeout: opts.timeout ) } @@ -161,6 +182,11 @@ public class ClaudeCodeClient: ClaudeCode { opts.verbose = true } + // Use cwd from options if not set, fallback to configuration workingDirectory + if opts.cwd == nil, let workingDir = configuration.workingDirectory { + opts.cwd = workingDir + } + var args = opts.toCommandArgs() args.append("--resume") args.append(sessionId) @@ -173,11 +199,12 @@ public class ClaudeCodeClient: ClaudeCode { return try await executeClaudeCommand( command: commandString, outputFormat: outputFormat, - stdinContent: prompt + stdinContent: prompt, + abortController: opts.abortController, + timeout: opts.timeout ) } - public func listSessions() async throws -> [SessionInfo] { let commandString = "\(configuration.command) logs --output-format json" @@ -204,7 +231,7 @@ public class ClaudeCodeClient: ClaudeCode { throw ClaudeCodeError.invalidOutput("Could not decode output as UTF-8") } - logger?.debug("Received session list output: \(output.prefix(100))...") + logger?.debug("Received session list output: \(output.prefix(10000))...") do { let sessions = try decoder.decode([SessionInfo].self, from: outputData) @@ -233,7 +260,9 @@ public class ClaudeCodeClient: ClaudeCode { private func executeClaudeCommand( command: String, outputFormat: ClaudeCodeOutputFormat, - stdinContent: String? = nil + stdinContent: String? = nil, + abortController: AbortController? = nil, + timeout: TimeInterval? = nil ) async throws -> ClaudeCodeResult { logger?.info("Executing command: \(command)") @@ -258,19 +287,55 @@ public class ClaudeCodeClient: ClaudeCode { // Store for cancellation self.task = process + // Set up abort controller handling + if let abortController = abortController { + abortController.signal.onAbort { [weak self] in + self?.task?.terminate() + } + } + + // Set up timeout handling + var timeoutTask: Task? + if let timeout = timeout { + timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if !Task.isCancelled && process.isRunning { + logger?.warning("Process timed out after \(timeout) seconds") + process.terminate() + } + } + } + do { // Handle stream-json differently if outputFormat == .streamJson { - return try await handleStreamJsonOutput(process: process, outputPipe: outputPipe, errorPipe: errorPipe) + let result = try await handleStreamJsonOutput( + process: process, + outputPipe: outputPipe, + errorPipe: errorPipe, + abortController: abortController, + timeout: timeout + ) + timeoutTask?.cancel() + return result } else { // For text and json formats, run synchronously try process.run() process.waitUntilExit() + // Cancel timeout task + timeoutTask?.cancel() + if process.terminationStatus != 0 { let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + // Check if it was a timeout + if let timeout = timeout, + errorString.isEmpty && !process.isRunning { + throw ClaudeCodeError.timeout(timeout) + } + if errorString.contains("No such file or directory") || errorString.contains("command not found") { logger?.error("Claude command not found: \(errorString)") @@ -320,7 +385,9 @@ public class ClaudeCodeClient: ClaudeCode { private func handleStreamJsonOutput( process: Process, outputPipe: Pipe, - errorPipe: Pipe + errorPipe: Pipe, + abortController: AbortController? = nil, + timeout: TimeInterval? = nil ) async throws -> ClaudeCodeResult { // Create a publisher for streaming JSON let subject = PassthroughSubject() diff --git a/Sources/ClaudeCodeSDK/Client/ClaudeCodeError.swift b/Sources/ClaudeCodeSDK/Client/ClaudeCodeError.swift index 67ead92..6d11ab0 100644 --- a/Sources/ClaudeCodeSDK/Client/ClaudeCodeError.swift +++ b/Sources/ClaudeCodeSDK/Client/ClaudeCodeError.swift @@ -13,8 +13,12 @@ public enum ClaudeCodeError: Error { case jsonParsingError(Error) case cancelled case notInstalled + case timeout(TimeInterval) + case rateLimitExceeded(retryAfter: TimeInterval?) + case networkError(Error) + case permissionDenied(String) - var localizedDescription: String { + public var localizedDescription: String { switch self { case .notInstalled: return "Claude Code is not installed. Please install with 'npm install -g @anthropic/claude-code'" @@ -26,6 +30,86 @@ public enum ClaudeCodeError: Error { return "JSON parsing error: \(error.localizedDescription)" case .cancelled: return "Operation cancelled" + case .timeout(let duration): + return "Operation timed out after \(Int(duration)) seconds" + case .rateLimitExceeded(let retryAfter): + if let retryAfter = retryAfter { + return "Rate limit exceeded. Retry after \(Int(retryAfter)) seconds" + } + return "Rate limit exceeded" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .permissionDenied(let message): + return "Permission denied: \(message)" + } + } +} + +// MARK: - Convenience Properties + +extension ClaudeCodeError { + /// Whether this error is due to rate limiting + public var isRateLimitError: Bool { + if case .rateLimitExceeded = self { return true } + if case .executionFailed(let message) = self { + return message.lowercased().contains("rate limit") || + message.lowercased().contains("too many requests") + } + return false + } + + /// Whether this error is due to timeout + public var isTimeoutError: Bool { + if case .timeout = self { return true } + if case .executionFailed(let message) = self { + return message.lowercased().contains("timeout") || + message.lowercased().contains("timed out") + } + return false + } + + /// Whether this error is retryable + public var isRetryable: Bool { + switch self { + case .rateLimitExceeded, .timeout, .networkError, .cancelled: + return true + case .executionFailed(let message): + // Check for transient errors + let transientErrors = ["timeout", "timed out", "rate limit", "network", "connection"] + return transientErrors.contains { message.lowercased().contains($0) } + default: + return false + } + } + + /// Whether this error indicates Claude Code is not installed + public var isInstallationError: Bool { + if case .notInstalled = self { return true } + return false + } + + /// Whether this error is due to permission issues + public var isPermissionError: Bool { + if case .permissionDenied = self { return true } + if case .executionFailed(let message) = self { + return message.lowercased().contains("permission") || + message.lowercased().contains("denied") || + message.lowercased().contains("unauthorized") + } + return false + } + + /// Suggested retry delay in seconds (if applicable) + public var suggestedRetryDelay: TimeInterval? { + switch self { + case .rateLimitExceeded(let retryAfter): + return retryAfter ?? 60 // Default to 60 seconds if not specified + case .timeout: + return 5 // Quick retry for timeouts + case .networkError: + return 10 // Network errors might need a bit more time + default: + return isRetryable ? 5 : nil } } } diff --git a/Sources/ClaudeCodeSDK/Examples/ErrorHandlingExample.swift b/Sources/ClaudeCodeSDK/Examples/ErrorHandlingExample.swift new file mode 100644 index 0000000..d03e639 --- /dev/null +++ b/Sources/ClaudeCodeSDK/Examples/ErrorHandlingExample.swift @@ -0,0 +1,186 @@ +// +// ErrorHandlingExample.swift +// ClaudeCodeSDK +// +// Example demonstrating error handling, retry logic, and rate limiting +// + +import Foundation +import ClaudeCodeSDK + +// MARK: - Basic Error Handling + +func basicErrorHandling() async throws { + let client = ClaudeCodeClient() + + do { + let result = try await client.runSinglePrompt( + prompt: "Write a hello world function", + outputFormat: .json, + options: nil + ) + print("Success: \(result)") + } catch let error as ClaudeCodeError { + switch error { + case .notInstalled: + print("Please install Claude Code first") + case .timeout(let duration): + print("Request timed out after \(duration) seconds") + case .rateLimitExceeded(let retryAfter): + print("Rate limited. Retry after: \(retryAfter ?? 60) seconds") + case .permissionDenied(let message): + print("Permission denied: \(message)") + default: + print("Error: \(error.localizedDescription)") + } + } +} + +// MARK: - Timeout Example + +func timeoutExample() async throws { + let client = ClaudeCodeClient() + + var options = ClaudeCodeOptions() + options.timeout = 30 // 30 second timeout + + do { + let result = try await client.runSinglePrompt( + prompt: "Analyze this large codebase...", + outputFormat: .json, + options: options + ) + print("Completed: \(result)") + } catch ClaudeCodeError.timeout(let duration) { + print("Operation timed out after \(duration) seconds") + } +} + +// MARK: - Retry Logic Example + +func retryExample() async { + let client = ClaudeCodeClient() + + // Use default retry policy (3 attempts with exponential backoff) + do { + let result = try await client.runSinglePromptWithRetry( + prompt: "Generate a REST API", + outputFormat: .json, + retryPolicy: .default + ) + print("Success after retries: \(result)") + } catch { + print("Failed after all retry attempts: \(error)") + } + + // Use conservative retry policy for rate-limited operations + do { + let result = try await client.runSinglePromptWithRetry( + prompt: "Complex analysis task", + outputFormat: .json, + retryPolicy: .conservative + ) + print("Success with conservative retry: \(result)") + } catch { + print("Failed with conservative retry: \(error)") + } +} + +// MARK: - Rate Limiting Example + +func rateLimitingExample() async { + let baseClient = ClaudeCodeClient() + + // Wrap with rate limiter - 10 requests per minute + let rateLimitedClient = RateLimitedClaudeCode( + wrapped: baseClient, + requestsPerMinute: 10, + burstCapacity: 3 // Allow 3 requests in burst + ) + + // Make multiple requests - they will be rate limited + for i in 1...15 { + do { + print("Making request \(i)...") + let result = try await rateLimitedClient.runSinglePrompt( + prompt: "Quick task \(i)", + outputFormat: .text, + options: nil + ) + print("Request \(i) completed") + } catch { + print("Request \(i) failed: \(error)") + } + } +} + +// MARK: - Combined Example with Smart Error Handling + +func smartErrorHandling() async throws { + let client = ClaudeCodeClient() + var options = ClaudeCodeOptions() + options.timeout = 60 + + var attempts = 0 + let maxAttempts = 3 + + while attempts < maxAttempts { + attempts += 1 + + do { + let result = try await client.runSinglePrompt( + prompt: "Complex task", + outputFormat: .json, + options: options + ) + print("Success: \(result)") + break // Success, exit loop + + } catch let error as ClaudeCodeError { + print("Attempt \(attempts) failed: \(error.localizedDescription)") + + // Check if error is retryable + if error.isRetryable && attempts < maxAttempts { + if let delay = error.suggestedRetryDelay { + print("Waiting \(delay) seconds before retry...") + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } else { + // Non-retryable error or max attempts reached + print("Giving up after \(attempts) attempts") + throw error + } + } + } +} + +// MARK: - Abort Controller Example + +func abortExample() async { + let client = ClaudeCodeClient() + + var options = ClaudeCodeOptions() + let abortController = AbortController() + options.abortController = abortController + + // Start a long-running task + Task { + do { + let result = try await client.runSinglePrompt( + prompt: "Very long running task...", + outputFormat: .streamJson, + options: options + ) + print("Task completed: \(result)") + } catch ClaudeCodeError.cancelled { + print("Task was cancelled") + } + } + + // Cancel after 5 seconds + Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + print("Aborting task...") + abortController.abort() + } +} diff --git a/Sources/ClaudeCodeSDK/Utilities/MCPToolFormatter.swift b/Sources/ClaudeCodeSDK/Utilities/MCPToolFormatter.swift new file mode 100644 index 0000000..58092ff --- /dev/null +++ b/Sources/ClaudeCodeSDK/Utilities/MCPToolFormatter.swift @@ -0,0 +1,62 @@ +// +// MCPToolFormatter.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/18/25. +// + +import Foundation + +/// Utility for formatting MCP tool names according to the Claude Code specification +public enum MCPToolFormatter { + + /// Formats an MCP tool name according to the specification: mcp__serverName__toolName + /// - Parameters: + /// - serverName: The name of the MCP server + /// - toolName: The name of the tool + /// - Returns: The formatted tool name + public static func formatToolName(serverName: String, toolName: String) -> String { + return "mcp__\(serverName)__\(toolName)" + } + + /// Formats a wildcard pattern for all tools from a specific MCP server + /// - Parameter serverName: The name of the MCP server + /// - Returns: The wildcard pattern for all tools from the server + public static func formatServerWildcard(serverName: String) -> String { + return "mcp__\(serverName)__*" + } + + /// Extracts MCP server names from a configuration dictionary + /// - Parameter mcpServers: Dictionary of MCP server configurations + /// - Returns: Array of server names + public static func extractServerNames(from mcpServers: [String: McpServerConfiguration]) -> [String] { + return Array(mcpServers.keys) + } + + /// Generates allowed tool patterns for all MCP servers in a configuration + /// - Parameter mcpServers: Dictionary of MCP server configurations + /// - Returns: Array of MCP tool patterns + public static func generateAllowedToolPatterns(from mcpServers: [String: McpServerConfiguration]) -> [String] { + return extractServerNames(from: mcpServers).map { formatServerWildcard(serverName: $0) } + } + + /// Parses an MCP configuration file and returns server names + /// - Parameter configPath: Path to the MCP configuration JSON file + /// - Returns: Array of server names, or empty array if parsing fails + public static func extractServerNames(fromConfigPath configPath: String) -> [String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: configPath)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let mcpServers = json["mcpServers"] as? [String: Any] else { + return [] + } + return Array(mcpServers.keys) + } + + /// Generates allowed tool patterns from an MCP configuration file + /// - Parameter configPath: Path to the MCP configuration JSON file + /// - Returns: Array of MCP tool patterns + public static func generateAllowedToolPatterns(fromConfigPath configPath: String) -> [String] { + return extractServerNames(fromConfigPath: configPath).map { formatServerWildcard(serverName: $0) } + } +} + diff --git a/Sources/ClaudeCodeSDK/Utilities/RateLimiter.swift b/Sources/ClaudeCodeSDK/Utilities/RateLimiter.swift new file mode 100644 index 0000000..c2cffab --- /dev/null +++ b/Sources/ClaudeCodeSDK/Utilities/RateLimiter.swift @@ -0,0 +1,222 @@ +// +// RateLimiter.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation +import OSLog + +/// Token bucket algorithm implementation for rate limiting +public actor RateLimiter { + private let capacity: Int + private let refillRate: Double // tokens per second + private var tokens: Double + private var lastRefill: Date + private let logger: Logger? + + /// Queue of waiting requests + private var waitingRequests: [CheckedContinuation] = [] + + /// Create a rate limiter with specified capacity and refill rate + /// - Parameters: + /// - capacity: Maximum number of tokens in the bucket + /// - refillRate: Number of tokens added per second + public init(capacity: Int, refillRate: Double, logger: Logger? = nil) { + self.capacity = capacity + self.refillRate = refillRate + self.tokens = Double(capacity) + self.lastRefill = Date() + self.logger = logger + } + + /// Create a rate limiter with requests per minute + public init(requestsPerMinute: Int, burstCapacity: Int? = nil, logger: Logger? = nil) { + let capacity = burstCapacity ?? requestsPerMinute + let refillRate = Double(requestsPerMinute) / 60.0 + + self.capacity = capacity + self.refillRate = refillRate + self.tokens = Double(capacity) + self.lastRefill = Date() + self.logger = logger + } + + /// Acquire a token, waiting if necessary + public func acquire() async throws { + // Refill tokens based on time passed + refillTokens() + + // If we have tokens available, consume one + if tokens >= 1 { + tokens -= 1 + let log = "Rate limiter: Token acquired, \(Int(tokens)) remaining" + logger?.debug("\(log)") + return + } + + // Calculate wait time + let waitTime = (1.0 - tokens) / refillRate + let log = "Rate limiter: No tokens available, waiting \(String(format: "%.1f", waitTime))s" + logger?.info("\(log)") + + // Wait for token to be available + try await withCheckedThrowingContinuation { continuation in + waitingRequests.append(continuation) + } + } + + /// Try to acquire a token without waiting + public func tryAcquire() -> Bool { + refillTokens() + + if tokens >= 1 { + tokens -= 1 + let log = "Rate limiter: Token acquired (try), \(Int(tokens)) remaining" + logger?.debug("\(log)") + return true + } + + logger?.debug("Rate limiter: No tokens available (try)") + return false + } + + /// Get the current number of available tokens + public func availableTokens() -> Int { + refillTokens() + return Int(tokens) + } + + /// Reset the rate limiter to full capacity + public func reset() { + tokens = Double(capacity) + lastRefill = Date() + + // Resume all waiting requests + for continuation in waitingRequests { + continuation.resume() + } + waitingRequests.removeAll() + + let log = "Rate limiter: Reset to full capacity (\(capacity) tokens)" + logger?.info("\(log)") + } + + private func refillTokens() { + let now = Date() + let elapsed = now.timeIntervalSince(lastRefill) + let tokensToAdd = elapsed * refillRate + + if tokensToAdd > 0 { + tokens = min(Double(capacity), tokens + tokensToAdd) + lastRefill = now + + // Process waiting requests if we have tokens + processWaitingRequests() + } + } + + private func processWaitingRequests() { + while !waitingRequests.isEmpty && tokens >= 1 { + tokens -= 1 + let continuation = waitingRequests.removeFirst() + continuation.resume() + } + + // Schedule next check if we still have waiting requests + if !waitingRequests.isEmpty { + let nextTokenTime = (1.0 - tokens) / refillRate + Task { + try? await Task.sleep(nanoseconds: UInt64(nextTokenTime * 1_000_000_000)) + refillTokens() + } + } + } +} + +/// Rate-limited wrapper for ClaudeCode operations +public class RateLimitedClaudeCode: ClaudeCode { + private var wrapped: ClaudeCode + private let rateLimiter: RateLimiter + + public var configuration: ClaudeCodeConfiguration { + get { wrapped.configuration } + set { wrapped.configuration = newValue } + } + + public init( + wrapped: ClaudeCode, + requestsPerMinute: Int, + burstCapacity: Int? = nil + ) { + self.wrapped = wrapped + self.rateLimiter = RateLimiter( + requestsPerMinute: requestsPerMinute, + burstCapacity: burstCapacity + ) + } + + public func runWithStdin( + stdinContent: String, + outputFormat: ClaudeCodeOutputFormat, + options: ClaudeCodeOptions? + ) async throws -> ClaudeCodeResult { + try await rateLimiter.acquire() + return try await wrapped.runWithStdin( + stdinContent: stdinContent, + outputFormat: outputFormat, + options: options + ) + } + + public func runSinglePrompt( + prompt: String, + outputFormat: ClaudeCodeOutputFormat, + options: ClaudeCodeOptions? + ) async throws -> ClaudeCodeResult { + try await rateLimiter.acquire() + return try await wrapped.runSinglePrompt( + prompt: prompt, + outputFormat: outputFormat, + options: options + ) + } + + public func continueConversation( + prompt: String?, + outputFormat: ClaudeCodeOutputFormat, + options: ClaudeCodeOptions? + ) async throws -> ClaudeCodeResult { + try await rateLimiter.acquire() + return try await wrapped.continueConversation( + prompt: prompt, + outputFormat: outputFormat, + options: options + ) + } + + public func resumeConversation( + sessionId: String, + prompt: String?, + outputFormat: ClaudeCodeOutputFormat, + options: ClaudeCodeOptions? + ) async throws -> ClaudeCodeResult { + try await rateLimiter.acquire() + return try await wrapped.resumeConversation( + sessionId: sessionId, + prompt: prompt, + outputFormat: outputFormat, + options: options + ) + } + + public func listSessions() async throws -> [SessionInfo] { + try await rateLimiter.acquire() + return try await wrapped.listSessions() + } + + public func cancel() { + wrapped.cancel() + } +} diff --git a/Sources/ClaudeCodeSDK/Utilities/RetryPolicy.swift b/Sources/ClaudeCodeSDK/Utilities/RetryPolicy.swift new file mode 100644 index 0000000..349ac98 --- /dev/null +++ b/Sources/ClaudeCodeSDK/Utilities/RetryPolicy.swift @@ -0,0 +1,199 @@ +// +// RetryPolicy.swift +// ClaudeCodeSDK +// +// Created by James Rochabrun on 6/17/25. +// + +import Foundation +import OSLog + +/// Configuration for retry behavior +public struct RetryPolicy: Sendable { + /// Maximum number of retry attempts + public let maxAttempts: Int + + /// Initial delay between retries in seconds + public let initialDelay: TimeInterval + + /// Maximum delay between retries in seconds + public let maxDelay: TimeInterval + + /// Multiplier for exponential backoff + public let backoffMultiplier: Double + + /// Whether to add jitter to retry delays + public let useJitter: Bool + + /// Default retry policy with reasonable defaults + public static let `default` = RetryPolicy( + maxAttempts: 3, + initialDelay: 1.0, + maxDelay: 60.0, + backoffMultiplier: 2.0, + useJitter: true + ) + + /// Conservative retry policy for rate-limited operations + public static let conservative = RetryPolicy( + maxAttempts: 5, + initialDelay: 5.0, + maxDelay: 300.0, + backoffMultiplier: 2.0, + useJitter: true + ) + + /// Aggressive retry policy for transient failures + public static let aggressive = RetryPolicy( + maxAttempts: 10, + initialDelay: 0.5, + maxDelay: 30.0, + backoffMultiplier: 1.5, + useJitter: true + ) + + public init( + maxAttempts: Int, + initialDelay: TimeInterval, + maxDelay: TimeInterval, + backoffMultiplier: Double, + useJitter: Bool + ) { + self.maxAttempts = maxAttempts + self.initialDelay = initialDelay + self.maxDelay = maxDelay + self.backoffMultiplier = backoffMultiplier + self.useJitter = useJitter + } + + /// Calculate delay for a given attempt number + func delay(for attempt: Int) -> TimeInterval { + let exponentialDelay = initialDelay * pow(backoffMultiplier, Double(attempt - 1)) + var delay = min(exponentialDelay, maxDelay) + + if useJitter { + // Add random jitter (±25% of delay) + let jitter = delay * 0.25 * (Double.random(in: -1...1)) + delay = max(0, delay + jitter) + } + + return delay + } +} + +/// Retry handler for ClaudeCode operations +public final class RetryHandler { + private let policy: RetryPolicy + private let logger: Logger? + + public init(policy: RetryPolicy = .default, logger: Logger? = nil) { + self.policy = policy + self.logger = logger + } + + /// Execute an operation with retry logic + public func execute( + operation: String, + task: () async throws -> T + ) async throws -> T { + var lastError: Error? + + for attempt in 1...policy.maxAttempts { + do { + let log = "Attempting \(operation) (attempt \(attempt)/\(policy.maxAttempts))" + logger?.debug("\(log)") + return try await task() + } catch let error as ClaudeCodeError { + lastError = error + + // Check if error is retryable + guard error.isRetryable else { + logger?.error("\(operation) failed with non-retryable error: \(error.localizedDescription)") + throw error + } + + // Don't retry on last attempt + guard attempt < policy.maxAttempts else { + let log = "\(operation) failed after \(policy.maxAttempts) attempts" + logger?.error("\(log)") + throw error + } + + // Calculate delay + let baseDelay = policy.delay(for: attempt) + let delay = error.suggestedRetryDelay ?? baseDelay + + let log = "\(operation) failed (attempt \(attempt)/\(policy.maxAttempts)), retrying in \(Int(delay))s: \(error.localizedDescription)" + logger?.info("\(log)") + + // Wait before retry + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + } catch { + // Non-ClaudeCodeError, don't retry + let log = "\(operation) failed with unexpected error: \(error.localizedDescription)" + logger?.error("\(log)") + throw error + } + } + + // Should never reach here, but just in case + throw lastError ?? ClaudeCodeError.executionFailed("Retry logic error") + } +} + +/// Extension to add retry support to ClaudeCode protocol +public extension ClaudeCode { + /// Run a single prompt with retry logic + func runSinglePromptWithRetry( + prompt: String, + outputFormat: ClaudeCodeOutputFormat, + options: ClaudeCodeOptions? = nil, + retryPolicy: RetryPolicy = .default + ) async throws -> ClaudeCodeResult { + let handler = RetryHandler(policy: retryPolicy) + return try await handler.execute(operation: "runSinglePrompt") { + try await self.runSinglePrompt( + prompt: prompt, + outputFormat: outputFormat, + options: options + ) + } + } + + /// Continue conversation with retry logic + func continueConversationWithRetry( + prompt: String?, + outputFormat: ClaudeCodeOutputFormat, + options: ClaudeCodeOptions? = nil, + retryPolicy: RetryPolicy = .default + ) async throws -> ClaudeCodeResult { + let handler = RetryHandler(policy: retryPolicy) + return try await handler.execute(operation: "continueConversation") { + try await self.continueConversation( + prompt: prompt, + outputFormat: outputFormat, + options: options + ) + } + } + + /// Resume conversation with retry logic + func resumeConversationWithRetry( + sessionId: String, + prompt: String?, + outputFormat: ClaudeCodeOutputFormat, + options: ClaudeCodeOptions? = nil, + retryPolicy: RetryPolicy = .default + ) async throws -> ClaudeCodeResult { + let handler = RetryHandler(policy: retryPolicy) + return try await handler.execute(operation: "resumeConversation") { + try await self.resumeConversation( + sessionId: sessionId, + prompt: prompt, + outputFormat: outputFormat, + options: options + ) + } + } +} diff --git a/Tests/ClaudeCodeSDKTests/MCPConfigurationTests.swift b/Tests/ClaudeCodeSDKTests/MCPConfigurationTests.swift new file mode 100644 index 0000000..924c017 --- /dev/null +++ b/Tests/ClaudeCodeSDKTests/MCPConfigurationTests.swift @@ -0,0 +1,152 @@ +// +// MCPConfigurationTests.swift +// ClaudeCodeSDKTests +// +// Created by James Rochabrun on 6/18/25. +// + +import XCTest +@testable import ClaudeCodeSDK +import Foundation + +final class MCPConfigurationTests: XCTestCase { + + func testMCPConfigWithFilePath() throws { + // Given + var options = ClaudeCodeOptions() + options.mcpConfigPath = "/path/to/mcp-config.json" + + // When + let args = options.toCommandArgs() + + // Then + XCTAssertTrue(args.contains("--mcp-config")) + if let index = args.firstIndex(of: "--mcp-config") { + XCTAssertEqual(args[index + 1], "/path/to/mcp-config.json") + } + XCTAssertFalse(args.contains("--mcp-servers")) + } + + func testMCPConfigWithProgrammaticServers() throws { + // Given + var options = ClaudeCodeOptions() + options.mcpServers = [ + "XcodeBuildMCP": .stdio(McpStdioServerConfig( + command: "npx", + args: ["-y", "xcodebuildmcp@latest"] + )) + ] + + // When + let args = options.toCommandArgs() + + // Then + XCTAssertTrue(args.contains("--mcp-config")) + if let index = args.firstIndex(of: "--mcp-config") { + let configPath = args[index + 1] + XCTAssertTrue(configPath.contains("mcp-config-")) + XCTAssertTrue(configPath.hasSuffix(".json")) + + // Verify the temporary file contains the correct structure + if let data = try? Data(contentsOf: URL(fileURLWithPath: configPath)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let mcpServers = json["mcpServers"] as? [String: Any] { + XCTAssertNotNil(mcpServers["XcodeBuildMCP"]) + } + } + XCTAssertFalse(args.contains("--mcp-servers")) + } + + func testMCPToolNaming() { + // Test basic tool naming + let toolName = MCPToolFormatter.formatToolName(serverName: "filesystem", toolName: "read_file") + XCTAssertEqual(toolName, "mcp__filesystem__read_file") + + // Test wildcard pattern + let wildcard = MCPToolFormatter.formatServerWildcard(serverName: "github") + XCTAssertEqual(wildcard, "mcp__github__*") + } + + func testMCPToolPatternsGeneration() { + // Given + let mcpServers: [String: McpServerConfiguration] = [ + "XcodeBuildMCP": .stdio(McpStdioServerConfig(command: "npx", args: ["xcodebuildmcp"])), + "filesystem": .stdio(McpStdioServerConfig(command: "npx", args: ["filesystem"])) + ] + + // When + let patterns = MCPToolFormatter.generateAllowedToolPatterns(from: mcpServers) + + // Then + XCTAssertEqual(patterns.count, 2) + XCTAssertTrue(patterns.contains("mcp__XcodeBuildMCP__*")) + XCTAssertTrue(patterns.contains("mcp__filesystem__*")) + } + + func testMCPConfigFileExtraction() throws { + // Given - Create a temporary MCP config file + let tempDir = FileManager.default.temporaryDirectory + let configFile = tempDir.appendingPathComponent("test-mcp-config.json") + + let config = """ + { + "mcpServers": { + "testServer1": { + "command": "test", + "args": ["arg1"] + }, + "testServer2": { + "command": "test2" + } + } + } + """ + + try config.write(to: configFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: configFile) } + + // When + let serverNames = MCPToolFormatter.extractServerNames(fromConfigPath: configFile.path) + let patterns = MCPToolFormatter.generateAllowedToolPatterns(fromConfigPath: configFile.path) + + // Then + XCTAssertEqual(serverNames.count, 2) + XCTAssertTrue(serverNames.contains("testServer1")) + XCTAssertTrue(serverNames.contains("testServer2")) + + XCTAssertEqual(patterns.count, 2) + XCTAssertTrue(patterns.contains(where: { $0.contains("testServer1") })) + XCTAssertTrue(patterns.contains(where: { $0.contains("testServer2") })) + } + + func testMCPServerConfigurationEncoding() throws { + // Test stdio server encoding + let stdioConfig = McpStdioServerConfig( + command: "npx", + args: ["-y", "test-server"], + env: ["API_KEY": "secret"] + ) + + let stdioWrapper = McpServerConfiguration.stdio(stdioConfig) + let encodedData = try JSONEncoder().encode(stdioWrapper) + let json = try JSONSerialization.jsonObject(with: encodedData) as? [String: Any] + + XCTAssertEqual(json?["command"] as? String, "npx") + XCTAssertEqual(json?["args"] as? [String], ["-y", "test-server"]) + XCTAssertEqual((json?["env"] as? [String: String])?["API_KEY"], "secret") + + // Test SSE server encoding + let sseConfig = McpSSEServerConfig( + url: "https://example.com/mcp", + headers: ["Authorization": "Bearer token"] + ) + + let sseWrapper = McpServerConfiguration.sse(sseConfig) + let sseData = try JSONEncoder().encode(sseWrapper) + let sseJson = try JSONSerialization.jsonObject(with: sseData) as? [String: Any] + + XCTAssertEqual(sseJson?["type"] as? String, "sse") + XCTAssertEqual(sseJson?["url"] as? String, "https://example.com/mcp") + XCTAssertEqual((sseJson?["headers"] as? [String: String])?["Authorization"], "Bearer token") + } +} \ No newline at end of file