clients/swift: initial Swift SDK for clawdforge

This commit is contained in:
Kayos 2026-04-28 22:48:18 -07:00
parent 15de6e765f
commit e4e8192d4d
7 changed files with 1453 additions and 0 deletions

View file

@ -0,0 +1,56 @@
// Basic example for the Clawdforge SDK.
//
// Run with: CLAWDFORGE_TOKEN=cf_... swift run ClawdforgeBasicExample
//
// This file is intentionally named `main.swift` so it can use top-level
// `await` rather than wrapping in `@main`. Swift treats a single-file
// executable target named `main.swift` as a script.
import Foundation
import Clawdforge
let baseURL = URL(string: ProcessInfo.processInfo.environment["CLAWDFORGE_URL"] ?? "http://192.168.0.5:8800")!
guard let token = ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"] else {
FileHandle.standardError.write(Data("error: set CLAWDFORGE_TOKEN\n".utf8))
exit(1)
}
let client = ForgeClient(baseURL: baseURL, token: token)
do {
// 1. Health check
let h = try await client.healthz()
print("claude_present: \(h.claudePresent)")
print("claude_version: \(h.claudeVersion ?? "unknown")")
// 2. 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")
if case .object(let dict) = res.result,
case .string(let hello) = dict["hello"] ?? .null {
print("hello = \(hello)")
} else {
print("result: \(res.result)")
}
// 3. Optional file upload + reuse uncomment to try.
//
// let recipe = URL(fileURLWithPath: "./recipe.png")
// let ft = try await client.uploadFile(at: recipe, ttlSecs: 3600)
// let extract = try await client.run(RunRequest(
// prompt: "extract recipe data as JSON",
// files: [ft.fileToken]
// ))
// print("extract: \(extract.result)")
} catch let err as ForgeError {
FileHandle.standardError.write(Data("clawdforge error: \(err.localizedDescription)\n".utf8))
exit(2)
} catch {
FileHandle.standardError.write(Data("unexpected error: \(error)\n".utf8))
exit(2)
}

View file

@ -0,0 +1,48 @@
// swift-tools-version:5.9
//
// Clawdforge Swift SDK
// MIT License see clawdforge repository LICENSE.
//
// Pure-Foundation HTTP client for the LAN-only clawdforge service.
// Builds on macOS, iOS, watchOS, tvOS, and Linux (swift-corelibs-foundation).
import PackageDescription
let package = Package(
name: "Clawdforge",
platforms: [
.macOS(.v13),
.iOS(.v16),
.tvOS(.v16),
.watchOS(.v9),
],
products: [
.library(
name: "Clawdforge",
targets: ["Clawdforge"]
),
.executable(
name: "ClawdforgeBasicExample",
targets: ["ClawdforgeBasicExample"]
),
],
dependencies: [
// Intentionally none. Foundation only.
],
targets: [
.target(
name: "Clawdforge",
path: "Sources/Clawdforge"
),
.executableTarget(
name: "ClawdforgeBasicExample",
dependencies: ["Clawdforge"],
path: "Examples/Basic"
),
.testTarget(
name: "ClawdforgeTests",
dependencies: ["Clawdforge"],
path: "Tests/ClawdforgeTests"
),
]
)

214
clients/swift/README.md Normal file
View file

@ -0,0 +1,214 @@
# 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`:
```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
```swift
import Clawdforge
let client = 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.
```swift
public struct ForgeClient: Sendable {
public init(baseURL: URL, token: String, session: URLSession = .shared)
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
```swift
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.
```swift
let admin = 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.
```swift
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:
```swift
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`.
```swift
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.
```swift
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.
```bash
swift build -c release
swift test
```
## Tests
Unit tests use a `URLProtocol` stub — no network, no clawdforge instance
required. Run:
```bash
swift test
```
## License
MIT.
[clawdforge]: http://192.168.0.5:3001/Sulkta-Coop/clawdforge

View file

