├── CHANGELOG.md ├── Examples └── InteractiveCLI │ ├── git-mcp-server.json │ ├── Package.swift │ └── README.md ├── Sources └── ClaudeCodeSwiftSDK │ ├── ClaudeCodeSwiftSDK.swift │ ├── SDKOptions.swift │ ├── Utils │ ├── CLIDiscovery.swift │ └── DebugLogger.swift │ ├── Errors │ └── ClaudeSDKError.swift │ ├── Types │ ├── ContentBlocks.swift │ ├── Options.swift │ └── Message.swift │ ├── ClaudeCodeSDKClient.swift │ ├── Client │ ├── SettingsAPI.swift │ ├── QueryAPI.swift │ └── ConnectionAPI.swift │ └── Internal │ ├── Transport │ ├── ArgumentBuilder.swift │ ├── MessageStreamHandler.swift │ ├── ProcessManager.swift │ └── SubprocessCLI.swift │ └── MessageDecoder.swift ├── .gitignore ├── LICENSE.md ├── Tests └── ClaudeCodeSwiftSDKTests │ ├── ClaudeCodeSwiftSDKTests.swift │ ├── ErrorsTests.swift │ ├── QueryClientTests.swift │ ├── TypesTests.swift │ ├── MessageParserTests.swift │ ├── TransportTests.swift │ └── MockingTests.swift ├── .releaserc.json ├── Package.swift └── .github └── workflows └── release.yml /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Examples/InteractiveCLI/git-mcp-server.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "git": { 4 | "command": "uvx", 5 | "args": ["mcp-server-git"] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/ClaudeCodeSwiftSDK.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// ClaudeCodeSwiftSDK - A Swift SDK for interacting with Claude Code CLI 4 | /// by Arunesh Singh and Claude 5 | 6 | public struct ClaudeCodeSDK { 7 | /// The current version of the SDK 8 | public static let version = "0.1.0" 9 | 10 | private init() {} 11 | } 12 | 13 | // Re-export public types for convenient access 14 | public typealias ClaudeCode = ClaudeCodeSDKClient -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | 3 | ## User settings 4 | xcuserdata/ 5 | 6 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 7 | *.xcscmblueprint 8 | *.xccheckout 9 | 10 | 11 | 12 | ## App packaging 13 | *.ipa 14 | *.dSYM.zip 15 | *.dSYM 16 | 17 | ## Playgrounds 18 | timeline.xctimeline 19 | playground.xcworkspace 20 | 21 | # Swift Package Manager 22 | .swiftpm/ 23 | .build/ 24 | 25 | # macOS 26 | .DS_Store 27 | 28 | # VSCode 29 | .vscode/ 30 | 31 | # JetBrains 32 | .idea/ 33 | 34 | # Package resolved file 35 | Package.resolved 36 | 37 | 38 | .claude/ 39 | 40 | CLAUDE.md -------------------------------------------------------------------------------- /Examples/InteractiveCLI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // Interactive CLI Example for ClaudeCodeSwiftSDK 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "InteractiveCLI", 8 | platforms: [ 9 | .macOS(.v15) 10 | ], 11 | products: [ 12 | .executable( 13 | name: "InteractiveCLI", 14 | targets: ["InteractiveCLI"] 15 | ) 16 | ], 17 | dependencies: [ 18 | .package(path: "../..") 19 | ], 20 | targets: [ 21 | .executableTarget( 22 | name: "InteractiveCLI", 23 | dependencies: [ 24 | .product(name: "ClaudeCodeSwiftSDK", package: "ClaudeCodeSwiftSDK") 25 | ], 26 | path: ".", 27 | exclude: ["README.md", ".gitignore"], 28 | sources: ["main.swift"] 29 | ) 30 | ] 31 | ) -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Arunesh Singh 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. -------------------------------------------------------------------------------- /Tests/ClaudeCodeSwiftSDKTests/ClaudeCodeSwiftSDKTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | @testable import ClaudeCodeSwiftSDK 4 | 5 | @Suite("Basic SDK Tests") 6 | struct ClaudeCodeSwiftSDKTests { 7 | 8 | @Test("SDK module imports successfully") 9 | func testSDKImport() throws { 10 | // Test that we can create basic types from the SDK 11 | let options = ClaudeCodeOptions() 12 | #expect(options != nil) 13 | 14 | let textBlock = TextBlock(text: "Test") 15 | #expect(textBlock.text == "Test") 16 | 17 | let client = ClaudeCodeSDKClient() 18 | #expect(client != nil) 19 | } 20 | 21 | @Test("Error types are available") 22 | func testErrorTypes() { 23 | let error = ClaudeSDKError.invalidConfiguration(reason: "Test error") 24 | 25 | if case .invalidConfiguration(let reason) = error { 26 | #expect(reason == "Test error") 27 | } else { 28 | Issue.record("Expected invalidConfiguration error") 29 | } 30 | 31 | let description = error.errorDescription 32 | #expect(description?.contains("Invalid configuration") == true) 33 | } 34 | } -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | "preset": "conventionalcommits", 8 | "releaseRules": [ 9 | {"type": "feat", "release": "minor"}, 10 | {"type": "fix", "release": "patch"}, 11 | {"type": "perf", "release": "patch"}, 12 | {"type": "revert", "release": "patch"}, 13 | {"breaking": true, "release": "minor"}, 14 | {"type": "docs", "release": false}, 15 | {"type": "style", "release": false}, 16 | {"type": "chore", "release": false}, 17 | {"type": "refactor", "release": false}, 18 | {"type": "test", "release": false}, 19 | {"type": "build", "release": false}, 20 | {"type": "ci", "release": false} 21 | ] 22 | } 23 | ], 24 | [ 25 | "@semantic-release/release-notes-generator", 26 | { 27 | "preset": "conventionalcommits" 28 | } 29 | ], 30 | [ 31 | "@semantic-release/changelog", 32 | { 33 | "changelogFile": "CHANGELOG.md" 34 | } 35 | ], 36 | [ 37 | "@semantic-release/git", 38 | { 39 | "assets": ["CHANGELOG.md"], 40 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 41 | } 42 | ], 43 | "@semantic-release/github" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /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 | // This file was generated by Claude 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "ClaudeCodeSwiftSDK", 9 | platforms: [ 10 | .macOS(.v13) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "ClaudeCodeSwiftSDK", 16 | targets: ["ClaudeCodeSwiftSDK"] 17 | ), 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | // We'll keep dependencies minimal for now 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package, defining a module or a test suite. 25 | // Targets can depend on other targets in this package and products from dependencies. 26 | .target( 27 | name: "ClaudeCodeSwiftSDK", 28 | dependencies: [], 29 | swiftSettings: [ 30 | .enableExperimentalFeature("StrictConcurrency"), 31 | .enableUpcomingFeature("ExistentialAny"), 32 | .define("SWIFT_PACKAGE") 33 | ] 34 | ), 35 | .testTarget( 36 | name: "ClaudeCodeSwiftSDKTests", 37 | dependencies: ["ClaudeCodeSwiftSDK"] 38 | ), 39 | ] 40 | ) -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/SDKOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - SDK Options 4 | 5 | /// Global SDK configuration options for development and debugging 6 | /// These options are set once at startup and affect all SDK operations 7 | public struct SDKOptions: Sendable { 8 | /// Enable debug logging globally 9 | public let debug: Bool 10 | 11 | /// Custom debug logger (uses default PrintDebugLogger if nil and debug is true) 12 | public let debugLogger: (any DebugLogger)? 13 | 14 | public init(debug: Bool = false, debugLogger: (any DebugLogger)? = nil) { 15 | self.debug = debug 16 | self.debugLogger = debugLogger ?? (debug ? PrintDebugLogger() : nil) 17 | } 18 | } 19 | 20 | // MARK: - Global SDK Configuration 21 | 22 | /// Global SDK configuration manager 23 | public final class ClaudeSDK: @unchecked Sendable { 24 | /// Shared SDK instance 25 | public static let shared = ClaudeSDK() 26 | 27 | private var _options: SDKOptions = SDKOptions() 28 | private let queue = DispatchQueue(label: "com.anthropic.claude-sdk.config", attributes: .concurrent) 29 | 30 | private init() {} 31 | 32 | /// Configure the SDK with global options 33 | /// This should be called once at the start of your application 34 | /// - Parameter options: SDK configuration options 35 | public func configure(with options: SDKOptions) { 36 | queue.async(flags: .barrier) { 37 | self._options = options 38 | } 39 | } 40 | 41 | /// Get current SDK options 42 | internal var options: SDKOptions { 43 | return queue.sync { 44 | return _options 45 | } 46 | } 47 | 48 | /// Get the current debug logger if debugging is enabled 49 | internal var debugLogger: (any DebugLogger)? { 50 | let opts = options 51 | return opts.debug ? opts.debugLogger : nil 52 | } 53 | } 54 | 55 | // MARK: - Convenience Configuration Functions 56 | 57 | /// Configure the Claude Code Swift SDK with debug logging enabled 58 | /// - Parameters: 59 | /// - debug: Enable debug logging (default: true) 60 | /// - debugLogger: Custom debug logger (uses default if nil) 61 | public func configureClaudeCodeSwiftSDK(debug: Bool = true, debugLogger: (any DebugLogger)? = nil) { 62 | ClaudeSDK.shared.configure(with: SDKOptions(debug: debug, debugLogger: debugLogger)) 63 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: macos-14 12 | if: | 13 | github.event_name == 'pull_request' || 14 | (github.event_name == 'push' && 15 | (contains(github.event.head_commit.message, 'feat:') || 16 | contains(github.event.head_commit.message, 'feat(') || 17 | contains(github.event.head_commit.message, 'fix:') || 18 | contains(github.event.head_commit.message, 'fix(') || 19 | contains(github.event.head_commit.message, 'perf:') || 20 | contains(github.event.head_commit.message, 'perf(') || 21 | contains(github.event.head_commit.message, 'revert:') || 22 | contains(github.event.head_commit.message, 'revert(') || 23 | contains(github.event.head_commit.message, 'refactor:') || 24 | contains(github.event.head_commit.message, 'refactor(') || 25 | contains(github.event.head_commit.message, 'test:') || 26 | contains(github.event.head_commit.message, 'test(') || 27 | contains(github.event.head_commit.message, 'build:') || 28 | contains(github.event.head_commit.message, 'build('))) 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup Swift 6.0 34 | uses: swift-actions/setup-swift@v2 35 | with: 36 | swift-version: "6.0" 37 | 38 | - name: Build package 39 | run: swift build 40 | 41 | - name: Run tests 42 | run: swift test 43 | 44 | release: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | if: | 48 | always() && 49 | github.ref == 'refs/heads/main' && 50 | github.event_name == 'push' && 51 | (needs.test.result == 'success' || needs.test.result == 'skipped') 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v4 55 | with: 56 | fetch-depth: 0 57 | token: ${{ secrets.GH_PERSONALTOKEN_SEMANTICRELEASE}} 58 | 59 | - name: Setup Node.js 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: 20 63 | 64 | - name: Install semantic-release 65 | run: | 66 | npm install -g semantic-release 67 | npm install -g @semantic-release/git 68 | npm install -g @semantic-release/github 69 | npm install -g @semantic-release/changelog 70 | npm install -g @semantic-release/commit-analyzer 71 | npm install -g @semantic-release/release-notes-generator 72 | npm install -g conventional-changelog-conventionalcommits 73 | 74 | - name: Release 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GH_PERSONALTOKEN_SEMANTICRELEASE}} 77 | run: semantic-release 78 | -------------------------------------------------------------------------------- /Examples/InteractiveCLI/README.md: -------------------------------------------------------------------------------- 1 | # Interactive CLI for ClaudeCodeSwiftSDK 2 | 3 | An interactive command-line interface for testing the ClaudeCodeSwiftSDK's streaming functionality. 4 | 5 | ## Features 6 | 7 | - ✅ Real-time streaming responses from Claude 8 | - ✅ Session management with persistent session IDs 9 | - ✅ Multi-turn conversations 10 | - ✅ Interrupt support (`/interrupt` command) 11 | - ✅ Configuration options (system prompt, model, tools, working directory) 12 | - ✅ Beautiful message formatting with emojis 13 | 14 | ## Building 15 | 16 | ```bash 17 | swift build 18 | ``` 19 | 20 | ## Running 21 | 22 | ```bash 23 | swift run 24 | ``` 25 | 26 | Or after building: 27 | 28 | ```bash 29 | .build/debug/InteractiveCLI 30 | ``` 31 | 32 | ## Commands 33 | 34 | - `/help`, `/h`, `/?` - Show help message 35 | - `/exit`, `/quit`, `/q` - Exit the program 36 | - `/interrupt`, `/int`, `/i` - Interrupt current operation 37 | - `/status`, `/s` - Show connection status and current session 38 | - `/clear`, `/c` - Clear the screen 39 | - `/session ` - Set session ID (default: "default") 40 | - `/system ` - Set system prompt 41 | - `/model ` - Set model (e.g., claude-3-opus-20240229) 42 | - `/tools ` - Set allowed tools (comma-separated) 43 | - `/cwd ` - Set working directory 44 | 45 | ## Usage Example 46 | 47 | ``` 48 | ╔══════════════════════════════════════╗ 49 | ║ Claude Code Interactive CLI ║ 50 | ║ Type /help for commands ║ 51 | ╚══════════════════════════════════════╝ 52 | ℹ️ Connecting to Claude Code CLI... 53 | ✅ Connected! Ready for interactive conversation. 54 | 55 | > Hello Claude! 56 | ℹ️ Message sent, waiting for response... 57 | 58 | 🔧 System: init - [:] 59 | 🤖 Assistant: 60 | Hello! I'm Claude, an AI assistant. How can I help you today? 61 | 📊 Result: 62 | Session ID: abc-123-def-456 63 | Duration: 1234ms 64 | API Duration: 1000ms 65 | Turns: 1 66 | Cost: $0.000123 67 | 68 | > What's 2 + 2? 69 | ℹ️ Message sent, waiting for response... 70 | 71 | 🤖 Assistant: 72 | 2 + 2 = 4 73 | 📊 Result: 74 | Session ID: abc-123-def-456 75 | Duration: 890ms 76 | API Duration: 750ms 77 | Turns: 2 78 | Cost: $0.000089 79 | 80 | > /status 81 | 82 | 📊 Status: 83 | Connected: Yes ✅ 84 | Current Session ID: default 85 | Waiting for Response: No 86 | System Prompt: none 87 | Model: default 88 | Allowed Tools: all 89 | Working Directory: none 90 | 91 | > /exit 92 | 👋 Goodbye! 93 | ``` 94 | 95 | ## Session Continuity 96 | 97 | The CLI maintains session continuity - you can see the same Session ID across multiple messages in a conversation. This allows Claude to maintain context throughout the interaction. 98 | 99 | ## Limitations 100 | 101 | - **Interrupts**: While the `/interrupt` command is available, the current implementation waits for responses to complete before accepting new input. Real-time interrupts during streaming would require concurrent input handling. 102 | 103 | ## Architecture 104 | 105 | The Interactive CLI demonstrates: 106 | - Bidirectional streaming with `ClaudeCodeSDKClient` 107 | - Proper session management 108 | - Message formatting and display 109 | - Error handling 110 | - Configuration options -------------------------------------------------------------------------------- /Tests/ClaudeCodeSwiftSDKTests/ErrorsTests.swift: -------------------------------------------------------------------------------- 1 | // Generated by Claude 2 | 3 | import Testing 4 | import Foundation 5 | @testable import ClaudeCodeSwiftSDK 6 | 7 | @Suite("Error Types Tests") 8 | struct ErrorTypesTests { 9 | 10 | @Test("CLI not found error") 11 | func cliNotFoundError() { 12 | let searchedPaths = [ 13 | URL(fileURLWithPath: "/usr/local/bin/claude"), 14 | URL(fileURLWithPath: "/opt/homebrew/bin/claude") 15 | ] 16 | let error = ClaudeSDKError.cliNotFound(searchedPaths: searchedPaths) 17 | 18 | let description = error.errorDescription ?? "" 19 | #expect(description.contains("Claude Code CLI not found")) 20 | #expect(description.contains("/usr/local/bin/claude")) 21 | #expect(description.contains("/opt/homebrew/bin/claude")) 22 | 23 | // Verify it's the correct error type 24 | if case .cliNotFound(let paths) = error { 25 | #expect(paths == searchedPaths) 26 | } else { 27 | Issue.record("Expected cliNotFound error case") 28 | } 29 | } 30 | 31 | @Test("CLI connection error") 32 | func cliConnectionError() { 33 | let underlyingError = NSError(domain: "TestDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connection failed"]) 34 | let error = ClaudeSDKError.cliConnectionError(underlying: underlyingError) 35 | 36 | let description = error.errorDescription ?? "" 37 | #expect(description.contains("Failed to connect to Claude Code CLI")) 38 | #expect(description.contains("Connection failed")) 39 | 40 | if case .cliConnectionError(let underlying) = error { 41 | #expect((underlying as NSError).domain == "TestDomain") 42 | } else { 43 | Issue.record("Expected cliConnectionError error case") 44 | } 45 | } 46 | 47 | @Test("Process error with exit code and message") 48 | func processError() { 49 | let error = ClaudeSDKError.processError(message: "Process failed", exitCode: 1) 50 | 51 | let description = error.errorDescription ?? "" 52 | #expect(description.contains("Process failed")) 53 | #expect(description.contains("exit code 1")) 54 | 55 | if case .processError(let message, let exitCode) = error { 56 | #expect(message == "Process failed") 57 | #expect(exitCode == 1) 58 | } else { 59 | Issue.record("Expected processError error case") 60 | } 61 | } 62 | 63 | @Test("JSON decode error") 64 | func jsonDecodeError() { 65 | let invalidLine = "{invalid json}" 66 | let jsonError = NSError(domain: "JSON", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"]) 67 | let error = ClaudeSDKError.jsonDecodeError(line: invalidLine, error: jsonError) 68 | 69 | let description = error.errorDescription ?? "" 70 | #expect(description.contains("Failed to decode JSON")) 71 | #expect(description.contains(invalidLine)) 72 | 73 | if case .jsonDecodeError(let line, let underlyingError) = error { 74 | #expect(line == invalidLine) 75 | #expect((underlyingError as NSError).domain == "JSON") 76 | } else { 77 | Issue.record("Expected jsonDecodeError error case") 78 | } 79 | } 80 | 81 | @Test("Invalid configuration error") 82 | func invalidConfigurationError() { 83 | let reason = "Missing required parameter" 84 | let error = ClaudeSDKError.invalidConfiguration(reason: reason) 85 | 86 | let description = error.errorDescription ?? "" 87 | #expect(description.contains("Invalid configuration")) 88 | #expect(description.contains(reason)) 89 | 90 | if case .invalidConfiguration(let errorReason) = error { 91 | #expect(errorReason == reason) 92 | } else { 93 | Issue.record("Expected invalidConfiguration error case") 94 | } 95 | } 96 | 97 | @Test("Error conforms to LocalizedError") 98 | func errorConformsToLocalizedError() { 99 | let error = ClaudeSDKError.cliNotFound(searchedPaths: []) 100 | 101 | // Should be able to cast to LocalizedError 102 | let localizedError = error as LocalizedError 103 | #expect(localizedError.errorDescription != nil) 104 | } 105 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSwiftSDKTests/QueryClientTests.swift: -------------------------------------------------------------------------------- 1 | // Generated by Claude 2 | 3 | import Testing 4 | import Foundation 5 | @testable import ClaudeCodeSwiftSDK 6 | 7 | @Suite("Query Function Tests") 8 | struct QueryFunctionTests { 9 | 10 | @Test("Query with single prompt") 11 | func querySinglePrompt() async throws { 12 | // Test the query functionality with a mock transport 13 | // This simulates what the real query function would do 14 | 15 | let assistantMessage = Message.assistant(AssistantMessage( 16 | id: "test-id-1", 17 | content: [TextBlock(text: "4")], 18 | model: "test-model", 19 | sessionId: "test-session" 20 | )) 21 | let resultMessage = Message.result(ResultMessage( 22 | subtype: "success", 23 | durationMs: 1000, 24 | durationApiMs: 800, 25 | isError: false, 26 | numTurns: 1, 27 | sessionId: "test-session" 28 | )) 29 | 30 | // Simulate the query execution 31 | let mockMessages: [Message] = [assistantMessage, resultMessage] 32 | var receivedMessages: [Message] = [] 33 | 34 | // This simulates what query("What is 2+2?") would do internally 35 | for message in mockMessages { 36 | receivedMessages.append(message) 37 | } 38 | 39 | #expect(receivedMessages.count == 2) 40 | 41 | if case .assistant(let assistantMsg) = receivedMessages[0], 42 | let textBlock = assistantMsg.content[0] as? TextBlock { 43 | #expect(textBlock.text == "4") 44 | } else { 45 | Issue.record("Expected AssistantMessage with TextBlock containing '4'") 46 | } 47 | } 48 | 49 | @Test("Query with options") 50 | func queryWithOptions() async throws { 51 | // Test that options are properly constructed and validated 52 | 53 | let options = ClaudeCodeOptions( 54 | systemPrompt: "You are helpful", 55 | maxTurns: 5, 56 | allowedTools: ["Read", "Write"], 57 | permissionMode: .acceptEdits 58 | ) 59 | 60 | // Verify options are correctly set 61 | #expect(options.allowedTools == ["Read", "Write"]) 62 | #expect(options.systemPrompt == "You are helpful") 63 | #expect(options.permissionMode == ClaudeCodeOptions.PermissionMode.acceptEdits) 64 | #expect(options.maxTurns == 5) 65 | 66 | // Simulate what query() would do with these options 67 | let assistantMessage = Message.assistant(AssistantMessage( 68 | id: "test-id-2", 69 | content: [TextBlock(text: "Hello!")], 70 | model: "test-model", 71 | sessionId: "test-session" 72 | )) 73 | let mockMessages: [Message] = [assistantMessage] 74 | 75 | // In real implementation, these options would be passed to transport 76 | // Here we just verify the query simulation works 77 | var receivedMessages: [Message] = [] 78 | for message in mockMessages { 79 | receivedMessages.append(message) 80 | } 81 | 82 | #expect(receivedMessages.count == 1) 83 | if case .assistant = receivedMessages[0] { 84 | // Test passes 85 | } else { 86 | Issue.record("Expected AssistantMessage") 87 | } 88 | } 89 | 90 | @Test("Query with custom working directory", .disabled("Requires mocking")) 91 | func queryWithCwd() async throws { 92 | // Test that custom working directory is properly handled 93 | 94 | let customPath = URL(fileURLWithPath: "/custom/path") 95 | let options = ClaudeCodeOptions(cwd: customPath) 96 | 97 | #expect(options.cwd == customPath) 98 | #expect(options.cwd?.path == "/custom/path") 99 | } 100 | } 101 | 102 | @Suite("Claude SDK Client Tests") 103 | struct ClaudeCodeSDKClientTests { 104 | 105 | @Test("Client initialization") 106 | func clientInitialization() { 107 | let client = ClaudeCodeSDKClient() 108 | 109 | // Client should initialize successfully 110 | // In a real implementation, we might check that it discovers the CLI path 111 | #expect(client != nil) 112 | } 113 | 114 | @Test("Client query method", .disabled("Requires mocking")) 115 | func clientQueryMethod() async throws { 116 | let client = ClaudeCodeSDKClient() 117 | 118 | // This would test the client's query method with mocking 119 | // For now, just verify the client exists 120 | #expect(client != nil) 121 | } 122 | } 123 | 124 | // MARK: - Helper Extensions for Testing 125 | 126 | extension ClaudeSDKError: Equatable { 127 | public static func == (lhs: ClaudeSDKError, rhs: ClaudeSDKError) -> Bool { 128 | switch (lhs, rhs) { 129 | case (.cliNotFound(let lhsPaths), .cliNotFound(let rhsPaths)): 130 | return lhsPaths == rhsPaths 131 | case (.cliConnectionError(_), .cliConnectionError(_)): 132 | return true // We can't easily compare underlying errors 133 | case (.processError(let lhsMessage, let lhsCode), .processError(let rhsMessage, let rhsCode)): 134 | return lhsMessage == rhsMessage && lhsCode == rhsCode 135 | case (.jsonDecodeError(let lhsLine, _), .jsonDecodeError(let rhsLine, _)): 136 | return lhsLine == rhsLine // We can't easily compare underlying errors 137 | case (.invalidConfiguration(let lhsReason), .invalidConfiguration(let rhsReason)): 138 | return lhsReason == rhsReason 139 | default: 140 | return false 141 | } 142 | } 143 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Utils/CLIDiscovery.swift: -------------------------------------------------------------------------------- 1 | // This file was generated by Claude 2 | 3 | import Foundation 4 | 5 | /// Utility for discovering the Claude Code CLI executable 6 | public enum CLIDiscovery { 7 | 8 | /// Default CLI command name 9 | public static let defaultCLICommand = "claude" 10 | 11 | /// Environment variable for custom CLI path 12 | public static let cliPathEnvironmentVariable = "CLAUDE_CODE_CLI_PATH" 13 | 14 | /// Discovers the Claude Code CLI executable path 15 | /// - Returns: The URL to the CLI executable 16 | /// - Throws: `ClaudeSDKError.cliNotFound` if the CLI cannot be found 17 | public static func discoverCLI() throws -> URL { 18 | var searchedPaths: [URL] = [] 19 | 20 | // 1. Check environment variable first 21 | if let envPath = ProcessInfo.processInfo.environment[cliPathEnvironmentVariable] { 22 | let url = URL(fileURLWithPath: envPath) 23 | searchedPaths.append(url) 24 | if isExecutable(at: url) { 25 | return url 26 | } 27 | } 28 | 29 | // 2. Check if 'claude' is in PATH 30 | if let pathResult = findInPath(command: defaultCLICommand) { 31 | return pathResult 32 | } 33 | 34 | // 3. Check common installation locations 35 | let commonPaths = getCommonCLIPaths() 36 | searchedPaths.append(contentsOf: commonPaths) 37 | 38 | for path in commonPaths { 39 | if isExecutable(at: path) { 40 | return path 41 | } 42 | } 43 | 44 | // 4. If not found, throw error with all searched paths 45 | throw ClaudeSDKError.cliNotFound(searchedPaths: searchedPaths) 46 | } 47 | 48 | /// Get common installation paths for the CLI 49 | private static func getCommonCLIPaths() -> [URL] { 50 | let fileManager = FileManager.default 51 | let homeDirectory = fileManager.homeDirectoryForCurrentUser 52 | 53 | var paths: [URL] = [] 54 | 55 | // npm global installations 56 | paths.append(homeDirectory.appendingPathComponent(".npm-global/bin/claude")) 57 | paths.append(URL(fileURLWithPath: "/usr/local/bin/claude")) 58 | 59 | // User local installations 60 | paths.append(homeDirectory.appendingPathComponent(".local/bin/claude")) 61 | paths.append(homeDirectory.appendingPathComponent("bin/claude")) 62 | 63 | // Node modules installations 64 | paths.append(homeDirectory.appendingPathComponent("node_modules/.bin/claude")) 65 | paths.append(URL(fileURLWithPath: "/opt/homebrew/bin/claude")) 66 | 67 | // Windows paths (if running on Windows via Swift on Windows) 68 | #if os(Windows) 69 | if let programFiles = ProcessInfo.processInfo.environment["ProgramFiles"] { 70 | paths.append(URL(fileURLWithPath: "\(programFiles)\\claude-code\\claude.exe")) 71 | } 72 | if let appData = ProcessInfo.processInfo.environment["APPDATA"] { 73 | paths.append(URL(fileURLWithPath: "\(appData)\\npm\\claude.cmd")) 74 | } 75 | #endif 76 | 77 | return paths 78 | } 79 | 80 | /// Find a command in the system PATH 81 | private static func findInPath(command: String) -> URL? { 82 | let process = Process() 83 | process.executableURL = URL(fileURLWithPath: "/usr/bin/which") 84 | process.arguments = [command] 85 | 86 | let pipe = Pipe() 87 | process.standardOutput = pipe 88 | process.standardError = Pipe() // Suppress errors 89 | 90 | do { 91 | try process.run() 92 | process.waitUntilExit() 93 | 94 | if process.terminationStatus == 0 { 95 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 96 | if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), 97 | !path.isEmpty { 98 | return URL(fileURLWithPath: path) 99 | } 100 | } 101 | } catch { 102 | // which command failed, continue with other methods 103 | } 104 | 105 | return nil 106 | } 107 | 108 | /// Check if a file at the given URL is executable 109 | private static func isExecutable(at url: URL) -> Bool { 110 | let fileManager = FileManager.default 111 | 112 | // Check if file exists 113 | guard fileManager.fileExists(atPath: url.path) else { 114 | return false 115 | } 116 | 117 | // Check if it's executable 118 | return fileManager.isExecutableFile(atPath: url.path) 119 | } 120 | 121 | /// Validate that the CLI at the given path is the correct Claude Code CLI 122 | /// - Parameter url: The URL to validate 123 | /// - Returns: true if this appears to be a valid Claude Code CLI 124 | public static func validateCLI(at url: URL) -> Bool { 125 | let process = Process() 126 | process.executableURL = url 127 | process.arguments = ["--version"] 128 | 129 | let pipe = Pipe() 130 | process.standardOutput = pipe 131 | process.standardError = Pipe() 132 | 133 | do { 134 | try process.run() 135 | process.waitUntilExit() 136 | 137 | if process.terminationStatus == 0 { 138 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 139 | if let output = String(data: data, encoding: .utf8), 140 | output.lowercased().contains("claude") { 141 | return true 142 | } 143 | } 144 | } catch { 145 | // Process failed to run 146 | } 147 | 148 | return false 149 | } 150 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Errors/ClaudeSDKError.swift: -------------------------------------------------------------------------------- 1 | // This file was generated by Claude 2 | 3 | import Foundation 4 | 5 | /// Errors that can occur when using the Claude Code SDK 6 | public enum ClaudeSDKError: LocalizedError, Sendable { 7 | /// The Claude Code CLI could not be found in any of the expected locations 8 | case cliNotFound(searchedPaths: [URL]) 9 | 10 | /// Failed to establish connection with the CLI 11 | case cliConnectionError(underlying: any Error) 12 | 13 | /// The CLI process exited with an error 14 | case processError(message: String, exitCode: Int32) 15 | 16 | /// Failed to decode JSON from CLI output 17 | case jsonDecodeError(line: String, error: any Error) 18 | 19 | /// Invalid configuration provided 20 | case invalidConfiguration(reason: String) 21 | 22 | /// The CLI process was terminated unexpectedly 23 | case processTerminated 24 | 25 | /// Timeout occurred while waiting for CLI response 26 | case timeout(duration: TimeInterval) 27 | 28 | /// Invalid message type received 29 | case invalidMessageType(description: String) 30 | 31 | /// Stream ended unexpectedly 32 | case unexpectedStreamEnd 33 | 34 | /// Failed to parse a message from CLI output 35 | case messageParseError(message: String, data: [String: AnyCodable]?) 36 | 37 | public var errorDescription: String? { 38 | switch self { 39 | case .cliNotFound(let paths): 40 | let pathList = paths.map { $0.path }.joined(separator: "\n - ") 41 | return """ 42 | Claude Code CLI not found. Please install it using: 43 | npm install -g @anthropic-ai/claude-code 44 | 45 | Searched paths: 46 | - \(pathList) 47 | """ 48 | 49 | case .cliConnectionError(let error): 50 | return "Failed to connect to Claude Code CLI: \(error.localizedDescription)" 51 | 52 | case .processError(let message, let exitCode): 53 | return "Claude Code CLI process failed with exit code \(exitCode): \(message)" 54 | 55 | case .jsonDecodeError(let line, let error): 56 | return "Failed to decode JSON from CLI output. Line: '\(line)'. Error: \(error.localizedDescription)" 57 | 58 | case .invalidConfiguration(let reason): 59 | return "Invalid configuration: \(reason)" 60 | 61 | case .processTerminated: 62 | return "Claude Code CLI process was terminated unexpectedly" 63 | 64 | case .timeout(let duration): 65 | return "Timeout occurred after \(duration) seconds while waiting for CLI response" 66 | 67 | case .invalidMessageType(let description): 68 | return "Invalid message type received: \(description)" 69 | 70 | case .unexpectedStreamEnd: 71 | return "The message stream ended unexpectedly without a result message" 72 | 73 | case .messageParseError(let message, let data): 74 | let dataDescription = data?.description ?? "nil" 75 | return "Failed to parse message from CLI output: \(message). Data: \(dataDescription)" 76 | } 77 | } 78 | 79 | public var recoverySuggestion: String? { 80 | switch self { 81 | case .cliNotFound: 82 | return "Install Claude Code CLI using npm or ensure it's in your PATH" 83 | 84 | case .cliConnectionError: 85 | return "Check that the CLI is properly installed and your system allows subprocess execution" 86 | 87 | case .processError: 88 | return "Review the error message and ensure your request is properly formatted" 89 | 90 | case .jsonDecodeError: 91 | return "This may indicate a CLI version mismatch. Try updating both the SDK and CLI" 92 | 93 | case .invalidConfiguration: 94 | return "Review your configuration options and ensure they are valid" 95 | 96 | case .processTerminated: 97 | return "The CLI may have crashed. Try reinstalling or updating it" 98 | 99 | case .timeout: 100 | return "Try increasing the timeout duration or simplifying your request" 101 | 102 | case .invalidMessageType: 103 | return "This may indicate a CLI version mismatch. Ensure you're using compatible versions" 104 | 105 | case .unexpectedStreamEnd: 106 | return "The CLI may have encountered an error. Check the CLI logs for more information" 107 | 108 | case .messageParseError: 109 | return "This may indicate a CLI version mismatch or malformed output. Try updating both the SDK and CLI" 110 | } 111 | } 112 | 113 | public var failureReason: String? { 114 | switch self { 115 | case .cliNotFound: 116 | return "CLI executable not found" 117 | 118 | case .cliConnectionError: 119 | return "Connection failed" 120 | 121 | case .processError: 122 | return "Process execution failed" 123 | 124 | case .jsonDecodeError: 125 | return "JSON parsing failed" 126 | 127 | case .invalidConfiguration: 128 | return "Configuration validation failed" 129 | 130 | case .processTerminated: 131 | return "Process terminated" 132 | 133 | case .timeout: 134 | return "Operation timed out" 135 | 136 | case .invalidMessageType: 137 | return "Message validation failed" 138 | 139 | case .unexpectedStreamEnd: 140 | return "Stream ended prematurely" 141 | 142 | case .messageParseError: 143 | return "Message parsing failed" 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Types/ContentBlocks.swift: -------------------------------------------------------------------------------- 1 | // This file was generated by Claude 2 | 3 | import Foundation 4 | 5 | // MARK: - ContentBlock Protocol 6 | 7 | /// Protocol for all content block types 8 | public protocol ContentBlock: Codable, Sendable {} 9 | 10 | // MARK: - Text Block 11 | 12 | /// A text content block 13 | public struct TextBlock: ContentBlock { 14 | public let text: String 15 | 16 | public init(text: String) { 17 | self.text = text 18 | } 19 | } 20 | 21 | // MARK: - Tool Use Block 22 | 23 | /// A tool use content block representing a tool invocation 24 | public struct ToolUseBlock: ContentBlock { 25 | public let id: String 26 | public let name: String 27 | public let input: [String: AnyCodable] 28 | 29 | public init(id: String, name: String, input: [String: AnyCodable]) { 30 | self.id = id 31 | self.name = name 32 | self.input = input 33 | } 34 | } 35 | 36 | // MARK: - Thinking Block 37 | 38 | /// A thinking content block representing Claude's internal reasoning 39 | public struct ThinkingBlock: ContentBlock { 40 | public let thinking: String 41 | public let signature: String 42 | 43 | public init(thinking: String, signature: String) { 44 | self.thinking = thinking 45 | self.signature = signature 46 | } 47 | } 48 | 49 | // MARK: - Tool Result Block 50 | 51 | /// A tool result content block containing the result of a tool invocation 52 | public struct ToolResultBlock: ContentBlock { 53 | public let toolUseId: String 54 | public let content: ContentResult? 55 | public let isError: Bool? 56 | 57 | public enum ContentResult: Codable, Sendable { 58 | case text(String) 59 | case structured([String: AnyCodable]) 60 | 61 | private enum CodingKeys: String, CodingKey { 62 | case type 63 | case value 64 | } 65 | 66 | public init(from decoder: any Decoder) throws { 67 | let container = try decoder.container(keyedBy: CodingKeys.self) 68 | let type = try container.decode(String.self, forKey: .type) 69 | 70 | switch type { 71 | case "text": 72 | let value = try container.decode(String.self, forKey: .value) 73 | self = .text(value) 74 | case "structured": 75 | let value = try container.decode([String: AnyCodable].self, forKey: .value) 76 | self = .structured(value) 77 | default: 78 | throw DecodingError.dataCorruptedError( 79 | forKey: .type, 80 | in: container, 81 | debugDescription: "Unknown content result type: \(type)" 82 | ) 83 | } 84 | } 85 | 86 | public func encode(to encoder: any Encoder) throws { 87 | var container = encoder.container(keyedBy: CodingKeys.self) 88 | 89 | switch self { 90 | case .text(let value): 91 | try container.encode("text", forKey: .type) 92 | try container.encode(value, forKey: .value) 93 | case .structured(let value): 94 | try container.encode("structured", forKey: .type) 95 | try container.encode(value, forKey: .value) 96 | } 97 | } 98 | } 99 | 100 | public init(toolUseId: String, content: ContentResult? = nil, isError: Bool? = nil) { 101 | self.toolUseId = toolUseId 102 | self.content = content 103 | self.isError = isError 104 | } 105 | 106 | private enum CodingKeys: String, CodingKey { 107 | case toolUseId = "tool_use_id" 108 | case content 109 | case isError = "is_error" 110 | } 111 | } 112 | 113 | 114 | // MARK: - AnyCodable 115 | 116 | /// A type-erased Codable value for handling dynamic JSON 117 | public struct AnyCodable: Codable, @unchecked Sendable { 118 | public let value: Any 119 | 120 | public init(_ value: Any) { 121 | self.value = value 122 | } 123 | 124 | public init(from decoder: any Decoder) throws { 125 | let container = try decoder.singleValueContainer() 126 | 127 | if let bool = try? container.decode(Bool.self) { 128 | value = bool 129 | } else if let int = try? container.decode(Int.self) { 130 | value = int 131 | } else if let double = try? container.decode(Double.self) { 132 | value = double 133 | } else if let string = try? container.decode(String.self) { 134 | value = string 135 | } else if let array = try? container.decode([AnyCodable].self) { 136 | value = array.map { $0.value } 137 | } else if let dictionary = try? container.decode([String: AnyCodable].self) { 138 | value = dictionary.mapValues { $0.value } 139 | } else if container.decodeNil() { 140 | value = NSNull() 141 | } else { 142 | throw DecodingError.dataCorruptedError( 143 | in: container, 144 | debugDescription: "Unable to decode value" 145 | ) 146 | } 147 | } 148 | 149 | public func encode(to encoder: any Encoder) throws { 150 | var container = encoder.singleValueContainer() 151 | 152 | switch value { 153 | case let bool as Bool: 154 | try container.encode(bool) 155 | case let int as Int: 156 | try container.encode(int) 157 | case let double as Double: 158 | try container.encode(double) 159 | case let string as String: 160 | try container.encode(string) 161 | case let array as [Any]: 162 | try container.encode(array.map { AnyCodable($0) }) 163 | case let dictionary as [String: Any]: 164 | try container.encode(dictionary.mapValues { AnyCodable($0) }) 165 | case is NSNull: 166 | try container.encodeNil() 167 | default: 168 | throw EncodingError.invalidValue( 169 | value, 170 | EncodingError.Context( 171 | codingPath: container.codingPath, 172 | debugDescription: "Unable to encode value of type \(type(of: value))" 173 | ) 174 | ) 175 | } 176 | } 177 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/ClaudeCodeSDKClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Client for bidirectional, interactive conversations with Claude Code. 4 | /// 5 | /// This client provides full control over the conversation flow with support 6 | /// for streaming, interrupts, and dynamic message sending. For simple one-shot 7 | /// queries, consider using the `query()` function instead. 8 | /// 9 | /// Key features: 10 | /// - **Bidirectional**: Send and receive messages at any time 11 | /// - **Stateful**: Maintains conversation context across messages 12 | /// - **Interactive**: Send follow-ups based on responses 13 | /// - **Control flow**: Support for interrupts and session management 14 | /// - **Tool Integration**: Unified tool lifecycle management with intelligent pairing 15 | /// - **Settings Management**: Dynamic configuration updates with session preservation 16 | /// 17 | /// When to use ClaudeCodeSDKClient: 18 | /// - Building chat interfaces or conversational UIs 19 | /// - Interactive debugging or exploration sessions 20 | /// - Multi-turn conversations with context 21 | /// - When you need to react to Claude's responses 22 | /// - Real-time applications with user input 23 | /// - When you need interrupt capabilities 24 | /// - Tool execution monitoring and analytics 25 | /// 26 | /// When to use query() instead: 27 | /// - Simple one-off questions 28 | /// - Batch processing of prompts 29 | /// - Fire-and-forget automation scripts 30 | /// - When all inputs are known upfront 31 | /// - Stateless operations 32 | /// 33 | /// ## API Organization 34 | /// The ClaudeCodeSDKClient is organized into three main areas: 35 | /// 36 | /// ### Connection & Query APIs (ConnectionAPI.swift) 37 | /// - Connection management: `connect()`, `disconnect()`, `reconnect()` 38 | /// - Message streaming: `receiveMessages()`, `receiveResponse()` 39 | /// - Query execution: `queryStream()`, `interrupt()` 40 | /// - Context managers: `withConnection()`, `withReconnection()` 41 | /// 42 | /// ### Settings Management (SettingsAPI.swift) 43 | /// - Individual settings: `updateModel()`, `updateSystemPrompt()`, etc. 44 | /// - Batch updates: `updateSettings()` 45 | /// - Settings inspection: `getCurrentModel()`, `getCurrentOptions()`, etc. 46 | /// - Presets: `enablePlanMode()`, `enableExecutionMode()`, etc. 47 | /// 48 | /// ### Tools API (ToolsAPI.swift) 49 | /// - Tool streams: `toolMessages()`, `pendingTools()`, `completedTools()`, `failedTools()` 50 | /// - Tool filtering: `toolsWithName()`, `slowTools()` 51 | /// - Tool management: `manuallyCompleteTool()`, `manuallyFailTool()` 52 | /// - Analytics: `getToolStatistics()`, `waitForAllToolsToComplete()` 53 | /// 54 | /// ## Example - Interactive conversation: 55 | /// ```swift 56 | /// let client = ClaudeCodeSDKClient() 57 | /// try await client.connect() 58 | /// 59 | /// // Send initial message 60 | /// try await client.queryStream("Let's solve a math problem step by step") 61 | /// 62 | /// // Monitor tool execution 63 | /// Task { 64 | /// for try await tool in client.toolMessages() { 65 | /// print("🔧 \(tool.name): \(tool.status)") 66 | /// } 67 | /// } 68 | /// 69 | /// // Receive and process response 70 | /// for try await message in client.receiveResponse() { 71 | /// if let assistant = message as? AssistantMessage { 72 | /// // Process response and decide on follow-up 73 | /// break 74 | /// } 75 | /// } 76 | /// 77 | /// // Send follow-up based on response 78 | /// try await client.queryStream("What's 15% of 80?") 79 | /// 80 | /// // Continue conversation... 81 | /// await client.disconnect() 82 | /// ``` 83 | /// 84 | /// ## Example - With settings management: 85 | /// ```swift 86 | /// let client = ClaudeCodeSDKClient() 87 | /// try await client.connect() 88 | /// 89 | /// // Enable development mode with batch settings update 90 | /// try await client.updateSettings { builder in 91 | /// builder.permissionMode(.acceptEdits) 92 | /// builder.allowedTools(["Read", "Write", "Edit", "Bash"]) 93 | /// builder.systemPrompt("You are a senior developer assistant") 94 | /// } 95 | /// 96 | /// // Start coding session 97 | /// try await client.queryStream("Help me implement a REST API") 98 | /// 99 | /// // Monitor tools and get analytics 100 | /// let stats = try await client.getToolStatistics() 101 | /// print("Active tools: \(stats["pendingToolsCount"] ?? 0)") 102 | /// 103 | /// await client.disconnect() 104 | /// ``` 105 | public final class ClaudeCodeSDKClient: Sendable { 106 | internal let connectionManager: ConnectionManager 107 | 108 | /// Thread-safe options management using actor 109 | internal actor OptionsManager { 110 | private var _options: ClaudeCodeOptions? 111 | 112 | init(options: ClaudeCodeOptions?) { 113 | self._options = options 114 | } 115 | 116 | func getOptions() -> ClaudeCodeOptions? { 117 | return _options 118 | } 119 | 120 | func updateOptions(_ newOptions: ClaudeCodeOptions) { 121 | self._options = newOptions 122 | } 123 | } 124 | 125 | internal let optionsManager: OptionsManager 126 | 127 | /// Internal actor to manage connection state safely 128 | internal actor ConnectionManager { 129 | private var transport: SubprocessCLITransport? 130 | private var messageStream: AsyncThrowingStream? 131 | 132 | func setConnection( 133 | transport: SubprocessCLITransport, 134 | messageStream: AsyncThrowingStream 135 | ) { 136 | self.transport = transport 137 | self.messageStream = messageStream 138 | } 139 | 140 | func getTransport() -> SubprocessCLITransport? { 141 | return transport 142 | } 143 | 144 | func getMessageStream() -> AsyncThrowingStream? { 145 | return messageStream 146 | } 147 | 148 | func clearConnection() { 149 | transport = nil 150 | messageStream = nil 151 | } 152 | } 153 | 154 | /// Initialize Claude SDK client. 155 | /// - Parameter options: Configuration options for the session 156 | public init(options: ClaudeCodeOptions? = nil) { 157 | self.optionsManager = OptionsManager(options: options) 158 | self.connectionManager = ConnectionManager() 159 | } 160 | 161 | deinit { 162 | Task { [connectionManager] in 163 | if let transport = await connectionManager.getTransport() { 164 | await transport.terminate() 165 | } 166 | } 167 | } 168 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Client/SettingsAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Settings Management API 4 | 5 | extension ClaudeCodeSDKClient { 6 | 7 | // MARK: - Individual Setting Updates 8 | 9 | /// Update permission mode and automatically reconnect with session preservation 10 | /// - Parameter mode: The new permission mode to use 11 | public func updatePermissionMode(_ mode: ClaudeCodeOptions.PermissionMode) async throws { 12 | try await updateSettings { builder in 13 | builder.permissionMode(mode) 14 | } 15 | } 16 | 17 | /// Update system prompt and automatically reconnect with session preservation 18 | /// - Parameter prompt: The new system prompt to use 19 | public func updateSystemPrompt(_ prompt: String) async throws { 20 | try await updateSettings { builder in 21 | builder.systemPrompt(prompt) 22 | } 23 | } 24 | 25 | /// Update allowed tools and automatically reconnect with session preservation 26 | /// - Parameter tools: The list of allowed tools 27 | public func updateAllowedTools(_ tools: [String]) async throws { 28 | try await updateSettings { builder in 29 | builder.allowedTools(tools) 30 | } 31 | } 32 | 33 | /// Update model and automatically reconnect with session preservation 34 | /// - Parameter model: The model identifier to use 35 | public func updateModel(_ model: String) async throws { 36 | try await updateSettings { builder in 37 | builder.model(model) 38 | } 39 | } 40 | 41 | /// Update working directory and automatically reconnect with session preservation 42 | /// - Parameter cwd: The new working directory 43 | public func updateWorkingDirectory(_ cwd: URL) async throws { 44 | try await updateSettings { builder in 45 | builder.cwd(cwd) 46 | } 47 | } 48 | 49 | /// Update max turns and automatically reconnect with session preservation 50 | /// - Parameter maxTurns: The maximum number of conversation turns 51 | public func updateMaxTurns(_ maxTurns: Int) async throws { 52 | try await updateSettings { builder in 53 | builder.maxTurns(maxTurns) 54 | } 55 | } 56 | 57 | /// Update disallowed tools and automatically reconnect with session preservation 58 | /// - Parameter tools: The list of disallowed tools 59 | public func updateDisallowedTools(_ tools: [String]) async throws { 60 | try await updateSettings { builder in 61 | builder.disallowedTools(tools) 62 | } 63 | } 64 | 65 | /// Update append system prompt and automatically reconnect with session preservation 66 | /// - Parameter prompt: The system prompt to append to existing one 67 | public func updateAppendSystemPrompt(_ prompt: String) async throws { 68 | try await updateSettings { builder in 69 | builder.appendSystemPrompt(prompt) 70 | } 71 | } 72 | 73 | // MARK: - Batch Settings Update 74 | 75 | /// Batch update multiple settings with a single reconnection 76 | /// - Parameter configure: A closure to configure the settings builder 77 | /// 78 | /// This is the most efficient way to update multiple settings as it only 79 | /// requires one reconnection instead of multiple reconnections. 80 | /// 81 | /// ## Example: 82 | /// ```swift 83 | /// try await client.updateSettings { builder in 84 | /// builder.permissionMode(.plan) 85 | /// builder.systemPrompt("You are in planning mode") 86 | /// builder.allowedTools(["Read", "Grep"]) 87 | /// builder.maxTurns(5) 88 | /// builder.model("claude-3-5-sonnet-20241022") 89 | /// } 90 | /// ``` 91 | public func updateSettings(_ configure: (ClaudeCodeOptionsBuilder) -> Void) async throws { 92 | let currentOptions = await optionsManager.getOptions() ?? ClaudeCodeOptions() 93 | let builder = ClaudeCodeOptionsBuilder(from: currentOptions) 94 | configure(builder) 95 | let newOptions = builder.build() 96 | 97 | // Update stored options 98 | await optionsManager.updateOptions(newOptions) 99 | 100 | // Automatically reconnect with session preservation (continue flag) 101 | try await reconnect() 102 | } 103 | 104 | // MARK: - Settings Inspection 105 | 106 | /// Get current options configuration 107 | /// - Returns: The current options being used by the client 108 | public func getCurrentOptions() async -> ClaudeCodeOptions? { 109 | return await optionsManager.getOptions() 110 | } 111 | 112 | /// Get current permission mode 113 | /// - Returns: The current permission mode, or nil if not set 114 | public func getCurrentPermissionMode() async -> ClaudeCodeOptions.PermissionMode? { 115 | let options = await optionsManager.getOptions() 116 | return options?.permissionMode 117 | } 118 | 119 | /// Get current system prompt 120 | /// - Returns: The current system prompt, or nil if not set 121 | public func getCurrentSystemPrompt() async -> String? { 122 | let options = await optionsManager.getOptions() 123 | return options?.systemPrompt 124 | } 125 | 126 | /// Get current model 127 | /// - Returns: The current model identifier, or nil if not set 128 | public func getCurrentModel() async -> String? { 129 | let options = await optionsManager.getOptions() 130 | return options?.model 131 | } 132 | 133 | /// Get current working directory 134 | /// - Returns: The current working directory, or nil if not set 135 | public func getCurrentWorkingDirectory() async -> URL? { 136 | let options = await optionsManager.getOptions() 137 | return options?.cwd 138 | } 139 | 140 | /// Get current allowed tools 141 | /// - Returns: The list of allowed tools, or nil if not set 142 | public func getCurrentAllowedTools() async -> [String]? { 143 | let options = await optionsManager.getOptions() 144 | return options?.allowedTools 145 | } 146 | 147 | /// Get current disallowed tools 148 | /// - Returns: The list of disallowed tools, or nil if not set 149 | public func getCurrentDisallowedTools() async -> [String]? { 150 | let options = await optionsManager.getOptions() 151 | return options?.disallowedTools 152 | } 153 | 154 | /// Get current max turns 155 | /// - Returns: The maximum number of turns, or nil if not set 156 | public func getCurrentMaxTurns() async -> Int? { 157 | let options = await optionsManager.getOptions() 158 | return options?.maxTurns 159 | } 160 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSwiftSDKTests/TypesTests.swift: -------------------------------------------------------------------------------- 1 | // Generated by Claude 2 | 3 | import Testing 4 | @testable import ClaudeCodeSwiftSDK 5 | 6 | @Suite("Message Types Tests") 7 | struct MessageTypesTests { 8 | 9 | @Test("User message creation") 10 | func userMessageCreation() { 11 | let message = UserMessage(content: [TextBlock(text: "Hello, Claude!")], sessionId: "test-session") 12 | 13 | if case .blocks(let blocks) = message.content { 14 | #expect(blocks.count == 1) 15 | if let textBlock = blocks.first as? TextBlock { 16 | #expect(textBlock.text == "Hello, Claude!") 17 | } else { 18 | Issue.record("Expected TextBlock") 19 | } 20 | } else { 21 | Issue.record("Expected blocks content") 22 | } 23 | } 24 | 25 | @Test("Assistant message with text content") 26 | func assistantMessageWithText() { 27 | let textBlock = TextBlock(text: "Hello, human!") 28 | let message = AssistantMessage( 29 | id: "test-id-1", 30 | content: [textBlock], 31 | model: "test-model", 32 | sessionId: "test-session" 33 | ) 34 | 35 | #expect(message.content.count == 1) 36 | #expect(message.model == "test-model") 37 | if let textBlock = message.content.first as? TextBlock { 38 | #expect(textBlock.text == "Hello, human!") 39 | } else { 40 | Issue.record("Expected TextBlock") 41 | } 42 | } 43 | 44 | @Test("Assistant message with thinking content") 45 | func assistantMessageWithThinking() { 46 | let thinkingBlock = ThinkingBlock( 47 | thinking: "I need to consider this carefully...", 48 | signature: "sig-456" 49 | ) 50 | let textBlock = TextBlock(text: "Based on my analysis...") 51 | let message = AssistantMessage( 52 | id: "test-id-2", 53 | content: [thinkingBlock, textBlock], 54 | model: "claude-3-5-sonnet-20241022", 55 | sessionId: "test-session" 56 | ) 57 | 58 | #expect(message.content.count == 2) 59 | #expect(message.model == "claude-3-5-sonnet-20241022") 60 | 61 | if let thinkingBlock = message.content[0] as? ThinkingBlock { 62 | #expect(thinkingBlock.thinking == "I need to consider this carefully...") 63 | #expect(thinkingBlock.signature == "sig-456") 64 | } else { 65 | Issue.record("Expected ThinkingBlock as first content") 66 | } 67 | 68 | if let textBlock = message.content[1] as? TextBlock { 69 | #expect(textBlock.text == "Based on my analysis...") 70 | } else { 71 | Issue.record("Expected TextBlock as second content") 72 | } 73 | } 74 | 75 | @Test("Tool use block creation") 76 | func toolUseBlockCreation() { 77 | let block = ToolUseBlock( 78 | id: "tool-123", 79 | name: "Read", 80 | input: ["file_path": AnyCodable("test.txt")] 81 | ) 82 | 83 | #expect(block.id == "tool-123") 84 | #expect(block.name == "Read") 85 | #expect(block.input["file_path"]?.value as? String == "test.txt") 86 | } 87 | 88 | @Test("Thinking block creation") 89 | func thinkingBlockCreation() { 90 | let block = ThinkingBlock( 91 | thinking: "Let me think about this problem...", 92 | signature: "signature-123" 93 | ) 94 | 95 | #expect(block.thinking == "Let me think about this problem...") 96 | #expect(block.signature == "signature-123") 97 | } 98 | 99 | @Test("Tool result block creation") 100 | func toolResultBlockCreation() { 101 | let block = ToolResultBlock( 102 | toolUseId: "tool-123", 103 | content: .text("File contents here"), 104 | isError: false 105 | ) 106 | 107 | #expect(block.toolUseId == "tool-123") 108 | if case .text(let content) = block.content { 109 | #expect(content == "File contents here") 110 | } else { 111 | Issue.record("Expected text content") 112 | } 113 | #expect(block.isError == false) 114 | } 115 | 116 | @Test("Result message creation") 117 | func resultMessageCreation() { 118 | let message = ResultMessage( 119 | subtype: "success", 120 | durationMs: 1500, 121 | durationApiMs: 1200, 122 | isError: false, 123 | numTurns: 1, 124 | sessionId: "session-123", 125 | totalCostUsd: 0.01, 126 | usage: nil, 127 | result: nil 128 | ) 129 | 130 | #expect(message.subtype == "success") 131 | #expect(message.totalCostUsd == 0.01) 132 | #expect(message.sessionId == "session-123") 133 | #expect(message.numTurns == 1) 134 | #expect(message.isError == false) 135 | } 136 | } 137 | 138 | @Suite("Options Configuration Tests") 139 | struct OptionsTests { 140 | 141 | @Test("Default options") 142 | func defaultOptions() { 143 | let options = ClaudeCodeOptions() 144 | 145 | #expect(options.allowedTools == []) 146 | #expect(options.systemPrompt == nil) 147 | #expect(options.permissionMode == nil) 148 | #expect(options.continueConversation == false) 149 | #expect(options.disallowedTools == []) 150 | #expect(options.maxTurns == nil) 151 | } 152 | 153 | @Test("Options with tools configuration") 154 | func optionsWithTools() { 155 | let options = ClaudeCodeOptions( 156 | allowedTools: ["Read", "Write", "Edit"], 157 | disallowedTools: ["Bash"] 158 | ) 159 | 160 | #expect(options.allowedTools == ["Read", "Write", "Edit"]) 161 | #expect(options.disallowedTools == ["Bash"]) 162 | } 163 | 164 | @Test("Options with permission mode") 165 | func optionsWithPermissionMode() { 166 | let options = ClaudeCodeOptions( 167 | permissionMode: .bypassPermissions 168 | ) 169 | 170 | #expect(options.permissionMode == .bypassPermissions) 171 | } 172 | 173 | @Test("Options with system prompt") 174 | func optionsWithSystemPrompt() { 175 | let options = ClaudeCodeOptions( 176 | systemPrompt: "You are a helpful assistant.", 177 | appendSystemPrompt: "Be concise." 178 | ) 179 | 180 | #expect(options.systemPrompt == "You are a helpful assistant.") 181 | #expect(options.appendSystemPrompt == "Be concise.") 182 | } 183 | 184 | @Test("Options with session continuation") 185 | func optionsWithSessionContinuation() { 186 | let options = ClaudeCodeOptions( 187 | continueConversation: true, 188 | resume: "session-123" 189 | ) 190 | 191 | #expect(options.continueConversation == true) 192 | #expect(options.resume == "session-123") 193 | } 194 | 195 | @Test("Options with model specification") 196 | func optionsWithModelSpecification() { 197 | let options = ClaudeCodeOptions( 198 | permissionPromptToolName: "CustomTool", 199 | model: "claude-3-5-sonnet-20241022" 200 | ) 201 | 202 | #expect(options.model == "claude-3-5-sonnet-20241022") 203 | #expect(options.permissionPromptToolName == "CustomTool") 204 | } 205 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSwiftSDKTests/MessageParserTests.swift: -------------------------------------------------------------------------------- 1 | // Generated by Claude 2 | 3 | import Testing 4 | import Foundation 5 | @testable import ClaudeCodeSwiftSDK 6 | 7 | @Suite("Message Decoder Tests") 8 | struct MessageDecoderTests { 9 | 10 | @Test("Decode valid user message with text", .disabled("MessageDecoder needs proper JSON structure")) 11 | func decodeValidUserMessageWithText() async throws { 12 | // Note: The MessageDecoder expects Claude CLI output format 13 | // This test is disabled until we implement proper test data structure 14 | 15 | let userMessage = UserMessage(content: "Hello, Claude!", sessionId: "test-session") 16 | if case .text(let text) = userMessage.content { 17 | #expect(text == "Hello, Claude!") 18 | } else { 19 | Issue.record("Expected text content") 20 | } 21 | } 22 | 23 | @Test("Message types can be created directly") 24 | func messageTypesCanBeCreatedDirectly() { 25 | // Test direct creation of message types 26 | 27 | let userMessage = UserMessage(content: "Test message", sessionId: "test-session") 28 | #expect(userMessage != nil) 29 | 30 | let textBlock = TextBlock(text: "Test text") 31 | let assistantMessage = AssistantMessage( 32 | id: "test-id", 33 | content: [textBlock], 34 | model: "test-model", 35 | sessionId: "test-session" 36 | ) 37 | #expect(assistantMessage.content.count == 1) 38 | 39 | let systemMessage = SystemMessage(subtype: "start", genericData: [:]) 40 | #expect(systemMessage.subtype == "start") 41 | 42 | let resultMessage = ResultMessage( 43 | subtype: "success", 44 | durationMs: 1000, 45 | durationApiMs: 500, 46 | isError: false, 47 | numTurns: 1, 48 | sessionId: "test-session" 49 | ) 50 | #expect(resultMessage.isError == false) 51 | } 52 | 53 | @Test("Content blocks work correctly") 54 | func contentBlocksWorkCorrectly() { 55 | // Test content block types 56 | 57 | let textBlock = TextBlock(text: "Hello") 58 | #expect(textBlock.text == "Hello") 59 | 60 | let toolUseBlock = ToolUseBlock( 61 | id: "tool-123", 62 | name: "Read", 63 | input: ["file_path": AnyCodable("/test.txt")] 64 | ) 65 | #expect(toolUseBlock.id == "tool-123") 66 | #expect(toolUseBlock.name == "Read") 67 | 68 | let toolResultBlock = ToolResultBlock( 69 | toolUseId: "tool-123", 70 | content: .text("File contents"), 71 | isError: false 72 | ) 73 | #expect(toolResultBlock.toolUseId == "tool-123") 74 | if case .text(let content) = toolResultBlock.content { 75 | #expect(content == "File contents") 76 | } else { 77 | Issue.record("Expected text content") 78 | } 79 | } 80 | 81 | @Test("User message with blocks content") 82 | func userMessageWithBlocksContent() { 83 | let textBlock = TextBlock(text: "Hello") 84 | let toolUseBlock = ToolUseBlock( 85 | id: "tool-456", 86 | name: "Read", 87 | input: ["file_path": AnyCodable("/example.txt")] 88 | ) 89 | 90 | let userMessage = UserMessage(content: [textBlock, toolUseBlock], sessionId: "test-session") 91 | 92 | if case .blocks(let blocks) = userMessage.content { 93 | #expect(blocks.count == 2) 94 | 95 | if let firstBlock = blocks[0] as? TextBlock { 96 | #expect(firstBlock.text == "Hello") 97 | } else { 98 | Issue.record("Expected TextBlock as first block") 99 | } 100 | 101 | if let secondBlock = blocks[1] as? ToolUseBlock { 102 | #expect(secondBlock.id == "tool-456") 103 | #expect(secondBlock.name == "Read") 104 | } else { 105 | Issue.record("Expected ToolUseBlock as second block") 106 | } 107 | } else { 108 | Issue.record("Expected blocks content") 109 | } 110 | } 111 | 112 | @Test("Error scenarios", .disabled("Full error testing requires integration")) 113 | func errorScenarios() { 114 | // Test various error conditions that would occur during actual message parsing 115 | // These are disabled until we have proper mocking infrastructure 116 | 117 | let error = ClaudeSDKError.jsonDecodeError( 118 | line: "invalid json", 119 | error: NSError(domain: "Test", code: 1) 120 | ) 121 | 122 | if case .jsonDecodeError(let line, _) = error { 123 | #expect(line == "invalid json") 124 | } else { 125 | Issue.record("Expected jsonDecodeError") 126 | } 127 | } 128 | 129 | @Test("Parse system init message with structured data") 130 | func parseSystemInitMessage() throws { 131 | let jsonString = """ 132 | {"type":"system","subtype":"init","cwd":"/Users/n3sh/Dev/repos/ClaudeCodeSwiftSDK/Examples/InteractiveCLI","session_id":"e974e06b-50f5-4367-a135-85ad67b9e77e","tools":["Task","Bash","Glob","Grep","LS","ExitPlanMode","Read","Edit","MultiEdit","Write","NotebookEdit","WebFetch","TodoWrite","WebSearch","BashOutput","KillBash"],"mcp_servers":[],"model":"claude-sonnet-4-20250514","permissionMode":"default","slash_commands":["add-dir","agents","clear","compact","config","cost","doctor","exit","help","ide","init","install-github-app","mcp","memory","migrate-installer","model","pr-comments","release-notes","resume","status","statusline","bug","review","security-review","terminal-setup","upgrade","vim","permissions","hooks","export","logout","login","bashes"],"apiKeySource":"none"} 133 | """ 134 | 135 | let data = Data(jsonString.utf8) 136 | let decoder = JSONDecoder() 137 | let message = try decoder.decode(Message.self, from: data) 138 | 139 | guard case let .system(systemMessage) = message else { 140 | throw TestError.unexpectedMessageType 141 | } 142 | 143 | #expect(systemMessage.subtype == "init") 144 | 145 | // Direct field access - much more efficient! 146 | #expect(systemMessage.cwd == "/Users/n3sh/Dev/repos/ClaudeCodeSwiftSDK/Examples/InteractiveCLI") 147 | #expect(systemMessage.sessionId == "e974e06b-50f5-4367-a135-85ad67b9e77e") 148 | #expect(systemMessage.model == "claude-sonnet-4-20250514") 149 | #expect(systemMessage.permissionMode == "default") 150 | #expect(systemMessage.apiKeySource == "none") 151 | #expect(systemMessage.tools?.contains("Task") == true) 152 | #expect(systemMessage.tools?.contains("Bash") == true) 153 | #expect(systemMessage.slashCommands?.contains("clear") == true) 154 | #expect(systemMessage.slashCommands?.contains("help") == true) 155 | #expect(systemMessage.mcpServers?.isEmpty == true) 156 | } 157 | 158 | enum TestError: Error { 159 | case unexpectedMessageType 160 | } 161 | } 162 | 163 | // MARK: - Integration Tests (Disabled) 164 | 165 | @Suite("Message Parser Integration Tests") 166 | struct MessageParserIntegrationTests { 167 | 168 | @Test("Full message parsing pipeline", .disabled("Requires Claude CLI integration")) 169 | func fullMessageParsingPipeline() async throws { 170 | // This would test the full pipeline from CLI output to parsed messages 171 | // It's disabled because it requires actual CLI integration 172 | 173 | // Example of what we'd test: 174 | // 1. Mock CLI output with valid JSON 175 | // 2. Parse it through MessageDecoder 176 | // 3. Verify correct message types are created 177 | // 4. Test error handling for malformed input 178 | 179 | #expect(Bool(true), "Integration test disabled - requires CLI") 180 | } 181 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Internal/Transport/ArgumentBuilder.swift: -------------------------------------------------------------------------------- 1 | // CLI argument building for Claude Code CLI subprocess 2 | 3 | import Foundation 4 | 5 | /// Builds command-line arguments for Claude Code CLI invocation 6 | struct ArgumentBuilder { 7 | 8 | /// Build command line arguments based on mode and options 9 | /// - Parameters: 10 | /// - isStreaming: Whether this is for streaming mode 11 | /// - options: Configuration options 12 | /// - Returns: Array of command line arguments 13 | static func buildArguments(isStreaming: Bool, options: ClaudeCodeOptions?) -> [String] { 14 | var args: [String] = [] 15 | 16 | // Always start with output format and verbose 17 | args.append("--output-format") 18 | args.append("stream-json") 19 | args.append("--verbose") 20 | 21 | // Add input format for streaming mode 22 | if isStreaming { 23 | args.append("--input-format") 24 | args.append("stream-json") 25 | } 26 | 27 | // Add options if provided 28 | if let options = options { 29 | addSystemPromptArgs(&args, options: options) 30 | addTurnLimitArgs(&args, options: options) 31 | addToolArgs(&args, options: options) 32 | addModelArgs(&args, options: options) 33 | addPermissionArgs(&args, options: options) 34 | addSessionArgs(&args, options: options) 35 | addSettingsArgs(&args, options: options) 36 | addDirectoryArgs(&args, options: options) 37 | addMCPArgs(&args, options: options) 38 | addExtraArgs(&args, options: options) 39 | } 40 | 41 | // Add --print flag for non-streaming mode 42 | if !isStreaming { 43 | args.append("--print") 44 | } 45 | 46 | return args 47 | } 48 | 49 | // MARK: - Private Argument Building Methods 50 | 51 | /// Add system prompt related arguments 52 | private static func addSystemPromptArgs(_ args: inout [String], options: ClaudeCodeOptions) { 53 | // System prompt 54 | if let systemPrompt = options.systemPrompt { 55 | args.append("--system-prompt") 56 | args.append(systemPrompt) 57 | } 58 | 59 | // Append system prompt 60 | if let appendSystemPrompt = options.appendSystemPrompt { 61 | args.append("--append-system-prompt") 62 | args.append(appendSystemPrompt) 63 | } 64 | } 65 | 66 | /// Add turn limit arguments 67 | private static func addTurnLimitArgs(_ args: inout [String], options: ClaudeCodeOptions) { 68 | // Max turns (exists but not documented in --help) 69 | if let maxTurns = options.maxTurns { 70 | args.append("--max-turns") 71 | args.append(String(maxTurns)) 72 | } 73 | } 74 | 75 | /// Add tool-related arguments 76 | private static func addToolArgs(_ args: inout [String], options: ClaudeCodeOptions) { 77 | // Allowed tools 78 | if !options.allowedTools.isEmpty { 79 | args.append("--allowedTools") 80 | args.append(options.allowedTools.joined(separator: ",")) 81 | } 82 | 83 | // Disallowed tools 84 | if !options.disallowedTools.isEmpty { 85 | args.append("--disallowedTools") 86 | args.append(options.disallowedTools.joined(separator: ",")) 87 | } 88 | } 89 | 90 | /// Add model selection arguments 91 | private static func addModelArgs(_ args: inout [String], options: ClaudeCodeOptions) { 92 | // Model 93 | if let model = options.model { 94 | args.append("--model") 95 | args.append(model) 96 | } 97 | } 98 | 99 | /// Add permission-related arguments 100 | private static func addPermissionArgs(_ args: inout [String], options: ClaudeCodeOptions) { 101 | // Permission mode 102 | if let permissionMode = options.permissionMode { 103 | args.append("--permission-mode") 104 | args.append(permissionMode.rawValue) 105 | } 106 | 107 | // Permission prompt tool name (undocumented but used by Python SDK) 108 | if let toolName = options.permissionPromptToolName { 109 | args.append("--permission-prompt-tool") 110 | args.append(toolName) 111 | } 112 | } 113 | 114 | /// Add session management arguments 115 | private static func addSessionArgs(_ args: inout [String], options: ClaudeCodeOptions) { 116 | // Continue conversation 117 | if options.continueConversation { 118 | args.append("--continue") 119 | } 120 | 121 | // Resume session ID 122 | if let sessionId = options.resume { 123 | args.append("--resume") 124 | args.append(sessionId) 125 | } 126 | } 127 | 128 | /// Add settings file arguments 129 | private static func addSettingsArgs(_ args: inout [String], options: ClaudeCodeOptions) { 130 | // Settings 131 | if let settings = options.settings { 132 | args.append("--settings") 133 | args.append(settings) 134 | } 135 | } 136 | 137 | /// Add directory-related arguments 138 | private static func addDirectoryArgs(_ args: inout [String], options: ClaudeCodeOptions) { 139 | // Add directories 140 | for dir in options.addDirs { 141 | args.append("--add-dir") 142 | args.append(dir.path) 143 | } 144 | } 145 | 146 | /// Add MCP (Model Context Protocol) arguments 147 | private static func addMCPArgs(_ args: inout [String], options: ClaudeCodeOptions) { 148 | // MCP servers 149 | switch options.mcpServers { 150 | case .dictionary(let servers): 151 | if !servers.isEmpty { 152 | // Dict format: serialize to JSON 153 | let mcpConfig = ["mcpServers": servers] 154 | if let jsonData = try? JSONSerialization.data(withJSONObject: mcpConfig), 155 | let jsonString = String(data: jsonData, encoding: .utf8) { 156 | args.append("--mcp-config") 157 | args.append(jsonString) 158 | } 159 | } 160 | case .string(let jsonString): 161 | // String format: pass directly as JSON string 162 | args.append("--mcp-config") 163 | args.append(jsonString) 164 | case .path(let filePath): 165 | // Path format: pass directly as file path 166 | args.append("--mcp-config") 167 | args.append(filePath.path) 168 | } 169 | } 170 | 171 | /// Add extra/future arguments 172 | private static func addExtraArgs(_ args: inout [String], options: ClaudeCodeOptions) { 173 | // Add extra args for future CLI flags 174 | for (flag, value) in options.extraArgs { 175 | if let value = value { 176 | // Flag with value 177 | args.append("--\(flag)") 178 | args.append(value) 179 | } else { 180 | // Boolean flag without value 181 | args.append("--\(flag)") 182 | } 183 | } 184 | } 185 | } 186 | 187 | // MARK: - Argument Validation 188 | 189 | extension ArgumentBuilder { 190 | 191 | /// Validate that arguments are properly formed 192 | /// - Parameter args: Arguments to validate 193 | /// - Returns: True if valid, false otherwise 194 | static func validateArguments(_ args: [String]) -> Bool { 195 | // Check for basic required arguments 196 | guard args.contains("--output-format") && args.contains("stream-json") else { 197 | return false 198 | } 199 | 200 | // Check for balanced flag-value pairs 201 | var i = 0 202 | while i < args.count { 203 | let arg = args[i] 204 | if arg.starts(with: "--") && !isBooleanFlag(arg) { 205 | // This flag should have a value 206 | if i + 1 >= args.count || args[i + 1].starts(with: "--") { 207 | return false // Missing value for flag 208 | } 209 | i += 2 // Skip flag and value 210 | } else { 211 | i += 1 212 | } 213 | } 214 | 215 | return true 216 | } 217 | 218 | /// Check if a flag is a boolean flag (doesn't require a value) 219 | /// - Parameter flag: Flag to check 220 | /// - Returns: True if it's a boolean flag 221 | private static func isBooleanFlag(_ flag: String) -> Bool { 222 | let booleanFlags = [ 223 | "--verbose", 224 | "--print", 225 | "--continue" 226 | ] 227 | return booleanFlags.contains(flag) 228 | } 229 | } 230 | 231 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Client/QueryAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Query Claude Code for one-shot or unidirectional streaming interactions. 4 | /// 5 | /// This function is ideal for simple, stateless queries where you don't need 6 | /// bidirectional communication or conversation management. For interactive, 7 | /// stateful conversations, use ClaudeCodeSDKClient instead. 8 | /// 9 | /// Key differences from ClaudeCodeSDKClient: 10 | /// - **Unidirectional**: Send all messages upfront, receive all responses 11 | /// - **Stateless**: Each query is independent, no conversation state 12 | /// - **Simple**: Fire-and-forget style, no connection management 13 | /// - **No interrupts**: Cannot interrupt or send follow-up messages 14 | /// 15 | /// When to use query(): 16 | /// - Simple one-off questions ("What is 2+2?") 17 | /// - Batch processing of independent prompts 18 | /// - Code generation or analysis tasks 19 | /// - Automated scripts and CI/CD pipelines 20 | /// - When you know all inputs upfront 21 | /// 22 | /// When to use ClaudeCodeSDKClient: 23 | /// - Interactive conversations with follow-ups 24 | /// - Chat applications or REPL-like interfaces 25 | /// - When you need to send messages based on responses 26 | /// - When you need interrupt capabilities 27 | /// - Long-running sessions with state 28 | /// 29 | /// - Parameters: 30 | /// - prompt: The prompt to send to Claude. Can be a string for single-shot queries 31 | /// or an AsyncSequence for streaming mode with continuous interaction. 32 | /// - options: Optional configuration (defaults to ClaudeCodeOptions() if nil). 33 | /// - Returns: An async throwing stream of messages from the conversation 34 | /// 35 | /// Example - Simple query: 36 | /// ```swift 37 | /// // One-off question 38 | /// for try await message in query(prompt: "What is the capital of France?") { 39 | /// print(message) 40 | /// } 41 | /// ``` 42 | /// 43 | /// Example - With options: 44 | /// ```swift 45 | /// // Code generation with specific settings 46 | /// for try await message in query( 47 | /// prompt: "Create a Python web server", 48 | /// options: ClaudeCodeOptions( 49 | /// systemPrompt: "You are an expert Python developer", 50 | /// cwd: URL(fileURLWithPath: "/home/user/project") 51 | /// ) 52 | /// ) { 53 | /// print(message) 54 | /// } 55 | /// ``` 56 | public func query( 57 | prompt: String, 58 | options: ClaudeCodeOptions? = nil 59 | ) async throws -> AsyncThrowingStream { 60 | let finalOptions = options ?? ClaudeCodeOptions() 61 | 62 | // Set environment variable 63 | setenv("CLAUDE_CODE_ENTRYPOINT", "sdk-swift", 1) 64 | 65 | let transport = try SubprocessCLITransport() 66 | return try await transport.execute(prompt: prompt, options: finalOptions) 67 | } 68 | 69 | /// Query Claude Code with an async sequence of prompts (still unidirectional). 70 | /// 71 | /// All prompts are sent, then all responses received. This is different from 72 | /// ClaudeCodeSDKClient which allows bidirectional communication. 73 | /// 74 | /// - Parameters: 75 | /// - prompts: An async sequence of message dictionaries 76 | /// - options: Optional configuration 77 | /// - Returns: An async throwing stream of messages 78 | /// 79 | /// Example - Streaming mode: 80 | /// ```swift 81 | /// func prompts() -> AsyncStream<[String: Any]> { 82 | /// AsyncStream { continuation in 83 | /// continuation.yield([ 84 | /// "type": "user", 85 | /// "message": ["role": "user", "content": "Hello"], 86 | /// "parent_tool_use_id": NSNull(), 87 | /// "session_id": "default" 88 | /// ]) 89 | /// continuation.yield([ 90 | /// "type": "user", 91 | /// "message": ["role": "user", "content": "How are you?"], 92 | /// "parent_tool_use_id": NSNull(), 93 | /// "session_id": "default" 94 | /// ]) 95 | /// continuation.finish() 96 | /// } 97 | /// } 98 | /// 99 | /// // All prompts are sent, then all responses received 100 | /// for try await message in query(prompts: prompts()) { 101 | /// print(message) 102 | /// } 103 | /// ``` 104 | public func query( 105 | prompts: S, 106 | options: ClaudeCodeOptions? = nil 107 | ) async throws -> AsyncThrowingStream where S.Element == [String: Any] { 108 | let finalOptions = options ?? ClaudeCodeOptions() 109 | 110 | // Set environment variable 111 | setenv("CLAUDE_CODE_ENTRYPOINT", "sdk-swift", 1) 112 | 113 | let transport = try SubprocessCLITransport() 114 | return try await transport.executeStream(prompts: prompts, options: finalOptions, closeStdinAfterPrompt: true) 115 | } 116 | 117 | // MARK: - Session Continuation Convenience Functions 118 | 119 | /// Continue the last conversation with a new prompt. 120 | /// 121 | /// This is a convenience function that automatically sets the continue flag 122 | /// to resume from the last conversation state. Equivalent to calling query() 123 | /// with ClaudeCodeOptions(continueConversation: true). 124 | /// 125 | /// - Parameters: 126 | /// - prompt: The prompt to send to Claude 127 | /// - options: Base configuration options (continueConversation will be set to true) 128 | /// - Returns: An async throwing stream of messages from the conversation 129 | /// 130 | /// Example: 131 | /// ```swift 132 | /// // Start initial conversation 133 | /// for try await message in query(prompt: "Let's start a coding project") { 134 | /// // Process initial response 135 | /// } 136 | /// 137 | /// // Continue from where we left off 138 | /// for try await message in continueQuery(prompt: "Add error handling to that code") { 139 | /// // Process continuation response 140 | /// } 141 | /// ``` 142 | public func continueQuery( 143 | prompt: String, 144 | options: ClaudeCodeOptions? = nil 145 | ) async throws -> AsyncThrowingStream { 146 | let baseOptions = options ?? ClaudeCodeOptions() 147 | 148 | // Create new options with continue flag enabled 149 | let continueOptions = ClaudeCodeOptions( 150 | systemPrompt: baseOptions.systemPrompt, 151 | appendSystemPrompt: baseOptions.appendSystemPrompt, 152 | maxTurns: baseOptions.maxTurns, 153 | cwd: baseOptions.cwd, 154 | addDirs: baseOptions.addDirs, 155 | allowedTools: baseOptions.allowedTools, 156 | disallowedTools: baseOptions.disallowedTools, 157 | permissionMode: baseOptions.permissionMode, 158 | permissionPromptToolName: baseOptions.permissionPromptToolName, 159 | continueConversation: true, // Enable continue 160 | resume: nil, // Don't use resume 161 | mcpServers: baseOptions.mcpServers, 162 | model: baseOptions.model, 163 | settings: baseOptions.settings, 164 | extraArgs: baseOptions.extraArgs 165 | ) 166 | 167 | return try await query(prompt: prompt, options: continueOptions) 168 | } 169 | 170 | /// Resume a specific session with a new prompt. 171 | /// 172 | /// This is a convenience function that automatically sets the resume flag 173 | /// with the specified session ID. Equivalent to calling query() 174 | /// with ClaudeCodeOptions(resume: sessionId). 175 | /// 176 | /// - Parameters: 177 | /// - prompt: The prompt to send to Claude 178 | /// - sessionId: The session ID to resume 179 | /// - options: Base configuration options (resume will be set to sessionId) 180 | /// - Returns: An async throwing stream of messages from the conversation 181 | /// 182 | /// Example: 183 | /// ```swift 184 | /// // Resume a specific session 185 | /// for try await message in resumeQuery( 186 | /// prompt: "Continue working on the authentication system", 187 | /// sessionId: "session-abc123" 188 | /// ) { 189 | /// // Process resumed session response 190 | /// } 191 | /// ``` 192 | public func resumeQuery( 193 | prompt: String, 194 | sessionId: String, 195 | options: ClaudeCodeOptions? = nil 196 | ) async throws -> AsyncThrowingStream { 197 | let baseOptions = options ?? ClaudeCodeOptions() 198 | 199 | // Create new options with resume flag enabled 200 | let resumeOptions = ClaudeCodeOptions( 201 | systemPrompt: baseOptions.systemPrompt, 202 | appendSystemPrompt: baseOptions.appendSystemPrompt, 203 | maxTurns: baseOptions.maxTurns, 204 | cwd: baseOptions.cwd, 205 | addDirs: baseOptions.addDirs, 206 | allowedTools: baseOptions.allowedTools, 207 | disallowedTools: baseOptions.disallowedTools, 208 | permissionMode: baseOptions.permissionMode, 209 | permissionPromptToolName: baseOptions.permissionPromptToolName, 210 | continueConversation: false, // Don't use continue 211 | resume: sessionId, // Use resume with session ID 212 | mcpServers: baseOptions.mcpServers, 213 | model: baseOptions.model, 214 | settings: baseOptions.settings, 215 | extraArgs: baseOptions.extraArgs 216 | ) 217 | 218 | return try await query(prompt: prompt, options: resumeOptions) 219 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Utils/DebugLogger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | // MARK: - Debug Logger Protocol 5 | 6 | /// Protocol for debug logging in the Claude SDK 7 | public protocol DebugLogger: Sendable { 8 | /// Log a debug message 9 | func debug(_ message: @autoclosure @escaping () -> String, file: String, function: String, line: Int) 10 | 11 | /// Log an info message 12 | func info(_ message: @autoclosure @escaping () -> String, file: String, function: String, line: Int) 13 | 14 | /// Log a warning message 15 | func warn(_ message: @autoclosure @escaping () -> String, file: String, function: String, line: Int) 16 | 17 | /// Log an error message 18 | func error(_ message: @autoclosure @escaping () -> String, file: String, function: String, line: Int) 19 | } 20 | 21 | 22 | // MARK: - Default Console Logger 23 | 24 | /// Default console logger implementation using structured logging 25 | public final class ConsoleDebugLogger: DebugLogger, @unchecked Sendable { 26 | private let logger = Logger(subsystem: "com.anthropic.claude-code-sdk", category: "debug") 27 | private let shouldLog: Bool 28 | 29 | public init(enabled: Bool = true) { 30 | self.shouldLog = enabled 31 | } 32 | 33 | public func debug(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 34 | guard shouldLog else { return } 35 | let fileName = URL(fileURLWithPath: file).lastPathComponent 36 | logger.debug("👀 \(message()) [\(fileName):\(line) \(function)]") 37 | } 38 | 39 | public func info(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 40 | guard shouldLog else { return } 41 | let fileName = URL(fileURLWithPath: file).lastPathComponent 42 | logger.info("ℹ️ \(message()) [\(fileName):\(line) \(function)]") 43 | } 44 | 45 | public func warn(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 46 | guard shouldLog else { return } 47 | let fileName = URL(fileURLWithPath: file).lastPathComponent 48 | logger.warning("⚠️ \(message()) [\(fileName):\(line) \(function)]") 49 | } 50 | 51 | public func error(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 52 | guard shouldLog else { return } 53 | let fileName = URL(fileURLWithPath: file).lastPathComponent 54 | logger.error("❌ \(message()) [\(fileName):\(line) \(function)]") 55 | } 56 | } 57 | 58 | // MARK: - Simple Print Logger 59 | 60 | /// Simple print-based logger for platforms without os.Logger 61 | public final class PrintDebugLogger: DebugLogger, @unchecked Sendable { 62 | private let shouldLog: Bool 63 | private let dateFormatter: DateFormatter 64 | 65 | public init(enabled: Bool = true) { 66 | self.shouldLog = enabled 67 | self.dateFormatter = DateFormatter() 68 | self.dateFormatter.dateFormat = "HH:mm:ss.SSS" 69 | } 70 | 71 | public func debug(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 72 | guard shouldLog else { return } 73 | let fileName = URL(fileURLWithPath: file).lastPathComponent 74 | let timestamp = dateFormatter.string(from: Date()) 75 | print("[\(timestamp)] 👀 DEBUG: \(message()) [\(fileName):\(line) \(function)]") 76 | } 77 | 78 | public func info(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 79 | guard shouldLog else { return } 80 | let fileName = URL(fileURLWithPath: file).lastPathComponent 81 | let timestamp = dateFormatter.string(from: Date()) 82 | print("[\(timestamp)] ℹ️ INFO: \(message()) [\(fileName):\(line) \(function)]") 83 | } 84 | 85 | public func warn(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 86 | guard shouldLog else { return } 87 | let fileName = URL(fileURLWithPath: file).lastPathComponent 88 | let timestamp = dateFormatter.string(from: Date()) 89 | print("[\(timestamp)] ⚠️ WARN: \(message()) [\(fileName):\(line) \(function)]") 90 | } 91 | 92 | public func error(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) { 93 | guard shouldLog else { return } 94 | let fileName = URL(fileURLWithPath: file).lastPathComponent 95 | let timestamp = dateFormatter.string(from: Date()) 96 | print("[\(timestamp)] ❌ ERROR: \(message()) [\(fileName):\(line) \(function)]") 97 | } 98 | } 99 | 100 | // MARK: - Null Logger 101 | 102 | /// No-op logger for when debugging is disabled 103 | public struct NullDebugLogger: DebugLogger { 104 | public init() {} 105 | 106 | public func debug(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) {} 107 | public func info(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) {} 108 | public func warn(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) {} 109 | public func error(_ message: @autoclosure @escaping () -> String, file: String = #file, function: String = #function, line: Int = #line) {} 110 | } 111 | 112 | // MARK: - Debug Utilities 113 | 114 | /// Utilities for debug logging 115 | public enum DebugUtils { 116 | /// Truncate a string to a maximum length for logging 117 | public static func truncate(_ string: String, maxLength: Int = 200) -> String { 118 | guard string.count > maxLength else { return string } 119 | let truncated = String(string.prefix(maxLength)) 120 | return "\(truncated)... (\(string.count - maxLength) more chars)" 121 | } 122 | 123 | /// Sanitize CLI arguments by masking sensitive information 124 | public static func sanitizeArguments(_ args: [String]) -> [String] { 125 | var sanitized = args 126 | var i = 0 127 | while i < sanitized.count - 1 { 128 | let arg = sanitized[i] 129 | if arg.contains("key") || arg.contains("secret") || arg.contains("token") || arg.contains("password") { 130 | sanitized[i + 1] = "***REDACTED***" 131 | i += 2 132 | } else { 133 | i += 1 134 | } 135 | } 136 | return sanitized 137 | } 138 | 139 | /// Format a JSON object for logging with size limits 140 | public static func formatJSON(_ object: Any, maxLength: Int = 500) -> String { 141 | do { 142 | // Convert the object to a JSON-serializable format first 143 | let serializable = makeJSONSerializable(object) 144 | let data = try JSONSerialization.data(withJSONObject: serializable, options: []) 145 | let jsonString = String(data: data, encoding: .utf8) ?? "Unable to encode JSON" 146 | return truncate(jsonString, maxLength: maxLength) 147 | } catch { 148 | // If JSON serialization fails, fall back to string representation 149 | return "Object: \(String(describing: object).prefix(maxLength))" 150 | } 151 | } 152 | 153 | /// Convert an object to a JSON-serializable format 154 | private static func makeJSONSerializable(_ object: Any) -> Any { 155 | switch object { 156 | case let dict as [String: Any]: 157 | var serializable: [String: Any] = [:] 158 | for (key, value) in dict { 159 | serializable[key] = makeJSONSerializable(value) 160 | } 161 | return serializable 162 | case let array as [Any]: 163 | return array.map { makeJSONSerializable($0) } 164 | case let string as String: 165 | return string 166 | case let number as NSNumber: 167 | return number 168 | case let bool as Bool: 169 | return bool 170 | case is NSNull: 171 | return NSNull() 172 | case let usageInfo as UsageInfo: 173 | // Convert UsageInfo to a JSON-serializable dictionary 174 | var dict: [String: Any] = [ 175 | "input_tokens": usageInfo.inputTokens, 176 | "output_tokens": usageInfo.outputTokens 177 | ] 178 | if let cacheCreation = usageInfo.cacheCreationInputTokens { 179 | dict["cache_creation_input_tokens"] = cacheCreation 180 | } 181 | if let cacheRead = usageInfo.cacheReadInputTokens { 182 | dict["cache_read_input_tokens"] = cacheRead 183 | } 184 | if let serviceTier = usageInfo.serviceTier { 185 | dict["service_tier"] = serviceTier 186 | } 187 | if let serverToolUse = usageInfo.serverToolUse { 188 | dict["server_tool_use"] = makeJSONSerializable(serverToolUse) 189 | } 190 | return dict 191 | case let anyCodable as AnyCodable: 192 | // Handle AnyCodable by extracting its value and recursing 193 | return makeJSONSerializable(anyCodable.value) 194 | default: 195 | // For any other type, convert to string representation 196 | return String(describing: object) 197 | } 198 | } 199 | 200 | /// Extract session ID from a message dictionary 201 | public static func extractSessionId(from message: [String: Any]) -> String { 202 | return message["session_id"] as? String ?? "unknown" 203 | } 204 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Types/Options.swift: -------------------------------------------------------------------------------- 1 | // This file was generated by Claude 2 | 3 | import Foundation 4 | 5 | // MARK: - ClaudeCodeOptions 6 | 7 | /// Configuration options for Claude Code SDK 8 | public struct ClaudeCodeOptions: Sendable { 9 | // Core conversation settings 10 | public let systemPrompt: String? 11 | public let appendSystemPrompt: String? 12 | public let maxTurns: Int? 13 | 14 | // Working directory and project context 15 | public let cwd: URL? // Changed from workingDirectory to match CLI flag 16 | public let addDirs: [URL] 17 | 18 | // Tool permissions 19 | public let allowedTools: [String] 20 | public let disallowedTools: [String] 21 | public let permissionMode: PermissionMode? 22 | public let permissionPromptToolName: String? 23 | 24 | // Session management 25 | public let continueConversation: Bool 26 | public let resume: String? // Changed from resumeSessionId to match CLI flag 27 | 28 | // MCP integration 29 | public let mcpServers: MCPServersConfig 30 | 31 | // Model and settings 32 | public let model: String? 33 | public let settings: String? 34 | 35 | // Extra CLI args for future flags 36 | public let extraArgs: [String: String?] 37 | 38 | public enum PermissionMode: String, Codable, Sendable { 39 | case `default` = "default" 40 | case acceptEdits = "acceptEdits" 41 | case bypassPermissions = "bypassPermissions" 42 | case plan = "plan" 43 | } 44 | 45 | 46 | public init( 47 | systemPrompt: String? = nil, 48 | appendSystemPrompt: String? = nil, 49 | maxTurns: Int? = nil, 50 | cwd: URL? = nil, 51 | addDirs: [URL] = [], 52 | allowedTools: [String] = [], 53 | disallowedTools: [String] = [], 54 | permissionMode: PermissionMode? = nil, 55 | permissionPromptToolName: String? = nil, 56 | continueConversation: Bool = false, 57 | resume: String? = nil, 58 | mcpServers: MCPServersConfig = .dictionary([:]), 59 | model: String? = nil, 60 | settings: String? = nil, 61 | extraArgs: [String: String?] = [:] 62 | ) { 63 | self.systemPrompt = systemPrompt 64 | self.appendSystemPrompt = appendSystemPrompt 65 | self.maxTurns = maxTurns 66 | self.cwd = cwd 67 | self.addDirs = addDirs 68 | self.allowedTools = allowedTools 69 | self.disallowedTools = disallowedTools 70 | self.permissionMode = permissionMode 71 | self.permissionPromptToolName = permissionPromptToolName 72 | self.continueConversation = continueConversation 73 | self.resume = resume 74 | self.mcpServers = mcpServers 75 | self.model = model 76 | self.settings = settings 77 | self.extraArgs = extraArgs 78 | } 79 | } 80 | 81 | // MARK: - MCP Server Configuration 82 | 83 | /// Configuration for MCP servers - supports both dictionary and string/path formats 84 | public enum MCPServersConfig: Sendable { 85 | case dictionary([String: any MCPServerConfig]) 86 | case string(String) 87 | case path(URL) 88 | } 89 | 90 | /// Protocol for Model Context Protocol server configurations 91 | public protocol MCPServerConfig: Codable, Sendable {} 92 | 93 | /// MCP stdio server configuration 94 | public struct McpStdioServerConfig: MCPServerConfig { 95 | public var type: String? = "stdio" // Optional for backwards compatibility 96 | public let command: String 97 | public let args: [String]? 98 | public let env: [String: String]? 99 | 100 | public init(command: String, args: [String]? = nil, env: [String: String]? = nil) { 101 | self.command = command 102 | self.args = args 103 | self.env = env 104 | } 105 | } 106 | 107 | /// MCP SSE server configuration 108 | public struct McpSSEServerConfig: MCPServerConfig { 109 | public var type: String = "sse" 110 | public let url: String 111 | public let headers: [String: String]? 112 | 113 | public init(url: String, headers: [String: String]? = nil) { 114 | self.url = url 115 | self.headers = headers 116 | } 117 | } 118 | 119 | /// MCP HTTP server configuration 120 | public struct McpHttpServerConfig: MCPServerConfig { 121 | public var type: String = "http" 122 | public let url: String 123 | public let headers: [String: String]? 124 | 125 | public init(url: String, headers: [String: String]? = nil) { 126 | self.url = url 127 | self.headers = headers 128 | } 129 | } 130 | 131 | // MARK: - Options Builder 132 | 133 | /// Builder for fluent API construction of ClaudeCodeOptions 134 | public class ClaudeCodeOptionsBuilder { 135 | private var systemPrompt: String? 136 | private var appendSystemPrompt: String? 137 | private var maxTurns: Int? 138 | private var cwd: URL? 139 | private var addDirs: [URL] = [] 140 | private var allowedTools: [String] = [] 141 | private var disallowedTools: [String] = [] 142 | private var permissionMode: ClaudeCodeOptions.PermissionMode? 143 | private var permissionPromptToolName: String? 144 | private var continueConversation: Bool = false 145 | private var resume: String? 146 | private var mcpServers: MCPServersConfig = .dictionary([:]) 147 | private var model: String? 148 | private var settings: String? 149 | private var extraArgs: [String: String?] = [:] 150 | 151 | public init() {} 152 | 153 | /// Initialize builder from existing options for updates 154 | /// - Parameter options: Existing options to use as base 155 | public init(from options: ClaudeCodeOptions) { 156 | self.systemPrompt = options.systemPrompt 157 | self.appendSystemPrompt = options.appendSystemPrompt 158 | self.maxTurns = options.maxTurns 159 | self.cwd = options.cwd 160 | self.addDirs = options.addDirs 161 | self.allowedTools = options.allowedTools 162 | self.disallowedTools = options.disallowedTools 163 | self.permissionMode = options.permissionMode 164 | self.permissionPromptToolName = options.permissionPromptToolName 165 | self.continueConversation = options.continueConversation 166 | self.resume = options.resume 167 | self.mcpServers = options.mcpServers 168 | self.model = options.model 169 | self.settings = options.settings 170 | self.extraArgs = options.extraArgs 171 | } 172 | 173 | @discardableResult 174 | public func systemPrompt(_ prompt: String) -> Self { 175 | self.systemPrompt = prompt 176 | return self 177 | } 178 | 179 | @discardableResult 180 | public func appendSystemPrompt(_ prompt: String) -> Self { 181 | self.appendSystemPrompt = prompt 182 | return self 183 | } 184 | 185 | @discardableResult 186 | public func maxTurns(_ turns: Int) -> Self { 187 | self.maxTurns = turns 188 | return self 189 | } 190 | 191 | @discardableResult 192 | public func cwd(_ url: URL) -> Self { 193 | self.cwd = url 194 | return self 195 | } 196 | 197 | @discardableResult 198 | public func addDirs(_ dirs: [URL]) -> Self { 199 | self.addDirs = dirs 200 | return self 201 | } 202 | 203 | @discardableResult 204 | public func allowedTools(_ tools: [String]) -> Self { 205 | self.allowedTools = tools 206 | return self 207 | } 208 | 209 | @discardableResult 210 | public func disallowedTools(_ tools: [String]) -> Self { 211 | self.disallowedTools = tools 212 | return self 213 | } 214 | 215 | @discardableResult 216 | public func permissionMode(_ mode: ClaudeCodeOptions.PermissionMode) -> Self { 217 | self.permissionMode = mode 218 | return self 219 | } 220 | 221 | @discardableResult 222 | public func permissionPromptToolName(_ name: String) -> Self { 223 | self.permissionPromptToolName = name 224 | return self 225 | } 226 | 227 | @discardableResult 228 | public func continueConversation(_ value: Bool) -> Self { 229 | self.continueConversation = value 230 | return self 231 | } 232 | 233 | @discardableResult 234 | public func resume(_ sessionId: String) -> Self { 235 | self.resume = sessionId 236 | return self 237 | } 238 | 239 | @discardableResult 240 | public func mcpServers(_ servers: [String: any MCPServerConfig]) -> Self { 241 | self.mcpServers = .dictionary(servers) 242 | return self 243 | } 244 | 245 | @discardableResult 246 | public func mcpServersFromString(_ jsonString: String) -> Self { 247 | self.mcpServers = .string(jsonString) 248 | return self 249 | } 250 | 251 | @discardableResult 252 | public func mcpServersFromPath(_ path: URL) -> Self { 253 | self.mcpServers = .path(path) 254 | return self 255 | } 256 | 257 | @discardableResult 258 | public func model(_ model: String) -> Self { 259 | self.model = model 260 | return self 261 | } 262 | 263 | @discardableResult 264 | public func settings(_ settings: String) -> Self { 265 | self.settings = settings 266 | return self 267 | } 268 | 269 | @discardableResult 270 | public func extraArgs(_ args: [String: String?]) -> Self { 271 | self.extraArgs = args 272 | return self 273 | } 274 | 275 | public func build() -> ClaudeCodeOptions { 276 | return ClaudeCodeOptions( 277 | systemPrompt: systemPrompt, 278 | appendSystemPrompt: appendSystemPrompt, 279 | maxTurns: maxTurns, 280 | cwd: cwd, 281 | addDirs: addDirs, 282 | allowedTools: allowedTools, 283 | disallowedTools: disallowedTools, 284 | permissionMode: permissionMode, 285 | permissionPromptToolName: permissionPromptToolName, 286 | continueConversation: continueConversation, 287 | resume: resume, 288 | mcpServers: mcpServers, 289 | model: model, 290 | settings: settings, 291 | extraArgs: extraArgs 292 | ) 293 | } 294 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Internal/Transport/MessageStreamHandler.swift: -------------------------------------------------------------------------------- 1 | // Message stream handling and JSON parsing for Claude Code CLI 2 | 3 | import Foundation 4 | 5 | /// Handles streaming JSON message parsing from Claude Code CLI output 6 | actor MessageStreamHandler { 7 | 8 | // MARK: - Properties 9 | 10 | /// JSON buffer for partial message accumulation 11 | private var jsonBuffer: String = "" 12 | 13 | /// Maximum buffer size to prevent memory issues 14 | private let maxBufferSize: Int 15 | 16 | /// Pending control responses (for interactive mode) 17 | private var pendingControlResponses: [String: [String: any Sendable]] = [:] 18 | 19 | /// Debug logger for tracing messages 20 | private let debugLogger: (any DebugLogger)? 21 | 22 | /// Whether to close connection after ResultMessage 23 | private var shouldCloseOnResult: Bool 24 | 25 | // MARK: - Initialization 26 | 27 | init( 28 | maxBufferSize: Int = 1024 * 1024, // 1MB default 29 | shouldCloseOnResult: Bool = true, 30 | debugLogger: (any DebugLogger)? = nil 31 | ) { 32 | self.maxBufferSize = maxBufferSize 33 | self.shouldCloseOnResult = shouldCloseOnResult 34 | self.debugLogger = debugLogger 35 | } 36 | 37 | // MARK: - Stream Processing 38 | 39 | /// Process messages from output pipe and emit parsed messages 40 | /// - Parameters: 41 | /// - outputPipe: Pipe to read from 42 | /// - continuation: Continuation to emit messages to 43 | func processMessageStream( 44 | from outputPipe: Pipe, 45 | continuation: AsyncThrowingStream.Continuation 46 | ) async { 47 | let outputHandle = outputPipe.fileHandleForReading 48 | 49 | do { 50 | for try await line in outputHandle.bytes.lines { 51 | // Skip empty lines 52 | guard !line.isEmpty else { continue } 53 | 54 | // Split line by newlines in case multiple JSON objects are on one line 55 | let jsonLines = line.split(separator: "\n", omittingEmptySubsequences: true) 56 | 57 | for jsonLine in jsonLines { 58 | let lineStr = String(jsonLine).trimmingCharacters(in: .whitespacesAndNewlines) 59 | guard !lineStr.isEmpty else { continue } 60 | 61 | // Check buffer size before concatenation 62 | if jsonBuffer.count + lineStr.count > maxBufferSize { 63 | jsonBuffer = "" 64 | continuation.finish(throwing: ClaudeSDKError.jsonDecodeError( 65 | line: "Buffer would exceed maximum size", 66 | error: NSError(domain: "ClaudeSDK", code: -1) 67 | )) 68 | return 69 | } 70 | 71 | // Accumulate JSON buffer 72 | jsonBuffer += lineStr 73 | 74 | // Try to parse JSON 75 | if let data = jsonBuffer.data(using: .utf8) { 76 | do { 77 | let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] 78 | jsonBuffer = "" // Clear buffer on successful parse 79 | 80 | if let json = json { 81 | // Process the JSON message 82 | let shouldContinue = await processJsonMessage(json, continuation: continuation) 83 | if !shouldContinue { 84 | return 85 | } 86 | } 87 | } catch { 88 | // JSON parsing failed - continue accumulating 89 | // (we might have a partial JSON object) 90 | continue 91 | } 92 | } 93 | } 94 | } 95 | 96 | // Stream ended - finish normally 97 | continuation.finish() 98 | } catch { 99 | continuation.finish(throwing: error) 100 | } 101 | } 102 | 103 | // MARK: - Control Message Handling 104 | 105 | /// Set a control response for the given request ID 106 | /// - Parameters: 107 | /// - requestId: Request identifier 108 | /// - response: Response data 109 | func setControlResponse(requestId: String, response: [String: any Sendable]) async { 110 | pendingControlResponses[requestId] = response 111 | } 112 | 113 | /// Wait for a control response with timeout 114 | /// - Parameters: 115 | /// - requestId: Request identifier to wait for 116 | /// - timeout: Maximum time to wait 117 | /// - Returns: Response data 118 | /// - Throws: ClaudeSDKError.timeout if response doesn't arrive in time 119 | func waitForControlResponse(requestId: String, timeout: TimeInterval = 30.0) async throws -> [String: any Sendable] { 120 | let startTime = Date() 121 | 122 | while pendingControlResponses[requestId] == nil { 123 | if Date().timeIntervalSince(startTime) > timeout { 124 | throw ClaudeSDKError.timeout(duration: timeout) 125 | } 126 | try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds 127 | } 128 | 129 | let response = pendingControlResponses.removeValue(forKey: requestId)! 130 | 131 | if response["subtype"] as? String == "error" { 132 | throw ClaudeSDKError.cliConnectionError( 133 | underlying: NSError( 134 | domain: "ClaudeSDK", 135 | code: -1, 136 | userInfo: [NSLocalizedDescriptionKey: response["error"] ?? "Control request failed"] 137 | ) 138 | ) 139 | } 140 | 141 | return response 142 | } 143 | 144 | /// Update whether to close connection after result message 145 | /// - Parameter shouldClose: Whether to close after result 146 | func setShouldCloseOnResult(_ shouldClose: Bool) async { 147 | shouldCloseOnResult = shouldClose 148 | } 149 | 150 | // MARK: - Private Methods 151 | 152 | /// Process a parsed JSON message 153 | /// - Parameters: 154 | /// - json: Parsed JSON object 155 | /// - continuation: Continuation to emit messages to 156 | /// - Returns: True if processing should continue, false to stop 157 | private func processJsonMessage( 158 | _ json: [String: Any], 159 | continuation: AsyncThrowingStream.Continuation 160 | ) async -> Bool { 161 | // Handle control responses separately 162 | if json["type"] as? String == "control_response", 163 | let response = json["response"] as? [String: Any], 164 | let requestId = response["request_id"] as? String { 165 | debugLogger?.debug("← Control response received for request: \(requestId)", file: #file, function: #function, line: #line) 166 | // Convert [String: Any] to [String: any Sendable] by mapping values to sendable types 167 | let sendableResponse: [String: any Sendable] = response.mapValues { value -> any Sendable in 168 | switch value { 169 | case let string as String: 170 | return string 171 | case let number as NSNumber: 172 | return number 173 | case let bool as Bool: 174 | return bool 175 | case let array as [Any]: 176 | return array.map { String(describing: $0) } // Convert arrays to string arrays 177 | case let dict as [String: Any]: 178 | return dict.mapValues { String(describing: $0) } // Convert nested dicts to string dicts 179 | default: 180 | return String(describing: value) // Fallback to string representation 181 | } 182 | } 183 | pendingControlResponses[requestId] = sendableResponse 184 | return true 185 | } 186 | 187 | // Decode regular message with type-specific optimization 188 | let messageType = json["type"] as? String 189 | let sessionId = DebugUtils.extractSessionId(from: json) 190 | 191 | do { 192 | // Fast path for ResultMessage to avoid type erasure overhead 193 | if messageType == "result" { 194 | let messageData = try JSONSerialization.data(withJSONObject: json) 195 | let resultMessage = try JSONDecoder().decode(ResultMessage.self, from: messageData) 196 | 197 | // Log cost and session information 198 | logResultMessage(resultMessage, sessionId: sessionId) 199 | 200 | continuation.yield(.result(resultMessage)) 201 | 202 | if shouldCloseOnResult { 203 | // One-shot mode: close connection 204 | debugLogger?.debug("Closing connection after result (one-shot mode)", file: #file, function: #function, line: #line) 205 | continuation.finish() 206 | return false 207 | } else { 208 | // Streaming mode: keep connection open 209 | debugLogger?.debug("Keeping connection open after result (streaming mode)", file: #file, function: #function, line: #line) 210 | return true 211 | } 212 | } else { 213 | // For other message types, use the general decoder 214 | let messageData = try JSONSerialization.data(withJSONObject: json) 215 | let message = try await MessageDecoder.decodeFromData(messageData) 216 | 217 | debugLogger?.debug("← [\(sessionId)] Received \(messageType ?? "unknown") message: \(DebugUtils.formatJSON(json, maxLength: 200))", file: #file, function: #function, line: #line) 218 | 219 | continuation.yield(message) 220 | return true 221 | } 222 | } catch { 223 | continuation.finish(throwing: ClaudeSDKError.jsonDecodeError( 224 | line: DebugUtils.formatJSON(json, maxLength: 500), 225 | error: error 226 | )) 227 | return false 228 | } 229 | } 230 | 231 | /// Log detailed result message information 232 | /// - Parameters: 233 | /// - resultMessage: The result message to log 234 | /// - sessionId: Session identifier 235 | private func logResultMessage(_ resultMessage: ResultMessage, sessionId: String) { 236 | let costInfo = "Cost=\(resultMessage.totalCostUsd.map { "$\($0)" } ?? "N/A")" 237 | let durationInfo = "Duration=\(Double(resultMessage.durationMs) / 1000.0)s" 238 | let apiDurationInfo = "API=\(Double(resultMessage.durationApiMs) / 1000.0)s" 239 | let turnsInfo = "Turns=\(resultMessage.numTurns)" 240 | let usageInfo = resultMessage.usage.map { "Usage=\(DebugUtils.formatJSON($0, maxLength: 100))" } ?? "Usage=N/A" 241 | 242 | debugLogger?.info("💰 Result [session:\(sessionId)]: \(costInfo), \(durationInfo), \(apiDurationInfo), \(turnsInfo), \(usageInfo)", file: #file, function: #function, line: #line) 243 | } 244 | } 245 | 246 | -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Internal/Transport/ProcessManager.swift: -------------------------------------------------------------------------------- 1 | // Process lifecycle management for Claude Code CLI subprocess 2 | 3 | import Foundation 4 | import Darwin 5 | 6 | /// Manages the lifecycle of a Claude Code CLI subprocess 7 | actor ProcessManager { 8 | 9 | // MARK: - State Management 10 | 11 | /// Current state of the process 12 | private var state: ProcessState = .idle 13 | 14 | /// The CLI executable path 15 | private let cliPath: URL 16 | 17 | /// Currently running process 18 | private var process: Process? 19 | 20 | /// Standard I/O pipes 21 | private var outputPipe: Pipe? 22 | private var inputPipe: Pipe? 23 | private var stderrFile: FileHandle? 24 | private var stderrTempPath: URL? 25 | 26 | /// Background task management 27 | private var backgroundTasks: Set> = [] 28 | 29 | // MARK: - Types 30 | 31 | enum ProcessState { 32 | case idle 33 | case starting 34 | case running 35 | case terminating 36 | case terminated 37 | } 38 | 39 | /// Process configuration for starting 40 | struct ProcessConfig { 41 | let arguments: [String] 42 | let workingDirectory: URL? 43 | let environment: [String: String] 44 | let isStreaming: Bool 45 | 46 | init(arguments: [String], workingDirectory: URL? = nil, environment: [String: String] = [:], isStreaming: Bool = false) { 47 | self.arguments = arguments 48 | self.workingDirectory = workingDirectory 49 | self.environment = environment 50 | self.isStreaming = isStreaming 51 | } 52 | } 53 | 54 | /// Process I/O handles for communication 55 | struct ProcessIO { 56 | let outputPipe: Pipe 57 | let inputPipe: Pipe 58 | let stderrFile: FileHandle 59 | let stderrTempPath: URL 60 | } 61 | 62 | // MARK: - Initialization 63 | 64 | init(cliPath: URL) { 65 | self.cliPath = cliPath 66 | } 67 | 68 | deinit { 69 | // Note: Can't call async cleanup from deinit 70 | // Swift's actor deinitialization handles cleanup automatically 71 | } 72 | 73 | // MARK: - Process Lifecycle 74 | 75 | /// Start a new process with the given configuration 76 | /// - Parameter config: Process configuration 77 | /// - Returns: Process I/O handles for communication 78 | /// - Throws: ClaudeSDKError if process cannot be started 79 | func startProcess(config: ProcessConfig) async throws -> ProcessIO { 80 | // Ensure any previous process is terminated 81 | await cleanup() 82 | 83 | // Check if we can start a new process 84 | guard state == .terminated || state == .idle else { 85 | throw ClaudeSDKError.invalidConfiguration(reason: "Process is in invalid state for starting: \(state)") 86 | } 87 | 88 | state = .starting 89 | 90 | // Create new process 91 | let process = Process() 92 | process.executableURL = cliPath 93 | process.arguments = config.arguments 94 | 95 | // Set up pipes 96 | let outputPipe = Pipe() 97 | let inputPipe = Pipe() 98 | process.standardOutput = outputPipe 99 | process.standardInput = inputPipe 100 | 101 | // Create temp file for stderr 102 | let stderrPath = FileManager.default.temporaryDirectory 103 | .appendingPathComponent("claude_stderr_\(UUID().uuidString).log") 104 | FileManager.default.createFile(atPath: stderrPath.path, contents: nil) 105 | 106 | let stderrFile: FileHandle 107 | do { 108 | stderrFile = try FileHandle(forWritingTo: stderrPath) 109 | } catch { 110 | // Clean up temp file if FileHandle creation fails 111 | try? FileManager.default.removeItem(at: stderrPath) 112 | throw ClaudeSDKError.cliConnectionError(underlying: error) 113 | } 114 | process.standardError = stderrFile 115 | 116 | // Store references 117 | self.process = process 118 | self.outputPipe = outputPipe 119 | self.inputPipe = inputPipe 120 | self.stderrFile = stderrFile 121 | self.stderrTempPath = stderrPath 122 | 123 | // Set environment variables 124 | var env = config.environment 125 | if env["CLAUDE_CODE_ENTRYPOINT"] == nil { 126 | env["CLAUDE_CODE_ENTRYPOINT"] = "sdk-swift" 127 | } 128 | process.environment = env 129 | 130 | // Set working directory if specified 131 | if let cwd = config.workingDirectory { 132 | process.currentDirectoryURL = cwd 133 | } 134 | 135 | // Start the process 136 | do { 137 | try process.run() 138 | state = .running 139 | } catch { 140 | state = .idle 141 | await cleanup() 142 | throw ClaudeSDKError.cliConnectionError(underlying: error) 143 | } 144 | 145 | return ProcessIO( 146 | outputPipe: outputPipe, 147 | inputPipe: inputPipe, 148 | stderrFile: stderrFile, 149 | stderrTempPath: stderrPath 150 | ) 151 | } 152 | 153 | /// Get the process ID if running 154 | /// - Returns: Process ID or nil if not running 155 | func getProcessId() async -> Int32? { 156 | return process?.processIdentifier 157 | } 158 | 159 | /// Get current process state 160 | /// - Returns: Current state 161 | func getState() async -> ProcessState { 162 | return state 163 | } 164 | 165 | /// Check if process is currently running 166 | /// - Returns: True if process is running 167 | func isRunning() async -> Bool { 168 | guard let process = process else { return false } 169 | return process.isRunning && state == .running 170 | } 171 | 172 | /// Write data to stdin 173 | /// - Parameter data: Data to write 174 | /// - Throws: ClaudeSDKError if writing fails 175 | func writeToStdin(_ data: Data) async throws { 176 | guard let inputPipe = inputPipe else { 177 | throw ClaudeSDKError.invalidConfiguration(reason: "stdin not available") 178 | } 179 | 180 | try inputPipe.fileHandleForWriting.write(contentsOf: data) 181 | } 182 | 183 | /// Close stdin 184 | /// - Throws: ClaudeSDKError if closing fails 185 | func closeStdin() async throws { 186 | guard let inputPipe = inputPipe else { return } 187 | try inputPipe.fileHandleForWriting.close() 188 | self.inputPipe = nil 189 | } 190 | 191 | /// Wait for process completion and get exit status 192 | /// - Returns: Exit code of the process 193 | func waitForCompletion() async -> Int32 { 194 | guard let process = process else { return -1 } 195 | 196 | return await withCheckedContinuation { continuation in 197 | Task { 198 | await Task.detached { 199 | process.waitUntilExit() 200 | continuation.resume(returning: process.terminationStatus) 201 | }.value 202 | } 203 | } 204 | } 205 | 206 | /// Get stderr content if process failed 207 | /// - Returns: stderr content as string, or empty string if none 208 | func getStderrContent() async -> String { 209 | guard let stderrPath = stderrTempPath, 210 | let stderrData = try? Data(contentsOf: stderrPath), 211 | let stderr = String(data: stderrData, encoding: .utf8) else { 212 | return "" 213 | } 214 | return stderr 215 | } 216 | 217 | /// Interrupt the process (SIGINT) 218 | func interrupt() async { 219 | guard let process = process, process.isRunning else { return } 220 | process.interrupt() 221 | } 222 | 223 | /// Terminate the process gracefully, then forcefully if needed 224 | func terminate() async { 225 | await cleanup() 226 | } 227 | 228 | // MARK: - Private Methods 229 | 230 | /// Clean up process and resources with timeout 231 | private func cleanup() async { 232 | // Prevent multiple cleanup calls 233 | guard state != .terminating && state != .terminated else { 234 | return 235 | } 236 | 237 | // Cancel all background tasks first 238 | for task in backgroundTasks { 239 | task.cancel() 240 | } 241 | backgroundTasks.removeAll() 242 | 243 | // Update state to prevent new operations 244 | state = .terminating 245 | 246 | // Close all file handles first to prevent resource leaks 247 | if let outputHandle = outputPipe?.fileHandleForReading { 248 | try? outputHandle.close() 249 | } 250 | if let inputHandle = inputPipe?.fileHandleForWriting { 251 | try? inputHandle.close() 252 | } 253 | if let stderr = stderrFile { 254 | try? stderr.close() 255 | } 256 | 257 | // Terminate process gracefully with timeout 258 | if let process = process, state != .terminated { 259 | process.terminate() 260 | 261 | // Wait for process to terminate with timeout 262 | do { 263 | try await withThrowingTaskGroup(of: Void.self) { group in 264 | // Add timeout task 265 | group.addTask { 266 | try await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds 267 | throw TimeoutError.timeout 268 | } 269 | 270 | // Add process wait task 271 | group.addTask { 272 | await withCheckedContinuation { continuation in 273 | Task.detached { 274 | process.waitUntilExit() 275 | continuation.resume() 276 | } 277 | } 278 | } 279 | 280 | // Wait for first task to complete 281 | try await group.next() 282 | group.cancelAll() 283 | } 284 | } catch { 285 | // Timeout occurred, force kill 286 | if process.isRunning { 287 | process.interrupt() 288 | try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second 289 | if process.isRunning { 290 | kill(process.processIdentifier, SIGKILL) 291 | } 292 | } 293 | } 294 | } 295 | 296 | // Clean up stderr temp file 297 | if let stderrPath = stderrTempPath { 298 | try? FileManager.default.removeItem(at: stderrPath) 299 | } 300 | 301 | // Clear all references 302 | self.process = nil 303 | self.outputPipe = nil 304 | self.inputPipe = nil 305 | self.stderrFile = nil 306 | self.stderrTempPath = nil 307 | 308 | // Update final state 309 | state = .terminated 310 | } 311 | 312 | /// Add a background task for tracking 313 | func addBackgroundTask(_ task: Task) async { 314 | backgroundTasks.insert(task) 315 | } 316 | 317 | /// Remove a completed background task 318 | func removeBackgroundTask(_ task: Task) async { 319 | backgroundTasks.remove(task) 320 | } 321 | } 322 | 323 | // MARK: - Supporting Types 324 | 325 | private enum TimeoutError: Error { 326 | case timeout 327 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Internal/Transport/SubprocessCLI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Darwin 3 | 4 | /// Actor that manages subprocess communication with the Claude Code CLI 5 | /// Refactored to use focused components for better maintainability 6 | actor SubprocessCLITransport { 7 | 8 | // MARK: - Components 9 | 10 | /// Process management 11 | private let processManager: ProcessManager 12 | 13 | /// Message stream handling 14 | private let messageHandler: MessageStreamHandler 15 | 16 | 17 | // MARK: - State 18 | 19 | /// Current session tracking 20 | private var currentSessionId: String = "default" 21 | 22 | /// Control request counter for unique IDs 23 | private var requestCounter = 0 24 | 25 | /// Debug logging 26 | private let debugLogger: (any DebugLogger)? 27 | 28 | // MARK: - Initialization 29 | 30 | /// Initialize the transport with an optional custom CLI path 31 | /// - Parameter cliPath: Custom path to the CLI executable, or nil to auto-discover 32 | init(cliPath: URL? = nil) throws { 33 | let resolvedCLIPath: URL 34 | if let customPath = cliPath { 35 | resolvedCLIPath = customPath 36 | } else { 37 | resolvedCLIPath = try CLIDiscovery.discoverCLI() 38 | } 39 | 40 | self.debugLogger = ClaudeSDK.shared.debugLogger 41 | self.processManager = ProcessManager(cliPath: resolvedCLIPath) 42 | self.messageHandler = MessageStreamHandler( 43 | shouldCloseOnResult: true, // Default for one-shot mode 44 | debugLogger: self.debugLogger 45 | ) 46 | 47 | // Log CLI discovery 48 | debugLogger?.info("CLI discovered at: \(resolvedCLIPath.path)", file: #file, function: #function, line: #line) 49 | } 50 | 51 | // MARK: - Execution Methods 52 | 53 | /// Execute a query with the given prompt and options (one-shot mode) 54 | /// - Parameters: 55 | /// - prompt: The user prompt to send to Claude 56 | /// - options: Configuration options for the query 57 | /// - Returns: An async stream of messages from the CLI 58 | func execute(prompt: String, options: ClaudeCodeOptions?) async throws -> AsyncThrowingStream { 59 | // Extract session ID for logging 60 | currentSessionId = "default" // For one-shot mode 61 | 62 | debugLogger?.debug("Starting one-shot execution for session: \(self.currentSessionId)", file: #file, function: #function, line: #line) 63 | debugLogger?.debug("Prompt: \(DebugUtils.truncate(prompt, maxLength: 200))", file: #file, function: #function, line: #line) 64 | 65 | // Build process configuration 66 | let arguments = ArgumentBuilder.buildArguments(isStreaming: false, options: options) 67 | let environment = ProcessInfo.processInfo.environment 68 | 69 | let config = ProcessManager.ProcessConfig( 70 | arguments: arguments, 71 | workingDirectory: options?.cwd, 72 | environment: environment, 73 | isStreaming: false 74 | ) 75 | 76 | debugLogger?.debug("Process arguments: \(DebugUtils.sanitizeArguments(arguments))", file: #file, function: #function, line: #line) 77 | 78 | // Start the process 79 | let processIO = try await processManager.startProcess(config: config) 80 | let processId = await processManager.getProcessId() 81 | debugLogger?.info("Process started with PID: \(processId ?? -1), mode: one-shot, session: \(self.currentSessionId)", file: #file, function: #function, line: #line) 82 | 83 | // Write prompt to stdin and close it immediately for one-shot mode 84 | if let promptData = prompt.data(using: .utf8) { 85 | try await processManager.writeToStdin(promptData) 86 | } 87 | try await processManager.closeStdin() 88 | 89 | // Configure message handler for one-shot mode 90 | await messageHandler.setShouldCloseOnResult(true) 91 | 92 | // Process messages through the handler (no tool processing) 93 | return AsyncThrowingStream { continuation in 94 | Task { 95 | await self.messageHandler.processMessageStream( 96 | from: processIO.outputPipe, 97 | continuation: continuation 98 | ) 99 | } 100 | } 101 | } 102 | 103 | /// Execute with an async sequence of prompts (streaming mode) 104 | /// - Parameters: 105 | /// - prompts: The async sequence of prompts 106 | /// - options: Configuration options 107 | /// - closeStdinAfterPrompt: Whether to close stdin after sending all prompts 108 | /// - Returns: An async stream of messages 109 | func executeStream( 110 | prompts: S, 111 | options: ClaudeCodeOptions?, 112 | closeStdinAfterPrompt: Bool 113 | ) async throws -> AsyncThrowingStream where S.Element == [String: Any] { 114 | 115 | // Build process configuration 116 | let arguments = ArgumentBuilder.buildArguments(isStreaming: true, options: options) 117 | let environment = ProcessInfo.processInfo.environment 118 | 119 | let config = ProcessManager.ProcessConfig( 120 | arguments: arguments, 121 | workingDirectory: options?.cwd, 122 | environment: environment, 123 | isStreaming: true 124 | ) 125 | 126 | // Start the process 127 | let processIO = try await processManager.startProcess(config: config) 128 | 129 | // Start streaming prompts to stdin 130 | let stdinTask = Task { [processManager] in 131 | do { 132 | for try await message in prompts { 133 | try Task.checkCancellation() 134 | 135 | let jsonData = try JSONSerialization.data(withJSONObject: message) 136 | if let jsonString = String(data: jsonData, encoding: .utf8) { 137 | let dataToWrite = (jsonString + "\n").data(using: .utf8)! 138 | try await processManager.writeToStdin(dataToWrite) 139 | } 140 | } 141 | 142 | if closeStdinAfterPrompt { 143 | try await processManager.closeStdin() 144 | } 145 | } catch { 146 | try? await processManager.closeStdin() 147 | } 148 | } 149 | 150 | // Track the background task 151 | await processManager.addBackgroundTask(stdinTask) 152 | 153 | // Configure message handler for streaming mode 154 | await messageHandler.setShouldCloseOnResult(false) 155 | 156 | // Process messages through the handler (no tool processing) 157 | return AsyncThrowingStream { continuation in 158 | Task { 159 | await self.messageHandler.processMessageStream( 160 | from: processIO.outputPipe, 161 | continuation: continuation 162 | ) 163 | } 164 | } 165 | } 166 | 167 | /// Connect for streaming mode (bidirectional communication) 168 | /// - Parameter options: Configuration options for the session 169 | /// - Returns: An async stream of messages from the CLI 170 | func connect(options: ClaudeCodeOptions?) async throws -> AsyncThrowingStream { 171 | 172 | // Build process configuration 173 | let arguments = ArgumentBuilder.buildArguments(isStreaming: true, options: options) 174 | let environment = ProcessInfo.processInfo.environment 175 | 176 | let config = ProcessManager.ProcessConfig( 177 | arguments: arguments, 178 | workingDirectory: options?.cwd, 179 | environment: environment, 180 | isStreaming: true 181 | ) 182 | 183 | // Start the process 184 | let processIO = try await processManager.startProcess(config: config) 185 | 186 | // Configure message handler for interactive mode 187 | await messageHandler.setShouldCloseOnResult(false) 188 | 189 | // Process messages through the handler (no tool processing) 190 | return AsyncThrowingStream { continuation in 191 | Task { 192 | await self.messageHandler.processMessageStream( 193 | from: processIO.outputPipe, 194 | continuation: continuation 195 | ) 196 | } 197 | } 198 | } 199 | 200 | // MARK: - Communication Methods 201 | 202 | /// Send a message in streaming mode 203 | /// - Parameter message: The message dictionary to send 204 | func sendMessage(_ message: [String: Any]) async throws { 205 | guard await processManager.isRunning() else { 206 | throw ClaudeSDKError.invalidConfiguration(reason: "Not in streaming mode or process not running") 207 | } 208 | 209 | let sessionId = DebugUtils.extractSessionId(from: message) 210 | let messageType = message["type"] as? String ?? "unknown" 211 | 212 | debugLogger?.debug("→ [\(sessionId)] Sending \(messageType) message: \(DebugUtils.formatJSON(message, maxLength: 200))", file: #file, function: #function, line: #line) 213 | 214 | let jsonData = try JSONSerialization.data(withJSONObject: message) 215 | var jsonString = String(data: jsonData, encoding: .utf8) ?? "" 216 | jsonString += "\n" 217 | 218 | guard let data = jsonString.data(using: .utf8) else { 219 | throw ClaudeSDKError.invalidConfiguration(reason: "Failed to encode message") 220 | } 221 | 222 | try await processManager.writeToStdin(data) 223 | } 224 | 225 | /// Send multiple messages in streaming mode (matches Python's send_request) 226 | /// - Parameters: 227 | /// - messages: Array of message dictionaries to send 228 | /// - options: Additional options (e.g., session_id) 229 | func sendRequest(_ messages: [[String: Any]], options: [String: Any]) async throws { 230 | guard await processManager.isRunning() else { 231 | throw ClaudeSDKError.invalidConfiguration(reason: "sendRequest only works when process is running") 232 | } 233 | 234 | // Send each message 235 | for var message in messages { 236 | // Ensure message has required structure 237 | if message["type"] == nil { 238 | message = [ 239 | "type": "user", 240 | "message": ["role": "user", "content": String(describing: message)], 241 | "parent_tool_use_id": NSNull(), 242 | "session_id": options["session_id"] ?? "default" 243 | ] 244 | } 245 | 246 | try await sendMessage(message) 247 | } 248 | } 249 | 250 | /// Send interrupt control request 251 | func interrupt() async throws { 252 | guard await processManager.isRunning() else { 253 | throw ClaudeSDKError.invalidConfiguration(reason: "Interrupt requires running process") 254 | } 255 | 256 | _ = try await sendControlRequest(["subtype": "interrupt"]) 257 | } 258 | 259 | /// Send a control request and wait for response 260 | private func sendControlRequest(_ request: [String: Any]) async throws -> [String: any Sendable] { 261 | guard await processManager.isRunning() else { 262 | throw ClaudeSDKError.invalidConfiguration(reason: "Process not running") 263 | } 264 | 265 | // Generate unique request ID 266 | requestCounter += 1 267 | let requestId = "req_\(requestCounter)_\(UUID().uuidString)" 268 | 269 | // Build control request 270 | let controlRequest: [String: Any] = [ 271 | "type": "control_request", 272 | "request_id": requestId, 273 | "request": request 274 | ] 275 | 276 | // Send request 277 | try await sendMessage(controlRequest) 278 | 279 | // Wait for response with timeout 280 | return try await messageHandler.waitForControlResponse(requestId: requestId) 281 | } 282 | 283 | // MARK: - Lifecycle Management 284 | 285 | /// Terminate the CLI process if running 286 | func terminate() async { 287 | await processManager.terminate() 288 | } 289 | 290 | deinit { 291 | // Note: Can't call async methods from deinit 292 | // Swift actor deinitialization handles cleanup automatically 293 | } 294 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Internal/MessageDecoder.swift: -------------------------------------------------------------------------------- 1 | // This file was generated by Claude 2 | 3 | import Foundation 4 | 5 | /// Decoder for messages from the Claude Code CLI 6 | enum MessageDecoder { 7 | 8 | /// Decode a message from a JSON line 9 | /// - Parameter line: The JSON line from CLI output 10 | /// - Returns: The decoded message 11 | /// - Throws: ClaudeSDKError if decoding fails 12 | static func decode(from line: String) async throws -> Message { 13 | guard let data = line.data(using: .utf8) else { 14 | throw ClaudeSDKError.jsonDecodeError( 15 | line: line, 16 | error: DecodingError.dataCorrupted( 17 | DecodingError.Context(codingPath: [], debugDescription: "Invalid UTF-8 string") 18 | ) 19 | ) 20 | } 21 | 22 | return try await decodeFromData(data, originalLine: line) 23 | } 24 | 25 | /// Decode a message from JSON data 26 | /// - Parameters: 27 | /// - data: The JSON data to decode 28 | /// - originalLine: Optional original line for error reporting 29 | /// - Returns: The decoded message 30 | /// - Throws: ClaudeSDKError if decoding fails 31 | static func decodeFromData(_ data: Data, originalLine: String? = nil) async throws -> Message { 32 | 33 | // First, decode to determine message type 34 | let decoder = JSONDecoder() 35 | 36 | do { 37 | // Try to decode a generic structure to determine type 38 | let genericMessage = try decoder.decode(GenericMessage.self, from: data) 39 | 40 | switch genericMessage.type { 41 | case .user: 42 | // User messages have content in message.content + optional parent_tool_use_id + session_id 43 | let parentToolUseId = genericMessage.parent_tool_use_id 44 | let sessionId = genericMessage.session_id ?? "unknown" 45 | let role = genericMessage.message?.role ?? "user" 46 | 47 | if let messageContent = genericMessage.message?.content?.value { 48 | if let content = messageContent as? String { 49 | return .user(UserMessage(content: content, parentToolUseId: parentToolUseId, sessionId: sessionId, role: role)) 50 | } else if let contentArray = messageContent as? [[String: Any]] { 51 | // Handle array of content blocks 52 | var blocks: [any ContentBlock] = [] 53 | 54 | for blockData in contentArray { 55 | // Use direct property access instead of re-encoding/decoding 56 | if let type = blockData["type"] as? String { 57 | let block: (any ContentBlock)? = { 58 | switch type { 59 | case "text": 60 | guard let text = blockData["text"] as? String else { return nil } 61 | return TextBlock(text: text) 62 | 63 | case "tool_result": 64 | guard let toolUseId = blockData["tool_use_id"] as? String else { return nil } 65 | let isError = blockData["is_error"] as? Bool 66 | 67 | var contentResult: ToolResultBlock.ContentResult? 68 | if let content = blockData["content"] { 69 | if let text = content as? String { 70 | contentResult = .text(text) 71 | } else if let structured = content as? [String: Any] { 72 | contentResult = .structured(structured.mapValues { AnyCodable($0) }) 73 | } 74 | } 75 | 76 | return ToolResultBlock( 77 | toolUseId: toolUseId, 78 | content: contentResult, 79 | isError: isError 80 | ) 81 | 82 | default: 83 | return nil 84 | } 85 | }() 86 | 87 | if let block = block { 88 | blocks.append(block) 89 | } 90 | } 91 | } 92 | 93 | return .user(UserMessage(content: blocks, parentToolUseId: parentToolUseId, sessionId: sessionId, role: role)) 94 | } 95 | } 96 | throw ClaudeSDKError.jsonDecodeError( 97 | line: originalLine ?? "unknown", 98 | error: DecodingError.dataCorrupted( 99 | DecodingError.Context(codingPath: [], debugDescription: "Invalid user message content") 100 | ) 101 | ) 102 | 103 | case .assistant: 104 | return .assistant(try decodeAssistantMessage(from: data)) 105 | 106 | case .system: 107 | return .system(try decoder.decode(SystemMessage.self, from: data)) 108 | 109 | case .result: 110 | return .result(try decoder.decode(ResultMessage.self, from: data)) 111 | } 112 | } catch { 113 | throw ClaudeSDKError.jsonDecodeError(line: originalLine ?? "unknown", error: error) 114 | } 115 | } 116 | 117 | /// Decode an assistant message with heterogeneous content blocks 118 | private static func decodeAssistantMessage(from data: Data) throws -> AssistantMessage { 119 | let decoder = JSONDecoder() 120 | 121 | // First decode to a raw structure 122 | let raw = try decoder.decode(RawAssistantMessage.self, from: data) 123 | 124 | var contentBlocks: [any ContentBlock] = [] 125 | 126 | for rawBlock in raw.message.content { 127 | switch rawBlock.type { 128 | case "text": 129 | if let text = rawBlock.text { 130 | contentBlocks.append(TextBlock(text: text)) 131 | } 132 | 133 | case "thinking": 134 | if let thinking = rawBlock.thinking, 135 | let signature = rawBlock.signature { 136 | contentBlocks.append(ThinkingBlock(thinking: thinking, signature: signature)) 137 | } 138 | 139 | case "tool_use": 140 | if let id = rawBlock.id, 141 | let name = rawBlock.name, 142 | let input = rawBlock.input { 143 | contentBlocks.append(ToolUseBlock( 144 | id: id, 145 | name: name, 146 | input: input.mapValues { AnyCodable($0) } 147 | )) 148 | } 149 | 150 | case "tool_result": 151 | if let toolUseId = rawBlock.tool_use_id { 152 | var contentResult: ToolResultBlock.ContentResult? 153 | 154 | if let content = rawBlock.content { 155 | if let text = content as? String { 156 | contentResult = .text(text) 157 | } else if let structured = content as? [String: Any] { 158 | contentResult = .structured(structured.mapValues { AnyCodable($0) }) 159 | } 160 | } 161 | 162 | contentBlocks.append(ToolResultBlock( 163 | toolUseId: toolUseId, 164 | content: contentResult, 165 | isError: rawBlock.is_error 166 | )) 167 | } 168 | 169 | default: 170 | // Unknown block type, skip 171 | continue 172 | } 173 | } 174 | 175 | return AssistantMessage( 176 | id: raw.message.id, 177 | role: raw.message.role, 178 | content: contentBlocks, 179 | model: raw.message.model, 180 | stopReason: raw.message.stop_reason, 181 | stopSequence: raw.message.stop_sequence, 182 | usage: raw.message.usage, 183 | parentToolUseId: raw.parent_tool_use_id, 184 | sessionId: raw.session_id 185 | ) 186 | } 187 | 188 | // MARK: - Helper Types 189 | 190 | /// Generic message structure for type detection 191 | private struct GenericMessage: Decodable { 192 | let type: MessageType 193 | let message: MessageContent? 194 | let subtype: String? 195 | let parent_tool_use_id: String? 196 | let session_id: String? 197 | 198 | enum MessageType: String, Decodable { 199 | case user 200 | case assistant 201 | case system 202 | case result 203 | } 204 | 205 | struct MessageContent: Decodable { 206 | let id: String? 207 | let role: String? 208 | let content: AnyCodable? 209 | let model: String? 210 | let stop_reason: String? 211 | let stop_sequence: String? 212 | let usage: [String: AnyCodable]? 213 | } 214 | } 215 | 216 | /// Raw assistant message structure for parsing 217 | private struct RawAssistantMessage: Decodable { 218 | let type: String 219 | let message: MessageData 220 | let parent_tool_use_id: String? 221 | let session_id: String 222 | 223 | struct MessageData: Decodable { 224 | let id: String 225 | let role: String 226 | let content: [RawContentBlock] 227 | let model: String 228 | let stop_reason: String? 229 | let stop_sequence: String? 230 | let usage: UsageInfo? 231 | } 232 | } 233 | 234 | /// Raw content block for parsing 235 | private struct RawContentBlock: Decodable { 236 | let type: String 237 | 238 | // Text block fields 239 | let text: String? 240 | 241 | // Thinking block fields 242 | let thinking: String? 243 | let signature: String? 244 | 245 | // Tool use block fields 246 | let id: String? 247 | let name: String? 248 | let input: [String: Any]? 249 | 250 | // Tool result block fields 251 | let tool_use_id: String? 252 | let content: Any? 253 | let is_error: Bool? 254 | 255 | private enum CodingKeys: String, CodingKey { 256 | case type 257 | case text 258 | case thinking 259 | case signature 260 | case id 261 | case name 262 | case input 263 | case tool_use_id 264 | case content 265 | case is_error 266 | } 267 | 268 | init(from decoder: any Decoder) throws { 269 | let container = try decoder.container(keyedBy: CodingKeys.self) 270 | 271 | self.type = try container.decode(String.self, forKey: .type) 272 | self.text = try container.decodeIfPresent(String.self, forKey: .text) 273 | self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking) 274 | self.signature = try container.decodeIfPresent(String.self, forKey: .signature) 275 | self.id = try container.decodeIfPresent(String.self, forKey: .id) 276 | self.name = try container.decodeIfPresent(String.self, forKey: .name) 277 | self.tool_use_id = try container.decodeIfPresent(String.self, forKey: .tool_use_id) 278 | self.is_error = try container.decodeIfPresent(Bool.self, forKey: .is_error) 279 | 280 | // Decode dynamic fields 281 | if let inputData = try? container.decode(AnyCodable.self, forKey: .input) { 282 | self.input = inputData.value as? [String: Any] 283 | } else { 284 | self.input = nil 285 | } 286 | 287 | if let contentData = try? container.decode(AnyCodable.self, forKey: .content) { 288 | self.content = contentData.value 289 | } else { 290 | self.content = nil 291 | } 292 | } 293 | } 294 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Client/ConnectionAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Connection and Query APIs 4 | 5 | extension ClaudeCodeSDKClient { 6 | 7 | // MARK: - Connection Management 8 | 9 | /// Connect to Claude Code CLI with a prompt or message stream 10 | /// - Parameter prompt: String prompt, async sequence of messages, or nil for empty connection 11 | public func connect( 12 | prompt: S? = nil 13 | ) async throws where S.Element == [String: Any] { 14 | let transport = try SubprocessCLITransport() 15 | let options = await optionsManager.getOptions() 16 | 17 | if let prompt = prompt { 18 | // Use provided prompt 19 | let stream = try await transport.executeStream( 20 | prompts: prompt, 21 | options: options, 22 | closeStdinAfterPrompt: false // Keep stdin open for bidirectional communication 23 | ) 24 | 25 | await connectionManager.setConnection(transport: transport, messageStream: stream) 26 | } else { 27 | // Create empty stream for interactive use 28 | let stream = try await transport.connect(options: options) 29 | 30 | await connectionManager.setConnection(transport: transport, messageStream: stream) 31 | } 32 | } 33 | 34 | /// Connect to Claude Code CLI with a string prompt 35 | public func connect(prompt: String) async throws { 36 | let transport = try SubprocessCLITransport() 37 | let options = await optionsManager.getOptions() 38 | 39 | // For string prompts, use execute (one-shot mode) 40 | let stream = try await transport.execute(prompt: prompt, options: options) 41 | 42 | await connectionManager.setConnection(transport: transport, messageStream: stream) 43 | } 44 | 45 | /// Connect to Claude Code CLI in streaming mode (no initial prompts) 46 | public func connect() async throws { 47 | let transport = try SubprocessCLITransport() 48 | let options = await optionsManager.getOptions() 49 | 50 | let stream = try await transport.connect(options: options) 51 | 52 | await connectionManager.setConnection(transport: transport, messageStream: stream) 53 | } 54 | 55 | /// Reconnect to continue the last conversation or resume a specific session 56 | /// - Parameter sessionId: Session ID to resume. If nil, continues the last conversation. 57 | /// 58 | /// This method works by: 59 | /// - If sessionId is provided: uses --resume flag to reconnect to specific session 60 | /// - If sessionId is nil: uses --continue flag to continue the most recent conversation 61 | /// - Preserves all existing client options while adding session continuation 62 | public func reconnect(sessionId: String? = nil) async throws { 63 | // Disconnect any existing connection first 64 | await disconnect() 65 | 66 | // Create new options with session continuation 67 | let reconnectOptions: ClaudeCodeOptions 68 | 69 | // Always use existing options as base (these are the options passed to the client constructor) 70 | let baseOptions = await optionsManager.getOptions() ?? ClaudeCodeOptions() 71 | 72 | if let sessionId = sessionId { 73 | // Resume specific session - preserve all options but set resume flag 74 | reconnectOptions = ClaudeCodeOptionsBuilder(from: baseOptions) 75 | .resume(sessionId) // Use resume with specific session ID 76 | .build() 77 | } else { 78 | // Continue last conversation - preserve all options but set continue flag 79 | reconnectOptions = ClaudeCodeOptionsBuilder(from: baseOptions) 80 | .continueConversation(true) // Use continue flag 81 | .build() 82 | } 83 | 84 | // Create new transport and connect with session continuation 85 | let transport = try SubprocessCLITransport() 86 | let stream = try await transport.connect(options: reconnectOptions) 87 | await connectionManager.setConnection(transport: transport, messageStream: stream) 88 | } 89 | 90 | /// Disconnect from Claude Code CLI 91 | public func disconnect() async { 92 | if let transport = await connectionManager.getTransport() { 93 | await transport.terminate() 94 | } 95 | await connectionManager.clearConnection() 96 | } 97 | 98 | // MARK: - Message Streaming 99 | 100 | /// Receive all messages from Claude 101 | /// - Returns: An async sequence of messages 102 | public func receiveMessages() -> AsyncThrowingStream { 103 | return AsyncThrowingStream { continuation in 104 | Task { 105 | let stream = await connectionManager.getMessageStream() 106 | guard let stream = stream else { 107 | continuation.finish(throwing: ClaudeSDKError.invalidConfiguration( 108 | reason: "Not connected. Call connect() first." 109 | )) 110 | return 111 | } 112 | 113 | do { 114 | for try await message in stream { 115 | continuation.yield(message) 116 | } 117 | continuation.finish() 118 | } catch { 119 | continuation.finish(throwing: error) 120 | } 121 | } 122 | } 123 | } 124 | 125 | /// Receive messages until and including a ResultMessage 126 | /// 127 | /// This async iterator yields all messages in sequence and automatically terminates 128 | /// after yielding a ResultMessage (which indicates the response is complete). 129 | /// It's a convenience method over receiveMessages() for single-response workflows. 130 | /// 131 | /// **Stopping Behavior:** 132 | /// - Yields each message as it's received 133 | /// - Terminates immediately after yielding a ResultMessage 134 | /// - The ResultMessage IS included in the yielded messages 135 | /// - If no ResultMessage is received, the iterator continues indefinitely 136 | /// 137 | /// - Returns: An async sequence that terminates after ResultMessage 138 | /// 139 | /// ## Example: 140 | /// ```swift 141 | /// let client = ClaudeCodeSDKClient() 142 | /// try await client.connect() 143 | /// 144 | /// try await client.queryStream("What's the capital of France?") 145 | /// 146 | /// for try await msg in client.receiveResponse() { 147 | /// if let assistant = msg as? AssistantMessage { 148 | /// for block in assistant.content { 149 | /// if case .text(let text) = block { 150 | /// print("Claude: \(text)") 151 | /// } 152 | /// } 153 | /// } else if let result = msg as? ResultMessage { 154 | /// print("Cost: $\(result.totalCostUsd ?? 0)") 155 | /// // Iterator will terminate after this message 156 | /// } 157 | /// } 158 | /// ``` 159 | /// 160 | /// Note: To collect all messages: `let messages = try await Array(client.receiveResponse())` 161 | /// The final message in the array will always be a ResultMessage. 162 | public func receiveResponse() -> AsyncThrowingStream { 163 | let messages = self.receiveMessages() 164 | return AsyncThrowingStream { continuation in 165 | Task { 166 | do { 167 | for try await message in messages { 168 | continuation.yield(message) 169 | if case .result = message { 170 | continuation.finish() 171 | return 172 | } 173 | } 174 | continuation.finish() 175 | } catch { 176 | continuation.finish(throwing: error) 177 | } 178 | } 179 | } 180 | } 181 | 182 | 183 | // MARK: - Query Stream Methods 184 | 185 | /// Send a new request in streaming mode (string prompt) 186 | /// - Parameters: 187 | /// - prompt: The user prompt to send 188 | /// - sessionId: Session identifier for the conversation 189 | public func queryStream(_ prompt: String, sessionId: String = "default") async throws { 190 | guard let transport = await connectionManager.getTransport() else { 191 | throw ClaudeSDKError.invalidConfiguration(reason: "Not connected. Call connect() first.") 192 | } 193 | 194 | let message: [String: Any] = [ 195 | "type": "user", 196 | "message": [ 197 | "role": "user", 198 | "content": prompt 199 | ], 200 | "parent_tool_use_id": NSNull(), 201 | "session_id": sessionId 202 | ] 203 | 204 | // Use sendRequest to match Python SDK behavior 205 | try await transport.sendRequest([message], options: ["session_id": sessionId]) 206 | } 207 | 208 | /// Send a new request in streaming mode (async sequence of messages) 209 | /// - Parameters: 210 | /// - prompt: An async sequence of message dictionaries 211 | /// - sessionId: Session identifier for the conversation 212 | public func queryStream( 213 | prompt: S, 214 | sessionId: String = "default" 215 | ) async throws where S.Element == [String: Any] { 216 | guard let transport = await connectionManager.getTransport() else { 217 | throw ClaudeSDKError.invalidConfiguration(reason: "Not connected. Call connect() first.") 218 | } 219 | 220 | // Collect all messages first (like Python SDK) 221 | var messages: [[String: Any]] = [] 222 | for try await var msg in prompt { 223 | // Ensure session_id is set on each message 224 | if msg["session_id"] == nil { 225 | msg["session_id"] = sessionId 226 | } 227 | messages.append(msg) 228 | } 229 | 230 | if !messages.isEmpty { 231 | try await transport.sendRequest(messages, options: ["session_id": sessionId]) 232 | } 233 | } 234 | 235 | /// Send interrupt signal 236 | public func interrupt() async throws { 237 | guard let transport = await connectionManager.getTransport() else { 238 | throw ClaudeSDKError.invalidConfiguration(reason: "Not connected. Call connect() first.") 239 | } 240 | try await transport.interrupt() 241 | } 242 | } 243 | 244 | // MARK: - Async Context Manager Support 245 | 246 | extension ClaudeCodeSDKClient { 247 | /// Use the client as an async context manager 248 | /// ## Example: 249 | /// ```swift 250 | /// async let client = ClaudeCodeSDKClient.withConnection { client in 251 | /// try await client.queryStream("Hello!") 252 | /// // Use client here 253 | /// } 254 | /// // Automatically disconnects 255 | /// ``` 256 | public static func withConnection( 257 | options: ClaudeCodeOptions? = nil, 258 | _ block: (ClaudeCodeSDKClient) async throws -> T 259 | ) async throws -> T { 260 | let client = ClaudeCodeSDKClient(options: options) 261 | try await client.connect() 262 | do { 263 | let result = try await block(client) 264 | await client.disconnect() 265 | return result 266 | } catch { 267 | await client.disconnect() 268 | throw error 269 | } 270 | } 271 | 272 | /// Use the client as an async context manager with session continuation 273 | /// 274 | /// Automatically reconnects to continue the last conversation or resume a specific session. 275 | /// The connection is automatically terminated when the block completes. 276 | /// 277 | /// ## Example: 278 | /// ```swift 279 | /// // Continue last conversation 280 | /// async let result = ClaudeCodeSDKClient.withReconnection { client in 281 | /// try await client.queryStream("Continue where we left off...") 282 | /// // Process responses... 283 | /// } 284 | /// 285 | /// // Resume specific session 286 | /// async let result = ClaudeCodeSDKClient.withReconnection(sessionId: "session-123") { client in 287 | /// try await client.queryStream("Let's pick up from session 123") 288 | /// // Process responses... 289 | /// } 290 | /// ``` 291 | /// 292 | /// - Parameters: 293 | /// - sessionId: Session ID to resume. If nil, continues the last conversation. 294 | /// - options: Base configuration options (session settings will be overridden) 295 | /// - block: The async block to execute with the reconnected client 296 | /// - Returns: The result from the block 297 | public static func withReconnection( 298 | sessionId: String? = nil, 299 | options: ClaudeCodeOptions? = nil, 300 | _ block: (ClaudeCodeSDKClient) async throws -> T 301 | ) async throws -> T { 302 | let client = ClaudeCodeSDKClient(options: options) 303 | try await client.reconnect(sessionId: sessionId) 304 | do { 305 | let result = try await block(client) 306 | await client.disconnect() 307 | return result 308 | } catch { 309 | await client.disconnect() 310 | throw error 311 | } 312 | } 313 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSwiftSDKTests/TransportTests.swift: -------------------------------------------------------------------------------- 1 | // Generated by Claude 2 | 3 | import Testing 4 | import Foundation 5 | @testable import ClaudeCodeSwiftSDK 6 | 7 | @Suite("Subprocess CLI Transport Tests") 8 | struct SubprocessCLITransportTests { 9 | 10 | @Test("Transport initialization with valid CLI path") 11 | func transportInitializationWithValidCLIPath() { 12 | // Test that transport can be created with a valid CLI path 13 | let cliPath = URL(fileURLWithPath: "/usr/bin/claude") 14 | let options = ClaudeCodeOptions() 15 | 16 | // This would create a transport with the specified CLI path 17 | // In a real test, we'd verify the transport stores the path correctly 18 | #expect(cliPath.path == "/usr/bin/claude") 19 | #expect(options.allowedTools == []) 20 | } 21 | 22 | @Test("Build command with basic options") 23 | func buildCommandWithBasicOptions() { 24 | // Test that the transport builds the correct CLI command 25 | let options = ClaudeCodeOptions( 26 | systemPrompt: "Be helpful", 27 | maxTurns: 5, 28 | allowedTools: ["Read", "Write"], 29 | disallowedTools: ["Bash"], 30 | permissionMode: .acceptEdits, 31 | model: "claude-3-5-sonnet" 32 | ) 33 | 34 | // Verify options are set correctly 35 | #expect(options.systemPrompt == "Be helpful") 36 | #expect(options.allowedTools == ["Read", "Write"]) 37 | #expect(options.disallowedTools == ["Bash"]) 38 | #expect(options.model == "claude-3-5-sonnet") 39 | #expect(options.permissionMode == ClaudeCodeOptions.PermissionMode.acceptEdits) 40 | #expect(options.maxTurns == 5) 41 | 42 | // In a real implementation, we'd test that these options 43 | // are correctly converted to CLI arguments 44 | } 45 | 46 | @Test("Build command with add directories") 47 | func buildCommandWithAddDirectories() { 48 | let dir1 = URL(fileURLWithPath: "/path/to/dir1") 49 | let dir2 = URL(fileURLWithPath: "/path/to/dir2") 50 | 51 | let options = ClaudeCodeOptions(addDirs: [dir1, dir2]) 52 | 53 | #expect(options.addDirs.count == 2) 54 | #expect(options.addDirs[0] == dir1) 55 | #expect(options.addDirs[1] == dir2) 56 | 57 | // In a real implementation, we'd verify these are converted 58 | // to the correct --add-dir CLI arguments 59 | } 60 | 61 | @Test("Session continuation options") 62 | func sessionContinuationOptions() { 63 | let options = ClaudeCodeOptions( 64 | continueConversation: true, 65 | resume: "session-123" 66 | ) 67 | 68 | #expect(options.continueConversation == true) 69 | #expect(options.resume == "session-123") 70 | 71 | // In a real implementation, we'd verify these are converted 72 | // to --continue and --resume CLI arguments 73 | } 74 | 75 | @Test("CLI path discovery", .disabled("Requires file system access")) 76 | func cliPathDiscovery() { 77 | // This test would verify that CLI discovery works correctly 78 | // It's disabled because it requires actual file system access 79 | 80 | let searchPaths = [ 81 | URL(fileURLWithPath: "/usr/local/bin/claude"), 82 | URL(fileURLWithPath: "/opt/homebrew/bin/claude"), 83 | FileManager.default.homeDirectoryForCurrentUser 84 | .appendingPathComponent(".npm-global/bin/claude") 85 | ] 86 | 87 | // Verify search paths are reasonable 88 | #expect(searchPaths.count >= 3) 89 | #expect(searchPaths[0].lastPathComponent == "claude") 90 | } 91 | 92 | @Test("Transport error handling") 93 | func transportErrorHandling() { 94 | // Test various error conditions 95 | 96 | // CLI not found error 97 | let searchedPaths = [URL(fileURLWithPath: "/nonexistent/claude")] 98 | let cliNotFoundError = ClaudeSDKError.cliNotFound(searchedPaths: searchedPaths) 99 | 100 | if case .cliNotFound(let paths) = cliNotFoundError { 101 | #expect(paths == searchedPaths) 102 | } else { 103 | Issue.record("Expected cliNotFound error") 104 | } 105 | 106 | // Process error 107 | let processError = ClaudeSDKError.processError(message: "Command failed", exitCode: 127) 108 | 109 | if case .processError(let message, let exitCode) = processError { 110 | #expect(message == "Command failed") 111 | #expect(exitCode == 127) 112 | } else { 113 | Issue.record("Expected processError") 114 | } 115 | 116 | // Connection error 117 | let underlyingError = NSError(domain: "TestDomain", code: 1) 118 | let connectionError = ClaudeSDKError.cliConnectionError(underlying: underlyingError) 119 | 120 | if case .cliConnectionError(let underlying) = connectionError { 121 | #expect((underlying as NSError).domain == "TestDomain") 122 | } else { 123 | Issue.record("Expected cliConnectionError") 124 | } 125 | } 126 | 127 | @Test("Working directory validation") 128 | func workingDirectoryValidation() { 129 | // Test that working directory options are handled correctly 130 | let customPath = URL(fileURLWithPath: "/custom/working/directory") 131 | let options = ClaudeCodeOptions(cwd: customPath) 132 | 133 | #expect(options.cwd == customPath) 134 | #expect(options.cwd?.path == "/custom/working/directory") 135 | 136 | // In a real implementation, we'd verify that non-existent directories 137 | // are handled appropriately (either with an error or creating them) 138 | } 139 | 140 | @Test("Command argument escaping", .disabled("Requires full implementation")) 141 | func commandArgumentEscaping() { 142 | // Test that arguments with spaces and special characters are properly escaped 143 | let options = ClaudeCodeOptions( 144 | systemPrompt: "You are a helpful assistant with \"quotes\" and spaces", 145 | cwd: URL(fileURLWithPath: "/path with spaces/to directory") 146 | ) 147 | 148 | #expect(options.systemPrompt?.contains("\"") == true) 149 | #expect(options.cwd?.path.contains(" ") == true) 150 | 151 | // In a real implementation, we'd verify proper shell escaping 152 | } 153 | 154 | @Test("MCP server configuration with dictionary") 155 | func mcpServerConfigurationWithDictionary() { 156 | let stdioServer = McpStdioServerConfig( 157 | command: "python", 158 | args: ["-m", "my_server"], 159 | env: ["PATH": "/usr/bin"] 160 | ) 161 | 162 | let sseServer = McpSSEServerConfig( 163 | url: "https://api.example.com/mcp", 164 | headers: ["Authorization": "Bearer token"] 165 | ) 166 | 167 | let mcpServers: [String: any MCPServerConfig] = [ 168 | "stdio-server": stdioServer, 169 | "sse-server": sseServer 170 | ] 171 | 172 | let options = ClaudeCodeOptions(mcpServers: .dictionary(mcpServers)) 173 | 174 | if case .dictionary(let servers) = options.mcpServers { 175 | #expect(servers.count == 2) 176 | #expect(servers["stdio-server"] != nil) 177 | #expect(servers["sse-server"] != nil) 178 | } else { 179 | Issue.record("Expected dictionary MCP servers configuration") 180 | } 181 | 182 | // Verify server configurations 183 | #expect(stdioServer.command == "python") 184 | #expect(stdioServer.args == ["-m", "my_server"]) 185 | #expect(stdioServer.env?["PATH"] == "/usr/bin") 186 | 187 | #expect(sseServer.url == "https://api.example.com/mcp") 188 | #expect(sseServer.headers?["Authorization"] == "Bearer token") 189 | } 190 | 191 | @Test("MCP server configuration with JSON string") 192 | func mcpServerConfigurationWithString() { 193 | let jsonConfig = """ 194 | { 195 | "mcpServers": { 196 | "test-server": { 197 | "type": "stdio", 198 | "command": "/path/to/server", 199 | "args": ["--option", "value"] 200 | } 201 | } 202 | } 203 | """ 204 | 205 | let options = ClaudeCodeOptions(mcpServers: .string(jsonConfig)) 206 | 207 | if case .string(let configString) = options.mcpServers { 208 | #expect(configString.contains("test-server")) 209 | #expect(configString.contains("/path/to/server")) 210 | } else { 211 | Issue.record("Expected string MCP servers configuration") 212 | } 213 | } 214 | 215 | @Test("MCP server configuration with file path") 216 | func mcpServerConfigurationWithPath() { 217 | let configPath = URL(fileURLWithPath: "/path/to/mcp-config.json") 218 | 219 | let options = ClaudeCodeOptions(mcpServers: .path(configPath)) 220 | 221 | if case .path(let filePath) = options.mcpServers { 222 | #expect(filePath == configPath) 223 | #expect(filePath.path == "/path/to/mcp-config.json") 224 | } else { 225 | Issue.record("Expected path MCP servers configuration") 226 | } 227 | } 228 | 229 | @Test("MCP server configuration with builder") 230 | func mcpServerConfigurationWithBuilder() { 231 | let stdioServer = McpStdioServerConfig( 232 | command: "python", 233 | args: ["-m", "server"], 234 | env: ["PYTHONPATH": "/opt/server"] 235 | ) 236 | 237 | let servers: [String: any MCPServerConfig] = [ 238 | "test-server": stdioServer 239 | ] 240 | 241 | // Test dictionary builder method 242 | let options1 = ClaudeCodeOptionsBuilder() 243 | .mcpServers(servers) 244 | .build() 245 | 246 | if case .dictionary(let dictServers) = options1.mcpServers { 247 | #expect(dictServers.count == 1) 248 | #expect(dictServers["test-server"] != nil) 249 | } else { 250 | Issue.record("Expected dictionary MCP servers from builder") 251 | } 252 | 253 | // Test string builder method 254 | let jsonString = "{\"mcpServers\": {\"string-server\": {\"type\": \"stdio\"}}}" 255 | let options2 = ClaudeCodeOptionsBuilder() 256 | .mcpServersFromString(jsonString) 257 | .build() 258 | 259 | if case .string(let configString) = options2.mcpServers { 260 | #expect(configString == jsonString) 261 | } else { 262 | Issue.record("Expected string MCP servers from builder") 263 | } 264 | 265 | // Test path builder method 266 | let configPath = URL(fileURLWithPath: "/config/mcp.json") 267 | let options3 = ClaudeCodeOptionsBuilder() 268 | .mcpServersFromPath(configPath) 269 | .build() 270 | 271 | if case .path(let filePath) = options3.mcpServers { 272 | #expect(filePath == configPath) 273 | } else { 274 | Issue.record("Expected path MCP servers from builder") 275 | } 276 | } 277 | 278 | @Test("Build command with extra args for future CLI flags") 279 | func buildCommandWithExtraArgs() { 280 | // Test building CLI command with extra_args for future flags 281 | let options = ClaudeCodeOptions( 282 | extraArgs: [ 283 | "new-flag": "value", 284 | "boolean-flag": nil, 285 | "another-option": "test-value" 286 | ] 287 | ) 288 | 289 | // Verify extra args are set correctly 290 | #expect(options.extraArgs.count == 3) 291 | #expect(options.extraArgs["new-flag"] == "value") 292 | #expect(options.extraArgs["boolean-flag"] != nil) // Key exists 293 | #expect(options.extraArgs["boolean-flag"]! == nil) // Value is nil (boolean flag) 294 | #expect(options.extraArgs["another-option"] == "test-value") 295 | 296 | // In a real implementation, this would generate: 297 | // --new-flag value --boolean-flag --another-option test-value 298 | } 299 | 300 | @Test("Build command with settings as file path") 301 | func buildCommandWithSettingsFile() { 302 | // Test building CLI command with settings as file path 303 | let options = ClaudeCodeOptions( 304 | settings: "/path/to/settings.json" 305 | ) 306 | 307 | #expect(options.settings == "/path/to/settings.json") 308 | 309 | // In a real implementation, this would generate: 310 | // --settings /path/to/settings.json 311 | } 312 | 313 | @Test("Build command with settings as JSON object") 314 | func buildCommandWithSettingsJson() { 315 | // Test building CLI command with settings as JSON object 316 | let settingsJson = "{\"permissions\": {\"allow\": [\"Bash(ls:*)\"]}}"; 317 | let options = ClaudeCodeOptions( 318 | settings: settingsJson 319 | ) 320 | 321 | #expect(options.settings == settingsJson) 322 | 323 | // In a real implementation, this would generate: 324 | // --settings {"permissions": {"allow": ["Bash(ls:*)"]}} 325 | } 326 | 327 | @Test("Extra args command building integration", .disabled("Requires SubprocessCLI internal access")) 328 | func extraArgsCommandBuildingIntegration() { 329 | // This would test the actual command building with extra args 330 | // It's disabled because it requires access to the internal buildArguments method 331 | 332 | let options = ClaudeCodeOptions( 333 | systemPrompt: "test prompt", 334 | extraArgs: [ 335 | "new-flag": "value", 336 | "boolean-flag": nil, 337 | "file-path": "/path/to/file.txt" 338 | ] 339 | ) 340 | 341 | // In a real implementation with access to buildArguments: 342 | // let transport = SubprocessCLITransport(cliPath: URL(fileURLWithPath: "/test/claude")) 343 | // let args = transport.buildArguments(isStreaming: false, options: options) 344 | // 345 | // Expected args would include: 346 | // ["--output-format", "stream-json", "--verbose", "--system-prompt", "test prompt", 347 | // "--new-flag", "value", "--boolean-flag", "--file-path", "/path/to/file.txt", "--print"] 348 | 349 | #expect(options.systemPrompt == "test prompt") 350 | #expect(options.extraArgs["new-flag"] == "value") 351 | #expect(options.extraArgs["boolean-flag"] == nil) 352 | #expect(options.extraArgs["file-path"] == "/path/to/file.txt") 353 | } 354 | } -------------------------------------------------------------------------------- /Tests/ClaudeCodeSwiftSDKTests/MockingTests.swift: -------------------------------------------------------------------------------- 1 | // Generated by Claude 2 | 3 | import Testing 4 | import Foundation 5 | @testable import ClaudeCodeSwiftSDK 6 | 7 | // MARK: - Mock Transport for Testing 8 | 9 | /// Mock transport that doesn't require actual CLI 10 | class MockSubprocessCLITransport { 11 | private var mockMessages: [Message] = [] 12 | private var isConnected: Bool = false 13 | 14 | init(mockMessages: [Message] = []) { 15 | self.mockMessages = mockMessages 16 | } 17 | 18 | func connect() async throws { 19 | isConnected = true 20 | } 21 | 22 | func disconnect() async throws { 23 | isConnected = false 24 | } 25 | 26 | func executeQuery() -> AsyncThrowingStream { 27 | let messages = self.mockMessages 28 | return AsyncThrowingStream { continuation in 29 | Task { 30 | for message in messages { 31 | continuation.yield(message) 32 | } 33 | continuation.finish() 34 | } 35 | } 36 | } 37 | 38 | func addMockMessage(_ message: Message) { 39 | mockMessages.append(message) 40 | } 41 | } 42 | 43 | // MARK: - Mock Tests (equivalent to Python's mocked tests) 44 | 45 | @Suite("Mocked Query Function Tests") 46 | struct MockedQueryFunctionTests { 47 | 48 | @Test("Query with single prompt using mock") 49 | func queryWithSinglePrompt() async throws { 50 | // Create mock messages similar to Python test 51 | let assistantMessage = Message.assistant(AssistantMessage( 52 | id: "test-id-1", 53 | content: [TextBlock(text: "4")], 54 | model: "test-model", 55 | sessionId: "test-session" 56 | )) 57 | let resultMessage = Message.result(ResultMessage( 58 | subtype: "success", 59 | durationMs: 1000, 60 | durationApiMs: 800, 61 | isError: false, 62 | numTurns: 1, 63 | sessionId: "test-session" 64 | )) 65 | 66 | let mockTransport = MockSubprocessCLITransport(mockMessages: [assistantMessage, resultMessage]) 67 | 68 | // Simulate what the query function would do 69 | var messages: [Message] = [] 70 | for try await message in mockTransport.executeQuery() { 71 | messages.append(message) 72 | } 73 | 74 | // Verify results like Python test 75 | #expect(messages.count == 2) 76 | 77 | // Check first message is assistant 78 | if case .assistant(let assistantMsg) = messages[0], 79 | let textBlock = assistantMsg.content[0] as? TextBlock { 80 | #expect(textBlock.text == "4") 81 | } else { 82 | Issue.record("Expected AssistantMessage with TextBlock") 83 | } 84 | 85 | // Check second message is result 86 | if case .result = messages[1] { 87 | // Success 88 | } else { 89 | Issue.record("Expected ResultMessage") 90 | } 91 | } 92 | 93 | @Test("Query with options using mock") 94 | func queryWithOptions() async throws { 95 | // Test options handling similar to Python 96 | let options = ClaudeCodeOptions( 97 | systemPrompt: "You are helpful", 98 | maxTurns: 5, 99 | allowedTools: ["Read", "Write"], 100 | permissionMode: .acceptEdits 101 | ) 102 | 103 | let assistantMessage = Message.assistant(AssistantMessage( 104 | id: "test-id-2", 105 | content: [TextBlock(text: "Hello!")], 106 | model: "test-model", 107 | sessionId: "test-session" 108 | )) 109 | let mockTransport = MockSubprocessCLITransport(mockMessages: [assistantMessage]) 110 | 111 | // Verify options are correctly constructed 112 | #expect(options.allowedTools == ["Read", "Write"]) 113 | #expect(options.systemPrompt == "You are helpful") 114 | #expect(options.permissionMode == .acceptEdits) 115 | #expect(options.maxTurns == 5) 116 | 117 | // Simulate query execution 118 | var messages: [Message] = [] 119 | for try await message in mockTransport.executeQuery() { 120 | messages.append(message) 121 | } 122 | 123 | #expect(messages.count == 1) 124 | if case .assistant = messages[0] { 125 | // Success 126 | } else { 127 | Issue.record("Expected AssistantMessage") 128 | } 129 | } 130 | 131 | @Test("Query with custom working directory using mock") 132 | func queryWithCustomWorkingDirectory() async throws { 133 | let customPath = URL(fileURLWithPath: "/custom/path") 134 | let options = ClaudeCodeOptions(cwd: customPath) 135 | 136 | // Mock the message stream similar to Python test 137 | let assistantMessage = Message.assistant(AssistantMessage( 138 | id: "test-id-3", 139 | content: [TextBlock(text: "Done")], 140 | model: "test-model", 141 | sessionId: "test-session" 142 | )) 143 | let resultMessage = Message.result(ResultMessage( 144 | subtype: "success", 145 | durationMs: 1000, 146 | durationApiMs: 800, 147 | isError: false, 148 | numTurns: 1, 149 | sessionId: "test-session", 150 | totalCostUsd: 0.001 151 | )) 152 | 153 | let mockTransport = MockSubprocessCLITransport(mockMessages: [assistantMessage, resultMessage]) 154 | 155 | // Verify the options include the custom path 156 | #expect(options.cwd == customPath) 157 | #expect(options.cwd?.path == "/custom/path") 158 | 159 | // Execute mock query 160 | var messages: [Message] = [] 161 | for try await message in mockTransport.executeQuery() { 162 | messages.append(message) 163 | } 164 | 165 | #expect(messages.count == 2) 166 | if case .result(let result) = messages[1] { 167 | #expect(result.totalCostUsd == 0.001) 168 | #expect(result.sessionId == "test-session") 169 | } else { 170 | Issue.record("Expected ResultMessage") 171 | } 172 | } 173 | } 174 | 175 | @Suite("Mocked Transport Tests") 176 | struct MockedTransportTests { 177 | 178 | @Test("Transport connection lifecycle") 179 | func transportConnectionLifecycle() async throws { 180 | let mockTransport = MockSubprocessCLITransport() 181 | 182 | // Test connection 183 | try await mockTransport.connect() 184 | 185 | // Test message execution 186 | let testMessage = Message.assistant(AssistantMessage( 187 | id: "test-id-4", 188 | content: [TextBlock(text: "Test")], 189 | model: "test-model", 190 | sessionId: "test-session" 191 | )) 192 | mockTransport.addMockMessage(testMessage) 193 | 194 | var receivedMessages: [Message] = [] 195 | for try await message in mockTransport.executeQuery() { 196 | receivedMessages.append(message) 197 | } 198 | 199 | #expect(receivedMessages.count == 1) 200 | if case .assistant = receivedMessages[0] { 201 | // Success 202 | } else { 203 | Issue.record("Expected AssistantMessage") 204 | } 205 | 206 | // Test disconnection 207 | try await mockTransport.disconnect() 208 | } 209 | 210 | @Test("Transport command building simulation") 211 | func transportCommandBuildingSimulation() { 212 | // Simulate command building like Python's test_build_command_with_options 213 | let options = ClaudeCodeOptions( 214 | systemPrompt: "Be helpful", 215 | maxTurns: 5, 216 | allowedTools: ["Read", "Write"], 217 | disallowedTools: ["Bash"], 218 | permissionMode: .acceptEdits, 219 | model: "claude-3-5-sonnet" 220 | ) 221 | 222 | // In a real implementation, we'd test that these options 223 | // are converted to the correct CLI command arguments 224 | let expectedArguments = [ 225 | "--system-prompt", "Be helpful", 226 | "--max-turns", "5", 227 | "--allowedTools", "Read,Write", 228 | "--disallowedTools", "Bash", 229 | "--permission-mode", "acceptEdits", 230 | "--model", "claude-3-5-sonnet", 231 | "--output-format", "stream-json" 232 | ] 233 | 234 | // Verify all expected arguments would be present 235 | #expect(options.systemPrompt == "Be helpful") 236 | #expect(options.maxTurns == 5) 237 | #expect(options.allowedTools == ["Read", "Write"]) 238 | #expect(options.disallowedTools == ["Bash"]) 239 | #expect(options.permissionMode == .acceptEdits) 240 | #expect(options.model == "claude-3-5-sonnet") 241 | 242 | // In a real implementation, we'd verify the CLI command construction 243 | #expect(expectedArguments.contains("--system-prompt")) 244 | #expect(expectedArguments.contains("Be helpful")) 245 | } 246 | 247 | @Test("Add directories option simulation") 248 | func addDirectoriesOptionSimulation() { 249 | let dir1 = URL(fileURLWithPath: "/path/to/dir1") 250 | let dir2 = URL(fileURLWithPath: "/path/to/dir2") 251 | let options = ClaudeCodeOptions(addDirs: [dir1, dir2]) 252 | 253 | #expect(options.addDirs.count == 2) 254 | #expect(options.addDirs[0] == dir1) 255 | #expect(options.addDirs[1] == dir2) 256 | 257 | // Simulate CLI command building 258 | var cmdArgs: [String] = [] 259 | for dir in options.addDirs { 260 | cmdArgs.append("--add-dir") 261 | cmdArgs.append(dir.path) 262 | } 263 | 264 | let expectedCommand = "--add-dir /path/to/dir1 --add-dir /path/to/dir2" 265 | let actualCommand = cmdArgs.joined(separator: " ") 266 | 267 | #expect(actualCommand == expectedCommand) 268 | } 269 | 270 | @Test("Session continuation simulation") 271 | func sessionContinuationSimulation() { 272 | let options = ClaudeCodeOptions( 273 | continueConversation: true, 274 | resume: "session-123" 275 | ) 276 | 277 | #expect(options.continueConversation == true) 278 | #expect(options.resume == "session-123") 279 | 280 | // Simulate building CLI command with session options 281 | var cmdArgs: [String] = [] 282 | if options.continueConversation { 283 | cmdArgs.append("--continue") 284 | } 285 | if let resume = options.resume { 286 | cmdArgs.append("--resume") 287 | cmdArgs.append(resume) 288 | } 289 | 290 | #expect(cmdArgs.contains("--continue")) 291 | #expect(cmdArgs.contains("--resume")) 292 | #expect(cmdArgs.contains("session-123")) 293 | } 294 | 295 | @Test("Error handling with nonexistent directory") 296 | func errorHandlingWithNonexistentDirectory() { 297 | let nonexistentPath = "/this/directory/does/not/exist" 298 | let options = ClaudeCodeOptions(cwd: URL(fileURLWithPath: nonexistentPath)) 299 | 300 | #expect(options.cwd?.path == nonexistentPath) 301 | 302 | // In a real implementation, connecting with this path would throw CLIConnectionError 303 | let expectedError = ClaudeSDKError.cliConnectionError( 304 | underlying: NSError(domain: "Test", code: 1, userInfo: [ 305 | NSLocalizedDescriptionKey: "Directory does not exist: \(nonexistentPath)" 306 | ]) 307 | ) 308 | 309 | if case .cliConnectionError(let underlying) = expectedError { 310 | #expect(underlying.localizedDescription.contains(nonexistentPath)) 311 | } else { 312 | Issue.record("Expected cliConnectionError") 313 | } 314 | } 315 | } 316 | 317 | @Suite("Message Stream Simulation Tests") 318 | struct MessageStreamSimulationTests { 319 | 320 | @Test("Simulate receiving mixed message types") 321 | func simulateReceivingMixedMessageTypes() async throws { 322 | // Simulate the message stream from Python's test_receive_messages 323 | let messages: [Message] = [ 324 | .assistant(AssistantMessage( 325 | id: "test-id-5", 326 | content: [TextBlock(text: "Hello!")], 327 | model: "test-model", 328 | sessionId: "test-session" 329 | )), 330 | .user(UserMessage(content: .text("Hi there"), sessionId: "test-session")), 331 | .system(SystemMessage(subtype: "status", genericData: ["info": AnyCodable("Processing")])), 332 | .result(ResultMessage( 333 | subtype: "success", 334 | durationMs: 1500, 335 | durationApiMs: 1200, 336 | isError: false, 337 | numTurns: 2, 338 | sessionId: "test-session", 339 | totalCostUsd: 0.005 340 | )) 341 | ] 342 | 343 | let mockTransport = MockSubprocessCLITransport(mockMessages: messages) 344 | 345 | var receivedMessages: [Message] = [] 346 | for try await message in mockTransport.executeQuery() { 347 | receivedMessages.append(message) 348 | } 349 | 350 | #expect(receivedMessages.count == 4) 351 | 352 | // Verify content using enum pattern matching 353 | if case .assistant(let assistantMsg) = receivedMessages[0], 354 | let textBlock = assistantMsg.content[0] as? TextBlock { 355 | #expect(textBlock.text == "Hello!") 356 | } else { 357 | Issue.record("Expected AssistantMessage with text") 358 | } 359 | 360 | if case .user(let userMsg) = receivedMessages[1], 361 | case .text(let content) = userMsg.content { 362 | #expect(content == "Hi there") 363 | } else { 364 | Issue.record("Expected UserMessage with text content") 365 | } 366 | 367 | if case .system = receivedMessages[2] { 368 | // Success 369 | } else { 370 | Issue.record("Expected SystemMessage") 371 | } 372 | 373 | if case .result = receivedMessages[3] { 374 | // Success 375 | } else { 376 | Issue.record("Expected ResultMessage") 377 | } 378 | } 379 | 380 | @Test("Simulate response stream stopping at result") 381 | func simulateResponseStreamStoppingAtResult() async throws { 382 | // Simulate Python's test_receive_response behavior 383 | let messages: [Message] = [ 384 | .assistant(AssistantMessage( 385 | id: "test-id-6", 386 | content: [TextBlock(text: "Answer")], 387 | model: "test-model", 388 | sessionId: "test-session" 389 | )), 390 | .result(ResultMessage( 391 | subtype: "success", 392 | durationMs: 1000, 393 | durationApiMs: 800, 394 | isError: false, 395 | numTurns: 1, 396 | sessionId: "test", 397 | totalCostUsd: 0.001 398 | )), 399 | // This should not be included in response stream 400 | .assistant(AssistantMessage( 401 | id: "test-id-7", 402 | content: [TextBlock(text: "Should not see this")], 403 | model: "test-model", 404 | sessionId: "test-session" 405 | )) 406 | ] 407 | 408 | let mockTransport = MockSubprocessCLITransport(mockMessages: messages) 409 | 410 | // Simulate receive_response that stops at ResultMessage 411 | var receivedMessages: [Message] = [] 412 | for try await message in mockTransport.executeQuery() { 413 | receivedMessages.append(message) 414 | 415 | // Stop at result message (like receive_response does) 416 | if case .result = message { 417 | break 418 | } 419 | } 420 | 421 | // Should only get 2 messages (assistant + result) 422 | #expect(receivedMessages.count == 2) 423 | 424 | if case .assistant = receivedMessages[0] { 425 | // Success 426 | } else { 427 | Issue.record("Expected AssistantMessage") 428 | } 429 | 430 | if case .result(let lastMsg) = receivedMessages[1] { 431 | #expect(lastMsg.subtype == "success") 432 | } else { 433 | Issue.record("Expected ResultMessage as last message") 434 | } 435 | } 436 | } -------------------------------------------------------------------------------- /Sources/ClaudeCodeSwiftSDK/Types/Message.swift: -------------------------------------------------------------------------------- 1 | // This file was generated by Claude 2 | 3 | import Foundation 4 | 5 | // MARK: - Message Enum 6 | 7 | /// Unified message type for all Claude interactions 8 | /// This enum eliminates type erasure overhead and provides better performance 9 | public enum Message: Codable, Sendable { 10 | case user(UserMessage) // User text/tool results 11 | case assistant(AssistantMessage) // Assistant text/tool calls 12 | case system(SystemMessage) // System metadata 13 | case result(ResultMessage) // Session results 14 | 15 | // Custom Codable implementation to handle different message types 16 | private enum CodingKeys: String, CodingKey { 17 | case type 18 | } 19 | 20 | public init(from decoder: any Decoder) throws { 21 | let container = try decoder.container(keyedBy: CodingKeys.self) 22 | let type = try container.decode(String.self, forKey: .type) 23 | 24 | switch type { 25 | case "user": 26 | let userMessage = try UserMessage(from: decoder) 27 | self = .user(userMessage) 28 | case "assistant": 29 | let assistantMessage = try AssistantMessage(from: decoder) 30 | self = .assistant(assistantMessage) 31 | case "system": 32 | let systemMessage = try SystemMessage(from: decoder) 33 | self = .system(systemMessage) 34 | case "result": 35 | let resultMessage = try ResultMessage(from: decoder) 36 | self = .result(resultMessage) 37 | default: 38 | throw DecodingError.dataCorrupted( 39 | DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown message type: \(type)") 40 | ) 41 | } 42 | } 43 | 44 | public func encode(to encoder: any Encoder) throws { 45 | switch self { 46 | case .user(let userMessage): 47 | var container = encoder.container(keyedBy: CodingKeys.self) 48 | try container.encode("user", forKey: .type) 49 | try userMessage.encode(to: encoder) 50 | case .assistant(let assistantMessage): 51 | var container = encoder.container(keyedBy: CodingKeys.self) 52 | try container.encode("assistant", forKey: .type) 53 | try assistantMessage.encode(to: encoder) 54 | case .system(let systemMessage): 55 | var container = encoder.container(keyedBy: CodingKeys.self) 56 | try container.encode("system", forKey: .type) 57 | try systemMessage.encode(to: encoder) 58 | case .result(let resultMessage): 59 | var container = encoder.container(keyedBy: CodingKeys.self) 60 | try container.encode("result", forKey: .type) 61 | try resultMessage.encode(to: encoder) 62 | } 63 | } 64 | } 65 | 66 | // MARK: - User Message 67 | 68 | /// A message from the user 69 | public struct UserMessage: Codable, Sendable { 70 | public let role: String 71 | public let content: UserContent 72 | public let parentToolUseId: String? 73 | public let sessionId: String 74 | 75 | public enum UserContent: Codable, Sendable { 76 | case text(String) 77 | case blocks([any ContentBlock]) 78 | 79 | public init(from decoder: any Decoder) throws { 80 | let container = try decoder.singleValueContainer() 81 | 82 | // Try to decode as string first 83 | if let string = try? container.decode(String.self) { 84 | self = .text(string) 85 | return 86 | } 87 | 88 | // Try to decode as array of content blocks 89 | if let blocksData = try? container.decode([AnyCodable].self) { 90 | var blocks: [any ContentBlock] = [] 91 | 92 | for blockData in blocksData { 93 | if let dict = blockData.value as? [String: Any] { 94 | let data = try JSONSerialization.data(withJSONObject: dict) 95 | 96 | // Try to decode each content block type 97 | if let textBlock = try? JSONDecoder().decode(TextBlock.self, from: data) { 98 | blocks.append(textBlock) 99 | } else if let thinkingBlock = try? JSONDecoder().decode(ThinkingBlock.self, from: data) { 100 | blocks.append(thinkingBlock) 101 | } else if let toolResultBlock = try? JSONDecoder().decode(ToolResultBlock.self, from: data) { 102 | blocks.append(toolResultBlock) 103 | } 104 | } 105 | } 106 | 107 | self = .blocks(blocks) 108 | return 109 | } 110 | 111 | throw DecodingError.dataCorruptedError( 112 | in: container, 113 | debugDescription: "UserMessage content must be either String or [ContentBlock]" 114 | ) 115 | } 116 | 117 | public func encode(to encoder: any Encoder) throws { 118 | var container = encoder.singleValueContainer() 119 | 120 | switch self { 121 | case .text(let string): 122 | try container.encode(string) 123 | case .blocks(let blocks): 124 | // Encode as array of dictionaries with type information 125 | var blockDicts: [[String: Any]] = [] 126 | 127 | for block in blocks { 128 | if let textBlock = block as? TextBlock { 129 | blockDicts.append([ 130 | "type": "text", 131 | "text": textBlock.text 132 | ]) 133 | } else if let thinkingBlock = block as? ThinkingBlock { 134 | blockDicts.append([ 135 | "type": "thinking", 136 | "thinking": thinkingBlock.thinking, 137 | "signature": thinkingBlock.signature 138 | ]) 139 | } else if let toolResultBlock = block as? ToolResultBlock { 140 | var dict: [String: Any] = [ 141 | "type": "tool_result", 142 | "tool_use_id": toolResultBlock.toolUseId 143 | ] 144 | if let content = toolResultBlock.content { 145 | switch content { 146 | case .text(let text): 147 | dict["content"] = text 148 | case .structured(let structured): 149 | dict["content"] = structured 150 | } 151 | } 152 | if let isError = toolResultBlock.isError { 153 | dict["is_error"] = isError 154 | } 155 | blockDicts.append(dict) 156 | } 157 | } 158 | 159 | try container.encode(blockDicts.map { AnyCodable($0) }) 160 | } 161 | } 162 | } 163 | 164 | public init(content: String, parentToolUseId: String? = nil, sessionId: String, role: String = "user") { 165 | self.role = role 166 | self.content = .text(content) 167 | self.parentToolUseId = parentToolUseId 168 | self.sessionId = sessionId 169 | } 170 | 171 | public init(content: [any ContentBlock], parentToolUseId: String? = nil, sessionId: String, role: String = "user") { 172 | self.role = role 173 | self.content = .blocks(content) 174 | self.parentToolUseId = parentToolUseId 175 | self.sessionId = sessionId 176 | } 177 | 178 | public init(content: UserContent, parentToolUseId: String? = nil, sessionId: String, role: String = "user") { 179 | self.role = role 180 | self.content = content 181 | self.parentToolUseId = parentToolUseId 182 | self.sessionId = sessionId 183 | } 184 | 185 | // Custom Codable implementation for UserMessage 186 | private enum CodingKeys: String, CodingKey { 187 | case role 188 | case content 189 | case parentToolUseId = "parent_tool_use_id" 190 | case sessionId = "session_id" 191 | } 192 | 193 | public init(from decoder: any Decoder) throws { 194 | let container = try decoder.container(keyedBy: CodingKeys.self) 195 | self.role = try container.decode(String.self, forKey: .role) 196 | self.content = try container.decode(UserContent.self, forKey: .content) 197 | self.parentToolUseId = try container.decodeIfPresent(String.self, forKey: .parentToolUseId) 198 | self.sessionId = try container.decode(String.self, forKey: .sessionId) 199 | } 200 | 201 | public func encode(to encoder: any Encoder) throws { 202 | var container = encoder.container(keyedBy: CodingKeys.self) 203 | try container.encode(role, forKey: .role) 204 | try container.encode(content, forKey: .content) 205 | try container.encodeIfPresent(parentToolUseId, forKey: .parentToolUseId) 206 | try container.encode(sessionId, forKey: .sessionId) 207 | } 208 | } 209 | 210 | // MARK: - Assistant Message 211 | 212 | /// A message from the assistant containing various content blocks 213 | public struct AssistantMessage: Codable, Sendable { 214 | public let id: String 215 | public let role: String 216 | public let content: [any ContentBlock] 217 | public let model: String 218 | public let stopReason: String? 219 | public let stopSequence: String? 220 | public let usage: UsageInfo? 221 | public let parentToolUseId: String? 222 | public let sessionId: String 223 | 224 | public init( 225 | id: String, 226 | role: String = "assistant", 227 | content: [any ContentBlock], 228 | model: String, 229 | stopReason: String? = nil, 230 | stopSequence: String? = nil, 231 | usage: UsageInfo? = nil, 232 | parentToolUseId: String? = nil, 233 | sessionId: String 234 | ) { 235 | self.id = id 236 | self.role = role 237 | self.content = content 238 | self.model = model 239 | self.stopReason = stopReason 240 | self.stopSequence = stopSequence 241 | self.usage = usage 242 | self.parentToolUseId = parentToolUseId 243 | self.sessionId = sessionId 244 | } 245 | 246 | // Custom Codable implementation for heterogeneous array 247 | private enum CodingKeys: String, CodingKey { 248 | case id 249 | case role 250 | case content 251 | case model 252 | case stopReason = "stop_reason" 253 | case stopSequence = "stop_sequence" 254 | case usage 255 | case parentToolUseId = "parent_tool_use_id" 256 | case sessionId = "session_id" 257 | } 258 | 259 | private enum ContentBlockType: String, Codable { 260 | case text 261 | case thinking 262 | case toolUse = "tool_use" 263 | } 264 | 265 | private struct TypedContentBlock: Codable { 266 | let type: ContentBlockType 267 | let block: AnyCodable 268 | } 269 | 270 | public init(from decoder: any Decoder) throws { 271 | let container = try decoder.container(keyedBy: CodingKeys.self) 272 | let typedBlocks = try container.decode([TypedContentBlock].self, forKey: .content) 273 | 274 | self.id = try container.decode(String.self, forKey: .id) 275 | self.role = try container.decode(String.self, forKey: .role) 276 | self.model = try container.decode(String.self, forKey: .model) 277 | self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason) 278 | self.stopSequence = try container.decodeIfPresent(String.self, forKey: .stopSequence) 279 | self.usage = try container.decodeIfPresent(UsageInfo.self, forKey: .usage) 280 | self.parentToolUseId = try container.decodeIfPresent(String.self, forKey: .parentToolUseId) 281 | self.sessionId = try container.decode(String.self, forKey: .sessionId) 282 | 283 | var blocks: [any ContentBlock] = [] 284 | 285 | for typedBlock in typedBlocks { 286 | switch typedBlock.type { 287 | case .text: 288 | if let dict = typedBlock.block.value as? [String: Any], 289 | let text = dict["text"] as? String { 290 | blocks.append(TextBlock(text: text)) 291 | } 292 | case .thinking: 293 | if let dict = typedBlock.block.value as? [String: Any], 294 | let thinking = dict["thinking"] as? String, 295 | let signature = dict["signature"] as? String { 296 | blocks.append(ThinkingBlock(thinking: thinking, signature: signature)) 297 | } 298 | case .toolUse: 299 | if let dict = typedBlock.block.value as? [String: Any], 300 | let id = dict["id"] as? String, 301 | let name = dict["name"] as? String, 302 | let input = dict["input"] as? [String: Any] { 303 | let anyCodableInput = input.mapValues { AnyCodable($0) } 304 | blocks.append(ToolUseBlock(id: id, name: name, input: anyCodableInput)) 305 | } 306 | } 307 | } 308 | 309 | self.content = blocks 310 | } 311 | 312 | public func encode(to encoder: any Encoder) throws { 313 | var container = encoder.container(keyedBy: CodingKeys.self) 314 | 315 | try container.encode(id, forKey: .id) 316 | try container.encode(role, forKey: .role) 317 | try container.encode(model, forKey: .model) 318 | try container.encodeIfPresent(stopReason, forKey: .stopReason) 319 | try container.encodeIfPresent(stopSequence, forKey: .stopSequence) 320 | try container.encodeIfPresent(usage, forKey: .usage) 321 | try container.encodeIfPresent(parentToolUseId, forKey: .parentToolUseId) 322 | try container.encode(sessionId, forKey: .sessionId) 323 | 324 | var typedBlocks: [TypedContentBlock] = [] 325 | 326 | for block in content { 327 | if let textBlock = block as? TextBlock { 328 | typedBlocks.append(TypedContentBlock( 329 | type: .text, 330 | block: AnyCodable(["text": textBlock.text]) 331 | )) 332 | } else if let thinkingBlock = block as? ThinkingBlock { 333 | typedBlocks.append(TypedContentBlock( 334 | type: .thinking, 335 | block: AnyCodable([ 336 | "thinking": thinkingBlock.thinking, 337 | "signature": thinkingBlock.signature 338 | ]) 339 | )) 340 | } else if let toolUseBlock = block as? ToolUseBlock { 341 | typedBlocks.append(TypedContentBlock( 342 | type: .toolUse, 343 | block: AnyCodable([ 344 | "id": toolUseBlock.id, 345 | "name": toolUseBlock.name, 346 | "input": toolUseBlock.input 347 | ]) 348 | )) 349 | } 350 | } 351 | 352 | try container.encode(typedBlocks, forKey: .content) 353 | } 354 | } 355 | 356 | // MARK: - System Message 357 | 358 | /// A system message containing metadata or control information 359 | /// All fields are optional to handle different system message types efficiently 360 | public struct SystemMessage: Codable, Sendable { 361 | public let subtype: String 362 | 363 | // Init-specific fields (populated for init subtype) 364 | public let cwd: String? 365 | public let sessionId: String? 366 | public let tools: [String]? 367 | public let mcpServers: [MCPServerInfo]? 368 | public let model: String? 369 | public let permissionMode: String? 370 | public let slashCommands: [String]? 371 | public let apiKeySource: String? 372 | 373 | // Generic data field for other system message types 374 | public let genericData: [String: AnyCodable]? 375 | 376 | public init( 377 | subtype: String, 378 | cwd: String? = nil, 379 | sessionId: String? = nil, 380 | tools: [String]? = nil, 381 | mcpServers: [MCPServerInfo]? = nil, 382 | model: String? = nil, 383 | permissionMode: String? = nil, 384 | slashCommands: [String]? = nil, 385 | apiKeySource: String? = nil, 386 | genericData: [String: AnyCodable]? = nil 387 | ) { 388 | self.subtype = subtype 389 | self.cwd = cwd 390 | self.sessionId = sessionId 391 | self.tools = tools 392 | self.mcpServers = mcpServers 393 | self.model = model 394 | self.permissionMode = permissionMode 395 | self.slashCommands = slashCommands 396 | self.apiKeySource = apiKeySource 397 | self.genericData = genericData 398 | } 399 | 400 | private enum CodingKeys: String, CodingKey { 401 | case subtype 402 | case cwd 403 | case sessionId = "session_id" 404 | case tools 405 | case mcpServers = "mcp_servers" 406 | case model 407 | case permissionMode 408 | case slashCommands = "slash_commands" 409 | case apiKeySource 410 | } 411 | 412 | public init(from decoder: any Decoder) throws { 413 | let container = try decoder.container(keyedBy: CodingKeys.self) 414 | 415 | self.subtype = try container.decode(String.self, forKey: .subtype) 416 | 417 | // Decode all optional fields directly - much faster than enum switching 418 | self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd) 419 | self.sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId) 420 | self.tools = try container.decodeIfPresent([String].self, forKey: .tools) 421 | self.mcpServers = try container.decodeIfPresent([MCPServerInfo].self, forKey: .mcpServers) 422 | self.model = try container.decodeIfPresent(String.self, forKey: .model) 423 | self.permissionMode = try container.decodeIfPresent(String.self, forKey: .permissionMode) 424 | self.slashCommands = try container.decodeIfPresent([String].self, forKey: .slashCommands) 425 | self.apiKeySource = try container.decodeIfPresent(String.self, forKey: .apiKeySource) 426 | 427 | // For non-init messages, capture any remaining data as generic data 428 | if subtype != "init" { 429 | // Decode the entire object and remove known keys 430 | let allData = try [String: AnyCodable](from: decoder) 431 | var filtered = allData 432 | filtered.removeValue(forKey: "subtype") 433 | self.genericData = filtered.isEmpty ? nil : filtered 434 | } else { 435 | self.genericData = nil 436 | } 437 | } 438 | 439 | public func encode(to encoder: any Encoder) throws { 440 | var container = encoder.container(keyedBy: CodingKeys.self) 441 | 442 | try container.encode(subtype, forKey: .subtype) 443 | try container.encodeIfPresent(cwd, forKey: .cwd) 444 | try container.encodeIfPresent(sessionId, forKey: .sessionId) 445 | try container.encodeIfPresent(tools, forKey: .tools) 446 | try container.encodeIfPresent(mcpServers, forKey: .mcpServers) 447 | try container.encodeIfPresent(model, forKey: .model) 448 | try container.encodeIfPresent(permissionMode, forKey: .permissionMode) 449 | try container.encodeIfPresent(slashCommands, forKey: .slashCommands) 450 | try container.encodeIfPresent(apiKeySource, forKey: .apiKeySource) 451 | 452 | // For generic data, encode it as a separate object at the root level 453 | if let genericData = genericData { 454 | try genericData.encode(to: encoder) 455 | } 456 | } 457 | } 458 | 459 | // MARK: - MCP Server Info 460 | 461 | /// MCP server information 462 | public struct MCPServerInfo: Codable, Sendable { 463 | public let name: String 464 | public let status: String 465 | 466 | public init(name: String, status: String) { 467 | self.name = name 468 | self.status = status 469 | } 470 | } 471 | 472 | // MARK: - Usage Info 473 | 474 | /// Token usage information from Claude API 475 | public struct UsageInfo: Codable, Sendable { 476 | public let inputTokens: Int 477 | public let cacheCreationInputTokens: Int? 478 | public let cacheReadInputTokens: Int? 479 | public let outputTokens: Int 480 | public let serviceTier: String? 481 | public let serverToolUse: [String: AnyCodable]? 482 | 483 | public init( 484 | inputTokens: Int, 485 | cacheCreationInputTokens: Int? = nil, 486 | cacheReadInputTokens: Int? = nil, 487 | outputTokens: Int, 488 | serviceTier: String? = nil, 489 | serverToolUse: [String: AnyCodable]? = nil 490 | ) { 491 | self.inputTokens = inputTokens 492 | self.cacheCreationInputTokens = cacheCreationInputTokens 493 | self.cacheReadInputTokens = cacheReadInputTokens 494 | self.outputTokens = outputTokens 495 | self.serviceTier = serviceTier 496 | self.serverToolUse = serverToolUse 497 | } 498 | 499 | private enum CodingKeys: String, CodingKey { 500 | case inputTokens = "input_tokens" 501 | case cacheCreationInputTokens = "cache_creation_input_tokens" 502 | case cacheReadInputTokens = "cache_read_input_tokens" 503 | case outputTokens = "output_tokens" 504 | case serviceTier = "service_tier" 505 | case serverToolUse = "server_tool_use" 506 | } 507 | } 508 | 509 | // MARK: - Result Message 510 | 511 | /// A result message containing session summary and usage information 512 | public struct ResultMessage: Codable, Sendable { 513 | public let subtype: String 514 | public let durationMs: Int 515 | public let durationApiMs: Int 516 | public let isError: Bool 517 | public let numTurns: Int 518 | public let sessionId: String 519 | public let totalCostUsd: Double? 520 | public let usage: UsageInfo? 521 | public let result: String? 522 | 523 | public init( 524 | subtype: String, 525 | durationMs: Int, 526 | durationApiMs: Int, 527 | isError: Bool, 528 | numTurns: Int, 529 | sessionId: String, 530 | totalCostUsd: Double? = nil, 531 | usage: UsageInfo? = nil, 532 | result: String? = nil 533 | ) { 534 | self.subtype = subtype 535 | self.durationMs = durationMs 536 | self.durationApiMs = durationApiMs 537 | self.isError = isError 538 | self.numTurns = numTurns 539 | self.sessionId = sessionId 540 | self.totalCostUsd = totalCostUsd 541 | self.usage = usage 542 | self.result = result 543 | } 544 | 545 | private enum CodingKeys: String, CodingKey { 546 | case subtype 547 | case durationMs = "duration_ms" 548 | case durationApiMs = "duration_api_ms" 549 | case isError = "is_error" 550 | case numTurns = "num_turns" 551 | case sessionId = "session_id" 552 | case totalCostUsd = "total_cost_usd" 553 | case usage 554 | case result 555 | } 556 | } --------------------------------------------------------------------------------