P1 (release blocker):
- multipart now RFC 7578 compliant (was injecting bare LF before file
content via Swift """...""" multi-line literals; corrupted binary
uploads — PNG/PDF/JPEG). Body now built via explicit "\r\n"
concatenation so every byte on the wire is auditable.
P2:
- CustomStringConvertible redacts token on ForgeClient + AppToken
(default mirror was leaking plaintext via print / String(reflecting:)
/ SwiftUI string interpolation).
- revokeToken now pre-validates name against ^[a-z0-9_-]{1,64}$ and
rejects path-traversal sequences with ForgeError.invalidArgument
before percent-encoding (urlPathAllowed left /, +, ;, =, ,, @
unescaped).
- baseURL with non-empty path/query/fragment rejected at construct.
init is now `throws` — host-only URLs only, since the SDK builds
request URLs by string concatenation.
P3:
- Fixed misleading "custom encoding" comment on RunRequest (it's just
Optional + JSONEncoder default behavior).
- public init on RunFailure (was decode-only).
- Task.checkCancellation() inside the multipart chunk loop — multi-GB
uploads now abort promptly when the parent Task is cancelled.
- 0o600 perms on the staged temp upload file (was inheriting umask,
typically 0o644 — unwanted in multi-tenant /tmp).
- Documented JSONValue.number Double precision limit (loses precision
for ints > 2^53).
Tests:
- testMultipartIsCRLFCompliant: writes a PNG-signature payload, scans
the captured body for the `\r\n\n` bare-LF pattern AND verifies the
bytes after `Content-Type: image/png\r\n\r\n` match the payload
exactly.
- testForgeClientDescriptionRedactsToken
- testAppTokenDescriptionRedactsToken (covers both nil and non-nil
token cases)
- testRevokeTokenRejectsTraversalName: foo/../bar, FOO, spaces, +, ;,
=, @, 65-char names, empty
- testBaseURLWithPathRejected: /api, /v1, ?query, #fragment; host-only
variants still accepted
- testRunFailurePublicInit
- testTempFilePerms: scans /tmp during the in-flight upload to verify
the staged clawdforge-upload-* file is 0o600
- Existing tests updated for the now-throwing init.
README + Examples updated for the throwing init.
Audit: memory/clawdforge-audits/swift-e4e8192.md
Note: untested locally — Swift toolchain not present in this sandbox.
Needs `swift build -c release` + `swift test` verification on a Swift
5.9+ host (macOS or Linux) before tagging the next release.
|
||
|---|---|---|
| .. | ||
| Examples/Basic | ||
| Sources/Clawdforge | ||
| Tests/ClawdforgeTests | ||
| Package.swift | ||
| README.md | ||
Clawdforge — Swift SDK
Swift client for clawdforge, a LAN-only HTTP service that wraps claude -p
subprocess calls behind a bearer-token-gated REST API.
- Pure Foundation — no
Alamofire, no third-party HTTP libs. - Async/await throughout — every I/O method is
async throws. - Apple platforms + Linux — same source builds on macOS/iOS/tvOS/watchOS
and on Linux via
swift-corelibs-foundation. - MIT licensed.
Requirements
| Platform | Minimum |
|---|---|
| Swift toolchain | 5.9 |
| macOS | 13 |
| iOS | 16 |
| tvOS | 16 |
| watchOS | 9 |
| Linux | any distro with Swift 5.9+ |
Install
Add to your Package.swift:
dependencies: [
.package(url: "http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git", from: "0.1.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "Clawdforge", package: "clawdforge"),
]
),
]
In an Xcode project: File → Add Package Dependencies…, paste the URL,
select the Clawdforge library product.
Quickstart
import Clawdforge
// `baseURL` must be host-only — scheme + host (+ port). A path/query/fragment
// is rejected at construct with `ForgeError.invalidArgument`.
let client = try ForgeClient(
baseURL: URL(string: "http://192.168.0.5:8800")!,
token: ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"]!
)
// Health check (no token strictly required, but bearer is sent anyway).
let h = try await client.healthz()
print(h.claudeVersion ?? "unknown")
// Run a prompt.
let res = try await client.run(RunRequest(
prompt: #"Reply with JSON: {"hello": "world"}"#,
model: "sonnet",
timeoutSecs: 60
))
print("duration: \(res.durationMs)ms")
// Narrow the heterogeneous result.
if case .object(let dict) = res.result,
case .string(let hello) = dict["hello"] ?? .null {
print(hello) // "world"
}
Public API
ForgeClient
A Sendable struct. Build once, share everywhere — URLSession underneath
is thread-safe.
public struct ForgeClient: Sendable {
public init(baseURL: URL, token: String, session: URLSession = .shared) throws
public func healthz() async throws -> HealthStatus
public func run(_ request: RunRequest) async throws -> RunResult
public func uploadFile(at fileURL: URL, ttlSecs: Int = 3600) async throws -> FileToken
public func createToken(_ request: CreateTokenRequest) async throws -> AppToken
public func listTokens() async throws -> [AppToken]
public func revokeToken(name: String) async throws
}
File upload + reuse
let png = URL(fileURLWithPath: "./recipe.png")
let ft = try await client.uploadFile(at: png, ttlSecs: 3600)
let res = try await client.run(RunRequest(
prompt: "Extract recipe data as JSON.",
files: [ft.fileToken]
))
The upload streams from disk via URLSession.upload(for:fromFile:) — no
load-into-Data() for the file payload, so multi-megabyte files are fine.
Admin endpoints
Pass the ADMIN_BOOTSTRAP_TOKEN as the client's token to use these.
let admin = try ForgeClient(baseURL: url, token: adminToken)
let new = try await admin.createToken(
CreateTokenRequest(name: "myapp", ipCidrs: ["10.0.0.0/8"])
)
print("save this:", new.token!) // shown ONCE
let all = try await admin.listTokens()
try await admin.revokeToken(name: "myapp")
JSONValue
RunResult.result is a JSONValue. claude -p --output-format json may
emit either parsed JSON or a plain string when the model didn't return
JSON; JSONValue represents both losslessly.
public enum JSONValue: Codable, Sendable, Equatable {
case object([String: JSONValue])
case array([JSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
}
Convenience accessors: .stringValue, .numberValue, .boolValue,
.arrayValue, .objectValue — each returns nil if the case doesn't
match.
Errors
All thrown errors are the single ForgeError enum:
public enum ForgeError: Error {
case auth(statusCode: Int, body: String) // 401 / 403
case api(statusCode: Int, body: String) // any other non-2xx
case transport(URLError) // DNS, TLS, EOF, cancellation
case decoding(DecodingError) // server/client schema skew
case invalidArgument(String) // SDK precondition
}
Conforms to LocalizedError, so error.localizedDescription and SwiftUI's
.alert(error:) modifier produce sensible messages out of the box.
/run failures with HTTP 502 surface as .api(statusCode: 502, body: ...).
Decode the body as RunFailure to inspect error/stderr/durationMs/stopReason.
do {
_ = try await client.run(RunRequest(prompt: "..."))
} catch let ForgeError.api(502, body) {
if let data = body.data(using: .utf8),
let failure = try? JSONDecoder().decode(RunFailure.self, from: data) {
print("claude failed: \(failure.error)")
}
}
Cancellation
Task cancellation propagates into URLSession. The SDK also calls
Task.checkCancellation() before the network hop so a cancel before the
request is dispatched throws CancellationError immediately.
let handle = Task {
try await client.run(RunRequest(prompt: "long prompt"))
}
// ...
handle.cancel()
Linux
Builds on Linux via swift-corelibs-foundation. Async URLSession methods
(data(for:), upload(for:fromFile:)) are available there from Swift 5.9.
swift build -c release
swift test
Tests
Unit tests use a URLProtocol stub — no network, no clawdforge instance
required. Run:
swift test
License
MIT.