@ -0,0 +1,66 @@
// Errors.swift
//
// One enum to rule them all. Conforms to LocalizedError so error messages
// surface cleanly in `print()`, `localizedDescription`, and SwiftUI's
// `.alert(error:)` modifier.
import Foundation
/// Every error this SDK throws.
public enum ForgeError: Error, Sendable {
/// Server returned 401 or 403. Token is missing, wrong, or the caller
/// IP isn't on the global / per-app allowlist.
case auth(statusCode: Int, body: String)
/// Any other non-2xx response. ``body`` is the raw response body
/// (truncated to a sane size). For `/run` 502 failures, decode
/// ``body`` as ``RunFailure`` to inspect `error`/`stderr`/`duration_ms`.
case api(statusCode: Int, body: String)
/// Underlying transport problem: DNS, connect refused, TLS, EOF,
/// timeout from `URLSessionConfiguration.timeoutIntervalForRequest`,
/// or `Task` cancellation surfaced as `URLError(.cancelled)`.
case transport(URLError)
/// JSON decoding of a successful response failed. Almost always
/// indicates a server/client version skew.
case decoding(DecodingError)
/// A precondition guarded by the SDK was violated (e.g. empty prompt,
/// nonexistent file path on upload, invalid base URL).
case invalidArgument(String)
}
extension ForgeError: LocalizedError {
public var errorDescription: String? {
switch self {
case .auth(let code, let body):
let snippet = Self.snippet(body)
return "clawdforge: authentication failed (HTTP \(code))\(snippet.isEmpty ? "" : ": \(snippet)")"
case .api(let code, let body):
let snippet = Self.snippet(body)
return "clawdforge: HTTP \(code)\(snippet.isEmpty ? "" : ": \(snippet)")"
case .transport(let urlError):
return "clawdforge: transport error: \(urlError.localizedDescription)"
case .decoding(let decErr):
return "clawdforge: response decoding failed: \(decErr)"
case .invalidArgument(let msg):
return "clawdforge: invalid argument: \(msg)"
}
}
/// Trim/clean a body for inclusion in error messages. Tries to pull
/// `error` / `detail` / `message` out of a JSON envelope, otherwise
/// returns the first ~500 chars.
private static func snippet(_ body: String) -> String {
guard !body.isEmpty else { return "" }
if let data = body.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
for key in ["error", "detail", "message"] {
if let v = obj[key] as? String, !v.isEmpty { return v }
}
}
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.count > 500 ? String(trimmed.prefix(500)) + "..." : trimmed
}
}

View file

