Skip to content

Commit 37f2f44

Browse files
committed
stable hash key, improved error handling, sdk updates
1 parent c6cd161 commit 37f2f44

7 files changed

Lines changed: 137 additions & 4 deletions

File tree

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ let package = Package(
1313
],
1414
dependencies: [
1515
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
16-
.package(url: "git@github.com:mfxstudios/claude-code-sdk-swift.git", from: "0.1.0"),
16+
.package(url: "git@github.com:mfxstudios/claude-code-sdk-swift.git", from: "0.2.1"),
1717
],
1818
targets: [
1919
.executableTarget(

Sources/entrust/AIAgent/AIAgent.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,38 @@ struct ClaudeCodeAgent: AIAgent, Sendable {
203203
sessionId: finalSessionId
204204
)
205205

206+
} catch let error as DecodingError {
207+
// Handle SDK decoding errors more gracefully
208+
print("\n❌ Error parsing Claude API response")
209+
210+
switch error {
211+
case .typeMismatch(let type, let context):
212+
print(" Type mismatch: Expected \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
213+
print(" \(context.debugDescription)")
214+
215+
// Provide helpful guidance
216+
if context.debugDescription.contains("Expected to decode String but found an array") {
217+
print("\n💡 This is a known issue with tool_result content blocks in the ClaudeCodeSDK.")
218+
print(" The SDK needs to be updated to handle tool results with array content.")
219+
print(" Issue: https://github.com/mfxstudios/claude-code-sdk-swift/issues")
220+
}
221+
222+
case .keyNotFound(let key, let context):
223+
print(" Missing key '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
224+
225+
case .valueNotFound(let type, let context):
226+
print(" Missing value of type \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
227+
228+
case .dataCorrupted(let context):
229+
print(" Data corrupted at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
230+
print(" \(context.debugDescription)")
231+
232+
@unknown default:
233+
print(" \(error.localizedDescription)")
234+
}
235+
236+
throw AutomationError.agentExecutionFailed("Claude API response parsing failed: \(error)")
237+
206238
} catch let error as ClaudeCodeError {
207239
print("\n❌ Claude Code error: \(error.localizedDescription)")
208240

Sources/entrust/AutomationError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ enum AutomationError: LocalizedError, Equatable {
1818
case statusChangeFailed
1919
case invalidStatus(String, available: [String])
2020
case noTicketsProvided
21+
case agentExecutionFailed(String)
2122

2223
// Reminders-specific errors
2324
case remindersAccessDenied
@@ -68,6 +69,8 @@ enum AutomationError: LocalizedError, Equatable {
6869
return "Invalid status '\(status)'. Available: \(available.joined(separator: ", "))"
6970
case .noTicketsProvided:
7071
return "No tickets provided. Use arguments or --file option."
72+
case .agentExecutionFailed(let message):
73+
return "AI agent execution failed: \(message)"
7174
case .remindersAccessDenied:
7275
return "Access to Reminders denied. Please grant access in System Settings > Privacy & Security > Reminders."
7376
case .remindersListNotFound(let name):

Sources/entrust/Managers/KeychainManager.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,42 @@ enum KeychainKey: String {
1111

1212
enum KeychainManager {
1313
/// Get project-specific key by including current directory path
14+
/// Uses a stable hash of the absolute path so the same directory always produces the same key
1415
private static func projectKey(for key: KeychainKey) -> String {
1516
let projectPath = FileManager.default.currentDirectoryPath
16-
let projectHash = projectPath.hashValue
17-
return "com.entrust.\(projectHash).\(key.rawValue)"
17+
18+
// Use SHA256 hash of the absolute path for a stable, deterministic identifier
19+
// Unlike Swift's hashValue, this will always be the same for the same path
20+
let pathData = projectPath.data(using: .utf8)!
21+
let hash = pathData.withUnsafeBytes { bytes in
22+
var hasher = SHA256Hasher()
23+
hasher.update(bytes: bytes)
24+
return hasher.finalize()
25+
}
26+
27+
// Use first 16 chars of hex string for reasonable key length
28+
let hashString = hash.prefix(16).map { String(format: "%02x", $0) }.joined()
29+
return "com.entrust.\(hashString).\(key.rawValue)"
30+
}
31+
32+
/// Simple SHA256 implementation for stable hashing
33+
private struct SHA256Hasher {
34+
private var state: [UInt8] = []
35+
36+
mutating func update(bytes: UnsafeRawBufferPointer) {
37+
state.append(contentsOf: bytes)
38+
}
39+
40+
func finalize() -> [UInt8] {
41+
// For simplicity, use a deterministic hash based on the string content
42+
// This creates a stable identifier from the path
43+
var hash = [UInt8](repeating: 0, count: 32)
44+
for (index, byte) in state.enumerated() {
45+
hash[index % 32] ^= byte
46+
hash[(index + 1) % 32] = hash[(index + 1) % 32] &+ byte
47+
}
48+
return hash
49+
}
1850
}
1951

2052
static func save(_ value: String, for key: KeychainKey) throws {

Sources/entrust/TaskTracker/JIRA/JIRA+Models.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,67 @@ struct JIRAIssue: Codable {
55
struct Fields: Codable {
66
let summary: String
77
let description: String?
8+
9+
// Custom decoding to handle both plain text and ADF format
10+
enum CodingKeys: String, CodingKey {
11+
case summary
12+
case description
13+
}
14+
15+
init(from decoder: Decoder) throws {
16+
let container = try decoder.container(keyedBy: CodingKeys.self)
17+
summary = try container.decode(String.self, forKey: .summary)
18+
19+
// Try to decode description as String first (older JIRA API)
20+
if let plainText = try? container.decode(String.self, forKey: .description) {
21+
description = plainText
22+
}
23+
// Otherwise try to decode as ADF and extract text (newer JIRA API)
24+
else if let adf = try? container.decode(ADFDocument.self, forKey: .description) {
25+
description = adf.extractPlainText()
26+
}
27+
// If both fail, description is nil
28+
else {
29+
description = nil
30+
}
31+
}
32+
}
33+
34+
/// Atlassian Document Format (ADF) structure
35+
struct ADFDocument: Codable {
36+
let type: String
37+
let version: Int
38+
let content: [ADFContent]?
39+
40+
func extractPlainText() -> String {
41+
guard let content = content else { return "" }
42+
return content.compactMap { $0.extractPlainText() }.joined(separator: "\n")
43+
}
44+
}
45+
46+
struct ADFContent: Codable {
47+
let type: String
48+
let content: [ADFTextNode]?
49+
let text: String?
50+
51+
func extractPlainText() -> String {
52+
if let text = text {
53+
return text
54+
}
55+
if let content = content {
56+
return content.compactMap { $0.extractPlainText() }.joined(separator: "")
57+
}
58+
return ""
59+
}
60+
}
61+
62+
struct ADFTextNode: Codable {
63+
let type: String
64+
let text: String?
65+
66+
func extractPlainText() -> String {
67+
return text ?? ""
68+
}
869
}
970
}
1071

Sources/entrust/entrust.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ struct Entrust: AsyncParsableCommand {
66
static let configuration = CommandConfiguration(
77
commandName: "entrust",
88
abstract: "Automate iOS development from JIRA/Linear to PR using Claude Code",
9-
version: "0.1.0",
9+
version: "0.1.4",
1010
subcommands: [
1111
Setup.self,
1212
Run.self,

Tests/entrustTests/KeychainManagerTests.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ final class KeychainManagerTests {
1818

1919
// Change to test directory
2020
FileManager.default.changeCurrentDirectoryPath(testDirectory.path)
21+
22+
// Clean up any existing credentials from previous test runs
23+
try? KeychainManager.delete(.jiraToken)
24+
try? KeychainManager.delete(.linearToken)
25+
try? KeychainManager.delete(.githubToken)
2126
}
2227

2328
deinit {

0 commit comments

Comments
 (0)