├── Example └── ClaudeCodeSDKExample │ ├── .gitignore │ ├── ClaudeCodeSDKExample │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── ClaudeCodeSDKExampleApp.swift │ ├── ClaudeCodeSDKExample.entitlements │ ├── ContentView.swift │ ├── Model │ │ └── ChatMessage.swift │ └── Chat │ │ ├── SessionsListView.swift │ │ ├── MCPConfigView.swift │ │ ├── ChatView.swift │ │ └── ChatMessageRow.swift │ ├── mcp-config-example.json │ ├── ClaudeCodeSDKExample.xcodeproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved │ ├── ClaudeCodeSDKExampleTests │ └── ClaudeCodeSDKExampleTests.swift │ ├── ClaudeCodeSDKExampleUITests │ ├── ClaudeCodeSDKExampleUITestsLaunchTests.swift │ └── ClaudeCodeSDKExampleUITests.swift │ └── README.md ├── Sources ├── ClaudeCodeSDK │ ├── ClaudeCodeSDK.swift │ ├── API │ │ ├── AssistantMessage.swift │ │ ├── ConfigScope.swift │ │ ├── UserMessage.swift │ │ ├── ClaudeCodeOutputFormat.swift │ │ ├── ApiKeySource.swift │ │ ├── ResponseChunk.swift │ │ ├── SessionInfo.swift │ │ ├── PermissionMode.swift │ │ ├── InitSystemMessage.swift │ │ ├── ClaudeCodeResult.swift │ │ ├── AbortController.swift │ │ ├── ResultMessage.swift │ │ ├── ExecutedCommandInfo.swift │ │ ├── ClaudeCode.swift │ │ ├── ClaudeCodeConfiguration.swift │ │ ├── McpServerConfig.swift │ │ └── ClaudeCodeOptions.swift │ ├── Storage │ │ ├── ClaudeSessionProtocol.swift │ │ └── ClaudeSessionModels.swift │ ├── Utilities │ │ ├── MCPToolFormatter.swift │ │ ├── NvmPathDetector.swift │ │ ├── RetryPolicy.swift │ │ └── RateLimiter.swift │ ├── Backend │ │ ├── BackendFactory.swift │ │ └── ClaudeCodeBackend.swift │ ├── Client │ │ ├── ClaudeCodeError.swift │ │ └── ClaudeCodeClient.swift │ ├── Resources │ │ └── sdk-wrapper.mjs │ └── Examples │ │ └── ErrorHandlingExample.swift └── QuickTest │ └── main.swift ├── .gitignore ├── Tests └── ClaudeCodeSDKTests │ ├── ClaudeCodeSDKTests.swift │ ├── OutputFormatTests.swift │ ├── BasicClientTests.swift │ ├── ShellEscapingTests.swift │ ├── CancellationTests.swift │ ├── OptionsTests.swift │ ├── RetryLogicTests.swift │ ├── RateLimitingTests.swift │ ├── ProcessLaunchTests.swift │ ├── MCPConfigurationTests.swift │ ├── BackendTests.swift │ ├── ErrorHandlingTests.swift │ ├── SDKWrapperTests.swift │ ├── BackendConfigurationTests.swift │ └── NodePathDetectorTests.swift ├── LICENSE ├── Package.swift ├── test-agent-sdk.swift ├── Example-AgentSDK.swift ├── test-nvm-fix.swift ├── TestBackends.swift └── Package.resolved /Example/ClaudeCodeSDKExample/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | build/ 3 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/ClaudeCodeSDK.swift: -------------------------------------------------------------------------------- 1 | // The Swift Programming Language 2 | // https://docs.swift.org/swift-book 3 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | Example/ClaudeCodeSDKExample/build/ 10 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/mcp-config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "XcodeBuildMCP": { 4 | "command": "npx", 5 | "args": [ 6 | "-y", 7 | "xcodebuildmcp@latest" 8 | ] 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/AssistantMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AssistantMessage.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | import SwiftAnthropic 10 | 11 | public struct AssistantMessage: Decodable { 12 | public let type: String 13 | public let sessionId: String 14 | public let message: MessageResponse 15 | } 16 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/ClaudeCodeSDKExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeSDKExampleApp.swift 3 | // ClaudeCodeSDKExample 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ClaudeCodeSDKExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ChatView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/ClaudeCodeSDKExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/ClaudeCodeSDKTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import ClaudeCodeSDK 3 | 4 | // Main test file - specific tests are organized in separate files: 5 | // - BasicClientTests.swift 6 | // - OutputFormatTests.swift 7 | // - OptionsTests.swift 8 | // - ErrorHandlingTests.swift 9 | // - RetryLogicTests.swift 10 | // - RateLimitingTests.swift 11 | // - CancellationTests.swift 12 | // - MCPConfigurationTests.swift 13 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExampleTests/ClaudeCodeSDKExampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeSDKExampleTests.swift 3 | // ClaudeCodeSDKExampleTests 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Testing 9 | @testable import ClaudeCodeSDKExample 10 | 11 | struct ClaudeCodeSDKExampleTests { 12 | 13 | @Test func example() async throws { 14 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ConfigScope.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigScope.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/17/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents the scope of configuration settings 11 | public enum ConfigScope: String, Codable { 12 | /// Local configuration (current directory) 13 | case local = "local" 14 | 15 | /// User-level configuration 16 | case user = "user" 17 | 18 | /// Project-level configuration 19 | case project = "project" 20 | } 21 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/UserMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | import SwiftAnthropic 10 | 11 | public struct UserMessage: Decodable { 12 | public let type: String 13 | public let sessionId: String 14 | public let message: UserMessageContent 15 | 16 | public struct UserMessageContent: Decodable { 17 | public let role: String 18 | public let content: [MessageResponse.Content] 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ClaudeCodeSDKExample 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var body: some View { 12 | VStack { 13 | Image(systemName: "globe") 14 | .imageScale(.large) 15 | .foregroundStyle(.tint) 16 | Text("Hello, world!") 17 | } 18 | .padding() 19 | } 20 | } 21 | 22 | #Preview { 23 | ContentView() 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ClaudeCodeOutputFormat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum ClaudeCodeOutputFormat: String { 11 | /// Plain text output (default) 12 | case text 13 | 14 | /// JSON formatted output 15 | case json 16 | 17 | /// Streaming JSON output 18 | case streamJson = "stream-json" 19 | 20 | /// Command line argument 21 | var commandArgument: String { 22 | return "--output-format \(rawValue)" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ApiKeySource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiKeySource.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/17/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents the source of an API key used for authentication 11 | public enum ApiKeySource: String, Codable { 12 | /// API key from user configuration 13 | case user = "user" 14 | 15 | /// API key from project configuration 16 | case project = "project" 17 | 18 | /// API key from organization configuration 19 | case org = "org" 20 | 21 | /// Temporary API key 22 | case temporary = "temporary" 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ResponseChunk.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResponseChunk.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum ResponseChunk { 11 | case initSystem(InitSystemMessage) 12 | case user(UserMessage) 13 | case assistant(AssistantMessage) 14 | case result(ResultMessage) 15 | 16 | public var sessionId: String { 17 | switch self { 18 | case .initSystem(let msg): return msg.sessionId 19 | case .user(let msg): return msg.sessionId 20 | case .assistant(let msg): return msg.sessionId 21 | case .result(let msg): return msg.sessionId 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/SessionInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SessionInfo.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Information about a Claude Code session 11 | public struct SessionInfo: Codable, Identifiable { 12 | public let id: String 13 | public let created: String? 14 | public let lastActive: String? 15 | public let totalCostUsd: Double? 16 | public let project: String? 17 | 18 | private enum CodingKeys: String, CodingKey { 19 | case id 20 | case created 21 | case lastActive = "last_active" 22 | case totalCostUsd = "total_cost_usd" 23 | case project 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/PermissionMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PermissionMode.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/17/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents the permission mode for Claude Code operations 11 | public enum PermissionMode: String, Codable, Sendable { 12 | /// Default permission mode - asks for user confirmation 13 | case `default` = "default" 14 | 15 | /// Automatically accepts edit operations 16 | case acceptEdits = "acceptEdits" 17 | 18 | /// Bypasses all permission checks (use with caution) 19 | case bypassPermissions = "bypassPermissions" 20 | 21 | /// Plan mode - creates a plan before executing 22 | case plan = "plan" 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/InitSystemMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct InitSystemMessage: Codable { 11 | public let type: String 12 | public let subtype: String 13 | public let sessionId: String 14 | public let tools: [String] 15 | public let mcpServers: [MCPServer] 16 | 17 | public struct MCPServer: Codable { 18 | public let name: String 19 | public let status: String 20 | } 21 | } 22 | 23 | /// Represents system message subtypes 24 | public enum SystemSubtype: String, Codable { 25 | case `init` 26 | case success 27 | case errorMaxTurns = "error_max_turns" 28 | // Add other error types as needed 29 | } 30 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ClaudeCodeResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeResult.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | // MARK: - ClaudeCodeResult 12 | 13 | /// Represents the different types of results that can be returned by Claude Code. 14 | @frozen public enum ClaudeCodeResult { 15 | /// Plain text result 16 | case text(String) 17 | 18 | /// JSON result 19 | case json(ResultMessage) 20 | 21 | /// Streaming publisher for response chunks 22 | case stream(AnyPublisher) 23 | 24 | /// Session ID for resumed or continued conversations 25 | public var sessionId: String? { 26 | switch self { 27 | case .json(let result): 28 | return result.sessionId 29 | default: 30 | return nil 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExampleUITests/ClaudeCodeSDKExampleUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeSDKExampleUITestsLaunchTests.swift 3 | // ClaudeCodeSDKExampleUITests 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import XCTest 9 | 10 | final class ClaudeCodeSDKExampleUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | @MainActor 21 | func testLaunch() throws { 22 | let app = XCUIApplication() 23 | app.launch() 24 | 25 | // Insert steps here to perform after app launch but before taking a screenshot, 26 | // such as logging into a test account or navigating somewhere in the app 27 | 28 | let attachment = XCTAttachment(screenshot: app.screenshot()) 29 | attachment.name = "Launch Screen" 30 | attachment.lifetime = .keepAlways 31 | add(attachment) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Storage/ClaudeSessionProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeSessionProtocol.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by Assistant on 8/18/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Protocol for accessing Claude's native session storage 11 | public protocol ClaudeSessionStorageProtocol { 12 | /// Lists all projects that have sessions 13 | func listProjects() async throws -> [String] 14 | 15 | /// Gets all sessions for a specific project 16 | func getSessions(for projectPath: String) async throws -> [ClaudeStoredSession] 17 | 18 | /// Gets a specific session by ID 19 | func getSession(id: String, projectPath: String) async throws -> ClaudeStoredSession? 20 | 21 | /// Gets all sessions across all projects 22 | func getAllSessions() async throws -> [ClaudeStoredSession] 23 | 24 | /// Gets the messages for a specific session 25 | func getMessages(sessionId: String, projectPath: String) async throws -> [ClaudeStoredMessage] 26 | 27 | /// Gets the most recent session for a project 28 | func getMostRecentSession(for projectPath: String) async throws -> ClaudeStoredSession? 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 James Rochabrun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "ab7d44dca269f2faad50deb8b01f8c734d736a0834970a9c2720e5b7368b9278", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-subprocess", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swiftlang/swift-subprocess.git", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "48d9d5736b0c2b7afed798225acb4e258497680f" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-system", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-system", 17 | "state" : { 18 | "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", 19 | "version" : "1.4.2" 20 | } 21 | }, 22 | { 23 | "identity" : "swiftanthropic", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/jamesrochabrun/SwiftAnthropic", 26 | "state" : { 27 | "revision" : "c069979c681de4434b6611c091c0cab01f141213", 28 | "version" : "2.1.7" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/OutputFormatTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OutputFormatTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK Tests 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class OutputFormatTests: XCTestCase { 13 | 14 | func testOutputFormatCommandArguments() { 15 | // Test that output formats produce correct command arguments 16 | XCTAssertEqual(ClaudeCodeOutputFormat.text.commandArgument, "--output-format text") 17 | XCTAssertEqual(ClaudeCodeOutputFormat.json.commandArgument, "--output-format json") 18 | XCTAssertEqual(ClaudeCodeOutputFormat.streamJson.commandArgument, "--output-format stream-json") 19 | } 20 | 21 | func testResultTypeMatching() { 22 | // Test result type creation for different output formats 23 | let textContent = "Hello, world!" 24 | let textResult = ClaudeCodeResult.text(textContent) 25 | 26 | if case .text(let content) = textResult { 27 | XCTAssertEqual(content, textContent) 28 | } else { 29 | XCTFail("Expected text result") 30 | } 31 | 32 | // Test JSON result - ResultMessage is complex and requires all fields 33 | // For now, we'll just verify the enum case works 34 | // In real usage, ResultMessage would be decoded from JSON 35 | } 36 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/AbortController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AbortController.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/17/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Controller for aborting operations 11 | /// Similar to the web AbortController API 12 | public final class AbortController: Sendable { 13 | /// Signal that can be used to check if operation was aborted 14 | public let signal: AbortSignal 15 | 16 | public init() { 17 | self.signal = AbortSignal() 18 | } 19 | 20 | /// Abort the operation 21 | public func abort() { 22 | signal.abort() 23 | } 24 | } 25 | 26 | /// Signal that indicates if an operation was aborted 27 | public final class AbortSignal: @unchecked Sendable { 28 | private var _aborted = false 29 | private var callbacks: [() -> Void] = [] 30 | private let queue = DispatchQueue(label: "com.claudecode.abortsignal") 31 | 32 | /// Whether the operation has been aborted 33 | public var aborted: Bool { 34 | queue.sync { _aborted } 35 | } 36 | 37 | /// Add a callback to be called when aborted 38 | public func onAbort(_ callback: @escaping () -> Void) { 39 | queue.sync { 40 | if _aborted { 41 | callback() 42 | } else { 43 | callbacks.append(callback) 44 | } 45 | } 46 | } 47 | 48 | internal func abort() { 49 | queue.sync { 50 | guard !_aborted else { return } 51 | _aborted = true 52 | callbacks.forEach { $0() } 53 | callbacks.removeAll() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ClaudeCodeSDK", 8 | platforms: [ 9 | .macOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, making them visible to other packages. 13 | .library( 14 | name: "ClaudeCodeSDK", 15 | targets: ["ClaudeCodeSDK"]), 16 | .executable( 17 | name: "QuickTest", 18 | targets: ["QuickTest"]), 19 | ], 20 | dependencies: [ 21 | // Dependencies declare other packages that this package depends on. 22 | .package(url: "https://github.com/jamesrochabrun/SwiftAnthropic", exact: "2.2.0"), 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package, defining a module or a test suite. 26 | // Targets can depend on other targets in this package and products from dependencies. 27 | .target( 28 | name: "ClaudeCodeSDK", 29 | dependencies: [ 30 | .product(name: "SwiftAnthropic", package: "SwiftAnthropic"), 31 | ], 32 | resources: [ 33 | .process("Resources") 34 | ]), 35 | .executableTarget( 36 | name: "QuickTest", 37 | dependencies: ["ClaudeCodeSDK"] 38 | ), 39 | .testTarget( 40 | name: "ClaudeCodeSDKTests", 41 | dependencies: ["ClaudeCodeSDK"] 42 | ), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExampleUITests/ClaudeCodeSDKExampleUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeSDKExampleUITests.swift 3 | // ClaudeCodeSDKExampleUITests 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import XCTest 9 | 10 | final class ClaudeCodeSDKExampleUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | @MainActor 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | @MainActor 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTApplicationLaunchMetric()]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Model/ChatMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatMessage.swift 3 | // ClaudeCodeSDKExample 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ChatMessage: Identifiable, Equatable { 11 | public var id: UUID 12 | public var role: MessageRole 13 | public var content: String 14 | public var timestamp: Date 15 | public var isComplete: Bool 16 | public var messageType: MessageType 17 | public var toolName: String? 18 | 19 | public init( 20 | id: UUID = UUID(), 21 | role: MessageRole, 22 | content: String, 23 | timestamp: Date = Date(), 24 | isComplete: Bool = true, 25 | messageType: MessageType = .text, 26 | toolName: String? = nil 27 | ) { 28 | self.id = id 29 | self.role = role 30 | self.content = content 31 | self.timestamp = timestamp 32 | self.isComplete = isComplete 33 | self.messageType = messageType 34 | self.toolName = toolName 35 | } 36 | 37 | public static func == (lhs: ChatMessage, rhs: ChatMessage) -> Bool { 38 | return lhs.content == rhs.content && 39 | lhs.id == rhs.id && 40 | lhs.isComplete == rhs.isComplete && 41 | lhs.messageType == rhs.messageType && 42 | lhs.toolName == rhs.toolName 43 | } 44 | } 45 | 46 | /// Message content types 47 | public enum MessageType: String { 48 | case text 49 | case toolUse 50 | case toolResult 51 | case toolError 52 | case thinking 53 | case webSearch 54 | } 55 | 56 | /// Message role types 57 | public enum MessageRole: String { 58 | case user 59 | case assistant 60 | case system 61 | case toolUse 62 | case toolResult 63 | case toolError 64 | case thinking 65 | } 66 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ResultMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultMessage.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ResultMessage: Codable { 11 | public let type: String 12 | public let subtype: String 13 | public let totalCostUsd: Double 14 | public let durationMs: Int 15 | public let durationApiMs: Int 16 | public let isError: Bool 17 | public let numTurns: Int 18 | public let result: String? 19 | public let sessionId: String 20 | public let usage: Usage? 21 | 22 | /// Returns a formatted description of the result message with key information 23 | public func description() -> String { 24 | let resultText = result ?? "No result available" 25 | let durationSeconds = Double(durationMs) / 1000.0 26 | let durationApiSeconds = Double(durationApiMs) / 1000.0 27 | 28 | return """ 29 | Result: \(resultText) \n\n 30 | Subtype: \(subtype), 31 | Cost: $\(String(format: "%.6f", totalCostUsd)), 32 | Duration: \(String(format: "%.2f", durationSeconds))s, 33 | API Duration: \(String(format: "%.2f", durationApiSeconds))s 34 | Error: \(isError ? "Yes" : "No") 35 | Number of Turns: \(numTurns) 36 | Total Cost: $\(String(format: "%.6f", totalCostUsd)) 37 | """ 38 | } 39 | } 40 | 41 | // Usage struct to handle the nested usage data 42 | public struct Usage: Codable { 43 | public let inputTokens: Int 44 | public let cacheCreationInputTokens: Int 45 | public let cacheReadInputTokens: Int 46 | public let outputTokens: Int 47 | public let serverToolUse: ServerToolUse 48 | } 49 | 50 | public struct ServerToolUse: Codable { 51 | public let webSearchRequests: Int 52 | } 53 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ExecutedCommandInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExecutedCommandInfo.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Information about an executed command for debugging purposes 11 | public struct ExecutedCommandInfo: Sendable { 12 | /// The full command string with all flags (as passed to Process) 13 | /// Example: "claude -p --verbose --allowedTools \"Read,Write\" --output-format stream-json" 14 | public let commandString: String 15 | 16 | /// The working directory where the command was executed 17 | public let workingDirectory: String? 18 | 19 | /// The content sent to stdin (user message, typically) 20 | public let stdinContent: String? 21 | 22 | /// When the command was executed 23 | public let executedAt: Date 24 | 25 | /// The method that executed the command 26 | public let method: ExecutionMethod 27 | 28 | /// The shell executable used to run the command 29 | public let shellExecutable: String 30 | 31 | /// The shell arguments used (e.g., ["-l", "-c", command]) 32 | public let shellArguments: [String] 33 | 34 | /// The actual PATH environment variable used at runtime (system PATH merged with additional paths) 35 | /// Critical for debugging "command not found" errors 36 | public let pathEnvironment: String 37 | 38 | /// The full environment dictionary used at runtime (system env merged with custom env vars) 39 | /// Useful for debugging environment-dependent issues 40 | public let environment: [String: String] 41 | 42 | /// The output format requested for this execution 43 | public let outputFormat: String 44 | 45 | /// The type of method that executed a Claude Code command 46 | public enum ExecutionMethod: String, Sendable { 47 | case runSinglePrompt 48 | case continueConversation 49 | case resumeConversation 50 | case runWithStdin 51 | case listSessions 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test-agent-sdk.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env swift 2 | 3 | import Foundation 4 | 5 | // Simple inline test to verify Agent SDK backend works 6 | // Run: swift test-agent-sdk.swift 7 | 8 | print("🧪 Testing Agent SDK Backend\n") 9 | 10 | // Check if we have the SDK wrapper 11 | let wrapperPath = "./Resources/sdk-wrapper.mjs" 12 | let fileExists = FileManager.default.fileExists(atPath: wrapperPath) 13 | print("1. SDK Wrapper exists: \(fileExists ? "✅" : "❌")") 14 | 15 | // Check Node.js 16 | let nodeCheck = Process() 17 | nodeCheck.executableURL = URL(fileURLWithPath: "/usr/bin/env") 18 | nodeCheck.arguments = ["node", "--version"] 19 | let nodePipe = Pipe() 20 | nodeCheck.standardOutput = nodePipe 21 | 22 | do { 23 | try nodeCheck.run() 24 | nodeCheck.waitUntilExit() 25 | let nodeData = nodePipe.fileHandleForReading.readDataToEndOfFile() 26 | let nodeVersion = String(data: nodeData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown" 27 | print("2. Node.js version: \(nodeVersion) \(nodeCheck.terminationStatus == 0 ? "✅" : "❌")") 28 | } catch { 29 | print("2. Node.js check failed: ❌") 30 | } 31 | 32 | // Check Agent SDK 33 | let sdkCheck = Process() 34 | sdkCheck.executableURL = URL(fileURLWithPath: "/usr/bin/env") 35 | sdkCheck.arguments = ["npm", "list", "-g", "@anthropic-ai/claude-agent-sdk"] 36 | let sdkPipe = Pipe() 37 | sdkCheck.standardOutput = sdkPipe 38 | sdkCheck.standardError = Pipe() 39 | 40 | do { 41 | try sdkCheck.run() 42 | sdkCheck.waitUntilExit() 43 | let sdkData = sdkPipe.fileHandleForReading.readDataToEndOfFile() 44 | let sdkOutput = String(data: sdkData, encoding: .utf8) ?? "" 45 | 46 | if sdkOutput.contains("claude-agent-sdk") { 47 | // Extract version 48 | if let versionMatch = sdkOutput.range(of: "@\\d+\\.\\d+\\.\\d+", options: .regularExpression) { 49 | let version = String(sdkOutput[versionMatch]) 50 | print("3. Agent SDK: \(version) ✅") 51 | } else { 52 | print("3. Agent SDK: installed ✅") 53 | } 54 | } else { 55 | print("3. Agent SDK: not installed ❌") 56 | print("\n Install with: npm install -g @anthropic-ai/claude-agent-sdk") 57 | } 58 | } catch { 59 | print("3. Agent SDK check failed: ❌") 60 | } 61 | 62 | print("\n✅ All checks complete!") 63 | print("\nTo use Agent SDK backend:") 64 | print(" var config = ClaudeCodeConfiguration.default") 65 | print(" config.backend = .agentSDK") 66 | print(" let client = try ClaudeCodeClient(configuration: config)") 67 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/SessionsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SessionsListView.swift 3 | // ClaudeCodeSDKExample 4 | // 5 | // Created by Assistant on 6/17/25. 6 | // 7 | 8 | import SwiftUI 9 | import ClaudeCodeSDK 10 | 11 | struct SessionsListView: View { 12 | let sessions: [SessionInfo] 13 | @Binding var isPresented: Bool 14 | 15 | var body: some View { 16 | NavigationView { 17 | VStack { 18 | if sessions.isEmpty { 19 | Text("No sessions found") 20 | .foregroundColor(.secondary) 21 | .padding() 22 | } else { 23 | List(sessions, id: \.id) { session in 24 | VStack(alignment: .leading, spacing: 4) { 25 | Text(session.id) 26 | .font(.system(.caption, design: .monospaced)) 27 | .foregroundColor(.secondary) 28 | 29 | if let created = session.created { 30 | Text("Created: \(formattedDate(created))") 31 | .font(.caption) 32 | } 33 | 34 | HStack { 35 | if let lastActive = session.lastActive { 36 | Text("Last active: \(formattedDate(lastActive))") 37 | .font(.caption) 38 | } 39 | 40 | Spacer() 41 | 42 | if let totalCost = session.totalCostUsd { 43 | Text("$\(String(format: "%.4f", totalCost))") 44 | .font(.caption) 45 | .foregroundColor(.blue) 46 | } 47 | } 48 | 49 | if let project = session.project { 50 | Text("Project: \(project)") 51 | .font(.caption) 52 | .foregroundColor(.secondary) 53 | } 54 | } 55 | .padding(.vertical, 4) 56 | } 57 | } 58 | } 59 | .navigationTitle("Sessions") 60 | .toolbar { 61 | ToolbarItem(placement: .automatic) { 62 | Button("Done") { 63 | isPresented = false 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | private func formattedDate(_ dateString: String) -> String { 71 | // Try to parse ISO8601 date 72 | let formatter = ISO8601DateFormatter() 73 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 74 | 75 | if let date = formatter.date(from: dateString) { 76 | let displayFormatter = DateFormatter() 77 | displayFormatter.dateStyle = .short 78 | displayFormatter.timeStyle = .short 79 | return displayFormatter.string(from: date) 80 | } 81 | 82 | return dateString 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/README.md: -------------------------------------------------------------------------------- 1 | # ClaudeCodeSDK Example App 2 | 3 | This example demonstrates how to use the ClaudeCodeSDK with MCP (Model Context Protocol) support. 4 | 5 | ## Features 6 | 7 | - **Chat Interface**: Interactive chat with Claude Code 8 | - **Session Management**: View and manage previous Claude Code sessions 9 | - **MCP Configuration**: Enable and configure MCP servers 10 | 11 | ## Using MCP Configuration 12 | 13 | 1. Click the gear icon (⚙️) in the toolbar 14 | 2. Toggle "Enable MCP" to ON 15 | 3. Either: 16 | - Click "Load Example" to use the included `mcp-config-example.json` 17 | - Or provide your own path to an MCP configuration file 18 | 19 | ### Example MCP Config 20 | 21 | The included `mcp-config-example.json` demonstrates integration with XcodeBuildMCP: 22 | 23 | ```json 24 | { 25 | "mcpServers": { 26 | "XcodeBuildMCP": { 27 | "command": "npx", 28 | "args": [ 29 | "-y", 30 | "xcodebuildmcp@latest" 31 | ] 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | This configuration enables Claude Code to interact with Xcode build tools. 38 | 39 | ### Creating Your Own MCP Config 40 | 41 | You can create custom MCP configurations for different servers: 42 | 43 | ```json 44 | { 45 | "mcpServers": { 46 | "filesystem": { 47 | "command": "npx", 48 | "args": [ 49 | "-y", 50 | "@modelcontextprotocol/server-filesystem", 51 | "/path/to/allowed/files" 52 | ] 53 | }, 54 | "github": { 55 | "command": "npx", 56 | "args": ["-y", "@modelcontextprotocol/server-github"], 57 | "env": { 58 | "GITHUB_TOKEN": "your-github-token" 59 | } 60 | } 61 | } 62 | } 63 | ``` 64 | 65 | ## MCP Tool Naming Convention 66 | 67 | When MCP is enabled, tools from MCP servers follow a specific naming pattern: 68 | - `mcp____` 69 | 70 | For example, with the XcodeBuildMCP server, tools are available as: 71 | - `mcp__XcodeBuildMCP__build` 72 | - `mcp__XcodeBuildMCP__test` 73 | - `mcp__XcodeBuildMCP__clean` 74 | 75 | The example app automatically: 76 | 1. Reads your MCP configuration file 77 | 2. Extracts all server names 78 | 3. Generates wildcard patterns like `mcp__XcodeBuildMCP__*` to allow all tools from each server 79 | 4. Adds these patterns to the allowed tools list 80 | 81 | ## Important Notes 82 | 83 | - MCP tools must be explicitly allowed using the correct naming convention 84 | - Use wildcards like `mcp____*` to allow all tools from a server 85 | - The SDK handles the tool naming automatically when you provide an MCP configuration 86 | 87 | ## Session Management 88 | 89 | Click the list icon (📋) to view all your Claude Code sessions, including: 90 | - Session IDs 91 | - Creation and last active dates 92 | - Total cost per session 93 | - Associated project names -------------------------------------------------------------------------------- /Example-AgentSDK.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env swift 2 | 3 | // Example: Using the Agent SDK Backend 4 | // This demonstrates the simplest possible migration from headless to Agent SDK 5 | // 6 | // Run: swift Example-AgentSDK.swift 7 | // (Make sure you have: npm install -g @anthropic-ai/claude-agent-sdk) 8 | 9 | import Foundation 10 | 11 | // Add the ClaudeCodeSDK directory to the import path when running as a script 12 | // In a real project, you'd import it normally: import ClaudeCodeSDK 13 | 14 | print("🚀 Agent SDK Backend Example\n") 15 | 16 | // STEP 1: Check if Agent SDK is available 17 | print("Checking Agent SDK installation...") 18 | 19 | let npmCheck = Process() 20 | npmCheck.executableURL = URL(fileURLWithPath: "/usr/bin/env") 21 | npmCheck.arguments = ["npm", "list", "-g", "@anthropic-ai/claude-agent-sdk"] 22 | npmCheck.standardOutput = Pipe() 23 | npmCheck.standardError = Pipe() 24 | 25 | do { 26 | try npmCheck.run() 27 | npmCheck.waitUntilExit() 28 | 29 | if npmCheck.terminationStatus == 0 { 30 | print("✅ Agent SDK is installed\n") 31 | 32 | print("To use the Agent SDK backend in your code:") 33 | print("─────────────────────────────────────────────\n") 34 | 35 | print(""" 36 | import ClaudeCodeSDK 37 | 38 | // Configure for Agent SDK 39 | var config = ClaudeCodeConfiguration.default 40 | config.backend = .agentSDK // 👈 Just add this line! 41 | 42 | let client = try ClaudeCodeClient(configuration: config) 43 | 44 | // Run a prompt (use .streamJson for Agent SDK) 45 | let result = try await client.runSinglePrompt( 46 | prompt: "Explain what Swift is", 47 | outputFormat: .streamJson, 48 | options: nil 49 | ) 50 | 51 | // Handle the streaming response 52 | if case .stream(let publisher) = result { 53 | for await message in publisher.values { 54 | print(message) 55 | } 56 | } 57 | """) 58 | 59 | print("\n─────────────────────────────────────────────") 60 | print("\n📚 See AGENT_SDK_MIGRATION.md for complete examples") 61 | 62 | } else { 63 | print("❌ Agent SDK is NOT installed\n") 64 | print("Install it with:") 65 | print(" npm install -g @anthropic-ai/claude-agent-sdk\n") 66 | } 67 | 68 | } catch { 69 | print("❌ Error checking npm: \(error)") 70 | } 71 | 72 | print("\n💡 Quick comparison:") 73 | print("┌──────────────┬─────────────────┬─────────────────┐") 74 | print("│ Feature │ Headless │ Agent SDK │") 75 | print("├──────────────┼─────────────────┼─────────────────┤") 76 | print("│ Setup │ .headless │ .agentSDK │") 77 | print("│ Speed │ Baseline │ 2-10x faster │") 78 | print("│ Output │ .json/.text │ .streamJson │") 79 | print("│ Sessions │ Full support │ Full support │") 80 | print("│ MCP Servers │ ✅ │ ✅ │") 81 | print("└──────────────┴─────────────────┴─────────────────┘") 82 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Utilities/MCPToolFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCPToolFormatter.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/18/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Utility for formatting MCP tool names according to the Claude Code specification 11 | public enum MCPToolFormatter { 12 | 13 | /// Formats an MCP tool name according to the specification: mcp__serverName__toolName 14 | /// - Parameters: 15 | /// - serverName: The name of the MCP server 16 | /// - toolName: The name of the tool 17 | /// - Returns: The formatted tool name 18 | public static func formatToolName(serverName: String, toolName: String) -> String { 19 | return "mcp__\(serverName)__\(toolName)" 20 | } 21 | 22 | /// Formats a wildcard pattern for all tools from a specific MCP server 23 | /// - Parameter serverName: The name of the MCP server 24 | /// - Returns: The wildcard pattern for all tools from the server 25 | public static func formatServerWildcard(serverName: String) -> String { 26 | return "mcp__\(serverName)__*" 27 | } 28 | 29 | /// Extracts MCP server names from a configuration dictionary 30 | /// - Parameter mcpServers: Dictionary of MCP server configurations 31 | /// - Returns: Array of server names 32 | public static func extractServerNames(from mcpServers: [String: McpServerConfiguration]) -> [String] { 33 | return Array(mcpServers.keys) 34 | } 35 | 36 | /// Generates allowed tool patterns for all MCP servers in a configuration 37 | /// - Parameter mcpServers: Dictionary of MCP server configurations 38 | /// - Returns: Array of MCP tool patterns 39 | public static func generateAllowedToolPatterns(from mcpServers: [String: McpServerConfiguration]) -> [String] { 40 | return extractServerNames(from: mcpServers).map { formatServerWildcard(serverName: $0) } 41 | } 42 | 43 | /// Parses an MCP configuration file and returns server names 44 | /// - Parameter configPath: Path to the MCP configuration JSON file 45 | /// - Returns: Array of server names, or empty array if parsing fails 46 | public static func extractServerNames(fromConfigPath configPath: String) -> [String] { 47 | guard let data = try? Data(contentsOf: URL(fileURLWithPath: configPath)), 48 | let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 49 | let mcpServers = json["mcpServers"] as? [String: Any] else { 50 | return [] 51 | } 52 | return Array(mcpServers.keys) 53 | } 54 | 55 | /// Generates allowed tool patterns from an MCP configuration file 56 | /// - Parameter configPath: Path to the MCP configuration JSON file 57 | /// - Returns: Array of MCP tool patterns 58 | public static func generateAllowedToolPatterns(fromConfigPath configPath: String) -> [String] { 59 | return extractServerNames(fromConfigPath: configPath).map { formatServerWildcard(serverName: $0) } 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Backend/BackendFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackendFactory.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by ClaudeCodeSDK on 10/8/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Factory for creating backend instances based on configuration 11 | internal struct BackendFactory { 12 | 13 | /// Creates the appropriate backend based on configuration 14 | /// - Parameter configuration: The configuration to use 15 | /// - Returns: A backend instance 16 | /// - Throws: ClaudeCodeError if backend creation fails 17 | static func createBackend( 18 | for configuration: ClaudeCodeConfiguration 19 | ) throws -> ClaudeCodeBackend { 20 | switch configuration.backend { 21 | case .headless: 22 | return HeadlessBackend(configuration: configuration) 23 | 24 | case .agentSDK: 25 | // Validate Agent SDK setup 26 | guard NodePathDetector.detectNodePath() != nil || configuration.nodeExecutable != nil else { 27 | throw ClaudeCodeError.invalidConfiguration( 28 | "Node.js not found. Please install Node.js or specify nodeExecutable in configuration." 29 | ) 30 | } 31 | 32 | if !NodePathDetector.isAgentSDKInstalled(configuration: configuration) { 33 | throw ClaudeCodeError.invalidConfiguration( 34 | "Claude Agent SDK is not installed. Run: npm install -g @anthropic-ai/claude-agent-sdk" 35 | ) 36 | } 37 | 38 | return AgentSDKBackend(configuration: configuration) 39 | } 40 | } 41 | 42 | /// Validates that a backend can be created with the given configuration 43 | /// - Parameter configuration: The configuration to validate 44 | /// - Returns: true if valid, false otherwise 45 | static func validateConfiguration(_ configuration: ClaudeCodeConfiguration) -> Bool { 46 | switch configuration.backend { 47 | case .headless: 48 | // Headless just needs the command to be available 49 | return true 50 | 51 | case .agentSDK: 52 | // Check Node.js availability 53 | guard NodePathDetector.detectNodePath() != nil || configuration.nodeExecutable != nil else { 54 | return false 55 | } 56 | 57 | // Check Agent SDK installation 58 | return NodePathDetector.isAgentSDKInstalled(configuration: configuration) 59 | } 60 | } 61 | 62 | /// Gets a human-readable error message for configuration issues 63 | /// - Parameter configuration: The configuration to check 64 | /// - Returns: An error message if invalid, nil if valid 65 | static func getConfigurationError(_ configuration: ClaudeCodeConfiguration) -> String? { 66 | switch configuration.backend { 67 | case .headless: 68 | return nil 69 | 70 | case .agentSDK: 71 | if NodePathDetector.detectNodePath() == nil && configuration.nodeExecutable == nil { 72 | return "Node.js not found. Please install Node.js or specify nodeExecutable in configuration." 73 | } 74 | 75 | if !NodePathDetector.isAgentSDKInstalled(configuration: configuration) { 76 | return "Claude Agent SDK is not installed. Run: npm install -g @anthropic-ai/claude-agent-sdk" 77 | } 78 | 79 | return nil 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/BasicClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BasicClientTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK Tests 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class BasicClientTests: XCTestCase { 13 | 14 | func testClientInitializationWithDebug() throws { 15 | // Test basic client initialization as shown in README 16 | let client = try ClaudeCodeClient(debug: true) 17 | 18 | XCTAssertNotNil(client) 19 | XCTAssertTrue(client.configuration.enableDebugLogging) 20 | XCTAssertEqual(client.configuration.command, "claude") 21 | } 22 | 23 | func testClientInitializationWithConfiguration() throws { 24 | // Test custom configuration initialization 25 | let configuration = ClaudeCodeConfiguration( 26 | command: "claude", 27 | workingDirectory: "/path/to/project", 28 | environment: ["API_KEY": "value"], 29 | enableDebugLogging: true, 30 | additionalPaths: ["/custom/bin"] 31 | ) 32 | 33 | let client = try ClaudeCodeClient(configuration: configuration) 34 | 35 | XCTAssertNotNil(client) 36 | XCTAssertEqual(client.configuration.command, "claude") 37 | XCTAssertEqual(client.configuration.workingDirectory, "/path/to/project") 38 | XCTAssertEqual(client.configuration.environment["API_KEY"], "value") 39 | XCTAssertTrue(client.configuration.enableDebugLogging) 40 | XCTAssertEqual(client.configuration.additionalPaths, ["/custom/bin"]) 41 | } 42 | 43 | func testClientConfigurationModificationAtRuntime() throws { 44 | // Test runtime configuration modification as shown in README 45 | let client = try ClaudeCodeClient() 46 | 47 | // Modify configuration at runtime 48 | client.configuration.enableDebugLogging = false 49 | client.configuration.workingDirectory = "/new/path" 50 | 51 | XCTAssertFalse(client.configuration.enableDebugLogging) 52 | XCTAssertEqual(client.configuration.workingDirectory, "/new/path") 53 | } 54 | 55 | func testDefaultConfiguration() { 56 | // Test default configuration values 57 | let config = ClaudeCodeConfiguration.default 58 | 59 | XCTAssertEqual(config.command, "claude") 60 | XCTAssertNil(config.workingDirectory) 61 | XCTAssertTrue(config.environment.isEmpty) 62 | XCTAssertFalse(config.enableDebugLogging) 63 | XCTAssertEqual(config.additionalPaths, ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]) 64 | } 65 | 66 | func testBackwardCompatibilityInitializer() throws { 67 | // Test convenience initializer for backward compatibility 68 | let client1 = try ClaudeCodeClient(workingDirectory: "/test/path", debug: true) 69 | 70 | XCTAssertEqual(client1.configuration.workingDirectory, "/test/path") 71 | XCTAssertTrue(client1.configuration.enableDebugLogging) 72 | 73 | // Test with empty working directory 74 | let client2 = try ClaudeCodeClient(workingDirectory: "", debug: false) 75 | 76 | XCTAssertNil(client2.configuration.workingDirectory) 77 | XCTAssertFalse(client2.configuration.enableDebugLogging) 78 | } 79 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Backend/ClaudeCodeBackend.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeBackend.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by Assistant on 10/7/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Defines the type of backend implementation 11 | public enum BackendType: String, Codable, Sendable { 12 | /// Traditional headless mode using `claude -p` CLI 13 | case headless 14 | 15 | /// Node.js-based wrapper around @anthropic-ai/claude-agent-sdk 16 | case agentSDK 17 | } 18 | 19 | /// Protocol defining the interface for Claude Code execution backends 20 | /// This abstraction allows switching between different implementation strategies 21 | /// while maintaining a consistent API. 22 | internal protocol ClaudeCodeBackend: Sendable { 23 | 24 | /// Executes a single prompt and returns the result 25 | /// - Parameters: 26 | /// - prompt: The prompt text to send 27 | /// - outputFormat: The desired output format 28 | /// - options: Additional configuration options 29 | /// - Returns: The result in the specified format 30 | func runSinglePrompt( 31 | prompt: String, 32 | outputFormat: ClaudeCodeOutputFormat, 33 | options: ClaudeCodeOptions? 34 | ) async throws -> ClaudeCodeResult 35 | 36 | /// Runs with stdin content (for pipe functionality) 37 | /// - Parameters: 38 | /// - stdinContent: The content to pipe to stdin 39 | /// - outputFormat: The desired output format 40 | /// - options: Additional configuration options 41 | /// - Returns: The result in the specified format 42 | func runWithStdin( 43 | stdinContent: String, 44 | outputFormat: ClaudeCodeOutputFormat, 45 | options: ClaudeCodeOptions? 46 | ) async throws -> ClaudeCodeResult 47 | 48 | /// Continues the most recent conversation 49 | /// - Parameters: 50 | /// - prompt: Optional prompt text for the continuation 51 | /// - outputFormat: The desired output format 52 | /// - options: Additional configuration options 53 | /// - Returns: The result in the specified format 54 | func continueConversation( 55 | prompt: String?, 56 | outputFormat: ClaudeCodeOutputFormat, 57 | options: ClaudeCodeOptions? 58 | ) async throws -> ClaudeCodeResult 59 | 60 | /// Resumes a specific conversation by session ID 61 | /// - Parameters: 62 | /// - sessionId: The session ID to resume 63 | /// - prompt: Optional prompt text for the resumed session 64 | /// - outputFormat: The desired output format 65 | /// - options: Additional configuration options 66 | /// - Returns: The result in the specified format 67 | func resumeConversation( 68 | sessionId: String, 69 | prompt: String?, 70 | outputFormat: ClaudeCodeOutputFormat, 71 | options: ClaudeCodeOptions? 72 | ) async throws -> ClaudeCodeResult 73 | 74 | /// Gets a list of recent sessions 75 | /// - Returns: List of session information 76 | func listSessions() async throws -> [SessionInfo] 77 | 78 | /// Cancels any current operations 79 | func cancel() 80 | 81 | /// Validates if the backend is properly configured and available 82 | /// - Returns: true if the backend is ready to use 83 | func validateSetup() async throws -> Bool 84 | 85 | /// Debug information about the last command executed 86 | /// - Returns: Information about the last executed command, or nil if no commands have been executed 87 | var lastExecutedCommandInfo: ExecutedCommandInfo? { get } 88 | } 89 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/ShellEscapingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShellEscapingTests.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by Assistant on 10/15/25. 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | 11 | final class ShellEscapingTests: XCTestCase { 12 | 13 | func testBasicShellEscaping() { 14 | let options = ClaudeCodeOptions() 15 | 16 | // Create a test with problematic characters 17 | var testOptions = options 18 | testOptions.appendSystemPrompt = "Hello { world }" 19 | 20 | let args = testOptions.toCommandArgs() 21 | 22 | // The args should contain the --append-system-prompt flag 23 | XCTAssertTrue(args.contains("--append-system-prompt")) 24 | 25 | // Find the index of the flag 26 | if let index = args.firstIndex(of: "--append-system-prompt") { 27 | // The next element should be the escaped value 28 | let escapedValue = args[index + 1] 29 | 30 | // Should be wrapped in single quotes 31 | XCTAssertTrue(escapedValue.hasPrefix("'")) 32 | XCTAssertTrue(escapedValue.hasSuffix("'")) 33 | 34 | print("Escaped value: \(escapedValue)") 35 | } 36 | } 37 | 38 | func testShellEscapingWithNewlines() { 39 | let options = ClaudeCodeOptions() 40 | 41 | var testOptions = options 42 | testOptions.appendSystemPrompt = """ 43 | First line 44 | Second line with {braces} 45 | Third line 46 | """ 47 | 48 | let args = testOptions.toCommandArgs() 49 | 50 | // Should still work with newlines 51 | XCTAssertTrue(args.contains("--append-system-prompt")) 52 | 53 | if let index = args.firstIndex(of: "--append-system-prompt") { 54 | let escapedValue = args[index + 1] 55 | 56 | // Should be properly wrapped 57 | XCTAssertTrue(escapedValue.hasPrefix("'")) 58 | XCTAssertTrue(escapedValue.hasSuffix("'")) 59 | 60 | print("Escaped value with newlines: \(escapedValue)") 61 | } 62 | } 63 | 64 | func testShellEscapingWithSingleQuotes() { 65 | let options = ClaudeCodeOptions() 66 | 67 | var testOptions = options 68 | testOptions.appendSystemPrompt = "It's a test with 'quotes'" 69 | 70 | let args = testOptions.toCommandArgs() 71 | 72 | XCTAssertTrue(args.contains("--append-system-prompt")) 73 | 74 | if let index = args.firstIndex(of: "--append-system-prompt") { 75 | let escapedValue = args[index + 1] 76 | 77 | // Should handle single quotes correctly 78 | XCTAssertTrue(escapedValue.contains("'\\''")) 79 | 80 | print("Escaped value with single quotes: \(escapedValue)") 81 | } 82 | } 83 | 84 | func testShellEscapingWithComplexJSON() { 85 | let options = ClaudeCodeOptions() 86 | 87 | var testOptions = options 88 | testOptions.appendSystemPrompt = """ 89 | {"type":"result","data":{"value":123}} 90 | With unmatched { character 91 | """ 92 | 93 | let args = testOptions.toCommandArgs() 94 | 95 | XCTAssertTrue(args.contains("--append-system-prompt")) 96 | 97 | if let index = args.firstIndex(of: "--append-system-prompt") { 98 | let escapedValue = args[index + 1] 99 | 100 | // Should be properly escaped 101 | XCTAssertTrue(escapedValue.hasPrefix("'")) 102 | XCTAssertTrue(escapedValue.hasSuffix("'")) 103 | 104 | print("Escaped value with JSON: \(escapedValue)") 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test-nvm-fix.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env swift 2 | 3 | // Test script to verify NVM path detection fix 4 | // This demonstrates that the bug is fixed and nodeExecutable configuration is now respected 5 | 6 | import Foundation 7 | 8 | print("🧪 Testing NVM Configuration Fix\n") 9 | 10 | // Test 1: Auto-detection (original behavior) 11 | print("1️⃣ Test Auto-Detection") 12 | print(" Running: /bin/zsh -l -c 'npm config get prefix'") 13 | 14 | let autoDetect = Process() 15 | autoDetect.executableURL = URL(fileURLWithPath: "/bin/zsh") 16 | autoDetect.arguments = ["-l", "-c", "npm config get prefix"] 17 | let autoPipe = Pipe() 18 | autoDetect.standardOutput = autoPipe 19 | 20 | do { 21 | try autoDetect.run() 22 | autoDetect.waitUntilExit() 23 | let autoData = autoPipe.fileHandleForReading.readDataToEndOfFile() 24 | let autoPrefix = String(data: autoData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown" 25 | print(" Result: \(autoPrefix)") 26 | print(" SDK Path: \(autoPrefix)/lib/node_modules/@anthropic-ai/claude-agent-sdk") 27 | 28 | let autoSDKExists = FileManager.default.fileExists(atPath: "\(autoPrefix)/lib/node_modules/@anthropic-ai/claude-agent-sdk") 29 | print(" SDK Exists: \(autoSDKExists ? "✅" : "❌")\n") 30 | } catch { 31 | print(" Error: \(error)\n") 32 | } 33 | 34 | // Test 2: NVM path (the fix) 35 | print("2️⃣ Test NVM Explicit Path") 36 | let nvmNodePath = "\(NSHomeDirectory())/.nvm/versions/node/v22.16.0/bin/node" 37 | 38 | if FileManager.default.fileExists(atPath: nvmNodePath) { 39 | print(" Node Path: \(nvmNodePath)") 40 | 41 | // Derive SDK path from node path (this is what the fix does) 42 | let nodeBinDir = (nvmNodePath as NSString).deletingLastPathComponent 43 | let nodePrefix = (nodeBinDir as NSString).deletingLastPathComponent 44 | let nvmSDKPath = "\(nodePrefix)/lib/node_modules/@anthropic-ai/claude-agent-sdk" 45 | 46 | print(" Derived SDK Path: \(nvmSDKPath)") 47 | 48 | let nvmSDKExists = FileManager.default.fileExists(atPath: nvmSDKPath) 49 | print(" SDK Exists: \(nvmSDKExists ? "✅" : "❌")\n") 50 | 51 | // Test 3: Demonstrate the difference 52 | if nvmSDKExists { 53 | print("3️⃣ Fix Verification") 54 | print(" ✅ FIXED: nodeExecutable config now correctly detects SDK at NVM location") 55 | print(" ✅ Before fix: Would fail even though SDK is installed") 56 | print(" ✅ After fix: Successfully detects SDK using configured node path\n") 57 | } 58 | } else { 59 | print(" ⚠️ NVM installation not found at expected location") 60 | print(" Expected: \(nvmNodePath)\n") 61 | } 62 | 63 | // Test 4: Show the code change 64 | print("4️⃣ Code Fix Summary") 65 | print(" File: NodePathDetector.swift") 66 | print(" Method: isAgentSDKInstalled(configuration:)") 67 | print("") 68 | print(" OLD:") 69 | print(" ❌ public static func isAgentSDKInstalled() -> Bool") 70 | print(" ❌ // Ignored configuration.nodeExecutable") 71 | print("") 72 | print(" NEW:") 73 | print(" ✅ public static func isAgentSDKInstalled(configuration:) -> Bool") 74 | print(" ✅ // Respects configuration.nodeExecutable") 75 | print(" ✅ // Derives SDK path from configured node path") 76 | print("") 77 | 78 | print("5️⃣ Usage Example") 79 | print(" var config = ClaudeCodeConfiguration.default") 80 | print(" config.backend = .agentSDK") 81 | print(" config.nodeExecutable = \"\(nvmNodePath)\"") 82 | print(" let client = try ClaudeCodeClient(configuration: config)") 83 | print(" // ✅ Now works with NVM installations!") 84 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ClaudeCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCode.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | /// Protocol that defines the interface for interacting with Claude Code. 10 | /// This allows for dependency injection and easier testing with mocks. 11 | /// Documentation: https://docs.anthropic.com/en/docs/claude-code/sdk 12 | public protocol ClaudeCode { 13 | 14 | /// Configuration settings for the Claude Code client. 15 | /// Controls command execution, environment variables, and debug options. 16 | /// Can be modified at runtime to adjust client behavior. 17 | var configuration: ClaudeCodeConfiguration { get set } 18 | 19 | /// Runs Claude Code using stdin as input (for pipe functionality) 20 | /// - Parameters: 21 | /// - stdinContent: The content to pipe to Claude Code's stdin 22 | /// - outputFormat: The desired output format 23 | /// - options: Additional configuration options 24 | /// - Returns: The result in the specified format 25 | func runWithStdin( 26 | stdinContent: String, 27 | outputFormat: ClaudeCodeOutputFormat, 28 | options: ClaudeCodeOptions? 29 | ) async throws -> ClaudeCodeResult 30 | 31 | /// Runs a single prompt and returns the result 32 | /// - Parameters: 33 | /// - prompt: The prompt text to send to Claude Code 34 | /// - outputFormat: The desired output format 35 | /// - options: Additional configuration options 36 | /// - Returns: The result in the specified format 37 | func runSinglePrompt( 38 | prompt: String, 39 | outputFormat: ClaudeCodeOutputFormat, 40 | options: ClaudeCodeOptions? 41 | ) async throws -> ClaudeCodeResult 42 | 43 | /// Continues the most recent conversation 44 | /// - Parameters: 45 | /// - prompt: Optional prompt text for the continuation 46 | /// - outputFormat: The desired output format 47 | /// - options: Additional configuration options 48 | /// - Returns: The result in the specified format 49 | func continueConversation( 50 | prompt: String?, 51 | outputFormat: ClaudeCodeOutputFormat, 52 | options: ClaudeCodeOptions? 53 | ) async throws -> ClaudeCodeResult 54 | 55 | /// Resumes a specific conversation by session ID 56 | /// - Parameters: 57 | /// - sessionId: The session ID to resume 58 | /// - prompt: Optional prompt text for the resumed session 59 | /// - outputFormat: The desired output format 60 | /// - options: Additional configuration options 61 | /// - Returns: The result in the specified format 62 | func resumeConversation( 63 | sessionId: String, 64 | prompt: String?, 65 | outputFormat: ClaudeCodeOutputFormat, 66 | options: ClaudeCodeOptions? 67 | ) async throws -> ClaudeCodeResult 68 | 69 | /// Gets a list of recent sessions 70 | /// - Returns: List of session information 71 | func listSessions() async throws -> [SessionInfo] 72 | 73 | /// Cancels any current operations 74 | func cancel() 75 | 76 | /// Validates if a command is available in the configured PATH 77 | /// - Parameter command: The command to check (e.g., "npm", "node") 78 | /// - Returns: true if the command is found in PATH, false otherwise 79 | func validateCommand(_ command: String) async throws -> Bool 80 | 81 | /// Debug information about the last command executed 82 | /// Returns nil if no command has been executed yet 83 | var lastExecutedCommandInfo: ExecutedCommandInfo? { get } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/QuickTest/main.swift: -------------------------------------------------------------------------------- 1 | import ClaudeCodeSDK 2 | import Foundation 3 | 4 | print("🧪 ClaudeCodeSDK Dual-Backend Test\n") 5 | print(String(repeating: "=", count: 60)) 6 | 7 | // Test 1: Default Configuration 8 | print("\n✅ Test 1: Default Configuration") 9 | do { 10 | let client = try ClaudeCodeClient() 11 | print(" Backend: \(client.configuration.backend.rawValue)") 12 | print(" Command: \(client.configuration.command)") 13 | print(" ✓ Client created successfully with headless backend") 14 | } catch { 15 | print(" ✗ Failed: \(error)") 16 | } 17 | 18 | // Test 2: Explicit Headless Backend 19 | print("\n✅ Test 2: Explicit Headless Backend") 20 | do { 21 | var config = ClaudeCodeConfiguration.default 22 | config.backend = .headless 23 | let client = try ClaudeCodeClient(configuration: config) 24 | print(" Backend: \(client.configuration.backend.rawValue)") 25 | print(" ✓ Headless backend created successfully") 26 | } catch { 27 | print(" ✗ Failed: \(error)") 28 | } 29 | 30 | // Test 3: Agent SDK Backend (will fail if not installed) 31 | print("\n✅ Test 3: Agent SDK Backend") 32 | do { 33 | var config = ClaudeCodeConfiguration.default 34 | config.backend = .agentSDK 35 | let client = try ClaudeCodeClient(configuration: config) 36 | print(" Backend: \(client.configuration.backend.rawValue)") 37 | print(" ✓ Agent SDK backend created successfully") 38 | } catch { 39 | print(" ✗ Expected failure (Agent SDK not installed):") 40 | print(" \(error)") 41 | } 42 | 43 | // Test 4: Backend Validation (via client creation) 44 | print("\n✅ Test 4: Backend Validation via Client Creation") 45 | do { 46 | let headlessConfig = ClaudeCodeConfiguration(backend: .headless) 47 | _ = try ClaudeCodeClient(configuration: headlessConfig) 48 | print(" ✓ Headless backend validated successfully") 49 | } catch { 50 | print(" ✗ Headless validation failed: \(error)") 51 | } 52 | 53 | do { 54 | let agentSDKConfig = ClaudeCodeConfiguration(backend: .agentSDK) 55 | _ = try ClaudeCodeClient(configuration: agentSDKConfig) 56 | print(" ✓ Agent SDK backend validated successfully") 57 | } catch { 58 | print(" ✓ Agent SDK validation failed (expected): \(error)") 59 | } 60 | 61 | // Test 5: Backend Switching 62 | print("\n✅ Test 5: Runtime Backend Switching") 63 | do { 64 | let client = try ClaudeCodeClient() 65 | print(" Initial backend: \(client.configuration.backend.rawValue)") 66 | 67 | // Try to switch (will fail if Agent SDK not installed, but shouldn't crash) 68 | print(" Attempting to switch to agentSDK...") 69 | client.configuration.backend = .agentSDK 70 | print(" Current backend: \(client.configuration.backend.rawValue)") 71 | 72 | // If switch failed, it should have reverted 73 | if client.configuration.backend == .headless { 74 | print(" ✓ Switch failed gracefully, reverted to headless") 75 | } else { 76 | print(" ✓ Switch succeeded (Agent SDK is installed!)") 77 | } 78 | } catch { 79 | print(" ✗ Failed: \(error)") 80 | } 81 | 82 | // Test 6: Node Path Detection 83 | print("\n✅ Test 6: Node.js Detection") 84 | if let nodePath = NodePathDetector.detectNodePath() { 85 | print(" ✓ Node.js found: \(nodePath)") 86 | } else { 87 | print(" ✗ Node.js not found") 88 | } 89 | 90 | if let npmPath = NodePathDetector.detectNpmPath() { 91 | print(" ✓ npm found: \(npmPath)") 92 | } 93 | 94 | print(" Agent SDK installed: \(NodePathDetector.isAgentSDKInstalled())") 95 | 96 | if let sdkPath = NodePathDetector.getAgentSDKPath() { 97 | print(" ✓ Agent SDK path: \(sdkPath)") 98 | } 99 | 100 | print("\n" + String(repeating: "=", count: 60)) 101 | print("✅ All tests completed!\n") 102 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/CancellationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CancellationTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK Tests 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class CancellationTests: XCTestCase { 13 | 14 | func testAbortControllerInitialization() { 15 | // Test AbortController creation 16 | let abortController = AbortController() 17 | 18 | XCTAssertNotNil(abortController) 19 | XCTAssertFalse(abortController.signal.aborted) 20 | } 21 | 22 | func testAbortControllerAbort() { 23 | // Test abort functionality 24 | let abortController = AbortController() 25 | 26 | XCTAssertFalse(abortController.signal.aborted) 27 | 28 | abortController.abort() 29 | 30 | XCTAssertTrue(abortController.signal.aborted) 31 | } 32 | 33 | func testAbortControllerInOptions() { 34 | // Test AbortController in options as shown in README 35 | var options = ClaudeCodeOptions() 36 | let abortController = AbortController() 37 | options.abortController = abortController 38 | 39 | XCTAssertNotNil(options.abortController) 40 | XCTAssertFalse(options.abortController?.signal.aborted ?? true) 41 | 42 | // Simulate cancellation 43 | abortController.abort() 44 | 45 | XCTAssertTrue(options.abortController?.signal.aborted ?? false) 46 | } 47 | 48 | func testMultipleAborts() { 49 | // Test that multiple aborts are handled gracefully 50 | let abortController = AbortController() 51 | 52 | abortController.abort() 53 | XCTAssertTrue(abortController.signal.aborted) 54 | 55 | // Second abort should not cause issues 56 | abortController.abort() 57 | XCTAssertTrue(abortController.signal.aborted) 58 | } 59 | 60 | func testAbortSignalFunctionality() { 61 | // Test abort signal functionality 62 | let abortController = AbortController() 63 | 64 | // Should not be aborted initially 65 | XCTAssertFalse(abortController.signal.aborted) 66 | 67 | // Test onAbort callback 68 | var callbackCalled = false 69 | abortController.signal.onAbort { 70 | callbackCalled = true 71 | } 72 | 73 | // Abort the controller 74 | abortController.abort() 75 | 76 | // Should be aborted and callback should be called 77 | XCTAssertTrue(abortController.signal.aborted) 78 | XCTAssertTrue(callbackCalled) 79 | } 80 | 81 | func testCancellationPatternFromReadme() async throws { 82 | // Test the cancellation pattern from README 83 | var options = ClaudeCodeOptions() 84 | let abortController = AbortController() 85 | options.abortController = abortController 86 | 87 | // Simulate starting an operation in a task 88 | let operationTask = Task { 89 | // Simulate a long-running operation that checks for cancellation 90 | for _ in 0..<10 { 91 | if abortController.signal.aborted { 92 | throw ClaudeCodeError.cancelled 93 | } 94 | try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second 95 | } 96 | return "Completed" 97 | } 98 | 99 | // Let it run briefly 100 | try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds 101 | 102 | // Cancel when needed 103 | abortController.abort() 104 | 105 | // The operation should fail with cancellation error 106 | do { 107 | _ = try await operationTask.value 108 | XCTFail("Expected cancellation error") 109 | } catch ClaudeCodeError.cancelled { 110 | // Expected 111 | } catch { 112 | XCTFail("Expected ClaudeCodeError.cancelled, got \(error)") 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ClaudeCodeConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeConfiguration.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Configuration for ClaudeCodeClient 11 | public struct ClaudeCodeConfiguration { 12 | /// The backend type to use for execution 13 | /// - headless: Traditional CLI-based approach (default) 14 | /// - agentSDK: Node.js wrapper around @anthropic-ai/claude-agent-sdk 15 | public var backend: BackendType 16 | 17 | /// The command to execute (default: "claude") 18 | /// Used for headless backend 19 | public var command: String 20 | 21 | /// Path to Node.js executable (optional, auto-detected if not provided) 22 | /// Used for agentSDK backend 23 | public var nodeExecutable: String? 24 | 25 | /// Path to sdk-wrapper.mjs script (optional, uses bundled resource if not provided) 26 | /// Used for agentSDK backend 27 | public var sdkWrapperPath: String? 28 | 29 | /// The working directory for command execution 30 | public var workingDirectory: String? 31 | 32 | /// Additional environment variables 33 | public var environment: [String: String] 34 | 35 | /// Enable debug logging 36 | public var enableDebugLogging: Bool 37 | 38 | /// Additional paths to add to PATH environment variable 39 | public var additionalPaths: [String] 40 | 41 | /// Optional suffix to append after the command (e.g., "--" for "airchat --") 42 | public var commandSuffix: String? 43 | 44 | /// List of tools that should be disallowed for Claude to use 45 | public var disallowedTools: [String]? 46 | 47 | /// Default configuration (uses headless backend for backward compatibility) 48 | public static var `default`: ClaudeCodeConfiguration { 49 | ClaudeCodeConfiguration( 50 | backend: .headless, 51 | command: "claude", 52 | nodeExecutable: nil, 53 | sdkWrapperPath: nil, 54 | workingDirectory: nil, 55 | environment: [:], 56 | enableDebugLogging: false, 57 | additionalPaths: [ 58 | "/usr/local/bin", // Homebrew on Intel Macs, common Unix tools 59 | "/opt/homebrew/bin", // Homebrew on Apple Silicon 60 | "/usr/bin", // System binaries 61 | "/bin", // Core system binaries 62 | "/usr/sbin", // System administration binaries 63 | "/sbin" // Essential system binaries 64 | ], 65 | commandSuffix: nil, 66 | disallowedTools: nil 67 | ) 68 | } 69 | 70 | public init( 71 | backend: BackendType = .headless, 72 | command: String = "claude", 73 | nodeExecutable: String? = nil, 74 | sdkWrapperPath: String? = nil, 75 | workingDirectory: String? = nil, 76 | environment: [String: String] = [:], 77 | enableDebugLogging: Bool = false, 78 | additionalPaths: [String] = [ 79 | "/usr/local/bin", // Homebrew on Intel Macs, common Unix tools 80 | "/opt/homebrew/bin", // Homebrew on Apple Silicon 81 | "/usr/bin", // System binaries 82 | "/bin", // Core system binaries 83 | "/usr/sbin", // System administration binaries 84 | "/sbin" // Essential system binaries 85 | ], 86 | commandSuffix: String? = nil, 87 | disallowedTools: [String]? = nil 88 | ) { 89 | self.backend = backend 90 | self.command = command 91 | self.nodeExecutable = nodeExecutable 92 | self.sdkWrapperPath = sdkWrapperPath 93 | self.workingDirectory = workingDirectory 94 | self.environment = environment 95 | self.enableDebugLogging = enableDebugLogging 96 | self.additionalPaths = additionalPaths 97 | self.commandSuffix = commandSuffix 98 | self.disallowedTools = disallowedTools 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/McpServerConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // McpServerConfig.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/17/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Base protocol for MCP server configurations 11 | public protocol McpServerConfig: Codable { 12 | var type: McpServerType? { get } 13 | } 14 | 15 | /// Type of MCP server 16 | public enum McpServerType: String, Codable, Sendable { 17 | case stdio = "stdio" 18 | case sse = "sse" 19 | } 20 | 21 | /// Configuration for stdio-based MCP servers 22 | public struct McpStdioServerConfig: McpServerConfig, Sendable { 23 | /// Type of server (optional for backwards compatibility) 24 | public let type: McpServerType? 25 | 26 | /// Command to execute 27 | public let command: String 28 | 29 | /// Arguments to pass to the command 30 | public let args: [String]? 31 | 32 | /// Environment variables for the command 33 | public let env: [String: String]? 34 | 35 | public init( 36 | type: McpServerType? = .stdio, 37 | command: String, 38 | args: [String]? = nil, 39 | env: [String: String]? = nil 40 | ) { 41 | self.type = type 42 | self.command = command 43 | self.args = args 44 | self.env = env 45 | } 46 | } 47 | 48 | /// Configuration for SSE-based MCP servers 49 | public struct McpSSEServerConfig: McpServerConfig, Sendable { 50 | /// Type of server (required for SSE) 51 | public let type: McpServerType? 52 | 53 | /// URL of the SSE server 54 | public let url: String 55 | 56 | /// Headers to include in requests 57 | public let headers: [String: String]? 58 | 59 | public init(type: McpServerType? = .sse, url: String, headers: [String: String]? = nil) { 60 | self.type = type 61 | self.url = url 62 | self.headers = headers 63 | } 64 | } 65 | 66 | /// Container for MCP server configuration that handles both types 67 | public enum McpServerConfiguration: Codable, Sendable { 68 | case stdio(McpStdioServerConfig) 69 | case sse(McpSSEServerConfig) 70 | 71 | private enum CodingKeys: String, CodingKey { 72 | case type 73 | case command 74 | case args 75 | case env 76 | case url 77 | case headers 78 | } 79 | 80 | public init(from decoder: Decoder) throws { 81 | let container = try decoder.container(keyedBy: CodingKeys.self) 82 | 83 | // Try to decode type, but it's optional for stdio 84 | let type = try container.decodeIfPresent(McpServerType.self, forKey: .type) 85 | 86 | // If type is SSE or we have a URL, decode as SSE 87 | if type == .sse || container.contains(.url) { 88 | let url = try container.decode(String.self, forKey: .url) 89 | let headers = try container.decodeIfPresent([String: String].self, forKey: .headers) 90 | self = .sse(McpSSEServerConfig(type: type, url: url, headers: headers)) 91 | } else { 92 | // Otherwise decode as stdio 93 | let command = try container.decode(String.self, forKey: .command) 94 | let args = try container.decodeIfPresent([String].self, forKey: .args) 95 | let env = try container.decodeIfPresent([String: String].self, forKey: .env) 96 | self = .stdio(McpStdioServerConfig(type: type, command: command, args: args, env: env)) 97 | } 98 | } 99 | 100 | public func encode(to encoder: Encoder) throws { 101 | var container = encoder.container(keyedBy: CodingKeys.self) 102 | 103 | switch self { 104 | case .stdio(let config): 105 | try container.encodeIfPresent(config.type, forKey: .type) 106 | try container.encode(config.command, forKey: .command) 107 | try container.encodeIfPresent(config.args, forKey: .args) 108 | try container.encodeIfPresent(config.env, forKey: .env) 109 | 110 | case .sse(let config): 111 | try container.encode(McpServerType.sse, forKey: .type) 112 | try container.encode(config.url, forKey: .url) 113 | try container.encodeIfPresent(config.headers, forKey: .headers) 114 | } 115 | } 116 | } 117 | 118 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/MCPConfigView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCPConfigView.swift 3 | // ClaudeCodeSDKExample 4 | // 5 | // Created by Assistant on 6/17/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MCPConfigView: View { 11 | @Binding var isMCPEnabled: Bool 12 | @Binding var mcpConfigPath: String 13 | @Binding var isPresented: Bool 14 | 15 | var body: some View { 16 | Form { 17 | Section(header: Text("MCP Configuration")) { 18 | Toggle("Enable MCP", isOn: $isMCPEnabled) 19 | 20 | if isMCPEnabled { 21 | VStack(alignment: .leading, spacing: 8) { 22 | Text("Config File Path") 23 | .font(.caption) 24 | .foregroundColor(.secondary) 25 | 26 | HStack { 27 | TextField("e.g., /path/to/mcp-config.json", text: $mcpConfigPath) 28 | .textFieldStyle(RoundedBorderTextFieldStyle()) 29 | .disableAutocorrection(true) 30 | 31 | Button("Load Example") { 32 | // Get the absolute path to the example file 33 | let absolutePath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Example/ClaudeCodeSDKExample/mcp-config-example.json" 34 | 35 | // Check if file exists at the absolute path 36 | if FileManager.default.fileExists(atPath: absolutePath) { 37 | mcpConfigPath = absolutePath 38 | } else { 39 | // Try to find it relative to the app bundle (for when running from Xcode) 40 | if let bundlePath = Bundle.main.resourcePath { 41 | let bundleExamplePath = "\(bundlePath)/mcp-config-example.json" 42 | if FileManager.default.fileExists(atPath: bundleExamplePath) { 43 | mcpConfigPath = bundleExamplePath 44 | } else { 45 | // Show an error or use a placeholder 46 | mcpConfigPath = "Error: Could not find mcp-config-example.json" 47 | } 48 | } 49 | } 50 | } 51 | .buttonStyle(.bordered) 52 | } 53 | } 54 | } 55 | } 56 | 57 | if isMCPEnabled { 58 | Section(header: Text("Example Configuration")) { 59 | Text(exampleConfig) 60 | .font(.system(.caption, design: .monospaced)) 61 | .padding(8) 62 | .background(Color.gray.opacity(0.1)) 63 | .cornerRadius(8) 64 | } 65 | 66 | Section(header: Text("Notes")) { 67 | Text("• MCP tools must be explicitly allowed using allowedTools") 68 | .font(.caption) 69 | Text("• MCP tool names follow the pattern: mcp____") 70 | .font(.caption) 71 | Text("• Use mcp__ to allow all tools from a server") 72 | .font(.caption) 73 | } 74 | 75 | Section(header: Text("XcodeBuildMCP Features")) { 76 | Text("The example XcodeBuildMCP server provides tools for:") 77 | .font(.caption) 78 | .fontWeight(.semibold) 79 | Text("• Xcode project management") 80 | .font(.caption) 81 | Text("• iOS/macOS simulator management") 82 | .font(.caption) 83 | Text("• Building and running apps") 84 | .font(.caption) 85 | Text("• Managing provisioning profiles") 86 | .font(.caption) 87 | } 88 | } 89 | } 90 | .navigationTitle("MCP Settings") 91 | .toolbar { 92 | ToolbarItem(placement: .automatic) { 93 | Button("Done") { 94 | isPresented = false 95 | } 96 | } 97 | } 98 | } 99 | 100 | private var exampleConfig: String { 101 | """ 102 | { 103 | "mcpServers": { 104 | "XcodeBuildMCP": { 105 | "command": "npx", 106 | "args": [ 107 | "-y", 108 | "xcodebuildmcp@latest" 109 | ] 110 | } 111 | } 112 | } 113 | """ 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/OptionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionsTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK Tests 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class OptionsTests: XCTestCase { 13 | 14 | func testOptionsInitialization() { 15 | // Test default options initialization 16 | let options = ClaudeCodeOptions() 17 | 18 | XCTAssertNil(options.abortController) 19 | XCTAssertNil(options.allowedTools) 20 | XCTAssertNil(options.appendSystemPrompt) 21 | XCTAssertNil(options.systemPrompt) 22 | XCTAssertNil(options.disallowedTools) 23 | XCTAssertNil(options.maxThinkingTokens) 24 | XCTAssertNil(options.maxTurns) 25 | XCTAssertNil(options.mcpServers) 26 | XCTAssertNil(options.permissionMode) 27 | XCTAssertNil(options.permissionPromptToolName) 28 | XCTAssertNil(options.continue) 29 | XCTAssertNil(options.resume) 30 | XCTAssertNil(options.model) 31 | XCTAssertNil(options.timeout) 32 | XCTAssertNil(options.mcpConfigPath) 33 | XCTAssertFalse(options.verbose) 34 | } 35 | 36 | func testOptionsToCommandArgs() { 37 | // Test comprehensive options configuration as shown in README 38 | var options = ClaudeCodeOptions() 39 | options.verbose = true 40 | options.maxTurns = 5 41 | options.systemPrompt = "You are a senior backend engineer specializing in Swift." 42 | options.appendSystemPrompt = "After writing code, add comprehensive comments." 43 | options.timeout = 300 // 5 minute timeout 44 | options.model = "claude-3-sonnet-20240229" 45 | options.permissionMode = .acceptEdits 46 | options.maxThinkingTokens = 10000 47 | 48 | // Tool configuration 49 | options.allowedTools = ["Read", "Write", "Bash"] 50 | options.disallowedTools = ["Delete"] 51 | 52 | let args = options.toCommandArgs() 53 | 54 | // Verify all arguments are present 55 | XCTAssertTrue(args.contains("-p")) // printMode is always true internally 56 | XCTAssertTrue(args.contains("--verbose")) 57 | XCTAssertTrue(args.contains("--max-turns")) 58 | XCTAssertTrue(args.contains("5")) 59 | XCTAssertTrue(args.contains("--system-prompt")) 60 | XCTAssertTrue(args.contains("\"You are a senior backend engineer specializing in Swift.\"")) 61 | XCTAssertTrue(args.contains("--append-system-prompt")) 62 | XCTAssertTrue(args.contains("\"After writing code, add comprehensive comments.\"")) 63 | XCTAssertTrue(args.contains("--model")) 64 | XCTAssertTrue(args.contains("claude-3-sonnet-20240229")) 65 | XCTAssertTrue(args.contains("--permission-mode")) 66 | XCTAssertTrue(args.contains("acceptEdits")) 67 | XCTAssertTrue(args.contains("--max-thinking-tokens")) 68 | XCTAssertTrue(args.contains("10000")) 69 | XCTAssertTrue(args.contains("--allowedTools")) 70 | XCTAssertTrue(args.contains("\"Read,Write,Bash\"")) 71 | XCTAssertTrue(args.contains("--disallowedTools")) 72 | XCTAssertTrue(args.contains("\"Delete\"")) 73 | } 74 | 75 | func testPermissionModeValues() { 76 | // Test all permission mode values 77 | XCTAssertEqual(PermissionMode.default.rawValue, "default") 78 | XCTAssertEqual(PermissionMode.acceptEdits.rawValue, "acceptEdits") 79 | XCTAssertEqual(PermissionMode.bypassPermissions.rawValue, "bypassPermissions") 80 | XCTAssertEqual(PermissionMode.plan.rawValue, "plan") 81 | } 82 | 83 | func testContinueAndResumeOptions() { 84 | // Test continue and resume options 85 | var options = ClaudeCodeOptions() 86 | options.continue = true 87 | options.resume = "550e8400-e29b-41d4-a716-446655440000" 88 | 89 | // These are handled separately in the client methods, not in toCommandArgs 90 | let args = options.toCommandArgs() 91 | 92 | // Verify these don't appear in command args (they're added by specific methods) 93 | XCTAssertFalse(args.contains("--continue")) 94 | XCTAssertFalse(args.contains("--resume")) 95 | } 96 | 97 | func testTimeoutOption() { 98 | // Test timeout option 99 | var options = ClaudeCodeOptions() 100 | options.timeout = 600 // 10 minutes 101 | 102 | // Timeout is handled at the process level, not in command args 103 | let args = options.toCommandArgs() 104 | XCTAssertFalse(args.contains("--timeout")) 105 | } 106 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/RetryLogicTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RetryLogicTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK Tests 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class RetryLogicTests: XCTestCase { 13 | 14 | func testDefaultRetryPolicy() { 15 | // Test default retry policy as shown in README 16 | let defaultPolicy = RetryPolicy.default 17 | 18 | XCTAssertEqual(defaultPolicy.maxAttempts, 3) 19 | XCTAssertEqual(defaultPolicy.initialDelay, 1.0) 20 | XCTAssertEqual(defaultPolicy.maxDelay, 60.0) 21 | XCTAssertEqual(defaultPolicy.backoffMultiplier, 2.0) 22 | XCTAssertTrue(defaultPolicy.useJitter) 23 | } 24 | 25 | func testCustomRetryPolicy() { 26 | // Test custom retry policy from README 27 | let conservativePolicy = RetryPolicy( 28 | maxAttempts: 5, 29 | initialDelay: 5.0, 30 | maxDelay: 300.0, 31 | backoffMultiplier: 2.0, 32 | useJitter: true 33 | ) 34 | 35 | XCTAssertEqual(conservativePolicy.maxAttempts, 5) 36 | XCTAssertEqual(conservativePolicy.initialDelay, 5.0) 37 | XCTAssertEqual(conservativePolicy.maxDelay, 300.0) 38 | XCTAssertEqual(conservativePolicy.backoffMultiplier, 2.0) 39 | XCTAssertTrue(conservativePolicy.useJitter) 40 | } 41 | 42 | func testExponentialBackoffCalculation() { 43 | // Test exponential backoff calculation 44 | let policy = RetryPolicy( 45 | maxAttempts: 5, 46 | initialDelay: 1.0, 47 | maxDelay: 100.0, 48 | backoffMultiplier: 2.0, 49 | useJitter: false // Disable jitter for predictable testing 50 | ) 51 | 52 | // Test delay calculation for each attempt (attempt numbers start at 1) 53 | XCTAssertEqual(policy.delay(for: 1), 1.0) // First attempt: 1s 54 | XCTAssertEqual(policy.delay(for: 2), 2.0) // Second attempt: 2s 55 | XCTAssertEqual(policy.delay(for: 3), 4.0) // Third attempt: 4s 56 | XCTAssertEqual(policy.delay(for: 4), 8.0) // Fourth attempt: 8s 57 | XCTAssertEqual(policy.delay(for: 5), 16.0) // Fifth attempt: 16s 58 | 59 | // Test max delay capping 60 | let bigAttempt = 10 61 | XCTAssertEqual(policy.delay(for: bigAttempt), 100.0) // Should be capped at maxDelay 62 | } 63 | 64 | func testJitterApplication() { 65 | // Test that jitter adds randomness 66 | let policy = RetryPolicy( 67 | maxAttempts: 3, 68 | initialDelay: 10.0, 69 | maxDelay: 100.0, 70 | backoffMultiplier: 2.0, 71 | useJitter: true 72 | ) 73 | 74 | // Get multiple delays for the same attempt 75 | let delays = (0..<10).map { _ in policy.delay(for: 1) } 76 | 77 | // With jitter, delays should vary 78 | let uniqueDelays = Set(delays) 79 | XCTAssertGreaterThan(uniqueDelays.count, 1, "Jitter should produce varying delays") 80 | 81 | // All delays should be within expected range (50% to 100% of calculated delay) 82 | let expectedBase = 20.0 // 10 * 2^1 83 | for delay in delays { 84 | XCTAssertGreaterThanOrEqual(delay, expectedBase * 0.5) 85 | XCTAssertLessThanOrEqual(delay, expectedBase) 86 | } 87 | } 88 | 89 | func testRetryPolicyMaxAttempts() { 90 | // Test max attempts configuration 91 | let policy = RetryPolicy(maxAttempts: 3, initialDelay: 1.0, maxDelay: 10.0, backoffMultiplier: 2.0, useJitter: false) 92 | 93 | XCTAssertEqual(policy.maxAttempts, 3) 94 | 95 | // Test that delay calculation works for different attempts 96 | XCTAssertEqual(policy.delay(for: 1), 1.0) 97 | XCTAssertEqual(policy.delay(for: 2), 2.0) 98 | XCTAssertEqual(policy.delay(for: 3), 4.0) 99 | } 100 | 101 | func testNoRetryPolicy() { 102 | // Test policy with no retries 103 | let noRetryPolicy = RetryPolicy(maxAttempts: 1, initialDelay: 1.0, maxDelay: 1.0, backoffMultiplier: 1.0, useJitter: false) 104 | 105 | XCTAssertEqual(noRetryPolicy.maxAttempts, 1) 106 | } 107 | 108 | func testAggressiveRetryPolicy() { 109 | // Test aggressive retry policy 110 | let aggressivePolicy = RetryPolicy.aggressive 111 | 112 | XCTAssertEqual(aggressivePolicy.maxAttempts, 5) 113 | XCTAssertEqual(aggressivePolicy.initialDelay, 0.5) 114 | XCTAssertEqual(aggressivePolicy.maxDelay, 30.0) 115 | XCTAssertEqual(aggressivePolicy.backoffMultiplier, 1.5) 116 | } 117 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Client/ClaudeCodeError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeError.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum ClaudeCodeError: Error { 11 | case executionFailed(String) 12 | case invalidOutput(String) 13 | case jsonParsingError(Error) 14 | case cancelled 15 | case notInstalled 16 | case timeout(TimeInterval) 17 | case rateLimitExceeded(retryAfter: TimeInterval?) 18 | case networkError(Error) 19 | case permissionDenied(String) 20 | case processLaunchFailed(String) 21 | case invalidConfiguration(String) 22 | 23 | public var localizedDescription: String { 24 | switch self { 25 | case .notInstalled: 26 | return "Claude Code is not installed. Please install with 'npm install -g @anthropic/claude-code'" 27 | case .executionFailed(let message): 28 | return "Execution failed: \(message)" 29 | case .invalidOutput(let message): 30 | return "Invalid output: \(message)" 31 | case .jsonParsingError(let error): 32 | return "JSON parsing error: \(error.localizedDescription)" 33 | case .cancelled: 34 | return "Operation cancelled" 35 | case .timeout(let duration): 36 | return "Operation timed out after \(Int(duration)) seconds" 37 | case .rateLimitExceeded(let retryAfter): 38 | if let retryAfter = retryAfter { 39 | return "Rate limit exceeded. Retry after \(Int(retryAfter)) seconds" 40 | } 41 | return "Rate limit exceeded" 42 | case .networkError(let error): 43 | return "Network error: \(error.localizedDescription)" 44 | case .permissionDenied(let message): 45 | return "Permission denied: \(message)" 46 | case .processLaunchFailed(let message): 47 | return "Process failed to launch: \(message)" 48 | case .invalidConfiguration(let message): 49 | return "Invalid configuration: \(message)" 50 | } 51 | } 52 | } 53 | 54 | // MARK: - Convenience Properties 55 | 56 | extension ClaudeCodeError { 57 | /// Whether this error is due to rate limiting 58 | public var isRateLimitError: Bool { 59 | if case .rateLimitExceeded = self { return true } 60 | if case .executionFailed(let message) = self { 61 | return message.lowercased().contains("rate limit") || 62 | message.lowercased().contains("too many requests") 63 | } 64 | return false 65 | } 66 | 67 | /// Whether this error is due to timeout 68 | public var isTimeoutError: Bool { 69 | if case .timeout = self { return true } 70 | if case .executionFailed(let message) = self { 71 | return message.lowercased().contains("timeout") || 72 | message.lowercased().contains("timed out") 73 | } 74 | return false 75 | } 76 | 77 | /// Whether this error is retryable 78 | public var isRetryable: Bool { 79 | switch self { 80 | case .rateLimitExceeded, .timeout, .networkError, .cancelled: 81 | return true 82 | case .executionFailed(let message): 83 | // Check for transient errors 84 | let transientErrors = ["timeout", "timed out", "rate limit", "network", "connection"] 85 | return transientErrors.contains { message.lowercased().contains($0) } 86 | default: 87 | return false 88 | } 89 | } 90 | 91 | /// Whether this error indicates Claude Code is not installed 92 | public var isInstallationError: Bool { 93 | if case .notInstalled = self { return true } 94 | return false 95 | } 96 | 97 | /// Whether this error is due to permission issues 98 | public var isPermissionError: Bool { 99 | if case .permissionDenied = self { return true } 100 | if case .executionFailed(let message) = self { 101 | return message.lowercased().contains("permission") || 102 | message.lowercased().contains("denied") || 103 | message.lowercased().contains("unauthorized") 104 | } 105 | return false 106 | } 107 | 108 | /// Suggested retry delay in seconds (if applicable) 109 | public var suggestedRetryDelay: TimeInterval? { 110 | switch self { 111 | case .rateLimitExceeded(let retryAfter): 112 | return retryAfter ?? 60 // Default to 60 seconds if not specified 113 | case .timeout: 114 | return 5 // Quick retry for timeouts 115 | case .networkError: 116 | return 10 // Network errors might need a bit more time 117 | default: 118 | return isRetryable ? 5 : nil 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Utilities/NvmPathDetector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NvmPathDetector.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by ClaudeCodeSDK on 2025-01-16. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Utility to detect nvm (Node Version Manager) installation paths 11 | /// This is an optional helper - the SDK works perfectly fine without nvm. 12 | /// If nvm is not installed, all methods safely return nil or empty results. 13 | public struct NvmPathDetector { 14 | 15 | /// Detects the nvm node binary path for the current default version 16 | /// - Returns: The path to the node binary directory if found, nil otherwise 17 | /// - Note: Returns nil if nvm is not installed - this is safe and expected 18 | public static func detectNvmPath() -> String? { 19 | let homeDir = NSHomeDirectory() 20 | let nvmDefaultPath = "\(homeDir)/.nvm/alias/default" 21 | 22 | // Try to read the default version 23 | // If the file doesn't exist (no nvm), this safely returns nil 24 | if let version = try? String(contentsOfFile: nvmDefaultPath).trimmingCharacters(in: .whitespacesAndNewlines) { 25 | let nodePath = "\(homeDir)/.nvm/versions/node/\(version)/bin" 26 | // Verify the path exists before returning it 27 | if FileManager.default.fileExists(atPath: nodePath) { 28 | return nodePath 29 | } 30 | } 31 | 32 | // Fallback: check for any installed version 33 | // If nvm directory doesn't exist, this returns nil 34 | let nvmVersionsPath = "\(homeDir)/.nvm/versions/node" 35 | if let versions = try? FileManager.default.contentsOfDirectory(atPath: nvmVersionsPath), 36 | let latestVersion = versions.sorted().last { 37 | let nodePath = "\(nvmVersionsPath)/\(latestVersion)/bin" 38 | if FileManager.default.fileExists(atPath: nodePath) { 39 | return nodePath 40 | } 41 | } 42 | 43 | // No nvm installation found - this is perfectly fine 44 | return nil 45 | } 46 | 47 | /// Detects all available nvm node binary paths 48 | /// - Returns: An array of paths to node binary directories, empty if nvm not installed 49 | /// - Note: Returns empty array if nvm is not installed - this is safe and expected 50 | public static func detectAllNvmPaths() -> [String] { 51 | let homeDir = NSHomeDirectory() 52 | let nvmVersionsPath = "\(homeDir)/.nvm/versions/node" 53 | 54 | // If nvm isn't installed, this returns empty array 55 | guard let versions = try? FileManager.default.contentsOfDirectory(atPath: nvmVersionsPath) else { 56 | return [] 57 | } 58 | 59 | return versions.compactMap { version in 60 | let nodePath = "\(nvmVersionsPath)/\(version)/bin" 61 | return FileManager.default.fileExists(atPath: nodePath) ? nodePath : nil 62 | } 63 | } 64 | 65 | /// Checks if nvm is installed 66 | /// - Returns: true if nvm directory exists, false otherwise 67 | /// - Note: Used to provide helpful debugging information 68 | public static func isNvmInstalled() -> Bool { 69 | let nvmDir = "\(NSHomeDirectory())/.nvm" 70 | return FileManager.default.fileExists(atPath: nvmDir) 71 | } 72 | } 73 | 74 | // MARK: - ClaudeCodeConfiguration Extension 75 | 76 | public extension ClaudeCodeConfiguration { 77 | 78 | /// Creates a configuration with automatic nvm support 79 | /// - Returns: A configuration with nvm paths automatically detected and added 80 | /// - Note: If nvm is not installed, returns a standard configuration without modification 81 | /// 82 | /// Example: 83 | /// ```swift 84 | /// // This works whether or not nvm is installed 85 | /// let config = ClaudeCodeConfiguration.withNvmSupport() 86 | /// ``` 87 | static func withNvmSupport() -> ClaudeCodeConfiguration { 88 | var config = ClaudeCodeConfiguration.default 89 | 90 | // Add nvm path if detected, otherwise config remains unchanged 91 | if let nvmPath = NvmPathDetector.detectNvmPath() { 92 | config.additionalPaths.append(nvmPath) 93 | } 94 | 95 | return config 96 | } 97 | 98 | /// Adds nvm support to the current configuration 99 | /// - Returns: Self for chaining 100 | /// - Note: Safe to call even if nvm is not installed - will simply not modify paths 101 | /// 102 | /// Example: 103 | /// ```swift 104 | /// var config = ClaudeCodeConfiguration.default 105 | /// config.addNvmSupport() // Safe to call - no-op if nvm not found 106 | /// ``` 107 | mutating func addNvmSupport() { 108 | if let nvmPath = NvmPathDetector.detectNvmPath() { 109 | // Avoid duplicates 110 | if !additionalPaths.contains(nvmPath) { 111 | additionalPaths.append(nvmPath) 112 | } 113 | } 114 | // If nvm not found, configuration remains unchanged 115 | } 116 | } -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/ChatView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatView.swift 3 | // ClaudeCodeSDKExample 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import ClaudeCodeSDK 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct ChatView: View { 13 | 14 | @State var viewModel = ChatViewModel(claudeClient: ClaudeCodeClient(debug: true)) 15 | @State private var messageText: String = "" 16 | @FocusState private var isTextFieldFocused: Bool 17 | @State private var showingSessions = false 18 | @State private var showingMCPConfig = false 19 | 20 | var body: some View { 21 | VStack { 22 | // Top button bar 23 | HStack { 24 | // List sessions button 25 | Button(action: { 26 | viewModel.listSessions() 27 | showingSessions = true 28 | }) { 29 | Image(systemName: "list.bullet.rectangle") 30 | .font(.title2) 31 | } 32 | 33 | // MCP Config button 34 | Button(action: { 35 | showingMCPConfig = true 36 | }) { 37 | Image(systemName: "gearshape") 38 | .font(.title2) 39 | .foregroundColor(viewModel.isMCPEnabled ? .green : .primary) 40 | } 41 | 42 | Spacer() 43 | 44 | Button(action: { 45 | clearChat() 46 | }) { 47 | Image(systemName: "trash") 48 | .font(.title2) 49 | } 50 | .disabled(viewModel.messages.isEmpty) 51 | } 52 | .padding(.horizontal) 53 | .padding(.top, 8) 54 | 55 | // Chat messages list 56 | ScrollViewReader { scrollView in 57 | List { 58 | ForEach(viewModel.messages) { message in 59 | MessageRow(message: message) 60 | .id(message.id) 61 | } 62 | } 63 | .onChange(of: viewModel.messages) { _, newMessages in 64 | // Scroll to bottom when new messages are added 65 | if let lastMessage = viewModel.messages.last { 66 | withAnimation { 67 | scrollView.scrollTo(lastMessage.id, anchor: .bottom) 68 | } 69 | } 70 | } 71 | } 72 | .listStyle(PlainListStyle()) 73 | 74 | // Error message if present 75 | if let error = viewModel.error { 76 | Text(error.localizedDescription) 77 | .foregroundColor(.red) 78 | .padding() 79 | } 80 | // Input area 81 | HStack { 82 | TextEditor(text: $messageText) 83 | .padding(8) 84 | .frame(minHeight: 36, maxHeight: 90) 85 | .cornerRadius(20) 86 | .focused($isTextFieldFocused) 87 | .overlay( 88 | HStack { 89 | if messageText.isEmpty { 90 | Text("Type a message...") 91 | .foregroundColor(.gray) 92 | .padding(.leading, 12) 93 | .padding(.top, 8) 94 | Spacer() 95 | } 96 | }, 97 | alignment: .topLeading 98 | ) 99 | .onKeyPress(.return) { 100 | sendMessage() 101 | return .ignored 102 | } 103 | 104 | if viewModel.isLoading { 105 | Button(action: { 106 | viewModel.cancelRequest() 107 | }) { 108 | Image(systemName: "stop.fill") 109 | } 110 | .padding(10) 111 | } else { 112 | Button(action: { 113 | sendMessage() 114 | }) { 115 | Image(systemName: "arrow.up.circle.fill") 116 | .foregroundColor(.blue) 117 | .font(.title2) 118 | } 119 | .disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 120 | } 121 | } 122 | } 123 | .navigationTitle("Claude Code Chat") 124 | .sheet(isPresented: $showingSessions) { 125 | SessionsListView(sessions: viewModel.sessions, isPresented: $showingSessions) 126 | .frame(minWidth: 500, minHeight: 500) 127 | } 128 | .sheet(isPresented: $showingMCPConfig) { 129 | MCPConfigView( 130 | isMCPEnabled: $viewModel.isMCPEnabled, 131 | mcpConfigPath: $viewModel.mcpConfigPath, 132 | isPresented: $showingMCPConfig 133 | ) 134 | .frame(minWidth: 500, minHeight: 500) 135 | } 136 | } 137 | 138 | private func sendMessage() { 139 | let text = messageText.trimmingCharacters(in: .whitespacesAndNewlines) 140 | guard !text.isEmpty else { return } 141 | 142 | // Remove focus first 143 | viewModel.sendMessage(text) 144 | DispatchQueue.main.async { 145 | messageText = "" 146 | } 147 | } 148 | 149 | private func clearChat() { 150 | viewModel.clearConversation() 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/RateLimitingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RateLimitingTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK Tests 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class RateLimitingTests: XCTestCase { 13 | 14 | func testRateLimiterInitialization() async { 15 | // Test rate limiter initialization 16 | let limiter = RateLimiter(requestsPerMinute: 10, burstCapacity: 3) 17 | 18 | // RateLimiter doesn't expose these properties directly 19 | // We can only test available tokens 20 | let available = await limiter.availableTokens() 21 | XCTAssertEqual(available, 3) // Should start with full burst capacity 22 | } 23 | 24 | func testRateLimiterTokenConsumption() async throws { 25 | // Test token consumption 26 | let limiter = RateLimiter(requestsPerMinute: 60, burstCapacity: 3) 27 | 28 | // Should be able to consume burst capacity immediately 29 | let result1 = await limiter.tryAcquire() 30 | XCTAssertTrue(result1) 31 | let result2 = await limiter.tryAcquire() 32 | XCTAssertTrue(result2) 33 | let result3 = await limiter.tryAcquire() 34 | XCTAssertTrue(result3) 35 | 36 | // Fourth request should fail (no tokens left) 37 | let result4 = await limiter.tryAcquire() 38 | XCTAssertFalse(result4) 39 | } 40 | 41 | func testRateLimiterWaitForToken() async throws { 42 | // Test waiting for token 43 | let limiter = RateLimiter(requestsPerMinute: 60, burstCapacity: 1) 44 | 45 | // Consume the only token 46 | let consumed = await limiter.tryAcquire() 47 | XCTAssertTrue(consumed) 48 | 49 | // Measure wait time 50 | let startTime = Date() 51 | try await limiter.acquire() 52 | let endTime = Date() 53 | 54 | let waitTime = endTime.timeIntervalSince(startTime) 55 | 56 | // With 60 requests per minute, refill interval is 1 second 57 | // Wait time should be approximately 1 second (with some tolerance) 58 | XCTAssertGreaterThan(waitTime, 0.9) 59 | XCTAssertLessThan(waitTime, 1.2) 60 | } 61 | 62 | func testRateLimitedClaudeCodeWrapper() { 63 | // Test the RateLimitedClaudeCode wrapper from README 64 | let mockClient = MockClaudeCode() 65 | let rateLimitedClient = RateLimitedClaudeCode( 66 | wrapped: mockClient, 67 | requestsPerMinute: 10, 68 | burstCapacity: 3 69 | ) 70 | 71 | XCTAssertNotNil(rateLimitedClient) 72 | // RateLimitedClaudeCode doesn't expose limiter directly 73 | // Just verify it was created successfully 74 | } 75 | 76 | func testRateLimiterRefillRate() async { 77 | // Test refill rate by consuming tokens and waiting 78 | let limiter1 = RateLimiter(requestsPerMinute: 60, burstCapacity: 2) 79 | 80 | // Consume all tokens 81 | _ = await limiter1.tryAcquire() 82 | _ = await limiter1.tryAcquire() 83 | 84 | // Should have 0 tokens 85 | let available1 = await limiter1.availableTokens() 86 | XCTAssertEqual(available1, 0) 87 | 88 | // Wait ~1 second for one token to refill 89 | try? await Task.sleep(nanoseconds: 1_100_000_000) // 1.1 seconds 90 | 91 | // Should have 1 token refilled 92 | let available2 = await limiter1.availableTokens() 93 | XCTAssertGreaterThanOrEqual(available2, 1) 94 | } 95 | 96 | func testRateLimiterCancellation() async throws { 97 | // Test cancellation during wait 98 | let limiter = RateLimiter(requestsPerMinute: 1, burstCapacity: 1) 99 | 100 | // Consume the token 101 | _ = await limiter.tryAcquire() 102 | 103 | // Start waiting in a task 104 | let waitTask = Task { 105 | try await limiter.acquire() 106 | } 107 | 108 | // Cancel the task 109 | waitTask.cancel() 110 | 111 | // Should throw cancellation error 112 | do { 113 | try await waitTask.value 114 | XCTFail("Expected cancellation error") 115 | } catch { 116 | XCTAssertTrue(error is CancellationError) 117 | } 118 | } 119 | } 120 | 121 | // Mock implementation for testing 122 | private class MockClaudeCode: ClaudeCode { 123 | var configuration: ClaudeCodeConfiguration = .default 124 | var lastExecutedCommandInfo: ExecutedCommandInfo? 125 | 126 | func runWithStdin(stdinContent: String, outputFormat: ClaudeCodeOutputFormat, options: ClaudeCodeOptions?) async throws -> ClaudeCodeResult { 127 | return .text("Mock response") 128 | } 129 | 130 | func runSinglePrompt(prompt: String, outputFormat: ClaudeCodeOutputFormat, options: ClaudeCodeOptions?) async throws -> ClaudeCodeResult { 131 | return .text("Mock response") 132 | } 133 | 134 | func continueConversation(prompt: String?, outputFormat: ClaudeCodeOutputFormat, options: ClaudeCodeOptions?) async throws -> ClaudeCodeResult { 135 | return .text("Mock response") 136 | } 137 | 138 | func resumeConversation(sessionId: String, prompt: String?, outputFormat: ClaudeCodeOutputFormat, options: ClaudeCodeOptions?) async throws -> ClaudeCodeResult { 139 | return .text("Mock response") 140 | } 141 | 142 | func listSessions() async throws -> [SessionInfo] { 143 | return [] 144 | } 145 | 146 | func cancel() { 147 | // No-op 148 | } 149 | 150 | func validateCommand(_ command: String) async throws -> Bool { 151 | // Mock implementation - always returns true 152 | return true 153 | } 154 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Storage/ClaudeSessionModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeSessionModels.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by Assistant on 8/18/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents a stored Claude session from the native CLI storage 11 | public struct ClaudeStoredSession: Identifiable, Codable { 12 | public let id: String 13 | public let projectPath: String 14 | public let createdAt: Date 15 | public let lastAccessedAt: Date 16 | public var summary: String? 17 | public var gitBranch: String? 18 | public var messages: [ClaudeStoredMessage] 19 | 20 | public init( 21 | id: String, 22 | projectPath: String, 23 | createdAt: Date = Date(), 24 | lastAccessedAt: Date = Date(), 25 | summary: String? = nil, 26 | gitBranch: String? = nil, 27 | messages: [ClaudeStoredMessage] = [] 28 | ) { 29 | self.id = id 30 | self.projectPath = projectPath 31 | self.createdAt = createdAt 32 | self.lastAccessedAt = lastAccessedAt 33 | self.summary = summary 34 | self.gitBranch = gitBranch 35 | self.messages = messages 36 | } 37 | } 38 | 39 | /// Represents a message in a Claude session 40 | public struct ClaudeStoredMessage: Identifiable, Codable { 41 | public let id: String // UUID from the jsonl file 42 | public let parentId: String? 43 | public let sessionId: String 44 | public let role: MessageRole 45 | public let content: String 46 | public let timestamp: Date 47 | public let cwd: String? 48 | public let version: String? 49 | 50 | public enum MessageRole: String, Codable { 51 | case user 52 | case assistant 53 | case system 54 | } 55 | 56 | public init( 57 | id: String, 58 | parentId: String? = nil, 59 | sessionId: String, 60 | role: MessageRole, 61 | content: String, 62 | timestamp: Date, 63 | cwd: String? = nil, 64 | version: String? = nil 65 | ) { 66 | self.id = id 67 | self.parentId = parentId 68 | self.sessionId = sessionId 69 | self.role = role 70 | self.content = content 71 | self.timestamp = timestamp 72 | self.cwd = cwd 73 | self.version = version 74 | } 75 | } 76 | 77 | /// Raw JSON structure from Claude's .jsonl files 78 | internal struct ClaudeJSONLEntry: Codable { 79 | let type: String 80 | let uuid: String? 81 | let parentUuid: String? 82 | let sessionId: String? 83 | let timestamp: String? 84 | let cwd: String? 85 | let version: String? 86 | let gitBranch: String? 87 | let message: MessageContent? 88 | let summary: String? 89 | let leafUuid: String? 90 | let requestId: String? 91 | 92 | struct MessageContent: Codable { 93 | let role: String? 94 | let content: MessageContentValue? 95 | } 96 | 97 | enum MessageContentValue: Codable { 98 | case string(String) 99 | case array([ContentItem]) 100 | 101 | init(from decoder: Decoder) throws { 102 | let container = try decoder.singleValueContainer() 103 | if let stringValue = try? container.decode(String.self) { 104 | self = .string(stringValue) 105 | } else if let arrayValue = try? container.decode([ContentItem].self) { 106 | self = .array(arrayValue) 107 | } else { 108 | throw DecodingError.typeMismatch( 109 | MessageContentValue.self, 110 | DecodingError.Context( 111 | codingPath: decoder.codingPath, 112 | debugDescription: "Expected String or [ContentItem]" 113 | ) 114 | ) 115 | } 116 | } 117 | 118 | func encode(to encoder: Encoder) throws { 119 | var container = encoder.singleValueContainer() 120 | switch self { 121 | case .string(let value): 122 | try container.encode(value) 123 | case .array(let items): 124 | try container.encode(items) 125 | } 126 | } 127 | 128 | var textContent: String { 129 | switch self { 130 | case .string(let str): 131 | return str 132 | case .array(let items): 133 | return items.compactMap { item in 134 | if case .text(let text) = item.type { 135 | return text 136 | } 137 | return nil 138 | }.joined(separator: "\n") 139 | } 140 | } 141 | } 142 | 143 | struct ContentItem: Codable { 144 | let type: ContentType 145 | 146 | enum ContentType: Codable { 147 | case text(String) 148 | case other 149 | 150 | init(from decoder: Decoder) throws { 151 | let container = try decoder.container(keyedBy: CodingKeys.self) 152 | let typeString = try container.decode(String.self, forKey: .type) 153 | 154 | if typeString == "text" { 155 | let text = try container.decode(String.self, forKey: .text) 156 | self = .text(text) 157 | } else { 158 | self = .other 159 | } 160 | } 161 | 162 | func encode(to encoder: Encoder) throws { 163 | var container = encoder.container(keyedBy: CodingKeys.self) 164 | switch self { 165 | case .text(let text): 166 | try container.encode("text", forKey: .type) 167 | try container.encode(text, forKey: .text) 168 | case .other: 169 | try container.encode("other", forKey: .type) 170 | } 171 | } 172 | 173 | enum CodingKeys: String, CodingKey { 174 | case type 175 | case text 176 | } 177 | } 178 | } 179 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/ProcessLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProcessLaunchTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Tests for process launch failure handling 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Combine 11 | import Foundation 12 | 13 | final class ProcessLaunchTests: XCTestCase { 14 | 15 | func testProcessLaunchFailureWithBadCommand() async throws { 16 | // Create a client with a command that will fail 17 | var config = ClaudeCodeConfiguration.default 18 | // Using a command that doesn't exist 19 | config.command = "/nonexistent/command" 20 | let client = try ClaudeCodeClient(configuration: config) 21 | 22 | do { 23 | _ = try await client.runSinglePrompt( 24 | prompt: "test", 25 | outputFormat: .streamJson, 26 | options: nil 27 | ) 28 | XCTFail("Should have thrown processLaunchFailed error") 29 | } catch ClaudeCodeError.processLaunchFailed { 30 | // Expected - test passes 31 | XCTAssertTrue(true) 32 | } catch ClaudeCodeError.notInstalled { 33 | // Also acceptable 34 | XCTAssertTrue(true) 35 | } catch { 36 | XCTFail("Unexpected error: \(error)") 37 | } 38 | } 39 | 40 | func testProcessLaunchFailureWithMalformedArguments() async throws { 41 | // Create a client with malformed command suffix 42 | var config = ClaudeCodeConfiguration.default 43 | config.command = "echo" // Use echo for testing 44 | config.commandSuffix = "&& exit 1" // Force immediate failure 45 | let client = try ClaudeCodeClient(configuration: config) 46 | 47 | do { 48 | _ = try await client.runSinglePrompt( 49 | prompt: "test", 50 | outputFormat: .streamJson, 51 | options: nil 52 | ) 53 | XCTFail("Should have thrown processLaunchFailed error") 54 | } catch ClaudeCodeError.processLaunchFailed { 55 | // Expected - test passes 56 | XCTAssertTrue(true) 57 | } catch { 58 | // Any error is acceptable since we're forcing failure 59 | XCTAssertTrue(true) 60 | } 61 | } 62 | 63 | func testProcessLaunchFailureInResumeConversation() async throws { 64 | // Create a client with a failing command 65 | var config = ClaudeCodeConfiguration.default 66 | config.command = "/bin/false" // Command that always fails 67 | let client = try ClaudeCodeClient(configuration: config) 68 | 69 | do { 70 | _ = try await client.resumeConversation( 71 | sessionId: "test-session", 72 | prompt: "test", 73 | outputFormat: .streamJson, 74 | options: nil 75 | ) 76 | XCTFail("Should have thrown an error") 77 | } catch ClaudeCodeError.processLaunchFailed { 78 | // Expected - test passes 79 | XCTAssertTrue(true) 80 | } catch { 81 | // Any error is acceptable since we're forcing failure 82 | XCTAssertTrue(true) 83 | } 84 | } 85 | 86 | func testEmptyStderrErrorMessage() async throws { 87 | // Test that when stderr is empty, we get meaningful error messages 88 | var config = ClaudeCodeConfiguration.default 89 | // Use false command which exits with code 1 but produces no stderr 90 | config.command = "/usr/bin/false" 91 | let client = try ClaudeCodeClient(configuration: config) 92 | 93 | do { 94 | _ = try await client.runSinglePrompt( 95 | prompt: "test", 96 | outputFormat: .streamJson, 97 | options: nil 98 | ) 99 | XCTFail("Should have thrown an error") 100 | } catch ClaudeCodeError.processLaunchFailed(let message) { 101 | // Verify we get a meaningful error message even when stderr is empty 102 | XCTAssertFalse(message.isEmpty, "Error message should not be empty") 103 | XCTAssertTrue(message.contains("Process exited immediately with code"), "Should include exit code") 104 | XCTAssertTrue(message.contains("Command attempted:"), "Should include command info") 105 | // For exit code 1, should include hint about shell syntax 106 | XCTAssertTrue(message.contains("shell syntax error") || message.contains("code 1"), "Should provide context for exit code") 107 | print("Got meaningful error message: \(message)") 108 | } catch { 109 | XCTFail("Unexpected error type: \(error)") 110 | } 111 | } 112 | 113 | func testNormalOperationNotAffected() async throws { 114 | // Test that normal operations still work with valid commands 115 | var config = ClaudeCodeConfiguration.default 116 | config.command = "echo" // Use echo for testing 117 | config.commandSuffix = "\"test output\"" 118 | let client = try ClaudeCodeClient(configuration: config) 119 | 120 | // This should work normally (echo will succeed) 121 | do { 122 | let result = try await client.runSinglePrompt( 123 | prompt: "test", 124 | outputFormat: .text, 125 | options: nil 126 | ) 127 | 128 | // Should get some result (even if it's just echo output) 129 | switch result { 130 | case .text(let output): 131 | XCTAssertNotNil(output) 132 | default: 133 | XCTFail("Expected text output") 134 | } 135 | } catch { 136 | // Echo might not produce valid Claude output format, 137 | // but it shouldn't throw processLaunchFailed 138 | if case ClaudeCodeError.processLaunchFailed = error { 139 | XCTFail("Should not have thrown processLaunchFailed for valid command") 140 | } 141 | } 142 | } 143 | } -------------------------------------------------------------------------------- /TestBackends.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env swift 2 | // 3 | // TestBackends.swift 4 | // Manual test script for dual-backend architecture 5 | // 6 | // Run with: swift TestBackends.swift 7 | // 8 | 9 | import Foundation 10 | 11 | // This script tests the dual-backend architecture 12 | // You'll need to build the package first: swift build 13 | 14 | print("🧪 ClaudeCodeSDK Backend Testing\n") 15 | 16 | // Test 1: Check if claude CLI is available 17 | print("📋 Step 1: Checking for claude CLI...") 18 | let whichClaude = Process() 19 | whichClaude.executableURL = URL(fileURLWithPath: "/bin/zsh") 20 | whichClaude.arguments = ["-l", "-c", "which claude"] 21 | let claudePipe = Pipe() 22 | whichClaude.standardOutput = claudePipe 23 | whichClaude.standardError = Pipe() 24 | 25 | try? whichClaude.run() 26 | whichClaude.waitUntilExit() 27 | 28 | if whichClaude.terminationStatus == 0 { 29 | let data = claudePipe.fileHandleForReading.readDataToEndOfFile() 30 | if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { 31 | print(" ✅ claude CLI found at: \(path)") 32 | } 33 | } else { 34 | print(" ❌ claude CLI not found") 35 | print(" Install with: npm install -g @anthropic-ai/claude-code") 36 | } 37 | 38 | // Test 2: Check if Node.js is available 39 | print("\n📋 Step 2: Checking for Node.js...") 40 | let whichNode = Process() 41 | whichNode.executableURL = URL(fileURLWithPath: "/bin/zsh") 42 | whichNode.arguments = ["-l", "-c", "which node"] 43 | let nodePipe = Pipe() 44 | whichNode.standardOutput = nodePipe 45 | whichNode.standardError = Pipe() 46 | 47 | try? whichNode.run() 48 | whichNode.waitUntilExit() 49 | 50 | if whichNode.terminationStatus == 0 { 51 | let data = nodePipe.fileHandleForReading.readDataToEndOfFile() 52 | if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { 53 | print(" ✅ Node.js found at: \(path)") 54 | 55 | // Check Node version 56 | let nodeVersion = Process() 57 | nodeVersion.executableURL = URL(fileURLWithPath: "/bin/zsh") 58 | nodeVersion.arguments = ["-l", "-c", "node --version"] 59 | let versionPipe = Pipe() 60 | nodeVersion.standardOutput = versionPipe 61 | try? nodeVersion.run() 62 | nodeVersion.waitUntilExit() 63 | let versionData = versionPipe.fileHandleForReading.readDataToEndOfFile() 64 | if let version = String(data: versionData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { 65 | print(" 📦 Node version: \(version)") 66 | } 67 | } 68 | } else { 69 | print(" ❌ Node.js not found") 70 | print(" Install from: https://nodejs.org/") 71 | } 72 | 73 | // Test 3: Check if Agent SDK is installed 74 | print("\n📋 Step 3: Checking for Claude Agent SDK...") 75 | let npmList = Process() 76 | npmList.executableURL = URL(fileURLWithPath: "/bin/zsh") 77 | npmList.arguments = ["-l", "-c", "npm list -g @anthropic-ai/claude-agent-sdk --depth=0"] 78 | let npmPipe = Pipe() 79 | npmList.standardOutput = npmPipe 80 | npmList.standardError = Pipe() 81 | 82 | try? npmList.run() 83 | npmList.waitUntilExit() 84 | 85 | if npmList.terminationStatus == 0 { 86 | print(" ✅ Claude Agent SDK installed") 87 | let data = npmPipe.fileHandleForReading.readDataToEndOfFile() 88 | if let output = String(data: data, encoding: .utf8) { 89 | // Extract version if possible 90 | if let versionLine = output.split(separator: "\n").first(where: { $0.contains("claude-agent-sdk") }) { 91 | print(" 📦 \(versionLine)") 92 | } 93 | } 94 | } else { 95 | print(" ❌ Claude Agent SDK not installed") 96 | print(" Install with: npm install -g @anthropic-ai/claude-agent-sdk") 97 | } 98 | 99 | // Test 4: Check if sdk-wrapper.mjs exists 100 | print("\n📋 Step 4: Checking for sdk-wrapper.mjs...") 101 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 102 | if FileManager.default.fileExists(atPath: wrapperPath) { 103 | print(" ✅ sdk-wrapper.mjs found at: \(wrapperPath)") 104 | 105 | // Check if executable 106 | if FileManager.default.isExecutableFile(atPath: wrapperPath) { 107 | print(" ✅ sdk-wrapper.mjs is executable") 108 | } else { 109 | print(" ⚠️ sdk-wrapper.mjs is not executable") 110 | print(" Run: chmod +x \(wrapperPath)") 111 | } 112 | } else { 113 | print(" ❌ sdk-wrapper.mjs not found") 114 | } 115 | 116 | // Summary 117 | print("\n" + String(repeating: "=", count: 60)) 118 | print("📊 SUMMARY\n") 119 | 120 | var canUseHeadless = whichClaude.terminationStatus == 0 121 | var canUseAgentSDK = whichNode.terminationStatus == 0 && npmList.terminationStatus == 0 122 | 123 | print("Backend Availability:") 124 | print(" • Headless Backend: \(canUseHeadless ? "✅ Ready" : "❌ Not Available")") 125 | print(" • Agent SDK Backend: \(canUseAgentSDK ? "✅ Ready" : "❌ Not Available")") 126 | 127 | print("\n🚀 Next Steps:\n") 128 | 129 | if canUseHeadless { 130 | print("1. Test Headless Backend:") 131 | print(" swift run TestHeadlessBackend") 132 | } else { 133 | print("1. Install claude CLI:") 134 | print(" npm install -g @anthropic-ai/claude-code") 135 | } 136 | 137 | if canUseAgentSDK { 138 | print("\n2. Test Agent SDK Backend:") 139 | print(" swift run TestAgentSDKBackend") 140 | } else { 141 | print("\n2. Install Agent SDK:") 142 | print(" npm install -g @anthropic-ai/claude-agent-sdk") 143 | } 144 | 145 | print("\n3. Run the example:") 146 | print(" cd Example/ClaudeCodeSDKExample && open ClaudeCodeSDKExample.xcodeproj") 147 | 148 | print("\n" + String(repeating: "=", count: 60)) 149 | -------------------------------------------------------------------------------- /Example/ClaudeCodeSDKExample/ClaudeCodeSDKExample/Chat/ChatMessageRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatMessageRow.swift 3 | // ClaudeCodeSDKExample 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct MessageRow: View { 12 | let message: ChatMessage 13 | @Environment(\ .colorScheme) private var colorScheme 14 | 15 | var body: some View { 16 | HStack(alignment: .top, spacing: 10) { 17 | if message.role != .user { 18 | avatarView 19 | } 20 | 21 | VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 2) { 22 | if message.role != .user { 23 | HStack(spacing: 4) { 24 | Text(roleLabel) 25 | .font(.caption) 26 | .fontWeight(.medium) 27 | .foregroundColor(roleLabelColor) 28 | 29 | Text(timeFormatter.string(from: message.timestamp)) 30 | .font(.caption2) 31 | .foregroundColor(.secondary) 32 | } 33 | } 34 | 35 | messageContentView 36 | .contextMenu { 37 | Button(action: { 38 | NSPasteboard.general.clearContents() 39 | NSPasteboard.general.setString(message.content, forType: .string) 40 | }) { 41 | Label("Copy", systemImage: "doc.on.doc") 42 | } 43 | } 44 | } 45 | 46 | if message.role == .user { 47 | avatarView 48 | } 49 | } 50 | .padding(.horizontal, 12) 51 | .padding(.vertical, 6) 52 | .animation(.easeInOut(duration: 0.2), value: message.isComplete) 53 | } 54 | 55 | @ViewBuilder 56 | private var messageContentView: some View { 57 | Group { 58 | if message.content.isEmpty && !message.isComplete { 59 | loadingView 60 | } else { 61 | Text(message.content) 62 | .textSelection(.enabled) 63 | .fixedSize(horizontal: false, vertical: true) 64 | .foregroundColor(contentTextColor) 65 | } 66 | } 67 | .padding(.vertical, 8) 68 | .padding(.horizontal, 12) 69 | .background(backgroundShape) 70 | .frame(maxWidth: 550, alignment: message.role == .user ? .trailing : .leading) 71 | } 72 | 73 | @ViewBuilder 74 | private var loadingView: some View { 75 | HStack(spacing: 4) { 76 | ForEach(0..<3) { index in 77 | Circle() 78 | .fill(messageTint.opacity(0.6)) 79 | .frame(width: 6, height: 6) 80 | .scaleEffect(animationValues[index] ? 1.0 : 0.5) 81 | .animation( 82 | Animation.easeInOut(duration: 0.5) 83 | .repeatForever(autoreverses: true) 84 | .delay(Double(index) * 0.2), 85 | value: animationValues[index] 86 | ) 87 | .onAppear { 88 | animationValues[index].toggle() 89 | } 90 | } 91 | } 92 | .frame(height: 18) 93 | .frame(width: 36) 94 | } 95 | 96 | @ViewBuilder 97 | private var avatarView: some View { 98 | Group { 99 | if message.role == .user { 100 | EmptyView() 101 | } else { 102 | Image(systemName: avatarIcon) 103 | .foregroundStyle(messageTint.opacity(0.8)) 104 | } 105 | } 106 | .font(.system(size: 24)) 107 | .frame(width: 28, height: 28) 108 | } 109 | 110 | private var backgroundShape: some View { 111 | RoundedRectangle(cornerRadius: 12, style: .continuous) 112 | .fill(backgroundColor) 113 | .shadow(color: shadowColor, radius: 2, x: 0, y: 1) 114 | } 115 | 116 | private var avatarIcon: String { 117 | switch message.messageType { 118 | case .text: return "circle" 119 | case .toolUse: return "hammer.circle.fill" 120 | case .toolResult: return "checkmark.circle.fill" 121 | case .toolError: return "exclamationmark.circle.fill" 122 | case .thinking: return "brain.fill" 123 | case .webSearch: return "globe.circle.fill" 124 | } 125 | } 126 | 127 | private var roleLabel: String { 128 | switch message.messageType { 129 | case .text: return message.role == .assistant ? "Claude Code" : "You" 130 | case .toolUse: return message.toolName ?? "Tool" 131 | case .toolResult: return "Result" 132 | case .toolError: return "Error" 133 | case .thinking: return "Thinking" 134 | case .webSearch: return "Web Search" 135 | } 136 | } 137 | 138 | private var roleLabelColor: Color { 139 | messageTint.opacity(0.9) 140 | } 141 | 142 | private var messageTint: Color { 143 | switch message.messageType { 144 | case .text: return message.role == .assistant ? .purple : .blue 145 | case .toolUse: return .orange 146 | case .toolResult: return .green 147 | case .toolError: return .red 148 | case .thinking: return .blue 149 | case .webSearch: return .teal 150 | } 151 | } 152 | 153 | private var backgroundColor: Color { 154 | colorScheme == .dark 155 | ? Color.gray.opacity(0.2) 156 | : Color.gray.opacity(0.1) 157 | } 158 | 159 | private var contentTextColor: Color { 160 | colorScheme == .dark ? .white : .primary 161 | } 162 | 163 | private var shadowColor: Color { 164 | colorScheme == .dark ? .clear : Color.black.opacity(0.05) 165 | } 166 | 167 | private var timeFormatter: DateFormatter { 168 | let formatter = DateFormatter() 169 | formatter.timeStyle = .short 170 | return formatter 171 | } 172 | 173 | @State private var animationValues: [Bool] = [false, false, false] 174 | } 175 | 176 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/API/ClaudeCodeOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeOptions.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 5/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - ClaudeCodeOptions 11 | 12 | /// Configuration options for Claude Code execution 13 | /// Matches the TypeScript SDK Options interface 14 | public struct ClaudeCodeOptions { 15 | /// Abort controller for cancellation support 16 | public var abortController: AbortController? 17 | 18 | /// List of tools allowed for Claude to use 19 | public var allowedTools: [String]? 20 | 21 | /// Text to append to system prompt 22 | public var appendSystemPrompt: String? 23 | 24 | /// System prompt 25 | public var systemPrompt: String? 26 | 27 | /// List of tools denied for Claude to use 28 | public var disallowedTools: [String]? 29 | 30 | /// Maximum thinking tokens 31 | public var maxThinkingTokens: Int? 32 | 33 | /// Maximum number of turns allowed 34 | public var maxTurns: Int? 35 | 36 | /// MCP server configurations 37 | public var mcpServers: [String: McpServerConfiguration]? 38 | 39 | /// Permission mode for operations 40 | public var permissionMode: PermissionMode? 41 | 42 | /// Tool for handling permission prompts in non-interactive mode 43 | public var permissionPromptToolName: String? 44 | 45 | /// Continue flag for conversation continuation 46 | public var `continue`: Bool? 47 | 48 | /// Resume session ID 49 | public var resume: String? 50 | 51 | /// Model to use 52 | public var model: String? 53 | 54 | /// Timeout in seconds for command execution 55 | public var timeout: TimeInterval? 56 | 57 | /// Path to MCP configuration file 58 | /// Alternative to mcpServers for file-based configuration 59 | public var mcpConfigPath: String? 60 | 61 | // Internal properties maintained for compatibility 62 | /// Run in non-interactive mode (--print/-p flag) 63 | internal var printMode: Bool = true 64 | 65 | /// Enable verbose logging 66 | public var verbose: Bool = false 67 | 68 | public init() { 69 | // Default initialization 70 | } 71 | 72 | /// Properly escapes a string for safe shell usage 73 | /// Uses single quotes which protect against most special characters 74 | /// Single quotes inside the string are escaped as '\'' 75 | private func shellEscape(_ string: String) -> String { 76 | // Replace single quotes with '\'' (end quote, escaped quote, start quote) 77 | let escaped = string.replacingOccurrences(of: "'", with: "'\\''") 78 | // Wrap in single quotes 79 | return "'\(escaped)'" 80 | } 81 | 82 | /// Convert options to command line arguments 83 | internal func toCommandArgs() -> [String] { 84 | var args: [String] = [] 85 | 86 | // Add print mode flag for non-interactive mode 87 | if printMode { 88 | args.append("-p") 89 | } 90 | 91 | if verbose { 92 | args.append("--verbose") 93 | } 94 | 95 | if let maxTurns = maxTurns { 96 | args.append("--max-turns") 97 | args.append("\(maxTurns)") 98 | } 99 | 100 | if let maxThinkingTokens = maxThinkingTokens { 101 | args.append("--max-thinking-tokens") 102 | args.append("\(maxThinkingTokens)") 103 | } 104 | 105 | if let allowedTools = allowedTools, !allowedTools.isEmpty { 106 | args.append("--allowedTools") 107 | // Escape the joined string in quotes to prevent shell expansion 108 | let toolsList = allowedTools.joined(separator: ",") 109 | args.append("\"\(toolsList)\"") 110 | } 111 | 112 | if let disallowedTools = disallowedTools, !disallowedTools.isEmpty { 113 | args.append("--disallowedTools") 114 | // Escape the joined string in quotes to prevent shell expansion 115 | let toolsList = disallowedTools.joined(separator: ",") 116 | args.append("\"\(toolsList)\"") 117 | } 118 | 119 | if let permissionPromptToolName = permissionPromptToolName { 120 | args.append("--permission-prompt-tool") 121 | args.append(permissionPromptToolName) 122 | } 123 | 124 | if let systemPrompt = systemPrompt { 125 | args.append("--system-prompt") 126 | args.append(shellEscape(systemPrompt)) 127 | } 128 | 129 | if let appendSystemPrompt = appendSystemPrompt { 130 | args.append("--append-system-prompt") 131 | args.append(shellEscape(appendSystemPrompt)) 132 | } 133 | 134 | if let permissionMode = permissionMode { 135 | args.append("--permission-mode") 136 | args.append(permissionMode.rawValue) 137 | } 138 | 139 | if let model = model { 140 | args.append("--model") 141 | args.append(model) 142 | } 143 | 144 | // Handle MCP configuration 145 | if let mcpConfigPath = mcpConfigPath { 146 | // Use file-based configuration 147 | args.append("--mcp-config") 148 | args.append(mcpConfigPath) 149 | } else if let mcpServers = mcpServers, !mcpServers.isEmpty { 150 | // Create temporary file with MCP configuration 151 | let tempDir = FileManager.default.temporaryDirectory 152 | let configFile = tempDir.appendingPathComponent("mcp-config-\(UUID().uuidString).json") 153 | 154 | let config = ["mcpServers": mcpServers] 155 | if let jsonData = try? JSONEncoder().encode(config), 156 | (try? jsonData.write(to: configFile)) != nil { 157 | args.append("--mcp-config") 158 | args.append(configFile.path) 159 | } 160 | } 161 | 162 | return args 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/MCPConfigurationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCPConfigurationTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by James Rochabrun on 6/18/25. 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class MCPConfigurationTests: XCTestCase { 13 | 14 | func testMCPConfigWithFilePath() throws { 15 | // Given 16 | var options = ClaudeCodeOptions() 17 | options.mcpConfigPath = "/path/to/mcp-config.json" 18 | 19 | // When 20 | let args = options.toCommandArgs() 21 | 22 | // Then 23 | XCTAssertTrue(args.contains("--mcp-config")) 24 | if let index = args.firstIndex(of: "--mcp-config") { 25 | XCTAssertEqual(args[index + 1], "/path/to/mcp-config.json") 26 | } 27 | XCTAssertFalse(args.contains("--mcp-servers")) 28 | } 29 | 30 | func testMCPConfigWithProgrammaticServers() throws { 31 | // Given 32 | var options = ClaudeCodeOptions() 33 | options.mcpServers = [ 34 | "XcodeBuildMCP": .stdio(McpStdioServerConfig( 35 | command: "npx", 36 | args: ["-y", "xcodebuildmcp@latest"] 37 | )) 38 | ] 39 | 40 | // When 41 | let args = options.toCommandArgs() 42 | 43 | // Then 44 | XCTAssertTrue(args.contains("--mcp-config")) 45 | if let index = args.firstIndex(of: "--mcp-config") { 46 | let configPath = args[index + 1] 47 | XCTAssertTrue(configPath.contains("mcp-config-")) 48 | XCTAssertTrue(configPath.hasSuffix(".json")) 49 | 50 | // Verify the temporary file contains the correct structure 51 | if let data = try? Data(contentsOf: URL(fileURLWithPath: configPath)), 52 | let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 53 | let mcpServers = json["mcpServers"] as? [String: Any] { 54 | XCTAssertNotNil(mcpServers["XcodeBuildMCP"]) 55 | } 56 | } 57 | XCTAssertFalse(args.contains("--mcp-servers")) 58 | } 59 | 60 | func testMCPToolNaming() { 61 | // Test basic tool naming 62 | let toolName = MCPToolFormatter.formatToolName(serverName: "filesystem", toolName: "read_file") 63 | XCTAssertEqual(toolName, "mcp__filesystem__read_file") 64 | 65 | // Test wildcard pattern 66 | let wildcard = MCPToolFormatter.formatServerWildcard(serverName: "github") 67 | XCTAssertEqual(wildcard, "mcp__github__*") 68 | } 69 | 70 | func testMCPToolPatternsGeneration() { 71 | // Given 72 | let mcpServers: [String: McpServerConfiguration] = [ 73 | "XcodeBuildMCP": .stdio(McpStdioServerConfig(command: "npx", args: ["xcodebuildmcp"])), 74 | "filesystem": .stdio(McpStdioServerConfig(command: "npx", args: ["filesystem"])) 75 | ] 76 | 77 | // When 78 | let patterns = MCPToolFormatter.generateAllowedToolPatterns(from: mcpServers) 79 | 80 | // Then 81 | XCTAssertEqual(patterns.count, 2) 82 | XCTAssertTrue(patterns.contains("mcp__XcodeBuildMCP__*")) 83 | XCTAssertTrue(patterns.contains("mcp__filesystem__*")) 84 | } 85 | 86 | func testMCPConfigFileExtraction() throws { 87 | // Given - Create a temporary MCP config file 88 | let tempDir = FileManager.default.temporaryDirectory 89 | let configFile = tempDir.appendingPathComponent("test-mcp-config.json") 90 | 91 | let config = """ 92 | { 93 | "mcpServers": { 94 | "testServer1": { 95 | "command": "test", 96 | "args": ["arg1"] 97 | }, 98 | "testServer2": { 99 | "command": "test2" 100 | } 101 | } 102 | } 103 | """ 104 | 105 | try config.write(to: configFile, atomically: true, encoding: .utf8) 106 | defer { try? FileManager.default.removeItem(at: configFile) } 107 | 108 | // When 109 | let serverNames = MCPToolFormatter.extractServerNames(fromConfigPath: configFile.path) 110 | let patterns = MCPToolFormatter.generateAllowedToolPatterns(fromConfigPath: configFile.path) 111 | 112 | // Then 113 | XCTAssertEqual(serverNames.count, 2) 114 | XCTAssertTrue(serverNames.contains("testServer1")) 115 | XCTAssertTrue(serverNames.contains("testServer2")) 116 | 117 | XCTAssertEqual(patterns.count, 2) 118 | XCTAssertTrue(patterns.contains(where: { $0.contains("testServer1") })) 119 | XCTAssertTrue(patterns.contains(where: { $0.contains("testServer2") })) 120 | } 121 | 122 | func testMCPServerConfigurationEncoding() throws { 123 | // Test stdio server encoding 124 | let stdioConfig = McpStdioServerConfig( 125 | command: "npx", 126 | args: ["-y", "test-server"], 127 | env: ["API_KEY": "secret"] 128 | ) 129 | 130 | let stdioWrapper = McpServerConfiguration.stdio(stdioConfig) 131 | let encodedData = try JSONEncoder().encode(stdioWrapper) 132 | let json = try JSONSerialization.jsonObject(with: encodedData) as? [String: Any] 133 | 134 | XCTAssertEqual(json?["command"] as? String, "npx") 135 | XCTAssertEqual(json?["args"] as? [String], ["-y", "test-server"]) 136 | XCTAssertEqual((json?["env"] as? [String: String])?["API_KEY"], "secret") 137 | 138 | // Test SSE server encoding 139 | let sseConfig = McpSSEServerConfig( 140 | url: "https://example.com/mcp", 141 | headers: ["Authorization": "Bearer token"] 142 | ) 143 | 144 | let sseWrapper = McpServerConfiguration.sse(sseConfig) 145 | let sseData = try JSONEncoder().encode(sseWrapper) 146 | let sseJson = try JSONSerialization.jsonObject(with: sseData) as? [String: Any] 147 | 148 | XCTAssertEqual(sseJson?["type"] as? String, "sse") 149 | XCTAssertEqual(sseJson?["url"] as? String, "https://example.com/mcp") 150 | XCTAssertEqual((sseJson?["headers"] as? [String: String])?["Authorization"], "Bearer token") 151 | } 152 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/BackendTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackendTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK on 10/8/2025. 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | 11 | final class BackendTests: XCTestCase { 12 | 13 | func testHeadlessBackendCreation() throws { 14 | let config = ClaudeCodeConfiguration( 15 | backend: .headless, 16 | command: "claude" 17 | ) 18 | 19 | let backend = HeadlessBackend(configuration: config) 20 | XCTAssertNotNil(backend) 21 | } 22 | 23 | func testAgentSDKBackendCreation() throws { 24 | let config = ClaudeCodeConfiguration( 25 | backend: .agentSDK, 26 | nodeExecutable: "/usr/local/bin/node", 27 | sdkWrapperPath: "/path/to/wrapper.mjs" 28 | ) 29 | 30 | let backend = AgentSDKBackend(configuration: config) 31 | XCTAssertNotNil(backend) 32 | } 33 | 34 | func testBackendFactoryHeadless() throws { 35 | let config = ClaudeCodeConfiguration( 36 | backend: .headless, 37 | command: "claude" 38 | ) 39 | 40 | let backend = try BackendFactory.createBackend(for: config) 41 | XCTAssertTrue(backend is HeadlessBackend) 42 | } 43 | 44 | func testBackendFactoryAgentSDK() throws { 45 | // Skip if Agent SDK is not installed 46 | guard NodePathDetector.isAgentSDKInstalled() else { 47 | throw XCTSkip("Claude Agent SDK not installed") 48 | } 49 | 50 | let config = ClaudeCodeConfiguration( 51 | backend: .agentSDK 52 | ) 53 | 54 | let backend = try BackendFactory.createBackend(for: config) 55 | XCTAssertTrue(backend is AgentSDKBackend) 56 | } 57 | 58 | func testBackendFactoryValidation() { 59 | // Headless should always be valid 60 | let headlessConfig = ClaudeCodeConfiguration( 61 | backend: .headless, 62 | command: "claude" 63 | ) 64 | XCTAssertTrue(BackendFactory.validateConfiguration(headlessConfig)) 65 | 66 | // Agent SDK validation depends on installation 67 | let agentSDKConfig = ClaudeCodeConfiguration( 68 | backend: .agentSDK 69 | ) 70 | let isValid = BackendFactory.validateConfiguration(agentSDKConfig) 71 | 72 | // If Node.js is installed and Agent SDK is installed, should be valid 73 | if NodePathDetector.detectNodePath() != nil && NodePathDetector.isAgentSDKInstalled() { 74 | XCTAssertTrue(isValid) 75 | } else { 76 | XCTAssertFalse(isValid) 77 | } 78 | } 79 | 80 | func testBackendFactoryConfigurationError() { 81 | // Headless should have no errors 82 | let headlessConfig = ClaudeCodeConfiguration( 83 | backend: .headless 84 | ) 85 | XCTAssertNil(BackendFactory.getConfigurationError(headlessConfig)) 86 | 87 | // Agent SDK error message depends on system state 88 | let agentSDKConfig = ClaudeCodeConfiguration( 89 | backend: .agentSDK 90 | ) 91 | let error = BackendFactory.getConfigurationError(agentSDKConfig) 92 | 93 | // Should either be nil (if valid) or have an error message 94 | if NodePathDetector.detectNodePath() != nil && NodePathDetector.isAgentSDKInstalled() { 95 | XCTAssertNil(error) 96 | } else { 97 | XCTAssertNotNil(error) 98 | if NodePathDetector.detectNodePath() == nil { 99 | XCTAssertTrue(error?.contains("Node.js") ?? false) 100 | } else { 101 | XCTAssertTrue(error?.contains("Agent SDK") ?? false) 102 | } 103 | } 104 | } 105 | 106 | func testClientBackendSwitching() throws { 107 | var config = ClaudeCodeConfiguration.default 108 | config.backend = .headless 109 | 110 | let client = try ClaudeCodeClient(configuration: config) 111 | XCTAssertEqual(client.configuration.backend, .headless) 112 | 113 | // Switch to Agent SDK (if available) 114 | if NodePathDetector.isAgentSDKInstalled() { 115 | client.configuration.backend = .agentSDK 116 | XCTAssertEqual(client.configuration.backend, .agentSDK) 117 | } 118 | } 119 | 120 | func testClientThrowingInitializer() { 121 | // Test that client initialization can throw 122 | do { 123 | let config = ClaudeCodeConfiguration( 124 | backend: .agentSDK, 125 | nodeExecutable: "/nonexistent/node" 126 | ) 127 | 128 | _ = try ClaudeCodeClient(configuration: config) 129 | XCTFail("Should have thrown an error for invalid configuration") 130 | } catch { 131 | // Expected to throw 132 | XCTAssertTrue(error is ClaudeCodeError) 133 | } 134 | } 135 | 136 | func testBackwardCompatibilityInitializer() throws { 137 | // Test the backward compatibility initializer 138 | let client = try ClaudeCodeClient(workingDirectory: "/tmp", debug: true) 139 | 140 | XCTAssertEqual(client.configuration.workingDirectory, "/tmp") 141 | XCTAssertTrue(client.configuration.enableDebugLogging) 142 | XCTAssertEqual(client.configuration.backend, .headless) // Default backend 143 | } 144 | 145 | func testHeadlessBackendValidation() async throws { 146 | let config = ClaudeCodeConfiguration( 147 | backend: .headless, 148 | command: "claude" 149 | ) 150 | 151 | let backend = HeadlessBackend(configuration: config) 152 | 153 | // Validation depends on whether claude is installed 154 | // Just verify it doesn't crash 155 | _ = try await backend.validateSetup() 156 | } 157 | 158 | func testAgentSDKBackendValidation() async throws { 159 | let config = ClaudeCodeConfiguration( 160 | backend: .agentSDK 161 | ) 162 | 163 | let backend = AgentSDKBackend(configuration: config) 164 | 165 | // Validation depends on Node.js and Agent SDK installation 166 | let isValid = try await backend.validateSetup() 167 | 168 | // Should match the factory validation 169 | XCTAssertEqual(isValid, BackendFactory.validateConfiguration(config)) 170 | } 171 | 172 | func testBackendCancellation() { 173 | let config = ClaudeCodeConfiguration( 174 | backend: .headless, 175 | command: "claude" 176 | ) 177 | 178 | let backend = HeadlessBackend(configuration: config) 179 | 180 | // Should not crash when canceling without active tasks 181 | backend.cancel() 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/ErrorHandlingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorHandlingTests.swift 3 | // ClaudeCodeSDKTests 4 | // 5 | // Created by ClaudeCodeSDK Tests 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | import Foundation 11 | 12 | final class ErrorHandlingTests: XCTestCase { 13 | 14 | func testErrorTypes() { 15 | // Test all error types from README example 16 | let notInstalledError = ClaudeCodeError.notInstalled 17 | let invalidConfigError = ClaudeCodeError.executionFailed("Invalid API key") 18 | let executionFailedError = ClaudeCodeError.executionFailed("Command failed") 19 | let timeoutError = ClaudeCodeError.timeout(30.0) 20 | let cancelledError = ClaudeCodeError.cancelled 21 | let rateLimitError = ClaudeCodeError.rateLimitExceeded(retryAfter: 60.0) 22 | let networkError = ClaudeCodeError.networkError(NSError(domain: "network", code: -1009)) 23 | let permissionError = ClaudeCodeError.permissionDenied("Access denied") 24 | let processLaunchError = ClaudeCodeError.processLaunchFailed("Invalid command arguments") 25 | 26 | // Test error descriptions 27 | XCTAssertEqual(notInstalledError.localizedDescription, "Claude Code is not installed. Please install with 'npm install -g @anthropic/claude-code'") 28 | XCTAssertTrue(invalidConfigError.localizedDescription.contains("Invalid API key")) 29 | XCTAssertTrue(executionFailedError.localizedDescription.contains("Command failed")) 30 | XCTAssertTrue(timeoutError.localizedDescription.contains("timed out")) 31 | XCTAssertEqual(cancelledError.localizedDescription, "Operation cancelled") 32 | XCTAssertTrue(rateLimitError.localizedDescription.contains("Rate limit exceeded")) 33 | XCTAssertTrue(networkError.localizedDescription.contains("Network error")) 34 | XCTAssertTrue(permissionError.localizedDescription.contains("Access denied")) 35 | XCTAssertTrue(processLaunchError.localizedDescription.contains("Process failed to launch")) 36 | } 37 | 38 | func testErrorProperties() { 39 | // Test error convenience properties 40 | let rateLimitError = ClaudeCodeError.rateLimitExceeded(retryAfter: 60.0) 41 | XCTAssertTrue(rateLimitError.isRateLimitError) 42 | XCTAssertTrue(rateLimitError.isRetryable) 43 | XCTAssertFalse(rateLimitError.isTimeoutError) 44 | XCTAssertFalse(rateLimitError.isPermissionError) 45 | XCTAssertFalse(rateLimitError.isInstallationError) 46 | XCTAssertNotNil(rateLimitError.suggestedRetryDelay) 47 | 48 | let timeoutError = ClaudeCodeError.timeout(30.0) 49 | XCTAssertTrue(timeoutError.isTimeoutError) 50 | XCTAssertTrue(timeoutError.isRetryable) 51 | XCTAssertFalse(timeoutError.isRateLimitError) 52 | XCTAssertNotNil(timeoutError.suggestedRetryDelay) 53 | XCTAssertEqual(timeoutError.suggestedRetryDelay, 5.0) 54 | 55 | let permissionError = ClaudeCodeError.permissionDenied("Access denied") 56 | XCTAssertTrue(permissionError.isPermissionError) 57 | XCTAssertFalse(permissionError.isRetryable) 58 | 59 | let installError = ClaudeCodeError.notInstalled 60 | XCTAssertTrue(installError.isInstallationError) 61 | XCTAssertFalse(installError.isRetryable) 62 | 63 | let networkError = ClaudeCodeError.networkError(NSError(domain: "network", code: -1009)) 64 | XCTAssertTrue(networkError.isRetryable) 65 | 66 | let cancelledError = ClaudeCodeError.cancelled 67 | XCTAssertTrue(cancelledError.isRetryable) // Actually cancelled is retryable according to the code 68 | } 69 | 70 | func testSuggestedRetryDelay() { 71 | // Test suggested retry delay calculation 72 | let rateLimitError = ClaudeCodeError.rateLimitExceeded(retryAfter: 30.0) 73 | 74 | if let delay = rateLimitError.suggestedRetryDelay { 75 | XCTAssertEqual(delay, 30.0) 76 | } else { 77 | XCTFail("Expected suggested retry delay") 78 | } 79 | 80 | // Test with nil retry after 81 | let rateLimitErrorNoDate = ClaudeCodeError.rateLimitExceeded(retryAfter: nil) 82 | XCTAssertEqual(rateLimitErrorNoDate.suggestedRetryDelay, 60.0) // Default is 60 seconds 83 | } 84 | 85 | func testErrorUsagePatternFromReadme() { 86 | // Test the error handling pattern from README 87 | let errors: [ClaudeCodeError] = [ 88 | .rateLimitExceeded(retryAfter: 60.0), 89 | .timeout(30.0), 90 | .permissionDenied("Access denied"), 91 | .networkError(NSError(domain: "network", code: -1009)) 92 | ] 93 | 94 | for error in errors { 95 | // Test the README pattern 96 | if error.isRetryable { 97 | if let delay = error.suggestedRetryDelay { 98 | XCTAssertGreaterThan(delay, 0) 99 | } 100 | } else if error.isRateLimitError { 101 | XCTAssertTrue(error.isRetryable) // Rate limit should be retryable 102 | } else if error.isTimeoutError { 103 | XCTAssertTrue(error.isRetryable) // Timeout should be retryable 104 | } else if error.isPermissionError { 105 | XCTAssertFalse(error.isRetryable) // Permission errors not retryable 106 | } 107 | } 108 | } 109 | 110 | func testProcessLaunchFailure() { 111 | // Test process launch failure error 112 | let launchError = ClaudeCodeError.processLaunchFailed("zsh: bad option: -invalid") 113 | 114 | // Test error description 115 | XCTAssertTrue(launchError.localizedDescription.contains("Process failed to launch")) 116 | XCTAssertTrue(launchError.localizedDescription.contains("bad option")) 117 | 118 | // Process launch failures should not be retryable by default 119 | // (since they're usually due to malformed commands) 120 | XCTAssertFalse(launchError.isRetryable) 121 | 122 | // Test specific error patterns 123 | let syntaxError = ClaudeCodeError.processLaunchFailed("syntax error near unexpected token") 124 | XCTAssertTrue(syntaxError.localizedDescription.contains("syntax error")) 125 | 126 | let parseError = ClaudeCodeError.processLaunchFailed("parse error in command") 127 | XCTAssertTrue(parseError.localizedDescription.contains("parse error")) 128 | } 129 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Utilities/RetryPolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RetryPolicy.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/17/25. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | /// Configuration for retry behavior 12 | public struct RetryPolicy: Sendable { 13 | /// Maximum number of retry attempts 14 | public let maxAttempts: Int 15 | 16 | /// Initial delay between retries in seconds 17 | public let initialDelay: TimeInterval 18 | 19 | /// Maximum delay between retries in seconds 20 | public let maxDelay: TimeInterval 21 | 22 | /// Multiplier for exponential backoff 23 | public let backoffMultiplier: Double 24 | 25 | /// Whether to add jitter to retry delays 26 | public let useJitter: Bool 27 | 28 | /// Default retry policy with reasonable defaults 29 | public static let `default` = RetryPolicy( 30 | maxAttempts: 3, 31 | initialDelay: 1.0, 32 | maxDelay: 60.0, 33 | backoffMultiplier: 2.0, 34 | useJitter: true 35 | ) 36 | 37 | /// Conservative retry policy for rate-limited operations 38 | public static let conservative = RetryPolicy( 39 | maxAttempts: 5, 40 | initialDelay: 5.0, 41 | maxDelay: 300.0, 42 | backoffMultiplier: 2.0, 43 | useJitter: true 44 | ) 45 | 46 | /// Aggressive retry policy for transient failures 47 | public static let aggressive = RetryPolicy( 48 | maxAttempts: 10, 49 | initialDelay: 0.5, 50 | maxDelay: 30.0, 51 | backoffMultiplier: 1.5, 52 | useJitter: true 53 | ) 54 | 55 | public init( 56 | maxAttempts: Int, 57 | initialDelay: TimeInterval, 58 | maxDelay: TimeInterval, 59 | backoffMultiplier: Double, 60 | useJitter: Bool 61 | ) { 62 | self.maxAttempts = maxAttempts 63 | self.initialDelay = initialDelay 64 | self.maxDelay = maxDelay 65 | self.backoffMultiplier = backoffMultiplier 66 | self.useJitter = useJitter 67 | } 68 | 69 | /// Calculate delay for a given attempt number 70 | func delay(for attempt: Int) -> TimeInterval { 71 | let exponentialDelay = initialDelay * pow(backoffMultiplier, Double(attempt - 1)) 72 | var delay = min(exponentialDelay, maxDelay) 73 | 74 | if useJitter { 75 | // Add random jitter (±25% of delay) 76 | let jitter = delay * 0.25 * (Double.random(in: -1...1)) 77 | delay = max(0, delay + jitter) 78 | } 79 | 80 | return delay 81 | } 82 | } 83 | 84 | /// Retry handler for ClaudeCode operations 85 | public final class RetryHandler { 86 | private let policy: RetryPolicy 87 | private let logger: Logger? 88 | 89 | public init(policy: RetryPolicy = .default, logger: Logger? = nil) { 90 | self.policy = policy 91 | self.logger = logger 92 | } 93 | 94 | /// Execute an operation with retry logic 95 | public func execute( 96 | operation: String, 97 | task: () async throws -> T 98 | ) async throws -> T { 99 | var lastError: Error? 100 | 101 | for attempt in 1...policy.maxAttempts { 102 | do { 103 | let log = "Attempting \(operation) (attempt \(attempt)/\(policy.maxAttempts))" 104 | logger?.debug("\(log)") 105 | return try await task() 106 | } catch let error as ClaudeCodeError { 107 | lastError = error 108 | 109 | // Check if error is retryable 110 | guard error.isRetryable else { 111 | logger?.error("\(operation) failed with non-retryable error: \(error.localizedDescription)") 112 | throw error 113 | } 114 | 115 | // Don't retry on last attempt 116 | guard attempt < policy.maxAttempts else { 117 | let log = "\(operation) failed after \(policy.maxAttempts) attempts" 118 | logger?.error("\(log)") 119 | throw error 120 | } 121 | 122 | // Calculate delay 123 | let baseDelay = policy.delay(for: attempt) 124 | let delay = error.suggestedRetryDelay ?? baseDelay 125 | 126 | let log = "\(operation) failed (attempt \(attempt)/\(policy.maxAttempts)), retrying in \(Int(delay))s: \(error.localizedDescription)" 127 | logger?.info("\(log)") 128 | 129 | // Wait before retry 130 | try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) 131 | 132 | } catch { 133 | // Non-ClaudeCodeError, don't retry 134 | let log = "\(operation) failed with unexpected error: \(error.localizedDescription)" 135 | logger?.error("\(log)") 136 | throw error 137 | } 138 | } 139 | 140 | // Should never reach here, but just in case 141 | throw lastError ?? ClaudeCodeError.executionFailed("Retry logic error") 142 | } 143 | } 144 | 145 | /// Extension to add retry support to ClaudeCode protocol 146 | public extension ClaudeCode { 147 | /// Run a single prompt with retry logic 148 | func runSinglePromptWithRetry( 149 | prompt: String, 150 | outputFormat: ClaudeCodeOutputFormat, 151 | options: ClaudeCodeOptions? = nil, 152 | retryPolicy: RetryPolicy = .default 153 | ) async throws -> ClaudeCodeResult { 154 | let handler = RetryHandler(policy: retryPolicy) 155 | return try await handler.execute(operation: "runSinglePrompt") { 156 | try await self.runSinglePrompt( 157 | prompt: prompt, 158 | outputFormat: outputFormat, 159 | options: options 160 | ) 161 | } 162 | } 163 | 164 | /// Continue conversation with retry logic 165 | func continueConversationWithRetry( 166 | prompt: String?, 167 | outputFormat: ClaudeCodeOutputFormat, 168 | options: ClaudeCodeOptions? = nil, 169 | retryPolicy: RetryPolicy = .default 170 | ) async throws -> ClaudeCodeResult { 171 | let handler = RetryHandler(policy: retryPolicy) 172 | return try await handler.execute(operation: "continueConversation") { 173 | try await self.continueConversation( 174 | prompt: prompt, 175 | outputFormat: outputFormat, 176 | options: options 177 | ) 178 | } 179 | } 180 | 181 | /// Resume conversation with retry logic 182 | func resumeConversationWithRetry( 183 | sessionId: String, 184 | prompt: String?, 185 | outputFormat: ClaudeCodeOutputFormat, 186 | options: ClaudeCodeOptions? = nil, 187 | retryPolicy: RetryPolicy = .default 188 | ) async throws -> ClaudeCodeResult { 189 | let handler = RetryHandler(policy: retryPolicy) 190 | return try await handler.execute(operation: "resumeConversation") { 191 | try await self.resumeConversation( 192 | sessionId: sessionId, 193 | prompt: prompt, 194 | outputFormat: outputFormat, 195 | options: options 196 | ) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Resources/sdk-wrapper.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Node.js wrapper for @anthropic-ai/claude-agent-sdk 5 | * 6 | * This script bridges Swift and the TypeScript Claude Agent SDK by: 7 | * 1. Receiving configuration via command-line arguments 8 | * 2. Executing queries using the SDK 9 | * 3. Streaming results as JSONL (compatible with headless mode format) 10 | * 11 | * Usage: 12 | * node sdk-wrapper.mjs '' 13 | * 14 | * Config format: 15 | * { 16 | * "prompt": "string", 17 | * "options": { 18 | * "model": "sonnet", 19 | * "maxTurns": 50, 20 | * "allowedTools": ["Read", "Bash"], 21 | * "permissionMode": "default", 22 | * ... 23 | * } 24 | * } 25 | */ 26 | 27 | import { query } from '@anthropic-ai/claude-agent-sdk'; 28 | 29 | // Parse command-line arguments 30 | async function main() { 31 | try { 32 | // Get config from first argument 33 | const configJson = process.argv[2]; 34 | 35 | if (!configJson) { 36 | console.error('Error: No configuration provided'); 37 | console.error('Usage: node sdk-wrapper.mjs \'\''); 38 | process.exit(1); 39 | } 40 | 41 | // Parse configuration 42 | let config; 43 | try { 44 | config = JSON.parse(configJson); 45 | } catch (error) { 46 | console.error('Error: Invalid JSON configuration'); 47 | console.error(error.message); 48 | process.exit(1); 49 | } 50 | 51 | // Extract prompt and options 52 | const { prompt, options = {} } = config; 53 | 54 | if (!prompt) { 55 | console.error('Error: No prompt provided in configuration'); 56 | process.exit(1); 57 | } 58 | 59 | // Map Swift options to SDK options 60 | const sdkOptions = mapOptions(options); 61 | 62 | // DEBUG: Log configuration being passed to Agent SDK 63 | console.error('[SDK-WRAPPER] ===== AGENT SDK CONFIGURATION DEBUG ====='); 64 | console.error('[SDK-WRAPPER] Prompt length:', prompt?.length || 0); 65 | console.error('[SDK-WRAPPER] Permission mode:', sdkOptions.permissionMode || 'NOT SET'); 66 | console.error('[SDK-WRAPPER] Permission prompt tool:', sdkOptions.permissionPromptToolName || 'NOT SET'); 67 | console.error('[SDK-WRAPPER] Allowed tools:', JSON.stringify(sdkOptions.allowedTools || [])); 68 | console.error('[SDK-WRAPPER] MCP servers:', sdkOptions.mcpServers ? Object.keys(sdkOptions.mcpServers) : 'NOT SET'); 69 | console.error('[SDK-WRAPPER] Model:', sdkOptions.model || 'default'); 70 | console.error('[SDK-WRAPPER] Max turns:', sdkOptions.maxTurns || 'default'); 71 | console.error('[SDK-WRAPPER] =========================================='); 72 | 73 | // Execute query using the SDK 74 | const result = query({ 75 | prompt, 76 | options: sdkOptions 77 | }); 78 | 79 | // Track if any tools were used during the conversation 80 | let toolsWereUsed = false; 81 | 82 | // Stream results as JSONL (same format as headless mode) 83 | for await (const message of result) { 84 | // Output each message as a JSON line 85 | console.log(JSON.stringify(message)); 86 | 87 | // Check if this message contains tool usage 88 | if (message.type === 'assistant' && message.message?.content) { 89 | for (const content of message.message.content) { 90 | if (content.type === 'tool_use' || content.type === 'tool_result') { 91 | toolsWereUsed = true; 92 | } 93 | } 94 | } 95 | } 96 | 97 | // Smart exit: If NO tools were used, exit immediately to avoid 5s delay 98 | // If tools WERE used, let natural cleanup happen (safer for MCP/tool cleanup) 99 | if (!toolsWereUsed) { 100 | console.error('[SDK-WRAPPER] No tools used - exiting immediately to avoid delay'); 101 | process.exit(0); 102 | } else { 103 | console.error('[SDK-WRAPPER] Tools were used - allowing natural cleanup'); 104 | // Let event loop drain naturally (gives MCP servers time to cleanup) 105 | } 106 | 107 | } catch (error) { 108 | // Output error in a format that Swift can parse 109 | const errorMessage = { 110 | type: 'error', 111 | error: { 112 | message: error.message, 113 | stack: error.stack, 114 | name: error.name 115 | } 116 | }; 117 | console.error(JSON.stringify(errorMessage)); 118 | process.exit(1); 119 | } 120 | } 121 | 122 | /** 123 | * Maps Swift options to SDK options 124 | * Handles differences in naming and structure between the two APIs 125 | */ 126 | function mapOptions(options) { 127 | const sdkOptions = {}; 128 | 129 | // Direct mappings 130 | if (options.model) sdkOptions.model = options.model; 131 | if (options.maxTurns) sdkOptions.maxTurns = options.maxTurns; 132 | if (options.maxThinkingTokens) sdkOptions.maxThinkingTokens = options.maxThinkingTokens; 133 | if (options.allowedTools) sdkOptions.allowedTools = options.allowedTools; 134 | if (options.disallowedTools) sdkOptions.disallowedTools = options.disallowedTools; 135 | if (options.permissionMode) sdkOptions.permissionMode = options.permissionMode; 136 | if (options.permissionPromptToolName) sdkOptions.permissionPromptToolName = options.permissionPromptToolName; 137 | if (options.resume) sdkOptions.resume = options.resume; 138 | if (options.continue) sdkOptions.continue = options.continue; 139 | 140 | // System prompt handling 141 | if (options.systemPrompt) { 142 | sdkOptions.systemPrompt = options.systemPrompt; 143 | } else if (options.appendSystemPrompt) { 144 | // If only appendSystemPrompt is provided, use the preset with append 145 | sdkOptions.systemPrompt = { 146 | type: 'preset', 147 | preset: 'claude_code', 148 | append: options.appendSystemPrompt 149 | }; 150 | } 151 | 152 | // MCP servers configuration 153 | // NOTE: The Agent SDK only supports mcpServers, not mcpConfigPath 154 | // Config files must be read and parsed in Swift before passing servers here 155 | if (options.mcpServers) { 156 | sdkOptions.mcpServers = options.mcpServers; 157 | console.error('[SDK-WRAPPER] MCP servers configured:', Object.keys(options.mcpServers)); 158 | } 159 | 160 | // Abort controller handling 161 | if (options.timeout) { 162 | // SDK doesn't have direct timeout, but we can handle it at the wrapper level 163 | // For now, just pass it through and let the calling Swift code handle timeouts 164 | } 165 | 166 | // Additional options that SDK supports 167 | if (options.cwd) sdkOptions.cwd = options.cwd; 168 | if (options.env) sdkOptions.env = options.env; 169 | if (options.forkSession !== undefined) sdkOptions.forkSession = options.forkSession; 170 | if (options.resumeSessionAt) sdkOptions.resumeSessionAt = options.resumeSessionAt; 171 | if (options.includePartialMessages !== undefined) { 172 | sdkOptions.includePartialMessages = options.includePartialMessages; 173 | } 174 | 175 | return sdkOptions; 176 | } 177 | 178 | // Run the main function 179 | main().catch(error => { 180 | console.error('Fatal error:', error); 181 | process.exit(1); 182 | }); 183 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "46729771d20578ef301a7e82c682dff553a38e56bdf15309a81b441cb057e9e5", 3 | "pins" : [ 4 | { 5 | "identity" : "async-http-client", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swift-server/async-http-client.git", 8 | "state" : { 9 | "revision" : "8430dd49d4e2b417f472141805c9691ec2923cb8", 10 | "version" : "1.29.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-algorithms", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-algorithms.git", 17 | "state" : { 18 | "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", 19 | "version" : "1.2.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-asn1", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-asn1.git", 26 | "state" : { 27 | "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", 28 | "version" : "1.5.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-async-algorithms", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-async-algorithms.git", 35 | "state" : { 36 | "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", 37 | "version" : "1.0.4" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-atomics", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-atomics.git", 44 | "state" : { 45 | "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", 46 | "version" : "1.3.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-certificates", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-certificates.git", 53 | "state" : { 54 | "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", 55 | "version" : "1.15.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-collections", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-collections.git", 62 | "state" : { 63 | "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", 64 | "version" : "1.3.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-crypto", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/apple/swift-crypto.git", 71 | "state" : { 72 | "revision" : "bcd2b89f2a4446395830b82e4e192765edd71e18", 73 | "version" : "4.0.0" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-distributed-tracing", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/apple/swift-distributed-tracing.git", 80 | "state" : { 81 | "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", 82 | "version" : "1.3.1" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-http-structured-headers", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/apple/swift-http-structured-headers.git", 89 | "state" : { 90 | "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", 91 | "version" : "1.5.0" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-http-types", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-http-types.git", 98 | "state" : { 99 | "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", 100 | "version" : "1.5.1" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-log", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/apple/swift-log.git", 107 | "state" : { 108 | "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", 109 | "version" : "1.6.4" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-nio", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/apple/swift-nio.git", 116 | "state" : { 117 | "revision" : "4e8f4b1c9adaa59315c523540c1ff2b38adc20a9", 118 | "version" : "2.87.0" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-nio-extras", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/apple/swift-nio-extras.git", 125 | "state" : { 126 | "revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d", 127 | "version" : "1.29.0" 128 | } 129 | }, 130 | { 131 | "identity" : "swift-nio-http2", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/apple/swift-nio-http2.git", 134 | "state" : { 135 | "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", 136 | "version" : "1.38.0" 137 | } 138 | }, 139 | { 140 | "identity" : "swift-nio-ssl", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/apple/swift-nio-ssl.git", 143 | "state" : { 144 | "revision" : "d3bad3847c53015fe8ec1e6c3ab54e53a5b6f15f", 145 | "version" : "2.35.0" 146 | } 147 | }, 148 | { 149 | "identity" : "swift-nio-transport-services", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 152 | "state" : { 153 | "revision" : "df6c28355051c72c884574a6c858bc54f7311ff9", 154 | "version" : "1.25.2" 155 | } 156 | }, 157 | { 158 | "identity" : "swift-numerics", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/apple/swift-numerics.git", 161 | "state" : { 162 | "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", 163 | "version" : "1.1.1" 164 | } 165 | }, 166 | { 167 | "identity" : "swift-service-context", 168 | "kind" : "remoteSourceControl", 169 | "location" : "https://github.com/apple/swift-service-context.git", 170 | "state" : { 171 | "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", 172 | "version" : "1.2.1" 173 | } 174 | }, 175 | { 176 | "identity" : "swift-service-lifecycle", 177 | "kind" : "remoteSourceControl", 178 | "location" : "https://github.com/swift-server/swift-service-lifecycle.git", 179 | "state" : { 180 | "revision" : "0fcc4c9c2d58dd98504c06f7308c86de775396ff", 181 | "version" : "2.9.0" 182 | } 183 | }, 184 | { 185 | "identity" : "swift-system", 186 | "kind" : "remoteSourceControl", 187 | "location" : "https://github.com/apple/swift-system.git", 188 | "state" : { 189 | "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", 190 | "version" : "1.6.3" 191 | } 192 | }, 193 | { 194 | "identity" : "swiftanthropic", 195 | "kind" : "remoteSourceControl", 196 | "location" : "https://github.com/jamesrochabrun/SwiftAnthropic", 197 | "state" : { 198 | "revision" : "4f9e21b94e491138003903ff6c14ab4f04b6ba6c", 199 | "version" : "2.2.0" 200 | } 201 | } 202 | ], 203 | "version" : 3 204 | } 205 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/SDKWrapperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDKWrapperTests.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by Assistant on 10/7/2025. 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | 11 | final class SDKWrapperTests: XCTestCase { 12 | 13 | func testSDKWrapperExists() { 14 | // Verify the wrapper script exists in Resources 15 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 16 | 17 | XCTAssertTrue(FileManager.default.fileExists(atPath: wrapperPath), 18 | "SDK wrapper script should exist at: \(wrapperPath)") 19 | } 20 | 21 | func testSDKWrapperIsExecutable() { 22 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 23 | 24 | guard FileManager.default.fileExists(atPath: wrapperPath) else { 25 | XCTFail("Wrapper script not found") 26 | return 27 | } 28 | 29 | // Check if file is executable 30 | XCTAssertTrue(FileManager.default.isExecutableFile(atPath: wrapperPath), 31 | "SDK wrapper should be executable") 32 | } 33 | 34 | func testSDKWrapperSyntax() throws { 35 | // Skip this test if Node.js is not available 36 | guard NodePathDetector.detectNodePath() != nil else { 37 | throw XCTSkip("Node.js not available, skipping syntax check") 38 | } 39 | 40 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 41 | 42 | // Use node --check to validate syntax without executing 43 | let process = Process() 44 | process.executableURL = URL(fileURLWithPath: "/bin/zsh") 45 | process.arguments = ["-l", "-c", "node --check \(wrapperPath)"] 46 | 47 | let pipe = Pipe() 48 | process.standardOutput = pipe 49 | process.standardError = pipe 50 | 51 | try process.run() 52 | process.waitUntilExit() 53 | 54 | XCTAssertEqual(process.terminationStatus, 0, 55 | "SDK wrapper should have valid JavaScript syntax") 56 | } 57 | 58 | func testSDKWrapperHasShebang() throws { 59 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 60 | 61 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 62 | let firstLine = content.components(separatedBy: .newlines).first ?? "" 63 | 64 | XCTAssertTrue(firstLine.hasPrefix("#!/usr/bin/env node"), 65 | "SDK wrapper should have proper shebang") 66 | } 67 | 68 | func testSDKWrapperHasImport() throws { 69 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 70 | 71 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 72 | 73 | XCTAssertTrue(content.contains("import { query }"), 74 | "SDK wrapper should import query function") 75 | XCTAssertTrue(content.contains("@anthropic-ai/claude-agent-sdk"), 76 | "SDK wrapper should import from correct package") 77 | } 78 | 79 | func testSDKWrapperHasMainFunction() throws { 80 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 81 | 82 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 83 | 84 | XCTAssertTrue(content.contains("async function main()"), 85 | "SDK wrapper should have main function") 86 | XCTAssertTrue(content.contains("main().catch"), 87 | "SDK wrapper should call main and handle errors") 88 | } 89 | 90 | func testSDKWrapperHasOptionMapping() throws { 91 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 92 | 93 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 94 | 95 | // Check for key option mappings 96 | XCTAssertTrue(content.contains("function mapOptions"), 97 | "SDK wrapper should have mapOptions function") 98 | XCTAssertTrue(content.contains("options.model"), 99 | "Should map model option") 100 | XCTAssertTrue(content.contains("options.maxTurns"), 101 | "Should map maxTurns option") 102 | XCTAssertTrue(content.contains("options.allowedTools"), 103 | "Should map allowedTools option") 104 | XCTAssertTrue(content.contains("options.permissionMode"), 105 | "Should map permissionMode option") 106 | } 107 | 108 | func testSDKWrapperErrorHandling() throws { 109 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 110 | 111 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 112 | 113 | XCTAssertTrue(content.contains("try {"), 114 | "SDK wrapper should have error handling") 115 | XCTAssertTrue(content.contains("} catch (error) {"), 116 | "SDK wrapper should catch errors") 117 | XCTAssertTrue(content.contains("console.error"), 118 | "SDK wrapper should log errors") 119 | XCTAssertTrue(content.contains("process.exit(1)"), 120 | "SDK wrapper should exit with error code on failure") 121 | } 122 | 123 | func testSDKWrapperJSONOutput() throws { 124 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 125 | 126 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 127 | 128 | XCTAssertTrue(content.contains("JSON.parse"), 129 | "SDK wrapper should parse JSON input") 130 | XCTAssertTrue(content.contains("JSON.stringify"), 131 | "SDK wrapper should stringify JSON output") 132 | XCTAssertTrue(content.contains("console.log(JSON.stringify(message))"), 133 | "SDK wrapper should output messages as JSON") 134 | } 135 | 136 | func testSDKWrapperConfigValidation() throws { 137 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 138 | 139 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 140 | 141 | XCTAssertTrue(content.contains("if (!configJson)"), 142 | "Should validate config presence") 143 | XCTAssertTrue(content.contains("if (!prompt)"), 144 | "Should validate prompt presence") 145 | } 146 | 147 | func testSDKWrapperSystemPromptHandling() throws { 148 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 149 | 150 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 151 | 152 | // Check for system prompt handling 153 | XCTAssertTrue(content.contains("options.systemPrompt"), 154 | "Should handle systemPrompt option") 155 | XCTAssertTrue(content.contains("options.appendSystemPrompt"), 156 | "Should handle appendSystemPrompt option") 157 | } 158 | 159 | func testSDKWrapperMCPSupport() throws { 160 | let wrapperPath = "/Users/jamesrochabrun/Desktop/git/ClaudeCodeSDK/Resources/sdk-wrapper.mjs" 161 | 162 | let content = try String(contentsOfFile: wrapperPath, encoding: .utf8) 163 | 164 | XCTAssertTrue(content.contains("options.mcpServers"), 165 | "Should support MCP servers configuration") 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/BackendConfigurationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackendConfigurationTests.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by Assistant on 10/7/2025. 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | 11 | final class BackendConfigurationTests: XCTestCase { 12 | 13 | func testDefaultConfiguration() { 14 | let config = ClaudeCodeConfiguration.default 15 | 16 | // Default should use headless backend 17 | XCTAssertEqual(config.backend, .headless, 18 | "Default configuration should use headless backend") 19 | 20 | // Should have default command 21 | XCTAssertEqual(config.command, "claude", 22 | "Default command should be 'claude'") 23 | 24 | // Should not have node executable specified 25 | XCTAssertNil(config.nodeExecutable, 26 | "Default should not specify node executable") 27 | 28 | // Should not have SDK wrapper path specified 29 | XCTAssertNil(config.sdkWrapperPath, 30 | "Default should not specify SDK wrapper path") 31 | 32 | // Should have default paths 33 | XCTAssertFalse(config.additionalPaths.isEmpty, 34 | "Should have default additional paths") 35 | 36 | // Should not enable debug logging by default 37 | XCTAssertFalse(config.enableDebugLogging, 38 | "Debug logging should be disabled by default") 39 | } 40 | 41 | func testHeadlessBackendConfiguration() { 42 | let config = ClaudeCodeConfiguration( 43 | backend: .headless, 44 | command: "claude", 45 | workingDirectory: "/tmp/test", 46 | enableDebugLogging: true 47 | ) 48 | 49 | XCTAssertEqual(config.backend, .headless) 50 | XCTAssertEqual(config.command, "claude") 51 | XCTAssertEqual(config.workingDirectory, "/tmp/test") 52 | XCTAssertTrue(config.enableDebugLogging) 53 | } 54 | 55 | func testAgentSDKBackendConfiguration() { 56 | let config = ClaudeCodeConfiguration( 57 | backend: .agentSDK, 58 | command: "claude", 59 | nodeExecutable: "/usr/local/bin/node", 60 | sdkWrapperPath: "/path/to/wrapper.mjs", 61 | workingDirectory: "/tmp/test" 62 | ) 63 | 64 | XCTAssertEqual(config.backend, .agentSDK) 65 | XCTAssertEqual(config.nodeExecutable, "/usr/local/bin/node") 66 | XCTAssertEqual(config.sdkWrapperPath, "/path/to/wrapper.mjs") 67 | XCTAssertEqual(config.workingDirectory, "/tmp/test") 68 | } 69 | 70 | func testBackendTypeRawValues() { 71 | // Verify BackendType enum values 72 | XCTAssertEqual(BackendType.headless.rawValue, "headless") 73 | XCTAssertEqual(BackendType.agentSDK.rawValue, "agentSDK") 74 | } 75 | 76 | func testBackendTypeCodable() throws { 77 | // Test encoding 78 | let headless = BackendType.headless 79 | let encoder = JSONEncoder() 80 | let headlessData = try encoder.encode(headless) 81 | let headlessString = String(data: headlessData, encoding: .utf8) 82 | XCTAssertEqual(headlessString, "\"headless\"") 83 | 84 | let agentSDK = BackendType.agentSDK 85 | let agentSDKData = try encoder.encode(agentSDK) 86 | let agentSDKString = String(data: agentSDKData, encoding: .utf8) 87 | XCTAssertEqual(agentSDKString, "\"agentSDK\"") 88 | 89 | // Test decoding 90 | let decoder = JSONDecoder() 91 | let decodedHeadless = try decoder.decode(BackendType.self, from: headlessData) 92 | XCTAssertEqual(decodedHeadless, .headless) 93 | 94 | let decodedAgentSDK = try decoder.decode(BackendType.self, from: agentSDKData) 95 | XCTAssertEqual(decodedAgentSDK, .agentSDK) 96 | } 97 | 98 | func testCustomPathConfiguration() { 99 | var config = ClaudeCodeConfiguration.default 100 | 101 | // Add custom paths 102 | config.additionalPaths.append("/custom/bin") 103 | config.additionalPaths.append("/another/path") 104 | 105 | XCTAssertTrue(config.additionalPaths.contains("/custom/bin")) 106 | XCTAssertTrue(config.additionalPaths.contains("/another/path")) 107 | 108 | // Should still have default paths 109 | XCTAssertTrue(config.additionalPaths.contains("/usr/local/bin")) 110 | XCTAssertTrue(config.additionalPaths.contains("/opt/homebrew/bin")) 111 | } 112 | 113 | func testEnvironmentVariables() { 114 | let env = ["API_KEY": "test123", "NODE_ENV": "production"] 115 | let config = ClaudeCodeConfiguration( 116 | environment: env 117 | ) 118 | 119 | XCTAssertEqual(config.environment["API_KEY"], "test123") 120 | XCTAssertEqual(config.environment["NODE_ENV"], "production") 121 | } 122 | 123 | func testCommandSuffix() { 124 | let config = ClaudeCodeConfiguration( 125 | commandSuffix: "--" 126 | ) 127 | 128 | XCTAssertEqual(config.commandSuffix, "--") 129 | } 130 | 131 | func testDisallowedTools() { 132 | let config = ClaudeCodeConfiguration( 133 | disallowedTools: ["Delete", "Bash"] 134 | ) 135 | 136 | XCTAssertNotNil(config.disallowedTools) 137 | XCTAssertEqual(config.disallowedTools?.count, 2) 138 | XCTAssertTrue(config.disallowedTools?.contains("Delete") ?? false) 139 | XCTAssertTrue(config.disallowedTools?.contains("Bash") ?? false) 140 | } 141 | 142 | func testBackendMutability() { 143 | var config = ClaudeCodeConfiguration.default 144 | 145 | // Should be able to change backend at runtime 146 | XCTAssertEqual(config.backend, .headless) 147 | 148 | config.backend = .agentSDK 149 | XCTAssertEqual(config.backend, .agentSDK) 150 | 151 | config.backend = .headless 152 | XCTAssertEqual(config.backend, .headless) 153 | } 154 | 155 | func testNodeExecutableMutability() { 156 | var config = ClaudeCodeConfiguration.default 157 | 158 | XCTAssertNil(config.nodeExecutable) 159 | 160 | config.nodeExecutable = "/usr/local/bin/node" 161 | XCTAssertEqual(config.nodeExecutable, "/usr/local/bin/node") 162 | 163 | config.nodeExecutable = nil 164 | XCTAssertNil(config.nodeExecutable) 165 | } 166 | 167 | func testComprehensiveConfiguration() { 168 | // Test all configuration options together 169 | let config = ClaudeCodeConfiguration( 170 | backend: .agentSDK, 171 | command: "custom-claude", 172 | nodeExecutable: "/custom/node", 173 | sdkWrapperPath: "/custom/wrapper.mjs", 174 | workingDirectory: "/workspace", 175 | environment: ["KEY": "value"], 176 | enableDebugLogging: true, 177 | additionalPaths: ["/custom/path"], 178 | commandSuffix: "---", 179 | disallowedTools: ["DangerousTool"] 180 | ) 181 | 182 | XCTAssertEqual(config.backend, .agentSDK) 183 | XCTAssertEqual(config.command, "custom-claude") 184 | XCTAssertEqual(config.nodeExecutable, "/custom/node") 185 | XCTAssertEqual(config.sdkWrapperPath, "/custom/wrapper.mjs") 186 | XCTAssertEqual(config.workingDirectory, "/workspace") 187 | XCTAssertEqual(config.environment["KEY"], "value") 188 | XCTAssertTrue(config.enableDebugLogging) 189 | XCTAssertTrue(config.additionalPaths.contains("/custom/path")) 190 | XCTAssertEqual(config.commandSuffix, "---") 191 | XCTAssertEqual(config.disallowedTools, ["DangerousTool"]) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Utilities/RateLimiter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RateLimiter.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by James Rochabrun on 6/17/25. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | /// Token bucket algorithm implementation for rate limiting 12 | public actor RateLimiter { 13 | private let capacity: Int 14 | private let refillRate: Double // tokens per second 15 | private var tokens: Double 16 | private var lastRefill: Date 17 | private let logger: Logger? 18 | 19 | /// Queue of waiting requests 20 | private var waitingRequests: [CheckedContinuation] = [] 21 | 22 | /// Create a rate limiter with specified capacity and refill rate 23 | /// - Parameters: 24 | /// - capacity: Maximum number of tokens in the bucket 25 | /// - refillRate: Number of tokens added per second 26 | public init(capacity: Int, refillRate: Double, logger: Logger? = nil) { 27 | self.capacity = capacity 28 | self.refillRate = refillRate 29 | self.tokens = Double(capacity) 30 | self.lastRefill = Date() 31 | self.logger = logger 32 | } 33 | 34 | /// Create a rate limiter with requests per minute 35 | public init(requestsPerMinute: Int, burstCapacity: Int? = nil, logger: Logger? = nil) { 36 | let capacity = burstCapacity ?? requestsPerMinute 37 | let refillRate = Double(requestsPerMinute) / 60.0 38 | 39 | self.capacity = capacity 40 | self.refillRate = refillRate 41 | self.tokens = Double(capacity) 42 | self.lastRefill = Date() 43 | self.logger = logger 44 | } 45 | 46 | /// Acquire a token, waiting if necessary 47 | public func acquire() async throws { 48 | // Refill tokens based on time passed 49 | refillTokens() 50 | 51 | // If we have tokens available, consume one 52 | if tokens >= 1 { 53 | tokens -= 1 54 | let log = "Rate limiter: Token acquired, \(Int(tokens)) remaining" 55 | logger?.debug("\(log)") 56 | return 57 | } 58 | 59 | // Calculate wait time 60 | let waitTime = (1.0 - tokens) / refillRate 61 | let log = "Rate limiter: No tokens available, waiting \(String(format: "%.1f", waitTime))s" 62 | logger?.info("\(log)") 63 | 64 | // Wait for token to be available 65 | try await withCheckedThrowingContinuation { continuation in 66 | waitingRequests.append(continuation) 67 | } 68 | } 69 | 70 | /// Try to acquire a token without waiting 71 | public func tryAcquire() -> Bool { 72 | refillTokens() 73 | 74 | if tokens >= 1 { 75 | tokens -= 1 76 | let log = "Rate limiter: Token acquired (try), \(Int(tokens)) remaining" 77 | logger?.debug("\(log)") 78 | return true 79 | } 80 | 81 | logger?.debug("Rate limiter: No tokens available (try)") 82 | return false 83 | } 84 | 85 | /// Get the current number of available tokens 86 | public func availableTokens() -> Int { 87 | refillTokens() 88 | return Int(tokens) 89 | } 90 | 91 | /// Reset the rate limiter to full capacity 92 | public func reset() { 93 | tokens = Double(capacity) 94 | lastRefill = Date() 95 | 96 | // Resume all waiting requests 97 | for continuation in waitingRequests { 98 | continuation.resume() 99 | } 100 | waitingRequests.removeAll() 101 | 102 | let log = "Rate limiter: Reset to full capacity (\(capacity) tokens)" 103 | logger?.info("\(log)") 104 | } 105 | 106 | private func refillTokens() { 107 | let now = Date() 108 | let elapsed = now.timeIntervalSince(lastRefill) 109 | let tokensToAdd = elapsed * refillRate 110 | 111 | if tokensToAdd > 0 { 112 | tokens = min(Double(capacity), tokens + tokensToAdd) 113 | lastRefill = now 114 | 115 | // Process waiting requests if we have tokens 116 | processWaitingRequests() 117 | } 118 | } 119 | 120 | private func processWaitingRequests() { 121 | while !waitingRequests.isEmpty && tokens >= 1 { 122 | tokens -= 1 123 | let continuation = waitingRequests.removeFirst() 124 | continuation.resume() 125 | } 126 | 127 | // Schedule next check if we still have waiting requests 128 | if !waitingRequests.isEmpty { 129 | let nextTokenTime = (1.0 - tokens) / refillRate 130 | Task { 131 | try? await Task.sleep(nanoseconds: UInt64(nextTokenTime * 1_000_000_000)) 132 | refillTokens() 133 | } 134 | } 135 | } 136 | } 137 | 138 | /// Rate-limited wrapper for ClaudeCode operations 139 | public class RateLimitedClaudeCode: ClaudeCode { 140 | private var wrapped: ClaudeCode 141 | private let rateLimiter: RateLimiter 142 | 143 | public var configuration: ClaudeCodeConfiguration { 144 | get { wrapped.configuration } 145 | set { wrapped.configuration = newValue } 146 | } 147 | 148 | public var lastExecutedCommandInfo: ExecutedCommandInfo? { 149 | wrapped.lastExecutedCommandInfo 150 | } 151 | 152 | public init( 153 | wrapped: ClaudeCode, 154 | requestsPerMinute: Int, 155 | burstCapacity: Int? = nil 156 | ) { 157 | self.wrapped = wrapped 158 | self.rateLimiter = RateLimiter( 159 | requestsPerMinute: requestsPerMinute, 160 | burstCapacity: burstCapacity 161 | ) 162 | } 163 | 164 | public func runWithStdin( 165 | stdinContent: String, 166 | outputFormat: ClaudeCodeOutputFormat, 167 | options: ClaudeCodeOptions? 168 | ) async throws -> ClaudeCodeResult { 169 | try await rateLimiter.acquire() 170 | return try await wrapped.runWithStdin( 171 | stdinContent: stdinContent, 172 | outputFormat: outputFormat, 173 | options: options 174 | ) 175 | } 176 | 177 | public func runSinglePrompt( 178 | prompt: String, 179 | outputFormat: ClaudeCodeOutputFormat, 180 | options: ClaudeCodeOptions? 181 | ) async throws -> ClaudeCodeResult { 182 | try await rateLimiter.acquire() 183 | return try await wrapped.runSinglePrompt( 184 | prompt: prompt, 185 | outputFormat: outputFormat, 186 | options: options 187 | ) 188 | } 189 | 190 | public func continueConversation( 191 | prompt: String?, 192 | outputFormat: ClaudeCodeOutputFormat, 193 | options: ClaudeCodeOptions? 194 | ) async throws -> ClaudeCodeResult { 195 | try await rateLimiter.acquire() 196 | return try await wrapped.continueConversation( 197 | prompt: prompt, 198 | outputFormat: outputFormat, 199 | options: options 200 | ) 201 | } 202 | 203 | public func resumeConversation( 204 | sessionId: String, 205 | prompt: String?, 206 | outputFormat: ClaudeCodeOutputFormat, 207 | options: ClaudeCodeOptions? 208 | ) async throws -> ClaudeCodeResult { 209 | try await rateLimiter.acquire() 210 | return try await wrapped.resumeConversation( 211 | sessionId: sessionId, 212 | prompt: prompt, 213 | outputFormat: outputFormat, 214 | options: options 215 | ) 216 | } 217 | 218 | public func listSessions() async throws -> [SessionInfo] { 219 | try await rateLimiter.acquire() 220 | return try await wrapped.listSessions() 221 | } 222 | 223 | public func cancel() { 224 | wrapped.cancel() 225 | } 226 | 227 | public func validateCommand(_ command: String) async throws -> Bool { 228 | try await rateLimiter.acquire() 229 | return try await wrapped.validateCommand(command) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Examples/ErrorHandlingExample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorHandlingExample.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Example demonstrating error handling, retry logic, and rate limiting 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Basic Error Handling 11 | 12 | func basicErrorHandling() async throws { 13 | let client = try ClaudeCodeClient() 14 | 15 | do { 16 | let result = try await client.runSinglePrompt( 17 | prompt: "Write a hello world function", 18 | outputFormat: .json, 19 | options: nil 20 | ) 21 | print("Success: \(result)") 22 | } catch let error as ClaudeCodeError { 23 | switch error { 24 | case .notInstalled: 25 | print("Please install Claude Code first") 26 | case .timeout(let duration): 27 | print("Request timed out after \(duration) seconds") 28 | case .rateLimitExceeded(let retryAfter): 29 | print("Rate limited. Retry after: \(retryAfter ?? 60) seconds") 30 | case .permissionDenied(let message): 31 | print("Permission denied: \(message)") 32 | case .processLaunchFailed(let message): 33 | print("Failed to launch Claude process: \(message)") 34 | print("Check your command arguments and configuration") 35 | default: 36 | print("Error: \(error.localizedDescription)") 37 | } 38 | } 39 | } 40 | 41 | // MARK: - Timeout Example 42 | 43 | func timeoutExample() async throws { 44 | let client = try ClaudeCodeClient() 45 | 46 | var options = ClaudeCodeOptions() 47 | options.timeout = 30 // 30 second timeout 48 | 49 | do { 50 | let result = try await client.runSinglePrompt( 51 | prompt: "Analyze this large codebase...", 52 | outputFormat: .json, 53 | options: options 54 | ) 55 | print("Completed: \(result)") 56 | } catch ClaudeCodeError.timeout(let duration) { 57 | print("Operation timed out after \(duration) seconds") 58 | } 59 | } 60 | 61 | // MARK: - Retry Logic Example 62 | 63 | func retryExample() async { 64 | guard let client = try? ClaudeCodeClient() else { return } 65 | 66 | // Use default retry policy (3 attempts with exponential backoff) 67 | do { 68 | let result = try await client.runSinglePromptWithRetry( 69 | prompt: "Generate a REST API", 70 | outputFormat: .json, 71 | retryPolicy: .default 72 | ) 73 | print("Success after retries: \(result)") 74 | } catch { 75 | print("Failed after all retry attempts: \(error)") 76 | } 77 | 78 | // Use conservative retry policy for rate-limited operations 79 | do { 80 | let result = try await client.runSinglePromptWithRetry( 81 | prompt: "Complex analysis task", 82 | outputFormat: .json, 83 | retryPolicy: .conservative 84 | ) 85 | print("Success with conservative retry: \(result)") 86 | } catch { 87 | print("Failed with conservative retry: \(error)") 88 | } 89 | } 90 | 91 | // MARK: - Combined Example with Smart Error Handling 92 | 93 | func smartErrorHandling() async throws { 94 | let client = try ClaudeCodeClient() 95 | var options = ClaudeCodeOptions() 96 | options.timeout = 60 97 | 98 | var attempts = 0 99 | let maxAttempts = 3 100 | 101 | while attempts < maxAttempts { 102 | attempts += 1 103 | 104 | do { 105 | let result = try await client.runSinglePrompt( 106 | prompt: "Complex task", 107 | outputFormat: .json, 108 | options: options 109 | ) 110 | print("Success: \(result)") 111 | break // Success, exit loop 112 | 113 | } catch let error as ClaudeCodeError { 114 | print("Attempt \(attempts) failed: \(error.localizedDescription)") 115 | 116 | // Check if error is retryable 117 | if error.isRetryable && attempts < maxAttempts { 118 | if let delay = error.suggestedRetryDelay { 119 | print("Waiting \(delay) seconds before retry...") 120 | try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) 121 | } 122 | } else { 123 | // Non-retryable error or max attempts reached 124 | print("Giving up after \(attempts) attempts") 125 | throw error 126 | } 127 | } 128 | } 129 | } 130 | 131 | // MARK: - Abort Controller Example 132 | 133 | func abortExample() async { 134 | guard let client = try? ClaudeCodeClient() else { return } 135 | 136 | var options = ClaudeCodeOptions() 137 | let abortController = AbortController() 138 | options.abortController = abortController 139 | 140 | // Start a long-running task 141 | Task { 142 | do { 143 | let result = try await client.runSinglePrompt( 144 | prompt: "Very long running task...", 145 | outputFormat: .streamJson, 146 | options: options 147 | ) 148 | print("Task completed: \(result)") 149 | } catch ClaudeCodeError.cancelled { 150 | print("Task was cancelled") 151 | } 152 | } 153 | 154 | // Cancel after 5 seconds 155 | Task { 156 | try? await Task.sleep(nanoseconds: 5_000_000_000) 157 | print("Aborting task...") 158 | abortController.abort() 159 | } 160 | } 161 | 162 | // MARK: - Process Launch Failure Example 163 | 164 | func processLaunchFailureExample() async { 165 | guard let client = try? ClaudeCodeClient() else { return } 166 | 167 | // Example 1: Handle malformed command arguments 168 | var badOptions = ClaudeCodeOptions() 169 | badOptions.printMode = true 170 | // Simulate a bad configuration that might cause shell parsing errors 171 | badOptions.systemPrompt = "System prompt with \"unescaped quotes\" and bad syntax" 172 | 173 | do { 174 | let result = try await client.runSinglePrompt( 175 | prompt: "Test prompt", 176 | outputFormat: .streamJson, 177 | options: badOptions 178 | ) 179 | 180 | // This code won't be reached if process fails to launch 181 | if case .stream(_) = result { 182 | print("Got stream publisher") 183 | } 184 | } catch ClaudeCodeError.processLaunchFailed(let message) { 185 | // This error is now properly thrown instead of returning a dead stream 186 | print("Process failed to launch: \(message)") 187 | 188 | // Check for specific error patterns 189 | if message.contains("syntax error") || message.contains("parse error") { 190 | print("Command syntax error detected. Review your configuration.") 191 | } else if message.contains("bad option") { 192 | print("Invalid command option detected.") 193 | } 194 | } catch { 195 | print("Other error: \(error)") 196 | } 197 | 198 | // Example 2: Handle with resumeConversation 199 | do { 200 | let result = try await client.resumeConversation( 201 | sessionId: "some-session", 202 | prompt: "Continue", 203 | outputFormat: .streamJson, 204 | options: badOptions 205 | ) 206 | print("Resume succeeded: \(result)") 207 | } catch ClaudeCodeError.processLaunchFailed(let message) { 208 | // Now properly catches process launch failures 209 | print("Failed to resume conversation - process launch failed: \(message)") 210 | } catch { 211 | print("Resume failed with error: \(error)") 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSDK/Client/ClaudeCodeClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeCodeClient.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Refactored to use backend abstraction (Phase 2) 6 | // 7 | import Foundation 8 | import os.log 9 | 10 | /// Concrete implementation of ClaudeCodeSDK that uses pluggable backends 11 | public final class ClaudeCodeClient: ClaudeCode, @unchecked Sendable { 12 | private var backend: ClaudeCodeBackend 13 | private var logger: Logger? 14 | private var isUpdatingConfiguration = false 15 | 16 | /// Configuration for the client - can be updated at any time 17 | public var configuration: ClaudeCodeConfiguration { 18 | didSet { 19 | // Prevent re-entrance to avoid infinite recursion when restoring old config on error 20 | guard !isUpdatingConfiguration else { return } 21 | isUpdatingConfiguration = true 22 | defer { isUpdatingConfiguration = false } 23 | 24 | // Recreate backend if type or working directory changed 25 | if oldValue.backend != self.configuration.backend || 26 | oldValue.workingDirectory != self.configuration.workingDirectory { 27 | do { 28 | backend = try BackendFactory.createBackend(for: self.configuration) 29 | logger?.info("Backend recreated - type: \(self.configuration.backend.rawValue), workingDir: \(self.configuration.workingDirectory ?? "none")") 30 | } catch { 31 | logger?.error("Failed to create backend: \(error.localizedDescription)") 32 | // Restore old configuration to maintain consistent state 33 | // Safe now because guard prevents re-entrance 34 | configuration = oldValue 35 | } 36 | } 37 | } 38 | } 39 | 40 | /// Debug information about the last command executed 41 | public var lastExecutedCommandInfo: ExecutedCommandInfo? { 42 | return backend.lastExecutedCommandInfo 43 | } 44 | 45 | /// Initializes the client with a configuration 46 | /// - Parameter configuration: The configuration to use 47 | /// - Throws: ClaudeCodeError if backend creation fails 48 | public init(configuration: ClaudeCodeConfiguration = .default) throws { 49 | self.configuration = configuration 50 | 51 | if configuration.enableDebugLogging { 52 | self.logger = Logger(subsystem: "com.yourcompany.ClaudeCodeClient", category: "ClaudeCode") 53 | logger?.info("Initializing Claude Code client with backend: \(configuration.backend.rawValue)") 54 | } 55 | 56 | // Create the appropriate backend 57 | self.backend = try BackendFactory.createBackend(for: configuration) 58 | 59 | logger?.info("Claude Code client initialized successfully") 60 | } 61 | 62 | /// Convenience initializer for backward compatibility 63 | public convenience init(workingDirectory: String = "", debug: Bool = false) throws { 64 | var config = ClaudeCodeConfiguration.default 65 | config.workingDirectory = workingDirectory.isEmpty ? nil : workingDirectory 66 | config.enableDebugLogging = debug 67 | try self.init(configuration: config) 68 | } 69 | 70 | // MARK: - Protocol Implementation 71 | 72 | public func runWithStdin( 73 | stdinContent: String, 74 | outputFormat: ClaudeCodeOutputFormat, 75 | options: ClaudeCodeOptions? 76 | ) async throws -> ClaudeCodeResult { 77 | logger?.debug("Running with stdin (backend: \(self.configuration.backend.rawValue))") 78 | return try await backend.runWithStdin( 79 | stdinContent: stdinContent, 80 | outputFormat: outputFormat, 81 | options: options 82 | ) 83 | } 84 | 85 | public func runSinglePrompt( 86 | prompt: String, 87 | outputFormat: ClaudeCodeOutputFormat, 88 | options: ClaudeCodeOptions? 89 | ) async throws -> ClaudeCodeResult { 90 | logger?.debug("Running single prompt (backend: \(self.configuration.backend.rawValue))") 91 | return try await backend.runSinglePrompt( 92 | prompt: prompt, 93 | outputFormat: outputFormat, 94 | options: options 95 | ) 96 | } 97 | 98 | public func continueConversation( 99 | prompt: String?, 100 | outputFormat: ClaudeCodeOutputFormat, 101 | options: ClaudeCodeOptions? 102 | ) async throws -> ClaudeCodeResult { 103 | logger?.debug("Continuing conversation (backend: \(self.configuration.backend.rawValue))") 104 | return try await backend.continueConversation( 105 | prompt: prompt, 106 | outputFormat: outputFormat, 107 | options: options 108 | ) 109 | } 110 | 111 | public func resumeConversation( 112 | sessionId: String, 113 | prompt: String?, 114 | outputFormat: ClaudeCodeOutputFormat, 115 | options: ClaudeCodeOptions? 116 | ) async throws -> ClaudeCodeResult { 117 | logger?.debug("Resuming conversation \(sessionId) (backend: \(self.configuration.backend.rawValue))") 118 | return try await backend.resumeConversation( 119 | sessionId: sessionId, 120 | prompt: prompt, 121 | outputFormat: outputFormat, 122 | options: options 123 | ) 124 | } 125 | 126 | public func listSessions() async throws -> [SessionInfo] { 127 | logger?.debug("Listing sessions (backend: \(self.configuration.backend.rawValue))") 128 | return try await backend.listSessions() 129 | } 130 | 131 | public func cancel() { 132 | logger?.info("Canceling operations") 133 | backend.cancel() 134 | } 135 | 136 | public func validateCommand(_ command: String) async throws -> Bool { 137 | logger?.info("Validating command: \(command)") 138 | 139 | // Use backend-specific validation 140 | if configuration.backend == .agentSDK { 141 | // For Agent SDK, validate the setup instead 142 | return try await backend.validateSetup() 143 | } else { 144 | // For headless, use the traditional command validation 145 | let checkCommand = "which \(command)" 146 | 147 | let process = Process() 148 | process.executableURL = URL(fileURLWithPath: "/bin/zsh") 149 | process.arguments = ["-l", "-c", checkCommand] 150 | 151 | if let workingDirectory = configuration.workingDirectory { 152 | process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) 153 | } 154 | 155 | var env = ProcessInfo.processInfo.environment 156 | 157 | // Add additional paths to PATH 158 | if !configuration.additionalPaths.isEmpty { 159 | let additionalPathString = configuration.additionalPaths.joined(separator: ":") 160 | if let currentPath = env["PATH"] { 161 | env["PATH"] = "\(currentPath):\(additionalPathString)" 162 | } else { 163 | env["PATH"] = "\(additionalPathString):/bin" 164 | } 165 | } 166 | 167 | // Apply custom environment variables 168 | for (key, value) in configuration.environment { 169 | env[key] = value 170 | } 171 | 172 | process.environment = env 173 | 174 | let outputPipe = Pipe() 175 | let errorPipe = Pipe() 176 | process.standardOutput = outputPipe 177 | process.standardError = errorPipe 178 | 179 | do { 180 | try process.run() 181 | process.waitUntilExit() 182 | 183 | // 'which' returns 0 if command is found, non-zero otherwise 184 | let isValid = process.terminationStatus == 0 185 | 186 | if isValid { 187 | let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() 188 | if let path = String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { 189 | logger?.info("Command '\(command)' found at: \(path)") 190 | } 191 | } else { 192 | logger?.warning("Command '\(command)' not found in PATH") 193 | 194 | // Log current PATH for debugging 195 | if configuration.enableDebugLogging { 196 | let pathCheckCommand = "echo $PATH" 197 | let pathProcess = Process() 198 | pathProcess.executableURL = URL(fileURLWithPath: "/bin/zsh") 199 | pathProcess.arguments = ["-l", "-c", pathCheckCommand] 200 | pathProcess.environment = env 201 | 202 | let pathPipe = Pipe() 203 | pathProcess.standardOutput = pathPipe 204 | 205 | try pathProcess.run() 206 | pathProcess.waitUntilExit() 207 | 208 | let pathData = pathPipe.fileHandleForReading.readDataToEndOfFile() 209 | if let currentPath = String(data: pathData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { 210 | logger?.debug("Current PATH: \(currentPath)") 211 | } 212 | } 213 | } 214 | 215 | return isValid 216 | } catch { 217 | logger?.error("Error validating command '\(command)': \(error.localizedDescription)") 218 | throw ClaudeCodeError.executionFailed("Failed to validate command: \(error.localizedDescription)") 219 | } 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Tests/ClaudeCodeSDKTests/NodePathDetectorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodePathDetectorTests.swift 3 | // ClaudeCodeSDK 4 | // 5 | // Created by Assistant on 10/7/2025. 6 | // 7 | 8 | import XCTest 9 | @testable import ClaudeCodeSDK 10 | 11 | final class NodePathDetectorTests: XCTestCase { 12 | 13 | func testDetectNodePath() { 14 | // This test verifies that node path detection returns a valid path or nil 15 | let nodePath = NodePathDetector.detectNodePath() 16 | 17 | if let path = nodePath { 18 | // If a path is returned, it should exist and be executable 19 | XCTAssertTrue(FileManager.default.fileExists(atPath: path), 20 | "Node path should exist: \(path)") 21 | XCTAssertTrue(FileManager.default.isExecutableFile(atPath: path), 22 | "Node should be executable: \(path)") 23 | XCTAssertTrue(path.contains("node"), 24 | "Path should contain 'node': \(path)") 25 | } else { 26 | // If nil is returned, node is not installed (acceptable) 27 | print("ℹ️ Node.js not detected on this system") 28 | } 29 | } 30 | 31 | func testDetectNpmPath() { 32 | let npmPath = NodePathDetector.detectNpmPath() 33 | 34 | if let path = npmPath { 35 | XCTAssertTrue(FileManager.default.fileExists(atPath: path), 36 | "npm path should exist: \(path)") 37 | XCTAssertTrue(FileManager.default.isExecutableFile(atPath: path), 38 | "npm should be executable: \(path)") 39 | XCTAssertTrue(path.contains("npm"), 40 | "Path should contain 'npm': \(path)") 41 | } else { 42 | print("ℹ️ npm not detected on this system") 43 | } 44 | } 45 | 46 | func testDetectNpmGlobalPath() { 47 | let globalPath = NodePathDetector.detectNpmGlobalPath() 48 | 49 | if let path = globalPath { 50 | XCTAssertTrue(FileManager.default.fileExists(atPath: path), 51 | "npm global path should exist: \(path)") 52 | XCTAssertTrue(path.contains("bin"), 53 | "Global path should contain 'bin': \(path)") 54 | } else { 55 | print("ℹ️ npm global path not detected") 56 | } 57 | } 58 | 59 | func testIsAgentSDKInstalled() { 60 | let isInstalled = NodePathDetector.isAgentSDKInstalled() 61 | 62 | // Just verify the method runs without crashing 63 | // Result depends on system state 64 | if isInstalled { 65 | print("✓ Claude Agent SDK is installed") 66 | 67 | // If installed, we should be able to get the path 68 | let sdkPath = NodePathDetector.getAgentSDKPath() 69 | XCTAssertNotNil(sdkPath, "SDK path should be available if installed") 70 | 71 | if let path = sdkPath { 72 | XCTAssertTrue(FileManager.default.fileExists(atPath: path), 73 | "SDK path should exist: \(path)") 74 | } 75 | } else { 76 | print("ℹ️ Claude Agent SDK not installed") 77 | } 78 | } 79 | 80 | func testGetAgentSDKPath() { 81 | let sdkPath = NodePathDetector.getAgentSDKPath() 82 | 83 | if let path = sdkPath { 84 | XCTAssertTrue(FileManager.default.fileExists(atPath: path), 85 | "SDK path should exist: \(path)") 86 | XCTAssertTrue(path.contains("@anthropic-ai/claude-agent-sdk"), 87 | "Path should contain SDK package name: \(path)") 88 | } 89 | } 90 | 91 | func testNodePathConsistency() { 92 | // If node is found, npm should typically be in the same directory 93 | guard let nodePath = NodePathDetector.detectNodePath() else { 94 | print("ℹ️ Skipping consistency test - Node.js not detected") 95 | return 96 | } 97 | 98 | let nodeDir = (nodePath as NSString).deletingLastPathComponent 99 | 100 | // Check if npm exists in the same directory 101 | let expectedNpmPath = nodeDir + "/npm" 102 | let npmExists = FileManager.default.fileExists(atPath: expectedNpmPath) 103 | 104 | if npmExists { 105 | XCTAssertTrue(true, "npm found alongside node") 106 | } else { 107 | print("⚠️ npm not in same directory as node (might be using different installation)") 108 | } 109 | } 110 | 111 | func testMultipleDetectionCalls() { 112 | // Verify that multiple calls return consistent results 113 | let firstCall = NodePathDetector.detectNodePath() 114 | let secondCall = NodePathDetector.detectNodePath() 115 | 116 | XCTAssertEqual(firstCall, secondCall, 117 | "Multiple calls should return consistent results") 118 | } 119 | 120 | func testValidPathFormat() { 121 | if let nodePath = NodePathDetector.detectNodePath() { 122 | // Path should be absolute 123 | XCTAssertTrue(nodePath.hasPrefix("/"), 124 | "Node path should be absolute: \(nodePath)") 125 | 126 | // Should not contain spaces that aren't escaped 127 | // (this is a basic check - real paths can have spaces) 128 | XCTAssertFalse(nodePath.contains(" "), 129 | "Path should not contain double spaces") 130 | } 131 | } 132 | 133 | // MARK: - NVM Configuration Tests 134 | 135 | func testIsAgentSDKInstalledWithNVMConfiguration() { 136 | // Test that isAgentSDKInstalled respects nodeExecutable configuration 137 | guard let nodePath = NodePathDetector.detectNodePath() else { 138 | print("ℹ️ Skipping NVM config test - Node.js not detected") 139 | return 140 | } 141 | 142 | // Create config with explicit node path 143 | var config = ClaudeCodeConfiguration.default 144 | config.nodeExecutable = nodePath 145 | 146 | // Test with configuration 147 | let isInstalledWithConfig = NodePathDetector.isAgentSDKInstalled(configuration: config) 148 | 149 | // Test without configuration (should use auto-detection) 150 | let isInstalledWithoutConfig = NodePathDetector.isAgentSDKInstalled() 151 | 152 | print("ℹ️ SDK installed with config: \(isInstalledWithConfig)") 153 | print("ℹ️ SDK installed without config: \(isInstalledWithoutConfig)") 154 | 155 | // Both should give valid results (may differ if NVM is used) 156 | // The important thing is they don't crash 157 | } 158 | 159 | func testIsAgentSDKInstalledWithInvalidNodePath() { 160 | // Test with non-existent node path 161 | var config = ClaudeCodeConfiguration.default 162 | config.nodeExecutable = "/nonexistent/path/to/node" 163 | 164 | let isInstalled = NodePathDetector.isAgentSDKInstalled(configuration: config) 165 | 166 | // Should return false for invalid path, not crash 167 | XCTAssertFalse(isInstalled, "Should return false for invalid node path") 168 | } 169 | 170 | func testIsAgentSDKInstalledWithNVMPath() { 171 | // Test with actual NVM path if available 172 | let nvmNodePath = "\(NSHomeDirectory())/.nvm/versions/node/v22.16.0/bin/node" 173 | 174 | guard FileManager.default.fileExists(atPath: nvmNodePath) else { 175 | print("ℹ️ Skipping NVM path test - NVM installation not found at expected location") 176 | return 177 | } 178 | 179 | var config = ClaudeCodeConfiguration.default 180 | config.nodeExecutable = nvmNodePath 181 | 182 | let isInstalled = NodePathDetector.isAgentSDKInstalled(configuration: config) 183 | 184 | print("ℹ️ SDK installed with NVM path (\(nvmNodePath)): \(isInstalled)") 185 | 186 | // Should check the correct location based on the NVM node path 187 | // Expected SDK location: ~/.nvm/versions/node/v22.16.0/lib/node_modules/@anthropic-ai/claude-agent-sdk 188 | let expectedSDKPath = "\(NSHomeDirectory())/.nvm/versions/node/v22.16.0/lib/node_modules/@anthropic-ai/claude-agent-sdk" 189 | let sdkExists = FileManager.default.fileExists(atPath: expectedSDKPath) 190 | 191 | if sdkExists { 192 | XCTAssertTrue(isInstalled, "Should detect SDK when it exists at NVM location") 193 | } else { 194 | XCTAssertFalse(isInstalled, "Should not detect SDK when it doesn't exist at NVM location") 195 | } 196 | } 197 | 198 | func testNodeExecutablePathDerivation() { 199 | // Test the path derivation logic 200 | let testNodePath = "/Users/test/.nvm/versions/node/v22.16.0/bin/node" 201 | let expectedSDKPath = "/Users/test/.nvm/versions/node/v22.16.0/lib/node_modules/@anthropic-ai/claude-agent-sdk" 202 | 203 | // Simulate the logic from isAgentSDKInstalled 204 | let nodeBinDir = (testNodePath as NSString).deletingLastPathComponent 205 | let nodePrefix = (nodeBinDir as NSString).deletingLastPathComponent 206 | let derivedSDKPath = "\(nodePrefix)/lib/node_modules/@anthropic-ai/claude-agent-sdk" 207 | 208 | XCTAssertEqual(derivedSDKPath, expectedSDKPath, "Should correctly derive SDK path from node path") 209 | } 210 | } 211 | --------------------------------------------------------------------------------