@ -0,0 +1,375 @@
// ForgeClient.swift
//
// Foundation-only async/await client for clawdforge.
//
// Design choice: ForgeClient is a Sendable struct, not an actor. The client
// holds an immutable URL, an immutable bearer token, and a URLSession (which
// is itself thread-safe by Apple's contract). There is no mutable state to
// guard, so an actor would just add hop overhead for every call.
//
// All public methods are `async throws`. Cancellation propagates from the
// caller's Task into URLSession via the structured-concurrency bridge in
// `URLSession.data(for:)` / `URLSession.upload(for:fromFile:)`, so wrapping
// a call in `Task { ... }.cancel()` cleanly aborts the in-flight request.
//
// Linux: builds against swift-corelibs-foundation. The async URLSession
// methods used here (`data(for:)`, `upload(for:fromFile:)`) are available
// on Linux as of Swift 5.9.
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Thread-safe client for the clawdforge REST API.
///
/// Construct once per (baseURL, token) pair and share across the app.
/// Instances are cheap and `Sendable`.
public struct ForgeClient: Sendable {
// MARK: Stored
/// Base URL, e.g. `http://192.168.0.5:8800`. Trailing slashes are trimmed.
public let baseURL: URL
/// Bearer token. Sent as `Authorization: Bearer <token>` on every request.
/// For admin endpoints, pass the `ADMIN_BOOTSTRAP_TOKEN` here.
public let token: String
/// Underlying URLSession. Defaults to `.shared`. Inject a configured
/// session for custom timeouts, proxies, or test harnesses.
public let session: URLSession
/// JSON encoder used for all request bodies. Pre-configured for the
/// snake_case wire format via per-field `CodingKeys` on each model.
private let encoder: JSONEncoder
/// JSON decoder used for all response bodies.
private let decoder: JSONDecoder
// MARK: Init
/// Create a client.
///
/// - Parameters:
/// - baseURL: Root URL of the clawdforge instance. e.g.
/// `URL(string: "http://192.168.0.5:8800")!`.
/// - token: Bearer token (`cf_` for app endpoints, the admin
/// bootstrap token for `/admin/*`).
/// - session: Optional `URLSession`. Defaults to `.shared`. Provide
/// a custom one for timeouts or for tests using `URLProtocol`
/// stubs.
public init(
baseURL: URL,
token: String,
session: URLSession = .shared
) {
// Trim a trailing slash so path joins are predictable.
var s = baseURL.absoluteString
while s.hasSuffix("/") { s.removeLast() }
self.baseURL = URL(string: s) ?? baseURL
self.token = token
self.session = session
let enc = JSONEncoder()
enc.outputFormatting = [.withoutEscapingSlashes]
self.encoder = enc
self.decoder = JSONDecoder()
}
// MARK: Public API
/// `GET /healthz`. Liveness + `claude --version` smoke check.
///
/// Does not require a bearer token, but the caller IP must satisfy
/// the global allowlist on the server.
public func healthz() async throws -> HealthStatus {
let req = try makeRequest(method: "GET", path: "/healthz")
return try await send(req, decode: HealthStatus.self)
}
/// `POST /run`. Run a prompt through `claude -p` on the server.
///
/// On HTTP 200 returns ``RunResult``. On HTTP 502 the underlying
/// `claude` invocation failed and ``ForgeError/api(statusCode:body:)``
/// is thrown with a JSON body matching ``RunFailure``.
public func run(_ request: RunRequest) async throws -> RunResult {
guard !request.prompt.isEmpty else {
throw ForgeError.invalidArgument("prompt must not be empty")
}
let req = try makeRequest(
method: "POST",
path: "/run",
jsonBody: request
)
return try await send(req, decode: RunResult.self)
}
/// `POST /files`. Stream a file from disk to the server, receive an
/// `ff_` token suitable for ``RunRequest/files``.
///
/// Uses `URLSession.upload(for:fromFile:)` so the file body is streamed,
/// not buffered in memory.
///
/// - Parameters:
/// - fileURL: Local file URL. Must be a regular readable file.
/// - ttlSecs: Server clamps to `[60, 86400]`. Defaults to `3600`.
public func uploadFile(at fileURL: URL, ttlSecs: Int = 3600) async throws -> FileToken {
guard fileURL.isFileURL else {
throw ForgeError.invalidArgument("uploadFile: URL is not a file URL")
}
let path = fileURL.path
guard FileManager.default.fileExists(atPath: path) else {
throw ForgeError.invalidArgument("uploadFile: no file at \(path)")
}
// Stage the multipart body to a temp file, then `upload(for:fromFile:)`
// streams it from disk no Data() blob in memory.
let boundary = "clawdforge-\(UUID().uuidString)"
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdforge-upload-\(UUID().uuidString).bin")
try writeMultipartBody(
to: tempURL,
boundary: boundary,
ttlSecs: ttlSecs,
fileURL: fileURL
)
defer {
try? FileManager.default.removeItem(at: tempURL)
}
var req = try makeRequest(method: "POST", path: "/files")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
try Task.checkCancellation()
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.upload(for: req, fromFile: tempURL)
} catch let urlError as URLError {
throw ForgeError.transport(urlError)
} catch {
throw ForgeError.transport(URLError(.unknown))
}
return try decodeOrThrow(FileToken.self, data: data, response: response)
}
/// `POST /admin/tokens`. Mint a new per-app token. The plaintext is
/// returned in ``AppToken/token`` and is **not retrievable later**.
///
/// Requires the admin bootstrap token in ``token``.
public func createToken(_ request: CreateTokenRequest) async throws -> AppToken {
guard !request.name.isEmpty else {
throw ForgeError.invalidArgument("createToken: name must not be empty")
}
let req = try makeRequest(
method: "POST",
path: "/admin/tokens",
jsonBody: request
)
return try await send(req, decode: AppToken.self)
}
/// `GET /admin/tokens`. Returns the configured tokens (no plaintexts).
///
/// Requires the admin bootstrap token.
public func listTokens() async throws -> [AppToken] {
let req = try makeRequest(method: "GET", path: "/admin/tokens")
let envelope = try await send(req, decode: TokenList.self)
return envelope.tokens
}
/// `DELETE /admin/tokens/{name}`. Revoke a token by app name.
///
/// Requires the admin bootstrap token.
public func revokeToken(name: String) async throws {
guard !name.isEmpty else {
throw ForgeError.invalidArgument("revokeToken: name must not be empty")
}
let escaped = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name
let req = try makeRequest(method: "DELETE", path: "/admin/tokens/\(escaped)")
_ = try await sendVoid(req)
}
// MARK: Internals
/// Build a `URLRequest` with auth + accept headers. If `jsonBody` is
/// non-nil it is encoded and `Content-Type: application/json` is set.
private func makeRequest<Body: Encodable>(
method: String,
path: String,
jsonBody: Body
) throws -> URLRequest {
var req = try makeRequest(method: method, path: path)
do {
req.httpBody = try encoder.encode(jsonBody)
} catch {
throw ForgeError.invalidArgument("failed to encode request body: \(error)")
}
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
return req
}
private func makeRequest(method: String, path: String) throws -> URLRequest {
guard let url = URL(string: baseURL.absoluteString + path) else {
throw ForgeError.invalidArgument("could not form URL for path \(path)")
}
var req = URLRequest(url: url)
req.httpMethod = method
if !token.isEmpty {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.setValue("application/json", forHTTPHeaderField: "Accept")
return req
}
/// Execute a request and decode JSON into `T`. Cancellation-safe.
private func send<T: Decodable>(_ req: URLRequest, decode: T.Type) async throws -> T {
try Task.checkCancellation()
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch let urlError as URLError {
throw ForgeError.transport(urlError)
} catch {
throw ForgeError.transport(URLError(.unknown))
}
return try decodeOrThrow(T.self, data: data, response: response)
}
/// Like ``send(_:decode:)`` but ignores response body. Used for DELETE.
private func sendVoid(_ req: URLRequest) async throws -> Void {
try Task.checkCancellation()
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch let urlError as URLError {
throw ForgeError.transport(urlError)
} catch {
throw ForgeError.transport(URLError(.unknown))
}
try ensure2xx(data: data, response: response)
}
/// Validate a 2xx, then decode JSON into `T`. Maps non-2xx into
/// ``ForgeError`` variants and decoding failure into
/// ``ForgeError/decoding(_:)``.
private func decodeOrThrow<T: Decodable>(_ type: T.Type, data: Data, response: URLResponse) throws -> T {
try ensure2xx(data: data, response: response)
do {
return try decoder.decode(T.self, from: data)
} catch let decErr as DecodingError {
throw ForgeError.decoding(decErr)
} catch {
// Foundation should always raise DecodingError, but be defensive.
throw ForgeError.api(statusCode: -1, body: String(data: data, encoding: .utf8) ?? "")
}
}
private func ensure2xx(data: Data, response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw ForgeError.api(statusCode: -1, body: "non-HTTP response")
}
let code = http.statusCode
guard !(200..<300).contains(code) else { return }
// Cap error-body size at 8 MiB so a misbehaving server can't fill RAM.
let cap = 8 * 1024 * 1024
let bodyData = data.count > cap ? data.prefix(cap) : data
let body = String(data: bodyData, encoding: .utf8) ?? ""
if code == 401 || code == 403 {
throw ForgeError.auth(statusCode: code, body: body)
}
throw ForgeError.api(statusCode: code, body: body)
}
/// Stream a multipart/form-data body into a temp file. Stays disk-bound:
/// reads the source file in 1 MiB chunks rather than slurping it.
private func writeMultipartBody(
to dest: URL,
boundary: String,
ttlSecs: Int,
fileURL: URL
) throws {
FileManager.default.createFile(atPath: dest.path, contents: nil)
guard let out = FileHandle(forWritingAtPath: dest.path) else {
throw ForgeError.invalidArgument("cannot open temp upload at \(dest.path)")
}
defer { try? out.close() }
// ttl_secs field
let ttlPart = """
--\(boundary)\r
Content-Disposition: form-data; name="ttl_secs"\r
\r
\(ttlSecs)\r
"""
try out.write(contentsOf: Data(ttlPart.utf8))
// file field header
let filename = fileURL.lastPathComponent
let mime = mimeType(for: fileURL)
let fileHeader = """
--\(boundary)\r
Content-Disposition: form-data; name="file"; filename="\(escapeFilename(filename))"\r
Content-Type: \(mime)\r
\r
"""
try out.write(contentsOf: Data(fileHeader.utf8))
// file body chunked
guard let input = FileHandle(forReadingAtPath: fileURL.path) else {
throw ForgeError.invalidArgument("cannot open \(fileURL.path) for reading")
}
defer { try? input.close() }
let chunkSize = 1 * 1024 * 1024
while true {
let chunk = input.readData(ofLength: chunkSize)
if chunk.isEmpty { break }
try out.write(contentsOf: chunk)
}
// closing boundary
let trailer = "\r\n--\(boundary)--\r\n"
try out.write(contentsOf: Data(trailer.utf8))
}
/// Best-effort MIME type from extension. Defaults to
/// `application/octet-stream`. Server treats files as opaque blobs so
/// the value rarely matters; we just want to avoid sending nothing.
private func mimeType(for url: URL) -> String {
switch url.pathExtension.lowercased() {
case "txt", "md", "log": return "text/plain"
case "json": return "application/json"
case "html", "htm": return "text/html"
case "csv": return "text/csv"
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "webp": return "image/webp"
case "pdf": return "application/pdf"
default: return "application/octet-stream"
}
}
/// RFC 7578 says backslash and double-quote should be escaped in the
/// `filename=` parameter. Newlines we just strip no legitimate file
/// has them on POSIX.
private func escapeFilename(_ name: String) -> String {
name
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
}
}

