├── 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 |
--------------------------------------------------------------------------------