View file

@ -0,0 +1,321 @@
// Models.swift
//
// Codable wire models for the clawdforge HTTP API.
//
// Wire format note: clawdforge speaks snake_case end-to-end (Python /
// Pydantic conventions). Swift convention is camelCase, so each model
// declares explicit `CodingKeys` to bridge the two. We deliberately do
// NOT use `JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase` the
// per-field map is more legible and survives renames cleanly.
import Foundation
// MARK: - Health
/// Response from `GET /healthz`.
public struct HealthStatus: Codable, Sendable, Equatable {
/// Always `true` if the server returned 200.
public let ok: Bool
/// Whether the `claude` binary was found on `PATH` inside the container.
public let claudePresent: Bool
/// First line of `claude --version`, or `nil` if the smoke check failed.
public let claudeVersion: String?
public init(ok: Bool, claudePresent: Bool, claudeVersion: String?) {
self.ok = ok
self.claudePresent = claudePresent
self.claudeVersion = claudeVersion
}
private enum CodingKeys: String, CodingKey {
case ok
case claudePresent = "claude_present"
case claudeVersion = "claude_version"
}
}
// MARK: - Run
/// Body for `POST /run`.
///
/// Only ``prompt`` is required. Fields left `nil` are omitted from the
/// wire payload via custom encoding so the server sees the same shape
/// the Python/Go/Rust clients send.
public struct RunRequest: Codable, Sendable, Equatable {
/// The user prompt to feed to `claude -p`.
public let prompt: String
/// Override the server's default model (e.g. `"sonnet"`, `"opus"`). `nil` uses default.
public let model: String?
/// Optional system prompt prepended to the run.
public let system: String?
/// `ff_` file tokens previously returned by ``ForgeClient/uploadFile(at:ttlSecs:)``.
public let files: [String]?
/// Per-request timeout. Server clamps to `[5, 600]`. `nil` uses default.
public let timeoutSecs: Int?
public init(
prompt: String,
model: String? = nil,
system: String? = nil,
files: [String]? = nil,
timeoutSecs: Int? = nil
) {
self.prompt = prompt
self.model = model
self.system = system
self.files = files
self.timeoutSecs = timeoutSecs
}
private enum CodingKeys: String, CodingKey {
case prompt
case model
case system
case files
case timeoutSecs = "timeout_secs"
}
}
/// Successful response from `POST /run` (HTTP 200).
public struct RunResult: Codable, Sendable, Equatable {
/// Always `true` on success.
public let ok: Bool
/// The inner `result` from `claude -p --output-format json`. May be a
/// JSON object/array/scalar OR a plain string if the model didn't
/// return JSON. See ``JSONValue`` for narrowing helpers.
public let result: JSONValue
/// Wall-clock duration of the underlying `claude` subprocess.
public let durationMs: Int
/// Why the model stopped (e.g. `"end_turn"`, `"max_tokens"`).
public let stopReason: String?
public init(ok: Bool, result: JSONValue, durationMs: Int, stopReason: String?) {
self.ok = ok
self.result = result
self.durationMs = durationMs
self.stopReason = stopReason
}
private enum CodingKeys: String, CodingKey {
case ok
case result
case durationMs = "duration_ms"
case stopReason = "stop_reason"
}
}
/// Error-shape body returned by `POST /run` when `claude` failed (HTTP 502).
///
/// Surfaced inside ``ForgeError/api(statusCode:body:)`` for inspection.
public struct RunFailure: Codable, Sendable, Equatable {
public let ok: Bool
public let error: String
public let stderr: String?
public let durationMs: Int?
public let stopReason: String?
private enum CodingKeys: String, CodingKey {
case ok
case error
case stderr
case durationMs = "duration_ms"
case stopReason = "stop_reason"
}
}
// MARK: - Files
/// Response from `POST /files`.
public struct FileToken: Codable, Sendable, Equatable {
/// Opaque `ff_` token to pass in ``RunRequest/files``.
public let fileToken: String
/// Server-acknowledged TTL (clamped to `[60, 86400]`).
public let ttlSecs: Int
/// Size of the uploaded file in bytes.
public let size: Int
public init(fileToken: String, ttlSecs: Int, size: Int) {
self.fileToken = fileToken
self.ttlSecs = ttlSecs
self.size = size
}
private enum CodingKeys: String, CodingKey {
case fileToken = "file_token"
case ttlSecs = "ttl_secs"
case size
}
}
// MARK: - Admin tokens
/// Body for `POST /admin/tokens`.
public struct CreateTokenRequest: Codable, Sendable, Equatable {
/// Lowercase alphanumerics, `-`, `_`. Must start with `[a-z0-9]`. Max 64 chars.
public let name: String
/// Optional per-app CIDR allowlist on top of the global allowlist.
public let ipCidrs: [String]
public init(name: String, ipCidrs: [String] = []) {
self.name = name
self.ipCidrs = ipCidrs
}
private enum CodingKeys: String, CodingKey {
case name
case ipCidrs = "ip_cidrs"
}
}
/// One row from `GET /admin/tokens`. Also returned by `POST /admin/tokens`
/// with the plaintext ``token`` populated; on `list`, `token` is `nil`.
public struct AppToken: Codable, Sendable, Equatable {
public let name: String
/// Plaintext token. **Only populated on create** the server hashes it
/// and never exposes plaintext on subsequent list calls.
public let token: String?
public let ipCidrs: [String]?
public let createdAt: Int?
public let lastUsed: Int?
public let enabled: Int?
public init(
name: String,
token: String? = nil,
ipCidrs: [String]? = nil,
createdAt: Int? = nil,
lastUsed: Int? = nil,
enabled: Int? = nil
) {
self.name = name
self.token = token
self.ipCidrs = ipCidrs
self.createdAt = createdAt
self.lastUsed = lastUsed
self.enabled = enabled
}
private enum CodingKeys: String, CodingKey {
case name
case token
case ipCidrs = "ip_cidrs"
case createdAt = "created_at"
case lastUsed = "last_used"
case enabled
}
/// The server returns `ip_cidrs` as a comma-joined string in list
/// responses but as an array on create. Decode handles both.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.name = try c.decode(String.self, forKey: .name)
self.token = try c.decodeIfPresent(String.self, forKey: .token)
self.createdAt = try c.decodeIfPresent(Int.self, forKey: .createdAt)
self.lastUsed = try c.decodeIfPresent(Int.self, forKey: .lastUsed)
self.enabled = try c.decodeIfPresent(Int.self, forKey: .enabled)
if let arr = try? c.decodeIfPresent([String].self, forKey: .ipCidrs) {
self.ipCidrs = arr
} else if let s = try c.decodeIfPresent(String.self, forKey: .ipCidrs) {
let trimmed = s.split(separator: ",").map { String($0) }.filter { !$0.isEmpty }
self.ipCidrs = trimmed.isEmpty ? nil : trimmed
} else {
self.ipCidrs = nil
}
}
}
/// Wire envelope for `GET /admin/tokens`.
struct TokenList: Codable {
let tokens: [AppToken]
}
// MARK: - JSONValue
/// Heterogeneous JSON value used for ``RunResult/result``.
///
/// `claude -p --output-format json` returns either parsed JSON (object,
/// array, scalar) or a plain string when the model's reply wasn't valid
/// JSON. ``JSONValue`` represents both shapes losslessly.
///
/// Narrowing example:
/// ```swift
/// if case .object(let dict) = res.result,
/// case .string(let hello) = dict["hello"] {
/// print(hello)
/// }
/// ```
public enum JSONValue: Codable, Sendable, Equatable {
case object([String: JSONValue])
case array([JSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
public init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() {
self = .null
} else if let b = try? c.decode(Bool.self) {
self = .bool(b)
} else if let n = try? c.decode(Double.self) {
self = .number(n)
} else if let s = try? c.decode(String.self) {
self = .string(s)
} else if let arr = try? c.decode([JSONValue].self) {
self = .array(arr)
} else if let obj = try? c.decode([String: JSONValue].self) {
self = .object(obj)
} else {
throw DecodingError.dataCorruptedError(
in: c,
debugDescription: "JSONValue: unsupported JSON shape"
)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .null: try c.encodeNil()
case .bool(let b): try c.encode(b)
case .number(let n): try c.encode(n)
case .string(let s): try c.encode(s)
case .array(let a): try c.encode(a)
case .object(let o): try c.encode(o)
}
}
// MARK: Convenience accessors
/// Returns the string payload if this is `.string`, else `nil`.
public var stringValue: String? {
if case .string(let s) = self { return s }
return nil
}
/// Returns the numeric payload if this is `.number`, else `nil`.
public var numberValue: Double? {
if case .number(let n) = self { return n }
return nil
}
/// Returns the boolean payload if this is `.bool`, else `nil`.
public var boolValue: Bool? {
if case .bool(let b) = self { return b }
return nil
}
/// Returns the array payload if this is `.array`, else `nil`.
public var arrayValue: [JSONValue]? {
if case .array(let a) = self { return a }
return nil
}
/// Returns the object payload if this is `.object`, else `nil`.
public var objectValue: [String: JSONValue]? {
if case .object(let o) = self { return o }
return nil
}
}

View file

@ -0,0 +1,373 @@
// ForgeClientTests.swift
//
// XCTest suite for ForgeClient. All HTTP I/O is stubbed via URLProtocol so
// tests run hermetically (no network, no clawdforge required) on macOS,
// iOS simulator, and Linux via swift-corelibs-foundation.
import XCTest
@testable import Clawdforge
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - URLProtocol stub
/// Minimal `URLProtocol` subclass for canned responses + assertions on the
/// outgoing request. Set ``handler`` before each test; the handler is
/// called once per request and returns `(HTTPURLResponse, Data)`.
///
/// Test-only Swift 5.9 strict-concurrency does not flag these statics
/// when the package is built at language mode 5.
final class URLProtocolMock: URLProtocol, @unchecked Sendable {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
static var lastRequest: URLRequest?
static var lastBody: Data?
static func reset() {
handler = nil
lastRequest = nil
lastBody = nil
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
// Capture the body. URLProtocol receives it via either httpBody or
// httpBodyStream, depending on whether `upload(for:fromFile:)` or
// `data(for:)` was used.
Self.lastRequest = request
if let body = request.httpBody {
Self.lastBody = body
} else if let stream = request.httpBodyStream {
stream.open()
defer { stream.close() }
var collected = Data()
let bufSize = 64 * 1024
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufSize)
defer { buffer.deallocate() }
while stream.hasBytesAvailable {
let n = stream.read(buffer, maxLength: bufSize)
if n <= 0 { break }
collected.append(buffer, count: n)
}
Self.lastBody = collected
} else {
Self.lastBody = nil
}
guard let h = Self.handler else {
client?.urlProtocol(
self,
didFailWithError: URLError(.badServerResponse)
)
return
}
do {
let (resp, data) = try h(request)
client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
// MARK: - Helpers
private func makeClient() -> ForgeClient {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [URLProtocolMock.self]
let session = URLSession(configuration: config)
return ForgeClient(
baseURL: URL(string: "http://forge.test")!,
token: "cf_test_abcdef",
session: session
)
}
private func makeResponse(
url: URL,
status: Int,
headers: [String: String] = ["Content-Type": "application/json"]
) -> HTTPURLResponse {
HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers)!
}
// MARK: - Tests
final class ForgeClientTests: XCTestCase {
override func setUp() {
super.setUp()
URLProtocolMock.reset()
}
override func tearDown() {
URLProtocolMock.reset()
super.tearDown()
}
// 1. Healthz
func testHealthzDecodesAndOmitsAuthHeader() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/healthz")
XCTAssertEqual(req.httpMethod, "GET")
// Auth header is sent as-is server's healthz simply ignores it.
// We just verify the request is well-formed.
XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer cf_test_abcdef")
let body = #"{"ok": true, "claude_present": true, "claude_version": "1.2.3"}"#
return (makeResponse(url: req.url!, status: 200), Data(body.utf8))
}
let h = try await makeClient().healthz()
XCTAssertTrue(h.ok)
XCTAssertTrue(h.claudePresent)
XCTAssertEqual(h.claudeVersion, "1.2.3")
}
// 2. Run JSON object result
func testRunObjectResult() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/run")
XCTAssertEqual(req.httpMethod, "POST")
XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/json")
// Verify the body uses snake_case keys.
let body = URLProtocolMock.lastBody ?? Data()
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
XCTAssertEqual(json["prompt"] as? String, "Reply with JSON")
XCTAssertEqual(json["timeout_secs"] as? Int, 60)
let resp = #"""
{
"ok": true,
"result": {"hello": "world", "n": 42, "ok": true},
"duration_ms": 1234,
"stop_reason": "end_turn"
}
"""#
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let res = try await makeClient().run(RunRequest(
prompt: "Reply with JSON",
model: "sonnet",
timeoutSecs: 60
))
XCTAssertEqual(res.durationMs, 1234)
XCTAssertEqual(res.stopReason, "end_turn")
guard case .object(let dict) = res.result else {
return XCTFail("expected object")
}
XCTAssertEqual(dict["hello"]?.stringValue, "world")
XCTAssertEqual(dict["n"]?.numberValue, 42)
XCTAssertEqual(dict["ok"]?.boolValue, true)
}
// 3. Run string fallback when claude returned non-JSON
func testRunStringResult() async throws {
URLProtocolMock.handler = { req in
let resp = #"""
{"ok": true, "result": "hello world", "duration_ms": 10, "stop_reason": "end_turn"}
"""#
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let res = try await makeClient().run(RunRequest(prompt: "say hi"))
XCTAssertEqual(res.result.stringValue, "hello world")
}
// 4. Run empty prompt rejected client-side
func testRunEmptyPromptRejected() async {
do {
_ = try await makeClient().run(RunRequest(prompt: ""))
XCTFail("expected ForgeError.invalidArgument")
} catch ForgeError.invalidArgument {
// expected
} catch {
XCTFail("wrong error: \(error)")
}
}
// 5. Run 502 surfaces as .api with parseable RunFailure body
func testRun502SurfacesAsAPIError() async throws {
URLProtocolMock.handler = { req in
let body = #"""
{"ok": false, "error": "claude exited 1", "stderr": "boom", "duration_ms": 9, "stop_reason": "error"}
"""#
return (makeResponse(url: req.url!, status: 502), Data(body.utf8))
}
do {
_ = try await makeClient().run(RunRequest(prompt: "x"))
XCTFail("expected throw")
} catch ForgeError.api(let code, let body) {
XCTAssertEqual(code, 502)
let data = Data(body.utf8)
let failure = try JSONDecoder().decode(RunFailure.self, from: data)
XCTAssertEqual(failure.error, "claude exited 1")
XCTAssertEqual(failure.stderr, "boom")
} catch {
XCTFail("wrong error: \(error)")
}
}
// 6. Auth 401 maps to ForgeError.auth
func testAuthErrorOn401() async {
URLProtocolMock.handler = { req in
let body = #"{"detail":"bad token"}"#
return (makeResponse(url: req.url!, status: 401), Data(body.utf8))
}
do {
_ = try await makeClient().run(RunRequest(prompt: "x"))
XCTFail("expected throw")
} catch ForgeError.auth(let code, _) {
XCTAssertEqual(code, 401)
} catch {
XCTFail("wrong error: \(error)")
}
}
// 7. Admin create token
func testCreateTokenReturnsPlaintext() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/admin/tokens")
XCTAssertEqual(req.httpMethod, "POST")
let body = URLProtocolMock.lastBody ?? Data()
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
XCTAssertEqual(json["name"] as? String, "cauldron")
XCTAssertEqual(json["ip_cidrs"] as? [String], ["10.0.0.0/8"])
let resp = #"""
{"name": "cauldron", "token": "cf_NEW_xyz", "ip_cidrs": ["10.0.0.0/8"]}
"""#
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let tk = try await makeClient().createToken(
CreateTokenRequest(name: "cauldron", ipCidrs: ["10.0.0.0/8"])
)
XCTAssertEqual(tk.name, "cauldron")
XCTAssertEqual(tk.token, "cf_NEW_xyz")
XCTAssertEqual(tk.ipCidrs, ["10.0.0.0/8"])
}
// 8. Admin list tokens (server returns ip_cidrs as comma-string)
func testListTokensHandlesStringCidrs() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/admin/tokens")
XCTAssertEqual(req.httpMethod, "GET")
let resp = #"""
{
"tokens": [
{"name": "cauldron", "ip_cidrs": "10.0.0.0/8,172.24.0.0/16", "created_at": 1700000000, "last_used": null, "enabled": 1},
{"name": "petalparse", "ip_cidrs": "", "created_at": 1700000001, "last_used": 1700000999, "enabled": 1}
]
}
"""#
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let toks = try await makeClient().listTokens()
XCTAssertEqual(toks.count, 2)
XCTAssertEqual(toks[0].name, "cauldron")
XCTAssertEqual(toks[0].ipCidrs, ["10.0.0.0/8", "172.24.0.0/16"])
XCTAssertNil(toks[1].ipCidrs) // empty string -> nil
}
// 9. Admin revoke token issues DELETE with URL-encoded name
func testRevokeTokenDelete() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.httpMethod, "DELETE")
XCTAssertTrue(req.url!.path.hasPrefix("/admin/tokens/"))
return (makeResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8))
}
try await makeClient().revokeToken(name: "cauldron")
XCTAssertEqual(URLProtocolMock.lastRequest?.url?.path, "/admin/tokens/cauldron")
}
// 10. Upload multipart body wraps the file with ttl_secs field
func testUploadFileMultipart() async throws {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdforge-test-\(UUID().uuidString).txt")
try Data("hello upload".utf8).write(to: tmp)
defer { try? FileManager.default.removeItem(at: tmp) }
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/files")
XCTAssertEqual(req.httpMethod, "POST")
let ctype = req.value(forHTTPHeaderField: "Content-Type") ?? ""
XCTAssertTrue(ctype.hasPrefix("multipart/form-data; boundary="))
let body = URLProtocolMock.lastBody ?? Data()
let bodyStr = String(data: body, encoding: .utf8) ?? ""
XCTAssertTrue(bodyStr.contains("name=\"ttl_secs\""))
XCTAssertTrue(bodyStr.contains("7200"))
XCTAssertTrue(bodyStr.contains("name=\"file\""))
XCTAssertTrue(bodyStr.contains("hello upload"))
let resp = #"""
{"file_token": "ff_abc123", "ttl_secs": 7200, "size": 12}
"""#
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let ft = try await makeClient().uploadFile(at: tmp, ttlSecs: 7200)
XCTAssertEqual(ft.fileToken, "ff_abc123")
XCTAssertEqual(ft.ttlSecs, 7200)
XCTAssertEqual(ft.size, 12)
}
// 11. JSONValue round-trips array/null/nested
func testJSONValueRoundTrip() throws {
let json = #"""
{
"result": [1, "two", null, {"k": true}, [false, 3.14]]
}
"""#
struct W: Codable { let result: JSONValue }
let decoded = try JSONDecoder().decode(W.self, from: Data(json.utf8))
guard case .array(let arr) = decoded.result else {
return XCTFail("expected array")
}
XCTAssertEqual(arr.count, 5)
XCTAssertEqual(arr[0].numberValue, 1)
XCTAssertEqual(arr[1].stringValue, "two")
if case .null = arr[2] {} else { XCTFail("expected null") }
XCTAssertEqual(arr[3].objectValue?["k"]?.boolValue, true)
XCTAssertEqual(arr[4].arrayValue?[1].numberValue, 3.14)
// re-encode and ensure stable shape
let re = try JSONEncoder().encode(decoded)
let reDecoded = try JSONDecoder().decode(W.self, from: re)
XCTAssertEqual(reDecoded, decoded)
}
// 12. Cancellation propagates from Task to URLSession
func testCancellationThrows() async {
URLProtocolMock.handler = { _ in
// Block "forever" so the cancel path is the only way out. We
// simulate by throwing a cancellation-like URLError if the test
// harness doesn't tear down quickly enough.
Thread.sleep(forTimeInterval: 0.5)
throw URLError(.timedOut)
}
let client = makeClient()
let task = Task {
try await client.run(RunRequest(prompt: "x"))
}
task.cancel()
do {
_ = try await task.value
XCTFail("expected throw")
} catch is CancellationError {
// hit the early Task.checkCancellation in send(_:decode:)
} catch ForgeError.transport {
// hit URLSession's cancellation pathway
} catch {
XCTFail("wrong error: \(error)")
}
}
}