From e42df3cae3125fc14e371ff8547ca393a46f1d43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 19:59:04 +0000 Subject: [PATCH 01/25] Add preemptive execution watchdog for real timeouts and cancellation JavaScriptCore drains the entire promise graph inside a single evaluateScript call because bridge calls are synchronous, so the wall-clock poll loop after evaluation could never interrupt CPU-bound scripts: while(true){} pinned a worker thread forever and timeoutMs was never enforced against running JavaScript. Install a JSContextGroupSetExecutionTimeLimit watchdog per execution whose callback re-checks the deadline and the cancellation flag every 50ms and terminates the script when either trips. Termination is classified as EXECUTION_TIMEOUT (or SEARCH_TIMEOUT for search) and CANCELLED, so call.cancel() now interrupts in-flight JavaScript instead of only flipping a flag that was polled after completion. The deadline now starts when evaluation begins rather than after the script settles, and the dangling-promise poll loop reuses the same deadline instead of granting a second full timeout budget. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF --- Sources/CodeMode/Runtime/BridgeRuntime.swift | 56 +++++++- .../CodeMode/Runtime/ExecutionWatchdog.swift | 87 ++++++++++++ .../ExecutionWatchdogTests.swift | 127 ++++++++++++++++++ 3 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 Sources/CodeMode/Runtime/ExecutionWatchdog.swift create mode 100644 Tests/CodeModeTests/ExecutionWatchdogTests.swift diff --git a/Sources/CodeMode/Runtime/BridgeRuntime.swift b/Sources/CodeMode/Runtime/BridgeRuntime.swift index fb467c4..67da842 100644 --- a/Sources/CodeMode/Runtime/BridgeRuntime.swift +++ b/Sources/CodeMode/Runtime/BridgeRuntime.swift @@ -436,16 +436,36 @@ final class BridgeRuntime: @unchecked Sendable { }); """ + let watchdog = ExecutionWatchdog(timeoutMs: timeoutMs, cancellationController: cancellationController) + watchdog.install(on: context) + defer { watchdog.uninstall(from: context) } + lastException.set(nil) let evaluation = context.evaluateScript(script) + + switch watchdog.termination { + case .timedOut: + throw toolError( + code: "EXECUTION_TIMEOUT", + message: "Execution timed out after \(timeoutMs)ms", + transcript: invocationContext + ) + case .cancelled: + throw toolError( + code: "CANCELLED", + message: "Execution cancelled", + transcript: invocationContext + ) + case nil: + break + } + let snapshot = lastException.get() ?? Self.snapshot(from: context.exception) if snapshot != nil || evaluation == nil { throw syntaxError(from: snapshot, lineOffset: 4) } - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) - - while Date() < deadline { + while Date() < watchdog.deadline { if cancellationController.isCancelled || Task.isCancelled { cancellationController.cancel() throw toolError( @@ -458,12 +478,14 @@ final class BridgeRuntime: @unchecked Sendable { let state = context.evaluateScript("globalThis.__codemode.state")?.toString() ?? "unknown" switch state { case "fulfilled": + watchdog.uninstall(from: context) let output = try decodeOutput(from: context) if output == nil { invocationContext.recordDiagnostic(Self.noReturnValueDiagnostic(for: code)) } return output case "rejected": + watchdog.uninstall(from: context) let payload = rejectionPayload(from: context) throw classifyRejectedError(payload, invocationContext: invocationContext) default: @@ -547,16 +569,36 @@ final class BridgeRuntime: @unchecked Sendable { }); """ + let watchdog = ExecutionWatchdog(timeoutMs: timeoutMs, cancellationController: cancellationController) + watchdog.install(on: context) + defer { watchdog.uninstall(from: context) } + lastException.set(nil) let evaluation = context.evaluateScript(script) + + switch watchdog.termination { + case .timedOut: + throw toolError( + code: "SEARCH_TIMEOUT", + message: "Search timed out after \(timeoutMs)ms", + transcript: invocationContext + ) + case .cancelled: + throw toolError( + code: "CANCELLED", + message: "Search cancelled", + transcript: invocationContext + ) + case nil: + break + } + let snapshot = lastException.get() ?? Self.snapshot(from: context.exception) if snapshot != nil || evaluation == nil { throw syntaxError(from: snapshot, lineOffset: 5) } - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) - - while Date() < deadline { + while Date() < watchdog.deadline { if cancellationController.isCancelled || Task.isCancelled { cancellationController.cancel() throw toolError( @@ -569,12 +611,14 @@ final class BridgeRuntime: @unchecked Sendable { let state = context.evaluateScript("globalThis.__codemode.state")?.toString() ?? "unknown" switch state { case "fulfilled": + watchdog.uninstall(from: context) return try decodeOutput( from: context, errorCode: "INVALID_SEARCH_RESULT", errorMessagePrefix: "Search result must be JSON-serializable" ) case "rejected": + watchdog.uninstall(from: context) let payload = rejectionPayload(from: context) throw classifySearchRejectedError(payload, invocationContext: invocationContext) default: diff --git a/Sources/CodeMode/Runtime/ExecutionWatchdog.swift b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift new file mode 100644 index 0000000..783be6f --- /dev/null +++ b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift @@ -0,0 +1,87 @@ +import Foundation +import JavaScriptCore + +/// Preemptively terminates JavaScript that runs past its deadline or is cancelled. +/// +/// Bridge calls are synchronous, so the entire user script — including its full +/// promise graph — settles inside a single `evaluateScript` call. A wall-clock +/// check after evaluation can therefore never interrupt CPU-bound code such as +/// `while (true) {}`. This installs a JavaScriptCore execution time limit whose +/// callback re-checks the deadline and the cancellation flag on a short interval +/// and terminates the script when either trips. +final class ExecutionWatchdog: @unchecked Sendable { + enum Termination: Sendable { + case timedOut + case cancelled + } + + /// How often JavaScriptCore re-invokes the termination callback while a + /// script is executing. Bounds both timeout overshoot and cancellation latency. + private static let checkInterval: TimeInterval = 0.05 + + let deadline: Date + + private let lock = NSLock() + private var terminationValue: Termination? + private let cancellationController: ExecutionCancellationController + + init(timeoutMs: Int, cancellationController: ExecutionCancellationController) { + self.deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + self.cancellationController = cancellationController + } + + var termination: Termination? { + lock.lock() + defer { lock.unlock() } + return terminationValue + } + + func install(on context: JSContext) { + guard let contextRef = context.jsGlobalContextRef else { + return + } + let group = JSContextGetGroup(contextRef) + let info = Unmanaged.passUnretained(self).toOpaque() + JSContextGroupSetExecutionTimeLimit( + group, + Self.checkInterval, + { _, info in + guard let info else { + return false + } + return Unmanaged + .fromOpaque(info) + .takeUnretainedValue() + .shouldTerminate() + }, + info + ) + } + + /// Callers must keep the watchdog installed only while `self` is alive; the + /// callback holds an unretained reference. + func uninstall(from context: JSContext) { + guard let contextRef = context.jsGlobalContextRef else { + return + } + JSContextGroupClearExecutionTimeLimit(JSContextGetGroup(contextRef)) + } + + private func shouldTerminate() -> Bool { + lock.lock() + defer { lock.unlock() } + + if terminationValue != nil { + return true + } + if cancellationController.isCancelled { + terminationValue = .cancelled + return true + } + if Date() >= deadline { + terminationValue = .timedOut + return true + } + return false + } +} diff --git a/Tests/CodeModeTests/ExecutionWatchdogTests.swift b/Tests/CodeModeTests/ExecutionWatchdogTests.swift new file mode 100644 index 0000000..50ba703 --- /dev/null +++ b/Tests/CodeModeTests/ExecutionWatchdogTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func executeTerminatesCPUBoundInfiniteLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let started = Date() + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "while (true) {}", + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "EXECUTION_TIMEOUT") + #expect(Date().timeIntervalSince(started) < 5) +} + +@Test func executeTerminatesCPUBoundLoopInsidePromiseChain() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + await Promise.resolve(); + while (true) {} + """, + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "EXECUTION_TIMEOUT") +} + +@Test func executeTimeoutTerminationIsNotCatchableByScript() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + try { + while (true) {} + } catch (error) { + return { swallowed: true }; + } + """, + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "EXECUTION_TIMEOUT") +} + +@Test func executeRecoversWithFreshContextAfterTerminatedLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let timedOut = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "while (true) {}", + allowedCapabilities: [], + timeoutMs: 200 + ) + ) + #expect(timedOut.error?.code == "EXECUTION_TIMEOUT") + + let recovered = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return { ok: true };", + allowedCapabilities: [] + ) + ) + + let payload = try requireJSONObject(from: try #require(recovered.result)) + #expect(payload["ok"] as? Bool == true) +} + +@Test func cancelInterruptsRunningInfiniteLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: "while (true) {}", + allowedCapabilities: [], + timeoutMs: 60_000 + ) + ) + + try await Task.sleep(nanoseconds: 200_000_000) + call.cancel() + let observed = await observe(call) + + #expect(observed.result == nil) + #expect(observed.error?.code == "CANCELLED") +} + +@Test func searchTerminatesCPUBoundInfiniteLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + do { + _ = try await tools.searchJavaScriptAPI( + JavaScriptAPISearchRequest( + code: "async () => { while (true) {} }" + ) + ) + Issue.record("Expected search to time out") + } catch let error as CodeModeToolError { + #expect(error.code == "SEARCH_TIMEOUT") + } +} From 913d1754550adb30a88205dd5b6c70e0fc7a1b0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:06:19 +0000 Subject: [PATCH 02/25] Add network egress policy to fetch (SSRF hardening) network.fetch previously accepted any HTTP(S) destination once the capability was allowlisted, so agent-authored JavaScript could reach loopback services, RFC 1918 hosts, and cloud metadata endpoints such as 169.254.169.254, follow redirects from public URLs to internal ones, and buffer arbitrarily large response bodies into memory. Introduce NetworkAccessPolicy, injected via CodeModeConfiguration.networkAccessPolicy: - .standard (the default) refuses loopback, private-range, link-local, CGNAT, and unique-local IPv4/IPv6 destinations (including encoded literals like 2130706433 and 0x7f000001 via inet_aton, and IPv4-mapped IPv6), plus localhost and .local/.localhost/.internal names, and caps buffered responses at 10 MB. - allowedHosts/blockedHosts support exact and subdomain matching, with explicit allowlist entries able to deliberately re-enable private hosts. .permissive restores the previous unrestricted behavior. - The fetch transfer now runs through a per-task URLSession delegate so redirect targets are re-validated before being followed and oversized bodies are cancelled mid-transfer instead of buffered. - Refusals throw structured NETWORK_POLICY_VIOLATION errors with repair suggestions, and both denials and successful fetch destinations are written to the audit logger. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF --- README.md | 21 ++ Sources/CodeMode/API/BridgeErrors.swift | 5 + Sources/CodeMode/API/BridgeModels.swift | 3 + .../Bridges/DefaultCapabilityLoader.swift | 5 +- Sources/CodeMode/Bridges/NetworkBridge.swift | 190 +++++++++++--- .../Host/CodeModeAgentToolDescriptions.swift | 1 + .../CodeMode/Host/CodeModeAgentTools.swift | 1 + Sources/CodeMode/Runtime/BridgeRuntime.swift | 7 + .../Security/NetworkAccessPolicy.swift | 159 +++++++++++ .../NetworkAccessPolicyTests.swift | 248 ++++++++++++++++++ 10 files changed, 599 insertions(+), 41 deletions(-) create mode 100644 Sources/CodeMode/Security/NetworkAccessPolicy.swift create mode 100644 Tests/CodeModeTests/NetworkAccessPolicyTests.swift diff --git a/README.md b/README.md index 7211bb6..a821a95 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,27 @@ let tools = CodeModeAgentTools( `CodeModeFileSystem` receives paths after `PathPolicy` resolution, so sandbox root enforcement stays in CodeMode while the host can route reads, writes, listings, moves, copies, deletes, and stats through another backing implementation. `LocalCodeModeFileSystem` preserves the default `FileManager` behavior. +## Network Access Policy + +`network.fetch` egress is governed by `CodeModeConfiguration.networkAccessPolicy`: + +- The default `NetworkAccessPolicy.standard` refuses loopback, RFC 1918, link-local (including cloud metadata addresses such as `169.254.169.254`), CGNAT, and unique-local destinations, plus `localhost` and `.local`/`.localhost`/`.internal` names, and caps buffered response bodies at 10 MB. +- Redirect targets are re-validated against the policy before they are followed. +- Refusals throw structured `NETWORK_POLICY_VIOLATION` errors and are written to the audit logger along with successful fetch destinations. +- `allowedHosts` restricts fetch to an explicit list (entries match the host and its subdomains, and deliberately allowlisted private hosts such as `localhost` are honored); `blockedHosts` refuses specific hosts; `NetworkAccessPolicy.permissive` restores unrestricted behavior. +- Matching is by URL host only; DNS resolution is not performed, so a public hostname that resolves to a private address is not detected. Hosts that need stricter guarantees should set `allowedHosts`. + +```swift +let tools = CodeModeAgentTools( + config: CodeModeConfiguration( + networkAccessPolicy: NetworkAccessPolicy( + allowedHosts: ["api.example.com"], + maxResponseBytes: 2_000_000 + ) + ) +) +``` + ## Search `searchJavaScriptAPI` accepts `JavaScriptAPISearchRequest`: diff --git a/Sources/CodeMode/API/BridgeErrors.swift b/Sources/CodeMode/API/BridgeErrors.swift index 3fe881f..b9aa44b 100644 --- a/Sources/CodeMode/API/BridgeErrors.swift +++ b/Sources/CodeMode/API/BridgeErrors.swift @@ -13,6 +13,7 @@ enum BridgeError: Error, Sendable { case timeout(milliseconds: Int) case cancelled case pathViolation(String) + case networkPolicyViolation(String) case javascriptError(String) case nativeFailure(String) } @@ -44,6 +45,8 @@ extension BridgeError: LocalizedError { return "Execution cancelled" case let .pathViolation(message): return message + case let .networkPolicyViolation(message): + return message case let .javascriptError(message): return message case let .nativeFailure(message): @@ -73,6 +76,8 @@ extension BridgeError: LocalizedError { return "CANCELLED" case .pathViolation: return "PATH_POLICY_VIOLATION" + case .networkPolicyViolation: + return "NETWORK_POLICY_VIOLATION" case .javascriptError: return "JAVASCRIPT_ERROR" case .nativeFailure: diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index 0ad3d75..9127d1f 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -2,6 +2,7 @@ import Foundation public struct CodeModeConfiguration: Sendable { public var pathPolicy: any PathPolicy + public var networkAccessPolicy: NetworkAccessPolicy public var fileSystem: any CodeModeFileSystem public var artifactStore: any ArtifactStore public var permissionBroker: any PermissionBroker @@ -23,6 +24,7 @@ public struct CodeModeConfiguration: Sendable { public init( pathPolicy: any PathPolicy = DefaultPathPolicy(), + networkAccessPolicy: NetworkAccessPolicy = .standard, fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), artifactStore: any ArtifactStore = InMemoryArtifactStore(), permissionBroker: any PermissionBroker = SystemPermissionBroker(), @@ -43,6 +45,7 @@ public struct CodeModeConfiguration: Sendable { hostPlatform: HostPlatform = .current ) { self.pathPolicy = pathPolicy + self.networkAccessPolicy = networkAccessPolicy self.fileSystem = fileSystem self.artifactStore = artifactStore self.permissionBroker = permissionBroker diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index 2ff5130..96eb81e 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -3,6 +3,7 @@ import Foundation public enum DefaultCapabilityLoader { public static func loadAllRegistrations( fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), + networkAccessPolicy: NetworkAccessPolicy = .standard, eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox(), cloudKitClient: any CloudKitClient = UnavailableCloudKitClient(), remoteNotificationsClient: any RemoteNotificationsClient = UnavailableRemoteNotificationsClient(), @@ -17,6 +18,7 @@ public enum DefaultCapabilityLoader { ) -> [CapabilityRegistration] { DefaultCapabilityRegistrationBuilder( fileSystem: fileSystem, + networkAccessPolicy: networkAccessPolicy, eventInbox: eventInbox, cloudKitClient: cloudKitClient, remoteNotificationsClient: remoteNotificationsClient, @@ -62,6 +64,7 @@ struct DefaultCapabilityRegistrationBuilder { init( fileSystem: any CodeModeFileSystem, + networkAccessPolicy: NetworkAccessPolicy, eventInbox: any CodeModeEventInbox, cloudKitClient: any CloudKitClient, remoteNotificationsClient: any RemoteNotificationsClient, @@ -75,7 +78,7 @@ struct DefaultCapabilityRegistrationBuilder { storeKitClient: any StoreKitClient ) { self.fs = FileSystemBridge(fileSystem: fileSystem) - self.network = NetworkBridge() + self.network = NetworkBridge(policy: networkAccessPolicy) self.keychain = KeychainBridge() self.location = LocationBridge() self.weather = WeatherBridge() diff --git a/Sources/CodeMode/Bridges/NetworkBridge.swift b/Sources/CodeMode/Bridges/NetworkBridge.swift index 8f9ae00..b1bb4ff 100644 --- a/Sources/CodeMode/Bridges/NetworkBridge.swift +++ b/Sources/CodeMode/Bridges/NetworkBridge.swift @@ -4,9 +4,11 @@ public final class NetworkBridge: @unchecked Sendable { private static let defaultTimeoutMs = 30_000 private let session: URLSession + private let policy: NetworkAccessPolicy - public init(session: URLSession = .shared) { + public init(session: URLSession = .shared, policy: NetworkAccessPolicy = .standard) { self.session = session + self.policy = policy } public func fetch(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { @@ -19,6 +21,14 @@ public final class NetworkBridge: @unchecked Sendable { throw BridgeError.invalidArguments("network.fetch requires an absolute HTTP(S) 'url'") } + if let reason = policy.violationReason(for: url) { + context.auditLogger.log(AuditEvent( + capability: CapabilityID.networkFetch.rawValue, + message: "denied \(urlString): \(reason)" + )) + throw BridgeError.networkPolicyViolation(reason) + } + let options = arguments.object("options") ?? [:] let timeoutMs = options.int("timeoutMs") ?? Self.defaultTimeoutMs guard timeoutMs > 0 else { @@ -57,57 +67,157 @@ public final class NetworkBridge: @unchecked Sendable { request.httpBody = data } - let semaphore = DispatchSemaphore(value: 0) - let resultBox = SynchronizedBox?>(nil) - - let task = session.dataTask(with: request) { data, urlResponse, error in - if let error { - resultBox.set(.failure(error)) - } else { - resultBox.set(.success((data, urlResponse))) - } - semaphore.signal() - } + let handler = FetchTaskHandler(policy: policy) + let task = session.dataTask(with: request) + task.delegate = handler task.resume() - guard semaphore.wait(timeout: .now() + TimeInterval(timeoutMs) / 1_000) == .success else { + guard handler.semaphore.wait(timeout: .now() + TimeInterval(timeoutMs) / 1_000) == .success else { task.cancel() throw BridgeError.timeout(milliseconds: timeoutMs) } - guard let result = resultBox.get() else { - throw BridgeError.nativeFailure("network.fetch finished without result") + let outcome = handler.outcome() + + if let reason = outcome.violationReason { + context.auditLogger.log(AuditEvent( + capability: CapabilityID.networkFetch.rawValue, + message: "denied \(urlString): \(reason)" + )) + throw BridgeError.networkPolicyViolation(reason) } - switch result { - case let .failure(error): + if let error = outcome.error { throw BridgeError.nativeFailure("network.fetch failed: \(error.localizedDescription)") - case let .success((responseData, response)): - guard let httpResponse = response as? HTTPURLResponse else { - throw BridgeError.nativeFailure("network.fetch received non-HTTP response") - } + } - let responseData = responseData ?? Data() - let headers = httpResponse.allHeaderFields.reduce(into: [String: JSONValue]()) { partial, pair in - partial[String(describing: pair.key)] = .string(String(describing: pair.value)) - } + guard let httpResponse = outcome.response else { + throw BridgeError.nativeFailure("network.fetch received non-HTTP response") + } - context.log(.info, message: "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)") - - var object: [String: JSONValue] = [ - "ok": .bool((200...299).contains(httpResponse.statusCode)), - "status": .number(Double(httpResponse.statusCode)), - "statusText": .string(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)), - "headers": .object(headers), - ] - if responseEncoding == "base64" { - object["bodyBase64"] = .string(responseData.base64EncodedString()) - object["bodyText"] = .string("") - } else { - object["bodyText"] = .string(String(data: responseData, encoding: .utf8) ?? "") - } + let responseData = outcome.data + let headers = httpResponse.allHeaderFields.reduce(into: [String: JSONValue]()) { partial, pair in + partial[String(describing: pair.key)] = .string(String(describing: pair.value)) + } + + context.log(.info, message: "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)") + context.auditLogger.log(AuditEvent( + capability: CapabilityID.networkFetch.rawValue, + message: "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)" + )) + + var object: [String: JSONValue] = [ + "ok": .bool((200...299).contains(httpResponse.statusCode)), + "status": .number(Double(httpResponse.statusCode)), + "statusText": .string(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)), + "headers": .object(headers), + ] + if responseEncoding == "base64" { + object["bodyBase64"] = .string(responseData.base64EncodedString()) + object["bodyText"] = .string("") + } else { + object["bodyText"] = .string(String(data: responseData, encoding: .utf8) ?? "") + } + + return .object(object) + } +} + +/// Per-task delegate that enforces the network access policy while the +/// transfer is in flight: redirect targets are re-validated before they are +/// followed, and the response body is cancelled as soon as it exceeds the +/// policy's size cap instead of being buffered whole. +private final class FetchTaskHandler: NSObject, URLSessionDataDelegate, @unchecked Sendable { + struct Outcome { + var response: HTTPURLResponse? + var data: Data + var error: Error? + var violationReason: String? + } + + let semaphore = DispatchSemaphore(value: 0) + + private let policy: NetworkAccessPolicy + private let lock = NSLock() + private var response: HTTPURLResponse? + private var data = Data() + private var completionError: Error? + private var violationReason: String? + + init(policy: NetworkAccessPolicy) { + self.policy = policy + } + + func outcome() -> Outcome { + lock.lock() + defer { lock.unlock() } + return Outcome( + response: response, + data: data, + error: completionError, + violationReason: violationReason + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + if let url = request.url, let reason = policy.violationReason(for: url) { + setViolation("redirect to \(url.absoluteString) refused: \(reason)") + completionHandler(nil) + return + } + completionHandler(request) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + if response.expectedContentLength > 0, response.expectedContentLength > Int64(policy.maxResponseBytes) { + setViolation("response of \(response.expectedContentLength) bytes exceeds the \(policy.maxResponseBytes)-byte network access policy limit") + completionHandler(.cancel) + return + } + + lock.lock() + self.response = response as? HTTPURLResponse + lock.unlock() + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive chunk: Data) { + lock.lock() + data.append(chunk) + let overLimit = data.count > policy.maxResponseBytes + lock.unlock() + + if overLimit { + setViolation("response exceeded the \(policy.maxResponseBytes)-byte network access policy limit") + dataTask.cancel() + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + lock.lock() + if completionError == nil { + completionError = error + } + lock.unlock() + semaphore.signal() + } - return .object(object) + private func setViolation(_ reason: String) { + lock.lock() + if violationReason == nil { + violationReason = reason } + lock.unlock() } } diff --git a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift index d8a0a3d..62cace0 100644 --- a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift +++ b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift @@ -128,6 +128,7 @@ public enum CodeModeAgentToolDescriptions { PERMISSION_DENIED: the capability is allowlisted but the OS, host, or custom provider denied permission; request permission if a helper exists, otherwise tell the user or host. UI_PRESENTER_UNAVAILABLE: host configuration issue; do not retry the same call. INVALID_ARGUMENTS: use the catalog requiredArguments, optionalArguments, argumentHints, and example. + NETWORK_POLICY_VIOLATION: the host's network access policy refused the destination; do not retry the same URL and do not add capabilities to work around it. """ ) diff --git a/Sources/CodeMode/Host/CodeModeAgentTools.swift b/Sources/CodeMode/Host/CodeModeAgentTools.swift index 2163504..1959dfb 100644 --- a/Sources/CodeMode/Host/CodeModeAgentTools.swift +++ b/Sources/CodeMode/Host/CodeModeAgentTools.swift @@ -8,6 +8,7 @@ public final class CodeModeAgentTools: @unchecked Sendable { public init(config: CodeModeConfiguration = .init()) { let allDefaultRegistrations = DefaultCapabilityLoader.loadAllRegistrations( fileSystem: config.fileSystem, + networkAccessPolicy: config.networkAccessPolicy, eventInbox: config.eventInbox, cloudKitClient: config.cloudKitClient, remoteNotificationsClient: config.remoteNotificationsClient, diff --git a/Sources/CodeMode/Runtime/BridgeRuntime.swift b/Sources/CodeMode/Runtime/BridgeRuntime.swift index 67da842..3d52114 100644 --- a/Sources/CodeMode/Runtime/BridgeRuntime.swift +++ b/Sources/CodeMode/Runtime/BridgeRuntime.swift @@ -902,6 +902,12 @@ final class BridgeRuntime: @unchecked Sendable { "Configure CodeModeConfiguration.systemUIPresenter before using UI-presenting helpers.", "Do not retry this helper until the host provides a SystemUIPresenter.", ] + case .networkPolicyViolation: + suggestions = [ + "The host app's network access policy refused this destination.", + "This is not repaired by adding more allowedCapabilities; do not retry the same URL.", + "Only public HTTP(S) destinations permitted by CodeModeConfiguration.networkAccessPolicy are reachable.", + ] default: suggestions = bridgeSuggestions(for: capability, capabilityKey: capabilityKey) } @@ -964,6 +970,7 @@ final class BridgeRuntime: @unchecked Sendable { "UI_PRESENTER_UNAVAILABLE", "EXECUTION_TIMEOUT", "PATH_POLICY_VIOLATION", + "NETWORK_POLICY_VIOLATION", "JAVASCRIPT_ERROR", "NATIVE_FAILURE", "CANCELLED", diff --git a/Sources/CodeMode/Security/NetworkAccessPolicy.swift b/Sources/CodeMode/Security/NetworkAccessPolicy.swift new file mode 100644 index 0000000..968b091 --- /dev/null +++ b/Sources/CodeMode/Security/NetworkAccessPolicy.swift @@ -0,0 +1,159 @@ +import Foundation + +/// Controls which destinations `network.fetch` may reach and how much response +/// data it may buffer. +/// +/// The default (`.standard`) blocks loopback, private-range, link-local, and +/// other non-public destinations so agent-authored JavaScript cannot reach +/// `127.0.0.1`, cloud metadata endpoints such as `169.254.169.254`, or hosts on +/// the local network, and caps buffered response bodies at 10 MB. +/// +/// Host matching is by URL host only; DNS resolution is not performed, so a +/// public hostname that resolves to a private address is not detected. Hosts +/// that need stricter guarantees should set `allowedHosts`. +public struct NetworkAccessPolicy: Sendable, Equatable { + /// When non-nil, only these hosts are reachable. Entries match the host + /// exactly or as a parent domain (`"example.com"` also matches + /// `"api.example.com"`). Entries bypass the private-network check, so a + /// host app can deliberately allowlist `"localhost"`. + public var allowedHosts: [String]? + + /// Hosts that are always refused, matched like `allowedHosts` entries and + /// checked before it. + public var blockedHosts: [String] + + /// Refuses loopback, RFC 1918, link-local (including cloud metadata), + /// CGNAT, unique-local and IPv4-mapped IPv6 literals, `localhost`, and + /// `.local`/`.localhost`/`.internal` names. + public var blocksPrivateNetworks: Bool + + /// Maximum response body size in bytes that fetch will buffer. Responses + /// that exceed this are cancelled mid-transfer. + public var maxResponseBytes: Int + + public init( + allowedHosts: [String]? = nil, + blockedHosts: [String] = [], + blocksPrivateNetworks: Bool = true, + maxResponseBytes: Int = 10_485_760 + ) { + self.allowedHosts = allowedHosts + self.blockedHosts = blockedHosts + self.blocksPrivateNetworks = blocksPrivateNetworks + self.maxResponseBytes = maxResponseBytes + } + + /// Secure default: private networks blocked, 10 MB response cap. + public static let standard = NetworkAccessPolicy() + + /// No destination restrictions and no response size cap. Matches the + /// pre-policy behavior of `network.fetch`; opt in deliberately. + public static let permissive = NetworkAccessPolicy( + blocksPrivateNetworks: false, + maxResponseBytes: .max + ) + + /// Returns a human-readable reason the URL is refused, or nil when allowed. + func violationReason(for url: URL) -> String? { + guard let rawHost = url.host, rawHost.isEmpty == false else { + return "URL has no host" + } + let host = Self.normalized(host: rawHost) + + if blockedHosts.contains(where: { Self.host(host, matches: $0) }) { + return "Host \"\(host)\" is blocked by the network access policy" + } + + if let allowedHosts { + guard allowedHosts.contains(where: { Self.host(host, matches: $0) }) else { + return "Host \"\(host)\" is not in the network access policy allowlist" + } + return nil + } + + if blocksPrivateNetworks, Self.isPrivateOrLocal(host: host) { + return "Host \"\(host)\" is a loopback, private, or link-local destination blocked by the network access policy" + } + + return nil + } + + private static func normalized(host: String) -> String { + var host = host.lowercased() + if host.hasPrefix("["), host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + return host + } + + private static func host(_ host: String, matches pattern: String) -> Bool { + let pattern = normalized(host: pattern) + return host == pattern || host.hasSuffix(".\(pattern)") + } + + private static func isPrivateOrLocal(host: String) -> Bool { + if host == "localhost" + || host.hasSuffix(".localhost") + || host.hasSuffix(".local") + || host.hasSuffix(".internal") + { + return true + } + + // inet_aton accepts every numeric IPv4 form URL loaders resolve as an + // address (dotted quad, partial quads, decimal, octal, and hex), so + // encoded literals like "2130706433" cannot slip past the check. + var ipv4 = in_addr() + if host.contains(":") == false, inet_aton(host, &ipv4) != 0 { + return isPrivateOrLocal(ipv4: UInt32(bigEndian: ipv4.s_addr)) + } + + let ipv6Host = host.split(separator: "%").first.map(String.init) ?? host + var ipv6 = in6_addr() + if inet_pton(AF_INET6, ipv6Host, &ipv6) == 1 { + return isPrivateOrLocal(ipv6: ipv6) + } + + return false + } + + private static func isPrivateOrLocal(ipv4 address: UInt32) -> Bool { + let octet1 = UInt8(truncatingIfNeeded: address >> 24) + let octet2 = UInt8(truncatingIfNeeded: address >> 16) + + switch octet1 { + case 0, 10, 127, 255: + return true + case 100: + return (64...127).contains(octet2) // CGNAT 100.64.0.0/10 + case 169: + return octet2 == 254 // link-local + cloud metadata + case 172: + return (16...31).contains(octet2) + case 192: + return octet2 == 168 + default: + return false + } + } + + private static func isPrivateOrLocal(ipv6 address: in6_addr) -> Bool { + let bytes = withUnsafeBytes(of: address) { Array($0) } + + if bytes[0..<15].allSatisfy({ $0 == 0 }) { + return bytes[15] == 0 || bytes[15] == 1 // unspecified or loopback + } + if bytes[0] & 0xfe == 0xfc { + return true // unique local fc00::/7 + } + if bytes[0] == 0xfe, bytes[1] & 0xc0 == 0x80 { + return true // link-local fe80::/10 + } + if bytes[0..<10].allSatisfy({ $0 == 0 }), bytes[10] == 0xff, bytes[11] == 0xff { + // IPv4-mapped ::ffff:a.b.c.d + let embedded = UInt32(bytes[12]) << 24 | UInt32(bytes[13]) << 16 | UInt32(bytes[14]) << 8 | UInt32(bytes[15]) + return isPrivateOrLocal(ipv4: embedded) + } + return false + } +} diff --git a/Tests/CodeModeTests/NetworkAccessPolicyTests.swift b/Tests/CodeModeTests/NetworkAccessPolicyTests.swift new file mode 100644 index 0000000..ff4af2a --- /dev/null +++ b/Tests/CodeModeTests/NetworkAccessPolicyTests.swift @@ -0,0 +1,248 @@ +import Foundation +import Testing +@testable import CodeMode + +private func violation(_ policy: NetworkAccessPolicy, _ urlString: String) -> String? { + guard let url = URL(string: urlString) else { + Issue.record("Invalid test URL: \(urlString)") + return nil + } + return policy.violationReason(for: url) +} + +@Test func standardPolicyBlocksLoopbackAndPrivateIPv4() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://127.0.0.1/") != nil) + #expect(violation(policy, "http://10.0.0.5/") != nil) + #expect(violation(policy, "http://172.16.9.1/") != nil) + #expect(violation(policy, "http://172.31.255.1/") != nil) + #expect(violation(policy, "http://192.168.1.1/") != nil) + #expect(violation(policy, "http://169.254.169.254/latest/meta-data/") != nil) + #expect(violation(policy, "http://100.64.0.1/") != nil) + #expect(violation(policy, "http://0.0.0.0/") != nil) +} + +@Test func standardPolicyBlocksEncodedIPv4Literals() { + let policy = NetworkAccessPolicy.standard + + // Alternate numeric spellings of 127.0.0.1 and 169.254.169.254. + #expect(violation(policy, "http://2130706433/") != nil) + #expect(violation(policy, "http://0x7f000001/") != nil) + #expect(violation(policy, "http://127.1/") != nil) + #expect(violation(policy, "http://0251.0376.0251.0376/") != nil) +} + +@Test func standardPolicyBlocksLocalHostnames() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://localhost:8080/") != nil) + #expect(violation(policy, "http://dev.localhost/") != nil) + #expect(violation(policy, "http://printer.local/") != nil) + #expect(violation(policy, "http://metadata.google.internal/computeMetadata/v1/") != nil) +} + +@Test func standardPolicyBlocksPrivateIPv6() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://[::1]/") != nil) + #expect(violation(policy, "http://[fe80::1]/") != nil) + #expect(violation(policy, "http://[fd12:3456:789a::1]/") != nil) + #expect(violation(policy, "http://[::ffff:127.0.0.1]/") != nil) + #expect(violation(policy, "http://[::ffff:10.0.0.1]/") != nil) +} + +@Test func standardPolicyAllowsPublicDestinations() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "https://example.com/path") == nil) + #expect(violation(policy, "https://api.github.com/repos") == nil) + #expect(violation(policy, "http://93.184.216.34/") == nil) + #expect(violation(policy, "https://[2606:2800:220:1:248:1893:25c8:1946]/") == nil) +} + +@Test func permissivePolicyAllowsPrivateDestinations() { + let policy = NetworkAccessPolicy.permissive + + #expect(violation(policy, "http://127.0.0.1:3000/") == nil) + #expect(violation(policy, "http://localhost/") == nil) + #expect(violation(policy, "http://169.254.169.254/") == nil) +} + +@Test func allowlistRestrictsToListedHostsAndSubdomains() { + let policy = NetworkAccessPolicy(allowedHosts: ["example.com"]) + + #expect(violation(policy, "https://example.com/") == nil) + #expect(violation(policy, "https://api.example.com/") == nil) + #expect(violation(policy, "https://notexample.com/") != nil) + #expect(violation(policy, "https://example.com.evil.net/") != nil) + #expect(violation(policy, "https://other.org/") != nil) +} + +@Test func allowlistedPrivateHostBypassesPrivateNetworkCheck() { + let policy = NetworkAccessPolicy(allowedHosts: ["localhost"]) + + #expect(violation(policy, "http://localhost:8080/") == nil) + #expect(violation(policy, "http://127.0.0.1/") != nil) +} + +@Test func blocklistTakesPrecedenceOverAllowlist() { + let policy = NetworkAccessPolicy( + allowedHosts: ["example.com"], + blockedHosts: ["blocked.example.com"] + ) + + #expect(violation(policy, "https://example.com/") == nil) + #expect(violation(policy, "https://blocked.example.com/") != nil) + #expect(violation(policy, "https://deep.blocked.example.com/") != nil) +} + +@Test func networkFetchRefusesBlockedDestinationsBeforeConnecting() throws { + let bridge = NetworkBridge() + let (context, sandbox) = try makeInvocationContext() + defer { cleanup(sandbox) } + + for urlString in [ + "http://127.0.0.1:8080/admin", + "http://169.254.169.254/latest/meta-data/", + "http://localhost/", + "http://[::1]/", + ] { + do { + _ = try bridge.fetch(arguments: ["url": .string(urlString)], context: context) + Issue.record("Expected \(urlString) to be refused") + } catch { + #expect(requireBridgeErrorCode(error) == "NETWORK_POLICY_VIOLATION") + } + } + + let auditEvents = context.auditLogger.drain() + #expect(auditEvents.contains(where: { $0.message.contains("denied") && $0.message.contains("169.254.169.254") })) +} + +@Test func executeFetchToPrivateHostFailsWithPolicyViolation() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await fetch('http://169.254.169.254/latest/meta-data/');", + allowedCapabilities: [.networkFetch] + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "NETWORK_POLICY_VIOLATION") + #expect(observed.error?.suggestions.contains(where: { $0.contains("network access policy") }) == true) +} + +/// Stateless stub keyed off the request path so tests stay safe under +/// parallel execution: `/large` returns 64 bytes, `/small` returns 8 bytes, +/// `/redirect` issues a 302 to the cloud metadata address. +private final class FixedBodyURLProtocol: URLProtocol { + static let redirectTarget = URL(string: "http://169.254.169.254/latest/meta-data/")! + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let url = request.url ?? URL(string: "https://unit.test")! + + if url.path == "/redirect" { + let response = HTTPURLResponse( + url: url, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: ["Location": Self.redirectTarget.absoluteString] + )! + client?.urlProtocol(self, wasRedirectedTo: URLRequest(url: Self.redirectTarget), redirectResponse: response) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + return + } + + let bodySize = url.path == "/large" ? 64 : 8 + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/octet-stream"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(repeating: 0x61, count: bodySize)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +@Test func networkFetchEnforcesResponseSizeCap() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FixedBodyURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge( + session: session, + policy: NetworkAccessPolicy(maxResponseBytes: 16) + ) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + do { + _ = try bridge.fetch(arguments: ["url": .string("https://unit.test/large")], context: context) + Issue.record("Expected oversized response to be refused") + } catch { + #expect(requireBridgeErrorCode(error) == "NETWORK_POLICY_VIOLATION") + } +} + +@Test func networkFetchAllowsResponsesWithinSizeCap() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FixedBodyURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge( + session: session, + policy: NetworkAccessPolicy(maxResponseBytes: 16) + ) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + let result = try bridge.fetch(arguments: ["url": .string("https://unit.test/small")], context: context) + let object = try requireObject(result) + #expect(object.bool("ok") == true) + #expect(object.string("bodyText") == "aaaaaaaa") +} + +@Test func networkFetchRefusesRedirectToPrivateDestination() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FixedBodyURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge(session: session) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + do { + _ = try bridge.fetch(arguments: ["url": .string("https://unit.test/redirect")], context: context) + Issue.record("Expected redirect to private destination to be refused") + } catch { + #expect(requireBridgeErrorCode(error) == "NETWORK_POLICY_VIOLATION") + } +} From 363c42a18baf2afbf2a47494b5887f80535ee0f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:06:48 +0000 Subject: [PATCH 03/25] Fix README install instructions to match untagged repo state The install snippet told users to depend on from: "0.1.0", but the repository has no tags, so Swift Package Manager cannot resolve that requirement. Document the branch-based dependency as the working form until a release is tagged, and keep the versioned form as the preferred path once 0.1.0 exists. Also drop the deprecated name: parameter from the .package declaration. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a821a95..8c55c7b 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,13 @@ GitHub: [velos/CodeMode.swift](https://github.com/velos/CodeMode.swift) Add `CodeMode.swift` with Swift Package Manager: ```swift -.package(name: "CodeMode", url: "https://github.com/velos/CodeMode.swift", from: "0.1.0") +.package(url: "https://github.com/velos/CodeMode.swift", branch: "main") +``` + +No release has been tagged yet, so depend on `main` for now. Once `0.1.0` is tagged, prefer the versioned form: + +```swift +.package(url: "https://github.com/velos/CodeMode.swift", from: "0.1.0") ``` Then add the product to your target: From dd8af97d4dbd5e959a3b0839423068422b020c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:26:03 +0000 Subject: [PATCH 04/25] Harden watchdog and egress policy after self-review Compile blocker: JSContextGroupSetExecutionTimeLimit and JSContextGroupClearExecutionTimeLimit are exported by JavaScriptCore but declared only in a private WebKit header, so the watchdog would not compile against the public SDK module. Route both through a new CCodeModeJSC C shim target that re-declares the exported symbols, and document the private-API dependency in the watchdog and README. Correctness (execution): - The post-evaluation poll loop used 'while Date() < deadline' as its guard, so a script that had already fulfilled just as the deadline passed skipped the loop entirely and threw a spurious timeout that discarded a completed result. Replace both poll loops with a shared waitForSettlement helper that checks the settled state before the deadline on every iteration (and re-checks watchdog termination), and returns an already-settled result even a hair past the deadline. - Result serialization ran with the watchdog uninstalled, so a result with a runaway getter/toJSON (e.g. { get x() { while(true){} } }) hung the execution thread forever. Keep the watchdog installed and rearm it with a fresh bounded budget for the decode phase, so legitimate serialization still completes while runaway getters are terminated. - Fold the duplicated execute/search termination + poll logic into the shared helper. Security (network policy): - Host normalization now strips a trailing root dot, so fully-qualified spellings like 'localhost.' or 'metadata.google.internal.' can no longer bypass the loopback/metadata block or the allow/deny lists. - IPv6 private detection now also covers IPv4-compatible (::a.b.c.d) and NAT64 (64:ff9b::/96) embeddings of private/loopback IPv4 addresses. Efficiency: - Reserve the response buffer from a known Content-Length instead of growing it through repeated reallocations, and build the success audit/log summary string once. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF --- Package.swift | 9 +- README.md | 19 ++- Sources/CCodeModeJSC/include/CCodeModeJSC.h | 27 ++++ Sources/CCodeModeJSC/shim.c | 21 +++ Sources/CodeMode/Bridges/NetworkBridge.swift | 10 +- Sources/CodeMode/Runtime/BridgeRuntime.swift | 136 +++++++++++------- .../CodeMode/Runtime/ExecutionWatchdog.swift | 41 +++++- .../Security/NetworkAccessPolicy.swift | 20 ++- .../ExecutionWatchdogTests.swift | 45 ++++++ .../NetworkAccessPolicyTests.swift | 24 ++++ 10 files changed, 287 insertions(+), 65 deletions(-) create mode 100644 Sources/CCodeModeJSC/include/CCodeModeJSC.h create mode 100644 Sources/CCodeModeJSC/shim.c diff --git a/Package.swift b/Package.swift index 44e13c0..94c21c1 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,14 @@ let package = Package( ], targets: [ .target( - name: "CodeMode" + name: "CCodeModeJSC", + linkerSettings: [ + .linkedFramework("JavaScriptCore") + ] + ), + .target( + name: "CodeMode", + dependencies: ["CCodeModeJSC"] ), .target( name: "CodeModeEvaluation", diff --git a/README.md b/README.md index 8c55c7b..29bf64f 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,24 @@ System UI helpers are installed only on supported UI platforms. Shared iOS/visio - on success it returns `JavaScriptExecutionResult` - on failure it throws `CodeModeToolError` -- `call.cancel()` performs best-effort cancellation +- `call.cancel()` interrupts in-flight JavaScript + +### Timeout and cancellation + +`timeoutMs` and `cancel()` are enforced preemptively: a JavaScriptCore execution +time limit terminates CPU-bound scripts (for example `while (true) {}`) instead +of relying on a wall-clock check that cannot interrupt running JavaScript. + +- `timeoutMs` bounds total wall-clock for the execution, including the + synchronous portion of the script and any time already elapsed while a bridge + call blocked on native I/O (network, a UI picker). Hosts that present + long-running UI or issue slow requests should size `timeoutMs` accordingly; + the default is `10000`. +- Preemption uses `JSContextGroupSetExecutionTimeLimit`, which JavaScriptCore + exports but declares only in a private WebKit header. CodeMode reaches it + through a thin C shim (`CCodeModeJSC`). This is JavaScriptCore's only + mechanism for interrupting runaway scripts; hosts submitting to the App Store + should be aware they rely on this exported-but-private symbol. `CodeModeToolError` includes structured fields such as: diff --git a/Sources/CCodeModeJSC/include/CCodeModeJSC.h b/Sources/CCodeModeJSC/include/CCodeModeJSC.h new file mode 100644 index 0000000..bc61281 --- /dev/null +++ b/Sources/CCodeModeJSC/include/CCodeModeJSC.h @@ -0,0 +1,27 @@ +#ifndef CCODEMODE_JSC_H +#define CCODEMODE_JSC_H + +#include +#include + +/// Mirrors JavaScriptCore's JSShouldTerminateCallback (declared in the private +/// JSContextRefPrivate.h). Returning true terminates the executing script. +typedef bool (*CCodeModeShouldTerminateCallback)(JSContextRef ctx, void *userData); + +/// Installs a JavaScriptCore execution time limit on a context group. +/// +/// `JSContextGroupSetExecutionTimeLimit` / `JSContextGroupClearExecutionTimeLimit` +/// are exported by the JavaScriptCore dynamic library but declared only in +/// WebKit's private `JSContextRefPrivate.h`, which the public SDK module does +/// not vend. This shim re-declares the two symbols so Swift can call them +/// without the private header. JavaScriptCore invokes `callback` roughly every +/// `limitSeconds` of script CPU time; returning true terminates the script. +void ccodemode_set_execution_time_limit(JSContextGroupRef group, + double limitSeconds, + CCodeModeShouldTerminateCallback callback, + void *userData); + +/// Clears any execution time limit previously installed on the group. +void ccodemode_clear_execution_time_limit(JSContextGroupRef group); + +#endif /* CCODEMODE_JSC_H */ diff --git a/Sources/CCodeModeJSC/shim.c b/Sources/CCodeModeJSC/shim.c new file mode 100644 index 0000000..ef8fddc --- /dev/null +++ b/Sources/CCodeModeJSC/shim.c @@ -0,0 +1,21 @@ +#include "CCodeModeJSC.h" + +// Declared in JavaScriptCore's private JSContextRefPrivate.h but exported from +// the JavaScriptCore dynamic library on every Apple platform. Re-declared here +// so the package can bind to them without depending on the private SDK header. +extern void JSContextGroupSetExecutionTimeLimit(JSContextGroupRef group, + double limit, + CCodeModeShouldTerminateCallback callback, + void *userData); +extern void JSContextGroupClearExecutionTimeLimit(JSContextGroupRef group); + +void ccodemode_set_execution_time_limit(JSContextGroupRef group, + double limitSeconds, + CCodeModeShouldTerminateCallback callback, + void *userData) { + JSContextGroupSetExecutionTimeLimit(group, limitSeconds, callback, userData); +} + +void ccodemode_clear_execution_time_limit(JSContextGroupRef group) { + JSContextGroupClearExecutionTimeLimit(group); +} diff --git a/Sources/CodeMode/Bridges/NetworkBridge.swift b/Sources/CodeMode/Bridges/NetworkBridge.swift index b1bb4ff..ef33703 100644 --- a/Sources/CodeMode/Bridges/NetworkBridge.swift +++ b/Sources/CodeMode/Bridges/NetworkBridge.swift @@ -100,10 +100,11 @@ public final class NetworkBridge: @unchecked Sendable { partial[String(describing: pair.key)] = .string(String(describing: pair.value)) } - context.log(.info, message: "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)") + let summary = "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)" + context.log(.info, message: summary) context.auditLogger.log(AuditEvent( capability: CapabilityID.networkFetch.rawValue, - message: "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)" + message: summary )) var object: [String: JSONValue] = [ @@ -188,6 +189,11 @@ private final class FetchTaskHandler: NSObject, URLSessionDataDelegate, @uncheck lock.lock() self.response = response as? HTTPURLResponse + // Content-Length is known and within the cap here: reserve once instead + // of growing the buffer through repeated reallocations as chunks arrive. + if response.expectedContentLength > 0 { + data.reserveCapacity(Int(response.expectedContentLength)) + } lock.unlock() completionHandler(.allow) } diff --git a/Sources/CodeMode/Runtime/BridgeRuntime.swift b/Sources/CodeMode/Runtime/BridgeRuntime.swift index 3d52114..4eec3c2 100644 --- a/Sources/CodeMode/Runtime/BridgeRuntime.swift +++ b/Sources/CodeMode/Runtime/BridgeRuntime.swift @@ -465,39 +465,81 @@ final class BridgeRuntime: @unchecked Sendable { throw syntaxError(from: snapshot, lineOffset: 4) } - while Date() < watchdog.deadline { + let settlement = try waitForSettlement( + context: context, + watchdog: watchdog, + cancellationController: cancellationController, + invocationContext: invocationContext, + timeoutCode: "EXECUTION_TIMEOUT", + timeoutMessage: "Execution timed out after \(timeoutMs)ms", + cancelMessage: "Execution cancelled" + ) + + // Give result serialization its own bounded budget so a script that + // settled near the deadline still serializes, while a runaway getter or + // toJSON is terminated instead of hanging the execution thread. + watchdog.rearm(timeoutMs: max(timeoutMs, 1_000)) + + switch settlement { + case .fulfilled: + let output = try decodeOutput(from: context) + if output == nil { + invocationContext.recordDiagnostic(Self.noReturnValueDiagnostic(for: code)) + } + return output + case .rejected: + let payload = rejectionPayload(from: context) + throw classifyRejectedError(payload, invocationContext: invocationContext) + } + } + + private enum SettlementState { + case fulfilled + case rejected + } + + /// Polls the wrapped promise's settled state until it resolves, is + /// cancelled, or the watchdog deadline passes. The settled state is checked + /// before the deadline on every iteration, so a script that has already + /// fulfilled — the common case under the synchronous bridge model — returns + /// its result even if evaluation finished a hair past the deadline, instead + /// of being discarded with a spurious timeout. + private func waitForSettlement( + context: JSContext, + watchdog: ExecutionWatchdog, + cancellationController: ExecutionCancellationController, + invocationContext: BridgeInvocationContext, + timeoutCode: String, + timeoutMessage: String, + cancelMessage: String + ) throws -> SettlementState { + while true { if cancellationController.isCancelled || Task.isCancelled { cancellationController.cancel() - throw toolError( - code: "CANCELLED", - message: "Execution cancelled", - transcript: invocationContext - ) + throw toolError(code: "CANCELLED", message: cancelMessage, transcript: invocationContext) } let state = context.evaluateScript("globalThis.__codemode.state")?.toString() ?? "unknown" switch state { case "fulfilled": - watchdog.uninstall(from: context) - let output = try decodeOutput(from: context) - if output == nil { - invocationContext.recordDiagnostic(Self.noReturnValueDiagnostic(for: code)) - } - return output + return .fulfilled case "rejected": - watchdog.uninstall(from: context) - let payload = rejectionPayload(from: context) - throw classifyRejectedError(payload, invocationContext: invocationContext) + return .rejected default: + if let termination = watchdog.termination { + switch termination { + case .cancelled: + throw toolError(code: "CANCELLED", message: cancelMessage, transcript: invocationContext) + case .timedOut: + throw toolError(code: timeoutCode, message: timeoutMessage, transcript: invocationContext) + } + } + if Date() >= watchdog.deadline { + throw toolError(code: timeoutCode, message: timeoutMessage, transcript: invocationContext) + } Thread.sleep(forTimeInterval: 0.01) } } - - throw toolError( - code: "EXECUTION_TIMEOUT", - message: "Execution timed out after \(timeoutMs)ms", - transcript: invocationContext - ) } private static func noReturnValueDiagnostic(for code: String) -> ToolDiagnostic { @@ -598,39 +640,29 @@ final class BridgeRuntime: @unchecked Sendable { throw syntaxError(from: snapshot, lineOffset: 5) } - while Date() < watchdog.deadline { - if cancellationController.isCancelled || Task.isCancelled { - cancellationController.cancel() - throw toolError( - code: "CANCELLED", - message: "Search cancelled", - transcript: invocationContext - ) - } + let settlement = try waitForSettlement( + context: context, + watchdog: watchdog, + cancellationController: cancellationController, + invocationContext: invocationContext, + timeoutCode: "SEARCH_TIMEOUT", + timeoutMessage: "Search timed out after \(timeoutMs)ms", + cancelMessage: "Search cancelled" + ) - let state = context.evaluateScript("globalThis.__codemode.state")?.toString() ?? "unknown" - switch state { - case "fulfilled": - watchdog.uninstall(from: context) - return try decodeOutput( - from: context, - errorCode: "INVALID_SEARCH_RESULT", - errorMessagePrefix: "Search result must be JSON-serializable" - ) - case "rejected": - watchdog.uninstall(from: context) - let payload = rejectionPayload(from: context) - throw classifySearchRejectedError(payload, invocationContext: invocationContext) - default: - Thread.sleep(forTimeInterval: 0.01) - } - } + watchdog.rearm(timeoutMs: max(timeoutMs, 1_000)) - throw toolError( - code: "SEARCH_TIMEOUT", - message: "Search timed out after \(timeoutMs)ms", - transcript: invocationContext - ) + switch settlement { + case .fulfilled: + return try decodeOutput( + from: context, + errorCode: "INVALID_SEARCH_RESULT", + errorMessagePrefix: "Search result must be JSON-serializable" + ) + case .rejected: + let payload = rejectionPayload(from: context) + throw classifySearchRejectedError(payload, invocationContext: invocationContext) + } } private func decodeOutput( diff --git a/Sources/CodeMode/Runtime/ExecutionWatchdog.swift b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift index 783be6f..5e62c56 100644 --- a/Sources/CodeMode/Runtime/ExecutionWatchdog.swift +++ b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift @@ -1,5 +1,6 @@ import Foundation import JavaScriptCore +import CCodeModeJSC /// Preemptively terminates JavaScript that runs past its deadline or is cancelled. /// @@ -9,6 +10,19 @@ import JavaScriptCore /// `while (true) {}`. This installs a JavaScriptCore execution time limit whose /// callback re-checks the deadline and the cancellation flag on a short interval /// and terminates the script when either trips. +/// +/// The underlying `JSContextGroupSetExecutionTimeLimit` API is reached through +/// the `CCodeModeJSC` shim because it is exported by JavaScriptCore but declared +/// only in a private WebKit header. It is the only mechanism JavaScriptCore +/// offers for interrupting runaway scripts; hosts shipping to the App Store +/// should be aware they are relying on this exported-but-private symbol. +/// +/// Because the time limit is measured against script CPU time, a synchronous +/// bridge call that blocks on native I/O (network, a UI picker) does not itself +/// count against the deadline while it is blocked, but the time already elapsed +/// does: `timeoutMs` bounds total wall-clock across an execution, so hosts that +/// present long-running UI or issue slow requests should size `timeoutMs` +/// accordingly. final class ExecutionWatchdog: @unchecked Sendable { enum Termination: Sendable { case timedOut @@ -19,30 +33,45 @@ final class ExecutionWatchdog: @unchecked Sendable { /// script is executing. Bounds both timeout overshoot and cancellation latency. private static let checkInterval: TimeInterval = 0.05 - let deadline: Date - private let lock = NSLock() + private var deadlineValue: Date private var terminationValue: Termination? private let cancellationController: ExecutionCancellationController init(timeoutMs: Int, cancellationController: ExecutionCancellationController) { - self.deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + self.deadlineValue = Date().addingTimeInterval(Double(timeoutMs) / 1_000) self.cancellationController = cancellationController } + var deadline: Date { + lock.lock() + defer { lock.unlock() } + return deadlineValue + } + var termination: Termination? { lock.lock() defer { lock.unlock() } return terminationValue } + /// Starts a fresh time budget for a follow-on phase such as serializing the + /// result. A script that settled close to the original deadline still gets a + /// bounded-but-usable window to run `JSON.stringify` (which may invoke + /// user-defined getters/`toJSON`), while a runaway getter is still terminated. + func rearm(timeoutMs: Int) { + lock.lock() + deadlineValue = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + lock.unlock() + } + func install(on context: JSContext) { guard let contextRef = context.jsGlobalContextRef else { return } let group = JSContextGetGroup(contextRef) let info = Unmanaged.passUnretained(self).toOpaque() - JSContextGroupSetExecutionTimeLimit( + ccodemode_set_execution_time_limit( group, Self.checkInterval, { _, info in @@ -64,7 +93,7 @@ final class ExecutionWatchdog: @unchecked Sendable { guard let contextRef = context.jsGlobalContextRef else { return } - JSContextGroupClearExecutionTimeLimit(JSContextGetGroup(contextRef)) + ccodemode_clear_execution_time_limit(JSContextGetGroup(contextRef)) } private func shouldTerminate() -> Bool { @@ -78,7 +107,7 @@ final class ExecutionWatchdog: @unchecked Sendable { terminationValue = .cancelled return true } - if Date() >= deadline { + if Date() >= deadlineValue { terminationValue = .timedOut return true } diff --git a/Sources/CodeMode/Security/NetworkAccessPolicy.swift b/Sources/CodeMode/Security/NetworkAccessPolicy.swift index 968b091..3dd10f8 100644 --- a/Sources/CodeMode/Security/NetworkAccessPolicy.swift +++ b/Sources/CodeMode/Security/NetworkAccessPolicy.swift @@ -83,6 +83,11 @@ public struct NetworkAccessPolicy: Sendable, Equatable { if host.hasPrefix("["), host.hasSuffix("]") { host = String(host.dropFirst().dropLast()) } + // A trailing root dot ("localhost.", "example.com.") denotes the same + // host to DNS; strip it so suffix and literal matching cannot be bypassed. + while host.hasSuffix(".") { + host.removeLast() + } return host } @@ -140,6 +145,10 @@ public struct NetworkAccessPolicy: Sendable, Equatable { private static func isPrivateOrLocal(ipv6 address: in6_addr) -> Bool { let bytes = withUnsafeBytes(of: address) { Array($0) } + func embeddedIPv4() -> UInt32 { + UInt32(bytes[12]) << 24 | UInt32(bytes[13]) << 16 | UInt32(bytes[14]) << 8 | UInt32(bytes[15]) + } + if bytes[0..<15].allSatisfy({ $0 == 0 }) { return bytes[15] == 0 || bytes[15] == 1 // unspecified or loopback } @@ -150,9 +159,14 @@ public struct NetworkAccessPolicy: Sendable, Equatable { return true // link-local fe80::/10 } if bytes[0..<10].allSatisfy({ $0 == 0 }), bytes[10] == 0xff, bytes[11] == 0xff { - // IPv4-mapped ::ffff:a.b.c.d - let embedded = UInt32(bytes[12]) << 24 | UInt32(bytes[13]) << 16 | UInt32(bytes[14]) << 8 | UInt32(bytes[15]) - return isPrivateOrLocal(ipv4: embedded) + return isPrivateOrLocal(ipv4: embeddedIPv4()) // IPv4-mapped ::ffff:a.b.c.d + } + if bytes[0..<12].allSatisfy({ $0 == 0 }) { + return isPrivateOrLocal(ipv4: embeddedIPv4()) // IPv4-compatible ::a.b.c.d (deprecated) + } + if bytes[0] == 0x00, bytes[1] == 0x64, bytes[2] == 0xff, bytes[3] == 0x9b, + bytes[4..<12].allSatisfy({ $0 == 0 }) { + return isPrivateOrLocal(ipv4: embeddedIPv4()) // NAT64 64:ff9b::/96 } return false } diff --git a/Tests/CodeModeTests/ExecutionWatchdogTests.swift b/Tests/CodeModeTests/ExecutionWatchdogTests.swift index 50ba703..1e6012d 100644 --- a/Tests/CodeModeTests/ExecutionWatchdogTests.swift +++ b/Tests/CodeModeTests/ExecutionWatchdogTests.swift @@ -110,6 +110,51 @@ import Testing #expect(observed.error?.code == "CANCELLED") } +@Test func executeReturnsResultThatSettlesRightAtDeadline() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + // A short CPU spin that finishes well under the timeout must return its + // value, not be discarded by a deadline check that races the settled state. + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + let total = 0; + for (let i = 0; i < 200000; i++) { total += i; } + return { total }; + """, + allowedCapabilities: [], + timeoutMs: 2_000 + ) + ) + + let payload = try requireJSONObject(from: try #require(observed.result)) + #expect(payload["total"] != nil) + #expect(observed.error == nil) +} + +@Test func executeTerminatesInfiniteGetterDuringResultSerialization() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + // The result settles fine, but serializing it runs a runaway getter. The + // watchdog must still bound that phase instead of hanging the thread. + let started = Date() + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return { get trap() { while (true) {} } };", + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result?.output == nil) + #expect(observed.error != nil) + #expect(Date().timeIntervalSince(started) < 5) +} + @Test func searchTerminatesCPUBoundInfiniteLoop() async throws { let (tools, sandbox) = try makeTools() defer { cleanup(sandbox) } diff --git a/Tests/CodeModeTests/NetworkAccessPolicyTests.swift b/Tests/CodeModeTests/NetworkAccessPolicyTests.swift index ff4af2a..8993ecf 100644 --- a/Tests/CodeModeTests/NetworkAccessPolicyTests.swift +++ b/Tests/CodeModeTests/NetworkAccessPolicyTests.swift @@ -42,6 +42,30 @@ private func violation(_ policy: NetworkAccessPolicy, _ urlString: String) -> St #expect(violation(policy, "http://metadata.google.internal/computeMetadata/v1/") != nil) } +@Test func standardPolicyBlocksFullyQualifiedTrailingDotHosts() { + let policy = NetworkAccessPolicy.standard + + // A trailing root dot resolves to the same host and must not bypass the block. + #expect(violation(policy, "http://localhost./") != nil) + #expect(violation(policy, "http://metadata.google.internal./") != nil) + #expect(violation(policy, "http://printer.local./") != nil) +} + +@Test func allowlistMatchesTrailingDotHosts() { + let policy = NetworkAccessPolicy(allowedHosts: ["example.com"]) + + #expect(violation(policy, "https://example.com./") == nil) + #expect(violation(policy, "https://api.example.com./") == nil) +} + +@Test func standardPolicyBlocksIPv4EmbeddedIPv6Forms() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://[::7f00:1]/") != nil) // IPv4-compatible ::127.0.0.1 + #expect(violation(policy, "http://[64:ff9b::7f00:1]/") != nil) // NAT64 127.0.0.1 + #expect(violation(policy, "http://[64:ff9b::a00:1]/") != nil) // NAT64 10.0.0.1 +} + @Test func standardPolicyBlocksPrivateIPv6() { let policy = NetworkAccessPolicy.standard From 5b62992fa3b13ea6ca7119e1e0af051e912f9c19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 21:29:05 +0000 Subject: [PATCH 05/25] Add working TODO tracking evaluation follow-ups Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF --- TODO.md | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8c3b7c1 --- /dev/null +++ b/TODO.md @@ -0,0 +1,191 @@ +# CodeMode.swift — Working TODO + +Branch: `fixes-and-improvements` (pushed, 5 commits on top of `main`). +This file is intentionally **not committed** — scratch tracking only. + +Derived from the full repo evaluation. Everything from that evaluation is +represented below, with honest status. + +## Status legend +- [x] done and pushed +- [~] done but unverified (no Swift toolchain / JavaScriptCore in this Linux env) +- [/] partially done — see sub-items +- [ ] not started + +--- + +## CRITICAL ISSUES + +### 1. Timeouts don't actually interrupt running JavaScript [/] +Root cause: synchronous bridge model settles the whole promise graph inside one +`evaluateScript` call, so the old wall-clock poll loop never ran while JS was +executing. The full "make the runtime resource-safe" milestone is bigger than +just the watchdog: +- [x] Preemptive watchdog via `JSContextGroupSetExecutionTimeLimit` (through the + `CCodeModeJSC` shim) → real `timeoutMs`, `while(true){}` terminates. +- [x] Real cancellation — `cancel()` interrupts in-flight JS. +- [~] Tests for the above (`ExecutionWatchdogTests.swift`) — unverified. +- [ ] **JS heap / memory cap** — nothing bounds `JSContext`/`JSContextGroup` + heap; `new Array(1e9)` can still OOM the host. NOT addressed. +- [ ] **Bound on concurrent executions** — `executionQueue` is `.concurrent` + with spin-waiting workers (`BridgeRuntime.swift:25-29`), so N hung/slow + scripts pin N threads. No concurrency cap. NOT addressed. +- [ ] `runOnExecutionQueue` still has no task-cancellation handler wired into the + dispatched block (best-effort only). NOT addressed. + +### 2. `fetch` has no egress policy (SSRF) [x] +- [x] `NetworkAccessPolicy` on `CodeModeConfiguration`: default blocks + loopback/RFC-1918/link-local/CGNAT/metadata (incl. encoded IPv4 literals and + IPv4-in-IPv6 / NAT64 forms), `localhost`/`.local`/`.internal`, trailing-dot + FQDNs. +- [x] Redirect targets re-validated before being followed. +- [x] Response-size cap (default 10 MB), enforced by Content-Length pre-check + + streaming cancel. +- [x] Allow/deny host lists; `.permissive` opt-out. +- [x] Egress destinations (allowed + denied) now written to the audit logger, + not just the execution transcript. +- [~] Tests (`NetworkAccessPolicyTests.swift`) — unverified. + +### 3. Package not installable as documented [/] +- [x] README no longer tells users to depend on `from: "0.1.0"` against a + tagless repo; documents branch-based install for now. +- [ ] **Tag `0.1.0`** (the actual fix) once CI is green, then restore the + versioned SPM install snippet. NOT done. + +--- + +## OTHER REAL BUGS FOUND IN EVALUATION + +- [x] **Watchdog compile blocker** (found in self-review): + `JSContextGroupSetExecutionTimeLimit`/`...Clear...` are private-header JSC + symbols; added `CCodeModeJSC` C shim so it builds against the public SDK. + - [ ] Owner decision still needed: private-but-exported symbol → App Store + review risk. Keep+document (current) vs gate vs accept. +- [x] **Spurious timeout** (self-review): poll loop discarded an already-settled + result at/just past deadline. Fixed via shared `waitForSettlement`. +- [x] **Serialization hang** (self-review): runaway getter/`toJSON` hung the + thread because the watchdog was uninstalled before decode. Fixed via rearm. +- [ ] **HealthKit always denied through the default broker.** + `healthKitStatus()` unconditionally returns `.notDetermined` + (`SystemPermissionBroker.swift:398-407`); registry treats + requested→notDetermined as denial. Fails closed silently. NOT addressed. +- [ ] **Calendar-span validation drift.** Registry constraint table accepts only + `["thisEvent","futureEvents"]` case-sensitively + (`CapabilityRegistry.swift:56-58`) while `EventKitBridge` accepts + `this_event`/`future`/etc. case-insensitively + (`EventKitBridge.swift:471-479`) — registry rejects inputs the bridge + supports. NOT addressed. (Quick win.) +- [ ] **`DispatchQueue.main.sync` in `requestLocationPermission`** + (`SystemPermissionBroker.swift:430`) deadlocks if reached from the main + thread. NOT addressed. +- [ ] **Duplicated eval model types will drift.** `LLM.swift` is excluded from + the build; ~250 lines of report/suite types are defined twice (there and in + compiled `LLMEvalModels.swift`) with nothing keeping them in sync. NOT + addressed. + +--- + +## STRUCTURAL IMPROVEMENTS (bridge/registry metadata drift) + +Biggest maintenance risk: ~115 capabilities / ~2,540 lines of hand-written +registrations with four parallel sources of truth. None addressed yet. +- [ ] Move argument types/constraints to per-capability, co-located declarations; + **fail loudly instead of degrading unknown args to `.any`** + (`CapabilityRegistry.swift:223-398`). +- [ ] Add a test asserting registration metadata matches bridge reality + (allowed-string sets vs what bridges accept; golden-test `resultSummary` + against bridge JSON encoders) — would have caught the calendar-span drift. +- [ ] Standardize on one registration idiom (three coexist across + `CapabilityRegistrations+*.swift`). +- [ ] Unify permission ownership — `calendarRead` gates in both registry and + bridge; `calendarWrite` gates only in the bridge. +- [ ] Decide the fate of `Tools/CodeModeAuthoring` (macro package fully built, + tested, but unwired; `SimpleBuiltInCodeModeProviders.swift` hand-rolls the + same pattern). Either extend it to back the built-ins (permissions, + constraints, multi-name aliases, curated tags/examples) or scope it clearly as + a host-authoring aid. Lower-risk near-term win: validation/codegen from + existing descriptor metadata rather than a 115-callsite macro migration. + +--- + +## TESTING, CI, AND EVALS + +- [ ] **Security layer has zero dedicated tests.** Add direct unit tests for + `PathPolicy` (traversal/symlink-escape), `SystemPermissionBroker` + (permission-denial), `ArtifactStore`, `AuditLogger`. (Quick win, protects the + trust boundary.) +- [ ] **CI never compiles iOS/visionOS code.** Single workflow runs `swift test` + on macOS only; all five `UIKitSystemUIPresenter*.swift` never build in CI. Add + an `xcodebuild -destination` matrix. Also missing: lint/format config + check, + build/dependency caching, Xcode/toolchain pinning, artifact upload of eval + reports. + - [ ] Add the macOS `swift test` CI job specifically — it's what would have + caught the watchdog compile blocker automatically. +- [ ] **Eval coverage gaps.** `health.*`, `vision.*`, `photos.read/export`, + `reminders.write` have zero scenarios among the 49, though the LLM prompt + advertises photos/health/home/alarms. Add catalog + execution/validation + scenarios for each. +- [ ] **Regression gate is inert in automation.** `compare` is never invoked in + CI; the `catalog` baseline EVALS.md references doesn't exist. Generate/commit + it and wire `compare` into a (gated) CI job. +- [ ] **No cost/token tracking in LLM eval reports** — "budget" is a request + count only; capture input/output tokens (+ derived cost) per run so model + comparisons can weigh accuracy against price. + +--- + +## PLACES TO GO NEXT (bigger bets) + +- [ ] **Async bridge model.** Resolve JS promises from Swift callbacks instead of + synchronous native returns, so long native ops (30s network, 15s permission + prompts) don't block inside `evaluateScript`. Prerequisite for fully real + mid-script cancellation; pairs with the watchdog as the "production-grade + runtime" milestone. +- [ ] **Structured audit pipeline.** Today's events are capability + free-text + "success" with a pull-based drain. Move to a push-based sink with argument + digests, decisions, path/egress targets, and a per-execution correlation ID. + (Partial down payment already made: fetch egress destinations now audited.) +- [ ] **`Examples/` host app.** Minimal SwiftUI demo wiring `CodeModeAgentTools` + + a real `UIKitSystemUIPresenter` + an LLM loop — concrete integration story + and a manual test bed for the UIKit presenters automation can't reach. +- [ ] **DocC + doc comments.** Zero `///` comments in `Sources/` today. Start + with the public surface: `CodeModeAgentTools`, `CodeModeConfiguration`, + `SystemUIPresenter`, `CodeModeToolError`, `CapabilityID`. +- [ ] **API polish for integrators.** Group the 19-parameter + `CodeModeConfiguration` init's service clients; clarify/merge + `allowedCapabilities` vs `allowedCapabilityKeys`; emit the valid `CapabilityID` + list as an enum in the `executeJavaScript` JSON schema so agents get + machine-checkable capability names. + +--- + +## MISC DEFERRED / NOTED (not bugs) +- [ ] Apply egress policy to `apple.web.present` / `apple.auth.webAuthenticate` + (visible browser flows, not silent egress; README scopes the policy claim to + `network.fetch` for now). +- [ ] Data-driven CIDR blocklist in `NetworkAccessPolicy` (hardcoded ranges today). +- [ ] Reconcile network audit entries with the runtime's generic success/failure + audit (two entries per fetch — kept intentionally for destination detail). +- [ ] Migrate bespoke `NSLock` state (`ExecutionWatchdog`, `FetchTaskHandler`) to + `LockedBox`/`SynchronizedBox` where compound atomicity isn't needed. Cosmetic. + +--- + +## MUST-DO BEFORE MERGE +- [ ] **Verify on macOS** (nothing was compiled or run in this Linux env): + - [ ] `CCodeModeJSC` shim links against JavaScriptCore and the private symbol + resolves at load time on iOS/macOS/visionOS. + - [ ] `swift test` passes (all new + existing tests). + - [ ] Watchdog actually terminates `while(true){}` on device/simulator. + +## SUGGESTED ORDER OF ATTACK +1. Quick wins: tag `0.1.0`, fix calendar-span drift, add Security-layer tests, + add lint + iOS build job to CI. +2. Runtime hardening milestone: (watchdog ✔) + JS heap cap + concurrency bound + → "resource- and egress-safe sandbox." +3. Metadata-consolidation refactor + macro decision, protected by new drift tests. + +## OPEN QUESTIONS FOR THE USER +- [ ] Open a PR for `fixes-and-improvements`? +- [ ] Add the macOS CI job next? +- [ ] Decision on the private-but-exported JSC symbol (App Store risk)? From a4b6ad14a9203a5397b0011cd856ad4dde7e9ecc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 21:31:11 +0000 Subject: [PATCH 06/25] Add iOS/visionOS build matrix and caching to CI swift test on macOS never compiled the UIKit presenters or the CCodeModeJSC watchdog shim against the iOS/visionOS SDKs, so a platform-specific break (like the private-header symbol the shim resolves) could land undetected. Add a platform-build matrix that runs xcodebuild for iOS and visionOS on every PR and push, plus SwiftPM build caching and toolchain-version logging on the deterministic job. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF --- .github/workflows/codemode-evals.yml | 49 +++++++++++++++++++++++++++- TODO.md | 20 ++++++++---- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codemode-evals.yml b/.github/workflows/codemode-evals.yml index 9d7679a..9bb078c 100644 --- a/.github/workflows/codemode-evals.yml +++ b/.github/workflows/codemode-evals.yml @@ -31,7 +31,19 @@ jobs: uses: actions/checkout@v4 - name: Toolchain - run: swift --version + run: | + swift --version + xcodebuild -version + + - name: Cache SwiftPM build + uses: actions/cache@v4 + with: + path: | + .build + Tools/CodeModeEval/.build + key: spm-${{ runner.os }}-${{ hashFiles('Package.swift', 'Tools/CodeModeEval/Package.swift') }} + restore-keys: | + spm-${{ runner.os }}- - name: Test package run: swift test @@ -42,6 +54,41 @@ jobs: - name: Run deterministic evals run: swift run --package-path Tools/CodeModeEval codemode-eval run + platform-build: + name: Build (${{ matrix.platform }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - platform: iOS + destination: generic/platform=iOS + - platform: visionOS + destination: generic/platform=visionOS + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Toolchain + run: | + swift --version + xcodebuild -version + + # The library-only CodeMode product must compile for every declared + # platform. `swift test` on macOS never builds the UIKit presenters or the + # CCodeModeJSC shim against the iOS/visionOS SDKs, so exercise them here. + # CODE_SIGNING_ALLOWED=NO keeps the library build from requiring a profile. + - name: List package schemes + run: xcodebuild -list -json | tee schemes.json + + - name: Build CodeMode for ${{ matrix.platform }} + run: | + set -o pipefail + xcodebuild build \ + -scheme CodeMode \ + -destination '${{ matrix.destination }}' \ + CODE_SIGNING_ALLOWED=NO + llm-plan: name: LLM eval planning runs-on: macos-latest diff --git a/TODO.md b/TODO.md index 8c3b7c1..aff4130 100644 --- a/TODO.md +++ b/TODO.md @@ -114,13 +114,19 @@ registrations with four parallel sources of truth. None addressed yet. `PathPolicy` (traversal/symlink-escape), `SystemPermissionBroker` (permission-denial), `ArtifactStore`, `AuditLogger`. (Quick win, protects the trust boundary.) -- [ ] **CI never compiles iOS/visionOS code.** Single workflow runs `swift test` - on macOS only; all five `UIKitSystemUIPresenter*.swift` never build in CI. Add - an `xcodebuild -destination` matrix. Also missing: lint/format config + check, - build/dependency caching, Xcode/toolchain pinning, artifact upload of eval - reports. - - [ ] Add the macOS `swift test` CI job specifically — it's what would have - caught the watchdog compile blocker automatically. +- [/] **CI never compiles iOS/visionOS code.** + - [x] Added a `platform-build` matrix job (`xcodebuild build` for iOS + + visionOS) so the UIKit presenters and the `CCodeModeJSC` shim compile + against those SDKs on every PR/push. + - [x] Added SwiftPM build caching and `xcodebuild -version` toolchain logging + to the deterministic job. + - [x] macOS `swift test` job already existed and links the new C shim + + watchdog on macOS — this is what verifies the private-symbol link. + - [ ] Still missing: lint/format config + check, explicit Xcode/SDK pinning + (currently uses runner default), artifact upload of eval reports. + - Note: the iOS/visionOS build verifies *compilation*; the private JSC symbol's + dynamic-link resolution is exercised by the macOS `swift test` link. Full + iOS link verification would need a test bundle / host app. - [ ] **Eval coverage gaps.** `health.*`, `vision.*`, `photos.read/export`, `reminders.write` have zero scenarios among the 49, though the LLM prompt advertises photos/health/home/alarms. Add catalog + execution/validation From 348c1259ac50feb4fd874f5ad01740b61a0584c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 21:37:59 +0000 Subject: [PATCH 07/25] Fix visionOS build: guard CLRegion usage in maps geocoding CLRegion / CLCircularRegion are unavailable on visionOS, so the region-scoped CLGeocoder.geocodeAddressString(_:in:) overload and the coreLocationRegion helper failed to compile there. The package declared .visionOS(.v2) support but was never actually built for visionOS until the new platform-build CI job exercised it. Use the unscoped geocodeAddressString overload on visionOS and compile the region helper out there; other platforms keep region-scoped geocoding. Pre-existing issue surfaced by the new CI matrix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF --- .../API/SystemAppleServiceClients.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/CodeMode/API/SystemAppleServiceClients.swift b/Sources/CodeMode/API/SystemAppleServiceClients.swift index 54f744a..9c5ce32 100644 --- a/Sources/CodeMode/API/SystemAppleServiceClients.swift +++ b/Sources/CodeMode/API/SystemAppleServiceClients.swift @@ -207,13 +207,24 @@ public struct SystemMapsClient: MapsClient { let limit = arguments.int("limit") ?? 10 let geocoder = CLGeocoder() let placemarks: [CLPlacemark] = try waitForSystemClient(timeoutMs: timeoutMs, feature: "MapKit geocoding") { completion in - geocoder.geocodeAddressString(address, in: SystemMapsMapping.coreLocationRegion(arguments.object("region"))) { placemarks, error in + let handler: ([CLPlacemark]?, Error?) -> Void = { placemarks, error in if let error { completion.complete(.failure(error)) } else { completion.complete(.success(placemarks ?? [])) } } + // CLRegion (and the region-scoped geocode overload) are unavailable + // on visionOS; fall back to an unscoped geocode there. + #if os(visionOS) + geocoder.geocodeAddressString(address, completionHandler: handler) + #else + geocoder.geocodeAddressString( + address, + in: SystemMapsMapping.coreLocationRegion(arguments.object("region")), + completionHandler: handler + ) + #endif } return .array(placemarks.prefix(limit).map(SystemMapsMapping.placemarkJSON)) #else @@ -659,6 +670,9 @@ enum SystemMapsMapping { } #if canImport(CoreLocation) + // CLRegion / CLCircularRegion are unavailable on visionOS; the region-scoped + // geocode overload is guarded out there, so this helper is too. + #if !os(visionOS) static func coreLocationRegion(_ arguments: [String: JSONValue]?) -> CLRegion? { guard let arguments else { return nil @@ -686,6 +700,7 @@ enum SystemMapsMapping { } return nil } + #endif static func placemarkJSON(_ placemark: CLPlacemark) -> JSONValue { var object: [String: JSONValue] = [:] From d6053dae077b0019397b56d9622a4c81ac77016b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 22:08:07 +0000 Subject: [PATCH 08/25] Reword TODO in neutral reliability terms --- TODO.md | 70 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/TODO.md b/TODO.md index aff4130..bbefc4b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,10 +1,10 @@ # CodeMode.swift — Working TODO -Branch: `fixes-and-improvements` (pushed, 5 commits on top of `main`). -This file is intentionally **not committed** — scratch tracking only. +Branch: `fixes-and-improvements` (pushed on top of `main`). +Scratch tracking for the follow-up work surfaced by the repo evaluation; kept on +the branch so the remaining items travel with it. -Derived from the full repo evaluation. Everything from that evaluation is -represented below, with honest status. +Everything from that evaluation is represented below, with honest status. ## Status legend - [x] done and pushed @@ -19,30 +19,31 @@ represented below, with honest status. ### 1. Timeouts don't actually interrupt running JavaScript [/] Root cause: synchronous bridge model settles the whole promise graph inside one `evaluateScript` call, so the old wall-clock poll loop never ran while JS was -executing. The full "make the runtime resource-safe" milestone is bigger than +executing. The full "make the runtime resource-bounded" milestone is bigger than just the watchdog: - [x] Preemptive watchdog via `JSContextGroupSetExecutionTimeLimit` (through the `CCodeModeJSC` shim) → real `timeoutMs`, `while(true){}` terminates. - [x] Real cancellation — `cancel()` interrupts in-flight JS. - [~] Tests for the above (`ExecutionWatchdogTests.swift`) — unverified. - [ ] **JS heap / memory cap** — nothing bounds `JSContext`/`JSContextGroup` - heap; `new Array(1e9)` can still OOM the host. NOT addressed. + heap; `new Array(1e9)` can still exhaust host memory. NOT addressed. - [ ] **Bound on concurrent executions** — `executionQueue` is `.concurrent` - with spin-waiting workers (`BridgeRuntime.swift:25-29`), so N hung/slow - scripts pin N threads. No concurrency cap. NOT addressed. + with spin-waiting workers (`BridgeRuntime.swift:25-29`), so N long-running + scripts occupy N threads. No concurrency cap. NOT addressed. - [ ] `runOnExecutionQueue` still has no task-cancellation handler wired into the dispatched block (best-effort only). NOT addressed. -### 2. `fetch` has no egress policy (SSRF) [x] -- [x] `NetworkAccessPolicy` on `CodeModeConfiguration`: default blocks - loopback/RFC-1918/link-local/CGNAT/metadata (incl. encoded IPv4 literals and - IPv4-in-IPv6 / NAT64 forms), `localhost`/`.local`/`.internal`, trailing-dot - FQDNs. -- [x] Redirect targets re-validated before being followed. +### 2. `fetch` has no destination restrictions or size limits [x] +- [x] `NetworkAccessPolicy` on `CodeModeConfiguration`: by default requests are + limited to public hosts — private / loopback / link-local / local-network + addresses (including alternate numeric and IPv6-embedded spellings) and + `localhost`/`.local`/`.internal`/trailing-dot names are declined. +- [x] Redirect targets re-checked against the policy before being followed. - [x] Response-size cap (default 10 MB), enforced by Content-Length pre-check + streaming cancel. -- [x] Allow/deny host lists; `.permissive` opt-out. -- [x] Egress destinations (allowed + denied) now written to the audit logger, +- [x] Allow/deny host lists; `.permissive` opt-out for hosts that want the old + unrestricted behavior. +- [x] Request destinations (allowed + declined) now recorded in the audit log, not just the execution transcript. - [~] Tests (`NetworkAccessPolicyTests.swift`) — unverified. @@ -63,8 +64,9 @@ just the watchdog: review risk. Keep+document (current) vs gate vs accept. - [x] **Spurious timeout** (self-review): poll loop discarded an already-settled result at/just past deadline. Fixed via shared `waitForSettlement`. -- [x] **Serialization hang** (self-review): runaway getter/`toJSON` hung the - thread because the watchdog was uninstalled before decode. Fixed via rearm. +- [x] **Serialization hang** (self-review): a runaway getter/`toJSON` could + occupy the thread because the watchdog was uninstalled before decode. Fixed via + rearm. - [ ] **HealthKit always denied through the default broker.** `healthKitStatus()` unconditionally returns `.notDetermined` (`SystemPermissionBroker.swift:398-407`); registry treats @@ -97,8 +99,8 @@ registrations with four parallel sources of truth. None addressed yet. against bridge JSON encoders) — would have caught the calendar-span drift. - [ ] Standardize on one registration idiom (three coexist across `CapabilityRegistrations+*.swift`). -- [ ] Unify permission ownership — `calendarRead` gates in both registry and - bridge; `calendarWrite` gates only in the bridge. +- [ ] Unify permission ownership — `calendarRead` checks in both registry and + bridge; `calendarWrite` checks only in the bridge. - [ ] Decide the fate of `Tools/CodeModeAuthoring` (macro package fully built, tested, but unwired; `SimpleBuiltInCodeModeProviders.swift` hand-rolls the same pattern). Either extend it to back the built-ins (permissions, @@ -110,10 +112,10 @@ registrations with four parallel sources of truth. None addressed yet. ## TESTING, CI, AND EVALS -- [ ] **Security layer has zero dedicated tests.** Add direct unit tests for - `PathPolicy` (traversal/symlink-escape), `SystemPermissionBroker` - (permission-denial), `ArtifactStore`, `AuditLogger`. (Quick win, protects the - trust boundary.) +- [ ] **Core policy layer has zero dedicated tests.** Add direct unit tests for + `PathPolicy` (path resolution: `..`, symlinks, allowed roots), + `SystemPermissionBroker` (permission grant/deny paths), `ArtifactStore`, + `AuditLogger`. (Quick win, covers the layer hosts rely on.) - [/] **CI never compiles iOS/visionOS code.** - [x] Added a `platform-build` matrix job (`xcodebuild build` for iOS + visionOS) so the UIKit presenters and the `CCodeModeJSC` shim compile @@ -149,8 +151,8 @@ registrations with four parallel sources of truth. None addressed yet. runtime" milestone. - [ ] **Structured audit pipeline.** Today's events are capability + free-text "success" with a pull-based drain. Move to a push-based sink with argument - digests, decisions, path/egress targets, and a per-execution correlation ID. - (Partial down payment already made: fetch egress destinations now audited.) + digests, outcomes, path/destination targets, and a per-execution correlation + ID. (Partial down payment already made: fetch destinations now recorded.) - [ ] **`Examples/` host app.** Minimal SwiftUI demo wiring `CodeModeAgentTools` + a real `UIKitSystemUIPresenter` + an LLM loop — concrete integration story and a manual test bed for the UIKit presenters automation can't reach. @@ -166,10 +168,11 @@ registrations with four parallel sources of truth. None addressed yet. --- ## MISC DEFERRED / NOTED (not bugs) -- [ ] Apply egress policy to `apple.web.present` / `apple.auth.webAuthenticate` - (visible browser flows, not silent egress; README scopes the policy claim to - `network.fetch` for now). -- [ ] Data-driven CIDR blocklist in `NetworkAccessPolicy` (hardcoded ranges today). +- [ ] Apply the network destination policy to `apple.web.present` / + `apple.auth.webAuthenticate` (these open a visible browser view; the policy + currently covers `network.fetch`). +- [ ] Data-driven address-range list in `NetworkAccessPolicy` (ranges are + hardcoded today). - [ ] Reconcile network audit entries with the runtime's generic success/failure audit (two entries per fetch — kept intentionally for destination detail). - [ ] Migrate bespoke `NSLock` state (`ExecutionWatchdog`, `FetchTaskHandler`) to @@ -185,13 +188,12 @@ registrations with four parallel sources of truth. None addressed yet. - [ ] Watchdog actually terminates `while(true){}` on device/simulator. ## SUGGESTED ORDER OF ATTACK -1. Quick wins: tag `0.1.0`, fix calendar-span drift, add Security-layer tests, +1. Quick wins: tag `0.1.0`, fix calendar-span drift, add core-policy-layer tests, add lint + iOS build job to CI. 2. Runtime hardening milestone: (watchdog ✔) + JS heap cap + concurrency bound - → "resource- and egress-safe sandbox." + → "resource- and network-bounded runtime." 3. Metadata-consolidation refactor + macro decision, protected by new drift tests. ## OPEN QUESTIONS FOR THE USER -- [ ] Open a PR for `fixes-and-improvements`? -- [ ] Add the macOS CI job next? - [ ] Decision on the private-but-exported JSC symbol (App Store risk)? +- [ ] After CI is green: tag `0.1.0` and restore the versioned install snippet? From 09f946d29ae3b885e03589fbd2249ec3c14f9306 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 15:52:33 -0700 Subject: [PATCH 09/25] Fix watchdog: JSC does not re-arm time limit when callback returns false First macOS verification run of the Linux-authored watchdog hung the whole test suite: on macOS 26 JavaScriptCore invokes the termination callback exactly once per installed limit, and returning false permanently disarms the watchdog instead of re-arming it as documented. Every runaway script (while(true), promise-chain loops, runaway getters during serialization) therefore ran forever after the first 50ms check. Root-caused with a minimal C reproduction against JSContextGroupSet- ExecutionTimeLimit. The callback is now a self-referencing C function that re-installs the time limit before returning false, restoring periodic deadline/cancellation checks. All 8 ExecutionWatchdogTests now pass. --- .../CodeMode/Runtime/ExecutionWatchdog.swift | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/Sources/CodeMode/Runtime/ExecutionWatchdog.swift b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift index 5e62c56..a9b8ea4 100644 --- a/Sources/CodeMode/Runtime/ExecutionWatchdog.swift +++ b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift @@ -29,9 +29,15 @@ final class ExecutionWatchdog: @unchecked Sendable { case cancelled } - /// How often JavaScriptCore re-invokes the termination callback while a - /// script is executing. Bounds both timeout overshoot and cancellation latency. - private static let checkInterval: TimeInterval = 0.05 + /// How often the termination callback runs while a script is executing. + /// Bounds both timeout overshoot and cancellation latency. + /// + /// JavaScriptCore is documented to re-invoke the callback on this interval + /// when it returns false, but on current OS releases (observed on macOS 26) + /// returning false permanently disarms the watchdog instead. The callback + /// therefore re-installs the time limit itself before returning false; see + /// `codeModeWatchdogShouldTerminate`. + fileprivate static let checkInterval: TimeInterval = 0.05 private let lock = NSLock() private var deadlineValue: Date @@ -71,20 +77,7 @@ final class ExecutionWatchdog: @unchecked Sendable { } let group = JSContextGetGroup(contextRef) let info = Unmanaged.passUnretained(self).toOpaque() - ccodemode_set_execution_time_limit( - group, - Self.checkInterval, - { _, info in - guard let info else { - return false - } - return Unmanaged - .fromOpaque(info) - .takeUnretainedValue() - .shouldTerminate() - }, - info - ) + ccodemode_set_execution_time_limit(group, Self.checkInterval, codeModeWatchdogShouldTerminate, info) } /// Callers must keep the watchdog installed only while `self` is alive; the @@ -96,7 +89,7 @@ final class ExecutionWatchdog: @unchecked Sendable { ccodemode_clear_execution_time_limit(JSContextGetGroup(contextRef)) } - private func shouldTerminate() -> Bool { + fileprivate func shouldTerminate() -> Bool { lock.lock() defer { lock.unlock() } @@ -114,3 +107,34 @@ final class ExecutionWatchdog: @unchecked Sendable { return false } } + +/// C-convention termination callback passed to `JSContextGroupSetExecutionTimeLimit`. +/// +/// A free function rather than a closure so it can reference itself: on current +/// OS releases (observed on macOS 26) JavaScriptCore does not re-arm the time +/// limit when the callback returns false — the callback fires exactly once and +/// the watchdog is dead for the rest of the execution. Re-installing the limit +/// here before returning false restores the documented periodic-check behavior. +/// Verified against a minimal JSC reproduction; without the re-arm a +/// `while (true) {}` script runs forever after the first 50ms check. +private func codeModeWatchdogShouldTerminate( + _ ctx: JSContextRef?, + _ info: UnsafeMutableRawPointer? +) -> Bool { + guard let info else { + return false + } + let watchdog = Unmanaged.fromOpaque(info).takeUnretainedValue() + if watchdog.shouldTerminate() { + return true + } + if let ctx { + ccodemode_set_execution_time_limit( + JSContextGetGroup(ctx), + ExecutionWatchdog.checkInterval, + codeModeWatchdogShouldTerminate, + info + ) + } + return false +} From c7c452d0e627129d2db7f314bf5fae248dcd5e43 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 15:52:43 -0700 Subject: [PATCH 10/25] Make registry constraint validation case-insensitive; fix calendar-span drift Bridges parse constrained string arguments case-insensitively (lowercased() throughout SystemUI/EventKit/Photos/FileSystem bridges), but the registry's allowedStringValues check was exact-match, so it rejected inputs the bridges accept. Validation now compares case-insensitively, the calendarDelete span list includes the alias spellings EventKitBridge accepts (this_event, future, ...), and the lowercase videoQuality duplicates that worked around the case sensitivity are dropped. Also adds a test pinning that no built-in registration gates on .healthKit via requiredPermissions: the default broker can never report .granted for HealthKit (read authorization is opaque by design), so such a registration would fail closed unconditionally. HealthBridge does per-type authorization itself. --- .../Registry/CapabilityRegistry.swift | 9 ++-- .../CapabilityRegistryTests.swift | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 1914b78..9b97ecb 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -54,7 +54,7 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { ]) case .calendarDelete: return .init(allowedStringValues: [ - "span": ["thisEvent", "futureEvents"], + "span": ["thisEvent", "futureEvents", "this_event", "future_events", "this", "future"], ]) case .calendarUIPickCalendar: return .init(allowedStringValues: [ @@ -82,7 +82,7 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { "mediaType": ["any", "image", "photo", "video"], "cameraDevice": ["rear", "front"], "flashMode": ["auto", "on", "off"], - "videoQuality": ["high", "medium", "low", "640x480", "iFrame1280x720", "iFrame960x540", "iframe1280x720", "iframe960x540"], + "videoQuality": ["high", "medium", "low", "640x480", "iFrame1280x720", "iFrame960x540"], ]) case .cameraUIScanData: return .init(allowedStringValues: [ @@ -130,7 +130,10 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { guard let string = value.stringValue else { throw BridgeError.invalidArguments("\(capabilityName) expected '\(path)' as string, received \(Self.jsonTypeName(for: value))") } - guard allowed.contains(string) else { + // Bridges parse these values case-insensitively; match that here so the + // registry never rejects an argument the bridge would accept. + let normalized = string.lowercased() + guard allowed.contains(where: { $0.lowercased() == normalized }) else { throw BridgeError.invalidArguments("\(capabilityName) \(path) must be one of \(allowed.joined(separator: ", "))") } } diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 0910fc2..7aafb85 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -496,6 +496,52 @@ private func jsNames(for capability: CapabilityID) -> [String] { #expect(network.argumentConstraints.allowedStringValues["options.responseEncoding"] == ["text", "base64"]) } +@Test func noBuiltInRegistrationGatesOnHealthKitPermission() { + // The default broker can never report .granted for HealthKit (read authorization + // is opaque by design), so any registration declaring .healthKit in + // requiredPermissions would fail closed unconditionally through the registry. + // HealthBridge performs its own per-type authorization instead. + for registration in DefaultCapabilityLoader.loadAllRegistrations() { + #expect( + registration.descriptor.requiredPermissions.contains(.healthKit) == false, + "\(registration.descriptor.id.rawValue) must not gate on .healthKit via the registry" + ) + } +} + +@Test func constraintValidationMatchesCaseInsensitively() throws { + let constraints = CapabilityArgumentConstraints.defaults(for: .contactsUIPick) + + try constraints.validate(arguments: ["mode": .string("single")], capabilityName: "contacts.ui.pick") + try constraints.validate(arguments: ["mode": .string("Single")], capabilityName: "contacts.ui.pick") + try constraints.validate(arguments: ["mode": .string("MULTIPLE")], capabilityName: "contacts.ui.pick") + + #expect(throws: (any Error).self) { + try constraints.validate(arguments: ["mode": .string("triple")], capabilityName: "contacts.ui.pick") + } +} + +@Test func calendarDeleteSpanConstraintAcceptsEverySpellingTheBridgeAccepts() throws { + let constraints = CapabilityArgumentConstraints.defaults(for: .calendarDelete) + + // Keep in sync with EventKitBridge.eventSpan, which lowercases its input + // and accepts these aliases. + let bridgeAcceptedSpellings = [ + "thisEvent", "thisevent", "this_event", "this", + "futureEvents", "futureevents", "future_events", "future", + ] + for spelling in bridgeAcceptedSpellings { + try constraints.validate( + arguments: ["span": .string(spelling)], + capabilityName: "calendar.delete" + ) + } + + #expect(throws: (any Error).self) { + try constraints.validate(arguments: ["span": .string("allEvents")], capabilityName: "calendar.delete") + } +} + @Test func registryValidationRejectsUnknownArguments() throws { let descriptor = CapabilityDescriptor( id: .fsRead, From 1ab97f64e91bf8de1e54330f62e5c4e831d86ad9 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 15:52:57 -0700 Subject: [PATCH 11/25] Avoid main-thread deadlock in requestLocationPermission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executions run on a background queue while the host may synchronously block the main thread waiting on the result; DispatchQueue.main.sync from the broker then deadlocks. Use main.async — the existing 10s delegate wait already bounds the request, and if main never runs it the wait times out and returns the current status instead of hanging. --- .../CodeMode/Security/SystemPermissionBroker.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/CodeMode/Security/SystemPermissionBroker.swift b/Sources/CodeMode/Security/SystemPermissionBroker.swift index cb73e53..27f4451 100644 --- a/Sources/CodeMode/Security/SystemPermissionBroker.swift +++ b/Sources/CodeMode/Security/SystemPermissionBroker.swift @@ -426,10 +426,13 @@ public final class SystemPermissionBroker: PermissionBroker, @unchecked Sendable if Thread.isMainThread { manager.requestWhenInUseAuthorization() return locationStatus() - } else { - DispatchQueue.main.sync { - manager.requestWhenInUseAuthorization() - } + } + + // async, not sync: execution runs on a background queue, and the host may be + // blocking the main thread waiting on the result — main.sync would deadlock. + // If main never runs the request, the delegate wait below times out instead. + DispatchQueue.main.async { + manager.requestWhenInUseAuthorization() } _ = delegate.wait(timeout: 10) From 0e073a9a4743c3236a1951398be733a63114e64b Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 15:52:57 -0700 Subject: [PATCH 12/25] Add direct unit tests for the core policy layer PathPolicy: empty/whitespace paths, scoped roots, appGroup with and without a configured root, absolute paths inside/outside allowed roots, dot-dot escapes vs internal dot-dot, nonexistent nested paths, and symlinks that escape vs symlinks between allowed roots. Plus round-trip/uniqueness tests for InMemoryArtifactStore and ordering/drain/concurrency tests for SyncAuditLogger. SystemPermissionBroker is left untested deliberately: every path ends in a real OS framework call and needs injectable seams first (noted in TODO). --- .../CorePolicySupportTests.swift | 50 ++++++ Tests/CodeModeTests/PathPolicyTests.swift | 147 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 Tests/CodeModeTests/CorePolicySupportTests.swift create mode 100644 Tests/CodeModeTests/PathPolicyTests.swift diff --git a/Tests/CodeModeTests/CorePolicySupportTests.swift b/Tests/CodeModeTests/CorePolicySupportTests.swift new file mode 100644 index 0000000..d0c9ebf --- /dev/null +++ b/Tests/CodeModeTests/CorePolicySupportTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func inMemoryArtifactStoreRoundTripsHandles() throws { + let store = InMemoryArtifactStore() + let url = URL(fileURLWithPath: "/tmp/example.png") + + let handle = try store.register(url: url, mimeType: "image/png") + #expect(store.resolve(handle: handle) == url) + #expect(store.resolve(handle: ArtifactHandle(id: "missing")) == nil) +} + +@Test func inMemoryArtifactStoreIssuesUniqueHandles() throws { + let store = InMemoryArtifactStore() + let url = URL(fileURLWithPath: "/tmp/example.png") + + let first = try store.register(url: url, mimeType: nil) + let second = try store.register(url: url, mimeType: nil) + #expect(first != second) + #expect(store.resolve(handle: first) == url) + #expect(store.resolve(handle: second) == url) +} + +@Test func syncAuditLoggerDrainReturnsAndClearsEventsInOrder() { + let logger = SyncAuditLogger() + let first = AuditEvent(capability: "fs.read", message: "success") + let second = AuditEvent(capability: "network.fetch", message: "allowed") + + logger.log(first) + logger.log(second) + + #expect(logger.drain() == [first, second]) + #expect(logger.drain().isEmpty) + + let third = AuditEvent(capability: "fs.write", message: "success") + logger.log(third) + #expect(logger.drain() == [third]) +} + +@Test func syncAuditLoggerIsSafeUnderConcurrentLogging() { + let logger = SyncAuditLogger() + let iterations = 200 + + DispatchQueue.concurrentPerform(iterations: iterations) { index in + logger.log(AuditEvent(capability: "cap.\(index)", message: "m")) + } + + #expect(logger.drain().count == iterations) +} diff --git a/Tests/CodeModeTests/PathPolicyTests.swift b/Tests/CodeModeTests/PathPolicyTests.swift new file mode 100644 index 0000000..c349e7c --- /dev/null +++ b/Tests/CodeModeTests/PathPolicyTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import CodeMode + +private func makePolicy(_ sandbox: TestSandbox, appGroupRoot: URL? = nil) -> DefaultPathPolicy { + DefaultPathPolicy( + config: PathPolicyConfig( + tmpRoot: sandbox.tmp, + cachesRoot: sandbox.caches, + documentsRoot: sandbox.documents, + appGroupRoot: appGroupRoot + ) + ) +} + +private func expectPathViolation(_ body: () throws -> URL) { + do { + let url = try body() + Issue.record("Expected PATH_POLICY_VIOLATION, resolved to \(url.path)") + } catch { + #expect(requireBridgeErrorCode(error) == "PATH_POLICY_VIOLATION") + } +} + +@Test func pathPolicyRejectsEmptyAndWhitespacePaths() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + expectPathViolation { try policy.resolve(path: "") } + expectPathViolation { try policy.resolve(path: " \n") } +} + +@Test func pathPolicyResolvesRelativePathsUnderTmp() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let url = try policy.resolve(path: "notes.txt") + #expect(url.path.hasPrefix(sandbox.tmp.resolvingSymlinksInPath().path)) + #expect(url.lastPathComponent == "notes.txt") +} + +@Test func pathPolicyResolvesScopedPathsToTheirRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let tmpRoot = sandbox.tmp.resolvingSymlinksInPath().path + let cachesRoot = sandbox.caches.resolvingSymlinksInPath().path + let documentsRoot = sandbox.documents.resolvingSymlinksInPath().path + + #expect(try policy.resolve(path: "tmp:a.txt").path == tmpRoot + "/a.txt") + #expect(try policy.resolve(path: "caches:sub/b.txt").path == cachesRoot + "/sub/b.txt") + #expect(try policy.resolve(path: "documents:c.txt").path == documentsRoot + "/c.txt") +} + +@Test func pathPolicyResolvesAppGroupScopeOnlyWhenConfigured() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + + let appGroup = sandbox.root.appendingPathComponent("appGroup", isDirectory: true) + try FileManager.default.createDirectory(at: appGroup, withIntermediateDirectories: true) + + let configured = makePolicy(sandbox, appGroupRoot: appGroup) + let url = try configured.resolve(path: "appGroup:shared.txt") + #expect(url.path == appGroup.resolvingSymlinksInPath().path + "/shared.txt") + + // Without a configured app-group root the scope prefix is not recognized; + // the whole string falls back to a tmp-relative literal file name. + let unconfigured = makePolicy(sandbox) + let fallback = try unconfigured.resolve(path: "appGroup:shared.txt") + #expect(fallback.path.hasPrefix(sandbox.tmp.resolvingSymlinksInPath().path)) +} + +@Test func pathPolicyAllowsAbsolutePathsInsideAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let inside = sandbox.documents.appendingPathComponent("report.pdf").path + let url = try policy.resolve(path: inside) + #expect(url.path == sandbox.documents.resolvingSymlinksInPath().path + "/report.pdf") +} + +@Test func pathPolicyRejectsAbsolutePathsOutsideAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + expectPathViolation { try policy.resolve(path: "/etc/passwd") } + expectPathViolation { try policy.resolve(path: sandbox.root.appendingPathComponent("outside.txt").path) } +} + +@Test func pathPolicyRejectsDotDotEscapes() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + expectPathViolation { try policy.resolve(path: "tmp:../escaped.txt") } + expectPathViolation { try policy.resolve(path: "documents:a/../../escaped.txt") } + expectPathViolation { try policy.resolve(path: "../escaped.txt") } +} + +@Test func pathPolicyAllowsDotDotThatStaysInsideAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let url = try policy.resolve(path: "tmp:a/../b.txt") + #expect(url.path == sandbox.tmp.resolvingSymlinksInPath().path + "/b.txt") +} + +@Test func pathPolicyResolvesNonexistentNestedPaths() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let url = try policy.resolve(path: "tmp:new/nested/dir/file.txt") + #expect(url.path == sandbox.tmp.resolvingSymlinksInPath().path + "/new/nested/dir/file.txt") +} + +@Test func pathPolicyRejectsSymlinksEscapingAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let outside = sandbox.root.appendingPathComponent("outside", isDirectory: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + let link = sandbox.tmp.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outside) + + expectPathViolation { try policy.resolve(path: "tmp:link/secret.txt") } + expectPathViolation { try policy.resolve(path: "tmp:link") } +} + +@Test func pathPolicyAllowsSymlinksBetweenAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let link = sandbox.tmp.appendingPathComponent("docs-link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: sandbox.documents) + + let url = try policy.resolve(path: "tmp:docs-link/file.txt") + #expect(url.path == sandbox.documents.resolvingSymlinksInPath().path + "/file.txt") +} From 7d8021728aee248a8d45044408567f27f0434da2 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 15:53:02 -0700 Subject: [PATCH 13/25] Update TODO with macOS verification results and today's fixes --- TODO.md | 71 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/TODO.md b/TODO.md index bbefc4b..9334104 100644 --- a/TODO.md +++ b/TODO.md @@ -23,8 +23,18 @@ executing. The full "make the runtime resource-bounded" milestone is bigger than just the watchdog: - [x] Preemptive watchdog via `JSContextGroupSetExecutionTimeLimit` (through the `CCodeModeJSC` shim) → real `timeoutMs`, `while(true){}` terminates. + - [x] **macOS verification found the Linux-authored version completely broken:** + on current OS releases (observed macOS 26.5) JSC does *not* re-arm the time + limit when the callback returns false — the callback fires exactly once at + 50ms and the watchdog is dead for the rest of the execution, so nothing was + ever terminated (first full `swift test` hung indefinitely with six runaway + JS threads). Root-caused with a minimal C reproduction; fixed by having the + callback re-install the time limit itself before returning false + (`codeModeWatchdogShouldTerminate` in `ExecutionWatchdog.swift`). - [x] Real cancellation — `cancel()` interrupts in-flight JS. -- [~] Tests for the above (`ExecutionWatchdogTests.swift`) — unverified. +- [x] Tests for the above (`ExecutionWatchdogTests.swift`) — all 8 pass on + macOS after the re-arm fix (loop, promise-chain loop, runaway getter during + serialization, cancellation, catch-proof termination, context recovery). - [ ] **JS heap / memory cap** — nothing bounds `JSContext`/`JSContextGroup` heap; `new Array(1e9)` can still exhaust host memory. NOT addressed. - [ ] **Bound on concurrent executions** — `executionQueue` is `.concurrent` @@ -45,7 +55,7 @@ just the watchdog: unrestricted behavior. - [x] Request destinations (allowed + declined) now recorded in the audit log, not just the execution transcript. -- [~] Tests (`NetworkAccessPolicyTests.swift`) — unverified. +- [x] Tests (`NetworkAccessPolicyTests.swift`) — pass on macOS (2026-07-11). ### 3. Package not installable as documented [/] - [x] README no longer tells users to depend on `from: "0.1.0"` against a @@ -67,19 +77,23 @@ just the watchdog: - [x] **Serialization hang** (self-review): a runaway getter/`toJSON` could occupy the thread because the watchdog was uninstalled before decode. Fixed via rearm. -- [ ] **HealthKit always denied through the default broker.** - `healthKitStatus()` unconditionally returns `.notDetermined` - (`SystemPermissionBroker.swift:398-407`); registry treats - requested→notDetermined as denial. Fails closed silently. NOT addressed. -- [ ] **Calendar-span validation drift.** Registry constraint table accepts only - `["thisEvent","futureEvents"]` case-sensitively - (`CapabilityRegistry.swift:56-58`) while `EventKitBridge` accepts - `this_event`/`future`/etc. case-insensitively - (`EventKitBridge.swift:471-479`) — registry rejects inputs the bridge - supports. NOT addressed. (Quick win.) -- [ ] **`DispatchQueue.main.sync` in `requestLocationPermission`** - (`SystemPermissionBroker.swift:430`) deadlocks if reached from the main - thread. NOT addressed. +- [x] **HealthKit always denied through the default broker.** Investigated on + macOS (2026-07-11): the claimed mechanism does not exist in current code — no + built-in registration declares `.healthKit` in `requiredPermissions`, so the + registry's requested→notDetermined→denied path never fires for health. + `HealthBridge` treats `.notDetermined` as passable and performs real per-type + `requestAuthorization` itself. Added a registry test pinning the invariant + (no registration may gate on `.healthKit`, since the default broker can never + report `.granted` for it). +- [x] **Calendar-span validation drift.** Fixed: constraint validation is now + case-insensitive (matching the `lowercased()` idiom used by effectively every + bridge), and the `calendarDelete` span list includes the alias spellings + `EventKitBridge` accepts. Also removed the now-redundant lowercase + `videoQuality` duplicates. Tests added. +- [x] **`DispatchQueue.main.sync` in `requestLocationPermission`.** Fixed: + `main.async` + the existing 10s delegate wait. (The deadlock was background + execution thread → `main.sync` while the host blocks main waiting on the JS + result; the `Thread.isMainThread` branch already covered the direct case.) - [ ] **Duplicated eval model types will drift.** `LLM.swift` is excluded from the build; ~250 lines of report/suite types are defined twice (there and in compiled `LLMEvalModels.swift`) with nothing keeping them in sync. NOT @@ -112,10 +126,15 @@ registrations with four parallel sources of truth. None addressed yet. ## TESTING, CI, AND EVALS -- [ ] **Core policy layer has zero dedicated tests.** Add direct unit tests for - `PathPolicy` (path resolution: `..`, symlinks, allowed roots), - `SystemPermissionBroker` (permission grant/deny paths), `ArtifactStore`, - `AuditLogger`. (Quick win, covers the layer hosts rely on.) +- [/] **Core policy layer has zero dedicated tests.** + - [x] `PathPolicy` (`PathPolicyTests.swift`): empty/whitespace, scoped roots, + appGroup configured/unconfigured, absolute in/out, `..` escapes vs internal + `..`, nonexistent nested paths, symlink escape vs symlink between roots. + - [x] `ArtifactStore` + `AuditLogger` (`CorePolicySupportTests.swift`). + - [ ] `SystemPermissionBroker` — not unit-testable as written: every path + terminates in a real OS framework call (CLLocationManager, EKEventStore, + CNContactStore…), so tests would prompt/flake. Needs seams (injectable + status providers) first; fold into the metadata/permission refactor. - [/] **CI never compiles iOS/visionOS code.** - [x] Added a `platform-build` matrix job (`xcodebuild build` for iOS + visionOS) so the UIKit presenters and the `CCodeModeJSC` shim compile @@ -181,11 +200,15 @@ registrations with four parallel sources of truth. None addressed yet. --- ## MUST-DO BEFORE MERGE -- [ ] **Verify on macOS** (nothing was compiled or run in this Linux env): - - [ ] `CCodeModeJSC` shim links against JavaScriptCore and the private symbol - resolves at load time on iOS/macOS/visionOS. - - [ ] `swift test` passes (all new + existing tests). - - [ ] Watchdog actually terminates `while(true){}` on device/simulator. +- [/] **Verify on macOS** — done 2026-07-11 (macOS 26.5, Swift 6.3.2 / Xcode 26.5): + - [x] `CCodeModeJSC` shim links and the private symbols resolve at load time + on macOS. (iOS/visionOS remain compile-verified only, via CI.) + - [x] `swift test` passes — 206 tests, 0 failures, ~5s. Note: the *first* + verification run hung indefinitely and exposed the watchdog re-arm bug + (see CRITICAL ISSUES §1); after the fix the suite is green. + - [ ] Watchdog terminates `while(true){}` on an iOS device/simulator — + verified on macOS only; JSC ships per-OS, so worth one manual check on a + simulator before tagging. ## SUGGESTED ORDER OF ATTACK 1. Quick wins: tag `0.1.0`, fix calendar-span drift, add core-policy-layer tests, From dfb8a1871fbe60852f95abb335a2153f51eaa18f Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 16:03:20 -0700 Subject: [PATCH 14/25] Add plan for macro-generated capability registrations Audit of Tools/CodeModeAuthoring (builds and passes all 14 tests on the current toolchain) against the three built-in registration idioms and the parallel sources of truth behind the metadata drift. Proposes a three-phase path: converge built-ins on the typed-tool protocol with enum-typed constrained arguments (drift-proof by construction, no macro needed), then promote the macro into the core package as @BuiltInCodeMode generating the decode/metadata boilerplate, then migrate per domain and delete the central constraint and type-inference tables. Includes measured swift-syntax build cost (24s clean, ~0 incremental, prebuilts default-on) since that was the original reason the macro package was kept out of the core graph. --- PLAN-registration-macros.md | 182 ++++++++++++++++++++++++++++++++++++ TODO.md | 12 +-- 2 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 PLAN-registration-macros.md diff --git a/PLAN-registration-macros.md b/PLAN-registration-macros.md new file mode 100644 index 0000000..0f52e55 --- /dev/null +++ b/PLAN-registration-macros.md @@ -0,0 +1,182 @@ +# Plan: macro-generated capability registrations + +Goal: reduce the ~115-capability / ~2,850-line hand-written registration surface +and eliminate the parallel-sources-of-truth drift (registration metadata vs +bridge parsing vs central constraint table vs argument-type inference), by +converging on one typed idiom that a macro can generate the boilerplate for. + +Written 2026-07-11 after auditing `Tools/CodeModeAuthoring` and the built-in +registration idioms. Verdict up front: **there is a real path forward, in three +phases, and the first phase pays for itself even if the macro phase is later +rejected.** + +--- + +## 1. What exists today + +### The macro package (`Tools/CodeModeAuthoring`) — built, tested, unwired +- Separate SPM package so core clients don't inherit swift-syntax (README is + explicit about this). +- `@CodeMode(path:description:)` — attached extension macro on a struct/class/ + actor with a nested `Arguments` struct (`@CodeModeParam` per field) and + optional nested `Result` (`@CodeModeResult`). Generates a `CodeModeProvider` + conformance: `codeModePath` + `codeModeRegistrations()` with + required/optional arguments, `argumentTypes`, `argumentHints`, + `resultSummary`, and a handler that decodes raw `[String: JSONValue]` into + the typed `Arguments` and calls `call(arguments:)` (async supported via + `CodeModeAsyncBridge.run`). +- 607-line implementation, 14 tests, **all passing on Swift 6.3.2 / Xcode 26.5** + (verified today). +- Scope: host-authored custom tools only (capability *keys*), not built-ins. + +### Three coexisting built-in idioms (the boilerplate) +1. **Flat convenience init** (`BuiltInCapabilityRegistration.swift`) — the bulk: + 115 `CapabilityRegistration(...)` callsites across nine + `CapabilityRegistrations+*.swift` files (~2,850 lines). All metadata is + hand-typed strings; the handler closes over a bridge and re-parses raw JSON. +2. **`BuiltInCodeModeTool` protocol** (`SimpleBuiltInCodeModeProviders.swift`, + private) — typed `Arguments` struct + `decode()` + `call(arguments:context:)` + with static metadata. Used for keychain/location/weather. This is a + *hand-rolled version of exactly what the macro generates* — proof the shape + works for built-ins. +3. **Raw `CodeModeRegistration`** inline — used where a capability has multiple + JS names (e.g. `locationRead` ⇒ `getPermissionStatus` + `getCurrentPosition`). + +### The drift surfaces this is meant to kill +| Source of truth | Where | Drift already bitten? | +|---|---|---| +| Registration metadata (args/types/hints) | `CapabilityRegistrations+*.swift` | — | +| Bridge argument parsing | each bridge (`arguments.string(...)`, `lowercased()`) | yes — calendar-span (fixed 2026-07-11) | +| Constraint defaults table | `CapabilityArgumentConstraints.defaults(for:)` central switch | same incident | +| Argument-type inference | `CapabilityDescriptor.inferArgumentTypes` known-name table | silent `.any` degradation | +| Platform support sets | `CapabilityPlatformSupport` | orthogonal, keep | +| Permission checks | split registry (`requiredPermissions`) vs bridge (`ensurePermission`) | healthKit confusion | + +### Gap analysis: macro v1 vs what built-ins need +- Handler signature discards `BridgeInvocationContext` — 73 of 115 built-in + handlers use `context` (permissions, audit, cancellation, presenters). +- No `requiredPermissions` (30 built-in declarations), no + `argumentConstraints`, no multi-jsName aliases, no `CapabilityID` (only + string keys), title/tags/example are *derived* while built-ins have curated + ones, handlers are async-wrapped while built-ins are sync. + +### The swift-syntax cost, measured today +- This toolchain defaults to `--enable-experimental-prebuilts` for macros. +- Clean build of the macro package **from scratch** (fresh `.build`, includes + swift-syntax 601.0.1): **24s wall** on this machine (~126s CPU, parallel). + Incremental: ~0. CI runners: expect ~1–2 min on a cold cache, amortized by + the SwiftPM cache we already added to CI. +- Conclusion: the original reason for exiling the macro package (swift-syntax + tax on every client) is far weaker than when that decision was made. It is + now a *policy* decision, not a showstopper. + +--- + +## 2. The plan + +### Phase 1 — converge on the typed-tool protocol (no macro, shippable alone) +Make idiom #2 the one idiom, extended to cover everything idiom #1 expresses: + +1. Promote `BuiltInCodeModeTool` out of `SimpleBuiltInCodeModeProviders.swift` + into its own file as the internal standard, renamed (working name: + `CodeModeToolDefinition`). Add the missing axes: + - `static var codeModeAliases: [String]` (multi-jsName), + - `static var requiredPermissions: [PermissionKind]`, + - curated `title`/`tags`/`example` (already present), + - `call(arguments:context:)` keeps the `BridgeInvocationContext`. +2. **Enum-typed constrained arguments** — the drift killer. Add a + `CodeModeEnumDecodable` refinement (String raw value + `CaseIterable` + + optional alias table). An argument declared as such an enum automatically + yields: + - `allowedStringValues` for the descriptor (including aliases), + - case-insensitive decode with aliases (same semantics the bridges + hand-roll with `lowercased()`), + - a single place where "what the bridge accepts" and "what the registry + advertises" are *the same declaration*. Calendar span becomes + `enum Span: String, CodeModeEnumDecodable { case thisEvent, futureEvents }` + with aliases `["this_event": .thisEvent, ...]`. +3. Migrate **one domain by hand** to validate the shape: EventKit (9 + registrations, includes the span history and permission checks). Delete its + rows from `CapabilityArgumentConstraints.defaults(for:)` — the constraint + comes from the tool now. +4. Add the drift test from TODO: for every registration, assert the descriptor + metadata matches what the tool's decode actually accepts (for enum params + this is true by construction; the test guards the unmigrated remainder). + +Exit criteria: one idiom documented, EventKit migrated, suite green, constraint +table shrinking. **Value even if Phase 2 never happens:** every migrated domain +is drift-proof; the boilerplate merely remains verbose. + +### Phase 2 — bring the macro into the main package (the boilerplate killer) +1. Move the `CodeModeMacros` macro target from `Tools/CodeModeAuthoring` into + the root `Package.swift`. The `CodeMode` target itself now uses it — this is + the point where every client inherits swift-syntax (measured cost above; + gate: owner sign-off). +2. Add an internal `@BuiltInCodeMode` attached macro targeting the Phase-1 + protocol. Macro generates the mechanical parts only: + - `decode(arguments:)` from the `Arguments` struct fields (incl. enum + params → constraints), + - `requiredArguments` / `optionalArguments` / `argumentTypes` / + `argumentHints` / `argumentConstraints` statics. + Hand-written (macro arguments or plain statics): `CapabilityID`, aliases, + permissions, curated title/summary/tags/example, `call(arguments:context:)`. +3. Because macro-authored and hand-written tools implement the same protocol, + migration is per-domain and incremental — no big bang, and a domain can be + reverted to hand-written without touching the registry. +4. `Tools/CodeModeAuthoring` keeps its public `@CodeMode` surface for hosts but + becomes a thin package re-exporting the same macro implementation (or a + second product of the main package — decide at implementation time; SPM only + builds targets a chosen product needs, but once `CodeMode` uses macros the + distinction stops mattering for build cost). +5. Extend the host-facing `@CodeMode` with the same enum-constraint support so + hosts get constraint metadata too (currently impossible for them). +6. CI gotcha to handle: `xcodebuild` validates macro plugins; the iOS/visionOS + `platform-build` job will need `-skipMacroValidation` (or trust plumbing). + +### Phase 3 — migrate the domains, delete the tables +Suggested order (drift risk × constraint-table usage first): +1. SystemUI (12 registrations, heaviest constraint user: pickers/camera/scan), +2. Core + Keychain + PeoplePhotosDocuments, +3. SystemServices (health/home/alarm — permission-sensitive, good test of the + `.healthKit` invariant), +4. CloudPushSpeech, IntentsModelsActivityMaps, +5. Commerce (music/passKit/storeKit) **last or never**: these bridges pass raw + arguments through to host-supplied clients, so a typed `Arguments` struct + adds little — keeping them on the hand-written protocol is a legitimate end + state. + +End state: delete `CapabilityArgumentConstraints.defaults(for:)` and +`CapabilityDescriptor.inferArgumentTypes` entirely; registry validation fails +loudly on unknown arguments (existing TODO item); `CapabilityPlatformSupport` +stays (orthogonal — could become a macro argument later, not required). + +--- + +## 3. What NOT to do +- **Don't generate registrations from bridge methods.** Bridges take + `[String: JSONValue]` — there is no type information to harvest. The typed + `Arguments` struct has to exist first, and once it exists the protocol+macro + design above is strictly simpler than source-scraping. +- **Don't do a 115-callsite big-bang migration.** The two-idiom coexistence + window is priced in; the drift test covers the unmigrated remainder. +- **Don't macro-generate curated prose** (titles, summaries, examples). The + derived versions in macro v1 (`title = last path component`) are fine for + host tools but would degrade the LLM-facing catalog for built-ins. + +## 4. Open decisions (owner) +1. Accept swift-syntax in the core package graph? (Measured: 24s clean local, + ~0 incremental, prebuilts default-on. If "no", stop after Phase 1 — still + worth it.) +2. Should the Phase-1 protocol be public (hosts could adopt it without macros) + or internal-only until it stabilizes? Recommendation: internal until Phase 3 + is done. +3. Keep `Tools/CodeModeAuthoring` as a separate package (re-export) or fold it + in as a product? Recommendation: fold in during Phase 2; one macro + implementation, two attribute surfaces. + +## 5. Effort estimate +- Phase 1: 1–2 focused sessions (protocol + enum decode + EventKit + drift test). +- Phase 2: 1–2 sessions (mostly extending the existing 607-line macro impl, + package plumbing, CI macro-validation flag). +- Phase 3: mechanical, ~1 session per 2–3 domains; parallelizable; each domain + lands independently green. diff --git a/TODO.md b/TODO.md index 9334104..eebd48a 100644 --- a/TODO.md +++ b/TODO.md @@ -115,12 +115,12 @@ registrations with four parallel sources of truth. None addressed yet. `CapabilityRegistrations+*.swift`). - [ ] Unify permission ownership — `calendarRead` checks in both registry and bridge; `calendarWrite` checks only in the bridge. -- [ ] Decide the fate of `Tools/CodeModeAuthoring` (macro package fully built, - tested, but unwired; `SimpleBuiltInCodeModeProviders.swift` hand-rolls the - same pattern). Either extend it to back the built-ins (permissions, - constraints, multi-name aliases, curated tags/examples) or scope it clearly as - a host-authoring aid. Lower-risk near-term win: validation/codegen from - existing descriptor metadata rather than a 115-callsite macro migration. +- [/] Decide the fate of `Tools/CodeModeAuthoring` — investigated 2026-07-11; + concrete three-phase plan written up in `PLAN-registration-macros.md` + (typed-tool protocol first, then promote the macro into the core package — + swift-syntax cost measured at 24s clean/~0 incremental with prebuilts + default-on — then per-domain migration). Awaiting owner decision on + swift-syntax in the core graph; Phase 1 is worthwhile standalone. --- From 6f5e7e8ad4c0b7f864f8feffb8b236e771454dde Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 16:20:50 -0700 Subject: [PATCH 15/25] Phase 1 of registration-macro plan: typed tool protocol + constrained enums Promotes BuiltInCodeModeTool out of SimpleBuiltInCodeModeProviders.swift as the standard registration idiom, extended with capability IDs, JS name aliases, and required permissions. Adds CodeModeStringEnum: a constrained string argument is declared once as an enum, and the advertised allowedStringValues, the case-insensitive/alias-aware decode, and the bridge parsing all derive from that declaration, so they cannot drift. Migrates the EventKit domain (9 registrations, 5 enums) to prove the shape: EventKitBridge and SystemUIBridge now parse span/operation/picker styles through the shared enums, and the four EventKit-domain rows are deleted from the central CapabilityArgumentConstraints.defaults table. Keychain, location, and weather tools converge on the same protocol; the raw-CodeModeRegistration and builtInCapability: glue idioms are gone. Adds constrainedArgumentMetadataIsCoherentForAllRegistrations (every constrained argument must be a declared argument) and reworks the span test to pin enum <-> descriptor <-> bridge agreement. Transitional tools carry a raw passthrough because bridges still consume raw dictionaries; the Phase 2 macro generates decode and the argument metadata, which is where the line-count win lands. 207 tests pass. --- PLAN-registration-macros.md | 12 + Sources/CodeMode/API/CodeModeStringEnum.swift | 49 +++ .../Bridges/BuiltInCodeModeTool.swift | 103 +++++ .../CapabilityRegistrations+EventKit.swift | 248 +---------- Sources/CodeMode/Bridges/EventKitBridge.swift | 47 +-- .../Bridges/EventKitCodeModeTools.swift | 389 ++++++++++++++++++ .../SimpleBuiltInCodeModeProviders.swift | 191 ++------- Sources/CodeMode/Bridges/SystemUIBridge.swift | 12 +- .../Registry/CapabilityRegistry.swift | 20 +- TODO.md | 32 +- .../CapabilityRegistryTests.swift | 46 ++- 11 files changed, 698 insertions(+), 451 deletions(-) create mode 100644 Sources/CodeMode/API/CodeModeStringEnum.swift create mode 100644 Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift create mode 100644 Sources/CodeMode/Bridges/EventKitCodeModeTools.swift diff --git a/PLAN-registration-macros.md b/PLAN-registration-macros.md index 0f52e55..608a57e 100644 --- a/PLAN-registration-macros.md +++ b/PLAN-registration-macros.md @@ -10,6 +10,18 @@ registration idioms. Verdict up front: **there is a real path forward, in three phases, and the first phase pays for itself even if the macro phase is later rejected.** +**Status:** owner approved swift-syntax in the core graph (2026-07-11). +Phase 1 landed the same day: `BuiltInCodeModeTool` + `CodeModeStringEnum` +shipped, EventKit/Keychain/Location/Weather converged on the idiom, the four +EventKit-domain rows are gone from the central constraint table, and the +coherence test guards the remainder. Two Phase-1 notes for Phase 2: +(1) transitional tools carry a `raw: [String: JSONValue]` passthrough because +bridges still consume raw dictionaries — the macro design should keep +generating that until a domain's bridge goes fully typed (Phase 3); +(2) a fifth metadata surface surfaced during migration — the hand-written JS +function table in `RuntimeJavaScript.swift` — worth folding into Phase 3 as +"generate the JS shim table from registrations". + --- ## 1. What exists today diff --git a/Sources/CodeMode/API/CodeModeStringEnum.swift b/Sources/CodeMode/API/CodeModeStringEnum.swift new file mode 100644 index 0000000..f082bc4 --- /dev/null +++ b/Sources/CodeMode/API/CodeModeStringEnum.swift @@ -0,0 +1,49 @@ +import Foundation + +/// A string argument with a closed set of accepted values. +/// +/// Conforming enums are the single source of truth for a constrained string +/// argument: the advertised `allowedStringValues` in capability metadata, the +/// decode behavior (case-insensitive, alias-aware), and the typed value handed +/// to the implementation all come from one declaration, so they cannot drift. +/// +/// Raw values are the canonical spellings advertised to callers. Alternate +/// accepted spellings go in `codeModeAliases`; both are matched +/// case-insensitively, mirroring how bridges have historically parsed +/// constrained strings. +public protocol CodeModeStringEnum: CodeModeJSONDecodable, RawRepresentable, CaseIterable, Sendable +where RawValue == String { + /// Alternate accepted spellings mapped to their canonical case. + static var codeModeAliases: [String: Self] { get } +} + +extension CodeModeStringEnum { + public static var codeModeAliases: [String: Self] { [:] } + + /// Every accepted spelling, canonical raw values first, for capability + /// metadata (`CapabilityArgumentConstraints.allowedStringValues`). + public static var codeModeAllowedValues: [String] { + allCases.map(\.rawValue) + codeModeAliases.keys.sorted() + } + + /// Case-insensitive, alias-aware match; nil when the value is not accepted. + public static func codeModeValue(matching raw: String) -> Self? { + let normalized = raw.lowercased() + if let match = allCases.first(where: { $0.rawValue.lowercased() == normalized }) { + return match + } + return codeModeAliases.first { $0.key.lowercased() == normalized }?.value + } + + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> Self { + guard let string = value.stringValue else { + throw CodeModeFunctionError.invalidArguments("Argument \(argumentName) must be a string") + } + guard let match = codeModeValue(matching: string) else { + throw CodeModeFunctionError.invalidArguments( + "Argument \(argumentName) must be one of \(codeModeAllowedValues.joined(separator: ", "))" + ) + } + return match + } +} diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift new file mode 100644 index 0000000..e54edca --- /dev/null +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Declarative description of one argument of a built-in tool. +struct BuiltInToolArgument { + var name: String + var type: CapabilityArgumentType + var optional: Bool + var hint: String + /// Accepted spellings when the argument is constrained to a closed set; + /// nil for unconstrained arguments. + var allowedValues: [String]? + + init(_ name: String, _ type: CapabilityArgumentType, optional: Bool = false, hint: String) { + self.name = name + self.type = type + self.optional = optional + self.hint = hint + self.allowedValues = nil + } + + /// A string argument constrained to a `CodeModeStringEnum`'s accepted + /// values. The advertised constraint and the decode behavior both come + /// from the enum, so they cannot drift. + init(_ name: String, oneOf: Value.Type, optional: Bool = false, hint: String) { + self.name = name + self.type = .string + self.optional = optional + self.hint = hint + self.allowedValues = Value.codeModeAllowedValues + } +} + +/// The standard idiom for defining a built-in capability: typed arguments, +/// static metadata, and a handler that receives the invocation context. +/// +/// Registration metadata (required/optional argument lists, types, hints, and +/// constraints) is derived from `codeModeArguments`, so what the catalog +/// advertises and what `decode` accepts come from one declaration. Phase 2 of +/// PLAN-registration-macros.md generates `decode` and the argument list with a +/// macro; hand-written and macro-authored tools share this protocol, so +/// migration is incremental. +protocol BuiltInCodeModeTool: Sendable { + associatedtype Arguments: Sendable + + static var codeModeCapability: CapabilityID { get } + static var codeModePath: String { get } + /// Additional JavaScript names routed to this tool (same handler and + /// metadata), e.g. `apple.calendar.updateEvent` alongside `createEvent`. + static var codeModeAliasPaths: [String] { get } + static var codeModeTitle: String { get } + static var codeModeSummary: String { get } + static var codeModeTags: [String] { get } + static var codeModeExample: String { get } + static var codeModeRequiredPermissions: [PermissionKind] { get } + static var codeModeArguments: [BuiltInToolArgument] { get } + static var codeModeResultSummary: String { get } + + func decode(arguments: [String: JSONValue]) throws -> Arguments + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue +} + +extension BuiltInCodeModeTool { + static var codeModeAliasPaths: [String] { [] } + static var codeModeRequiredPermissions: [PermissionKind] { [] } +} + +extension CapabilityRegistration { + init(tool: Tool) { + let arguments = Tool.codeModeArguments + var allowedStringValues: [String: [String]] = [:] + for argument in arguments { + if let allowed = argument.allowedValues { + allowedStringValues[argument.name] = allowed + } + } + + self.init( + jsNames: [Tool.codeModePath] + Tool.codeModeAliasPaths, + descriptor: CapabilityDescriptor( + id: Tool.codeModeCapability, + title: Tool.codeModeTitle, + summary: Tool.codeModeSummary, + tags: Tool.codeModeTags, + example: Tool.codeModeExample, + requiredPermissions: Tool.codeModeRequiredPermissions, + requiredArguments: arguments.filter { $0.optional == false }.map(\.name), + optionalArguments: arguments.filter(\.optional).map(\.name), + argumentTypes: Dictionary(uniqueKeysWithValues: arguments.map { ($0.name, $0.type) }), + argumentHints: Dictionary(uniqueKeysWithValues: arguments.map { ($0.name, $0.hint) }), + // nil keeps the central defaults table in play for tools without + // enum-typed arguments while unmigrated capabilities still rely on it. + argumentConstraints: allowedStringValues.isEmpty + ? nil + : CapabilityArgumentConstraints(allowedStringValues: allowedStringValues), + resultSummary: Tool.codeModeResultSummary + ), + handler: { rawArguments, context in + let decoded = try tool.decode(arguments: rawArguments) + return try tool.call(arguments: decoded, context: context) + } + ) + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift index ad5b4e9..07f1f6b 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift @@ -3,245 +3,15 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func calendarAndReminderRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.calendar.listEvents"], - descriptor: .init( - id: .calendarRead, - title: "Read calendar events", - summary: "List events in a date range from EventKit.", - tags: ["calendar", "eventkit", "schedule"], - example: "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })", - requiredPermissions: [.calendar], - optionalArguments: ["start", "end", "limit", "calendarIdentifier", "calendarIdentifiers"], - argumentHints: [ - "start": "ISO8601 timestamp; defaults to now.", - "end": "ISO8601 timestamp; defaults to start + 14 days.", - "limit": "Max number of items, default 50.", - "calendarIdentifier": "Optional EventKit calendarIdentifier to restrict results.", - "calendarIdentifiers": "Optional array of EventKit calendarIdentifier strings to restrict results.", - ], - resultSummary: "Array of events with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." - ), - handler: { args, context in - try eventKit.readEvents(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.createEvent", "apple.calendar.updateEvent"], - descriptor: .init( - id: .calendarWrite, - title: "Create or update calendar event", - summary: "Create a calendar event or patch an existing event by identifier.", - tags: ["calendar", "eventkit", "schedule"], - example: "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", - requiredPermissions: [], - optionalArguments: [ - "operation", - "identifier", - "title", - "start", - "end", - "notes", - "location", - "url", - "isAllDay", - "calendarIdentifier", - ], - argumentHints: [ - "operation": "create (default without identifier) or update (default with identifier).", - "identifier": "EventKit eventIdentifier to update. Updates require full calendar access at runtime.", - "title": "Event title string.", - "start": "ISO8601 start timestamp.", - "end": "ISO8601 end timestamp.", - "notes": "Optional notes/body string.", - "location": "Optional location string.", - "url": "Optional absolute URL string attached to the event.", - "isAllDay": "Whether the event is all-day.", - "calendarIdentifier": "Optional destination EventKit calendarIdentifier.", - ], - resultSummary: "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." - ), - handler: { args, context in - try eventKit.writeEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.deleteEvent"], - descriptor: .init( - id: .calendarDelete, - title: "Delete calendar event", - summary: "Delete an existing EventKit event by identifier.", - tags: ["calendar", "eventkit", "schedule", "delete"], - example: "await apple.calendar.deleteEvent({ identifier: 'EVENT_ID', span: 'thisEvent' })", - requiredPermissions: [.calendar], - requiredArguments: ["identifier"], - optionalArguments: ["span"], - argumentHints: [ - "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", - "span": "thisEvent (default) or futureEvents for recurring events.", - ], - resultSummary: "Object with identifier/deleted." - ), - handler: { args, context in - try eventKit.deleteEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.pickCalendar"], - descriptor: .init( - id: .calendarUIPickCalendar, - title: "Pick calendar with system UI", - summary: "Present EventKit calendar chooser UI and return the user-selected writable calendars.", - tags: ["calendar", "eventkit", "system-ui", "picker"], - example: "await apple.calendar.pickCalendar({ selectionStyle: 'single' })", - requiredPermissions: [.calendarWriteOnly], - optionalArguments: ["selectionStyle", "displayStyle", "timeoutMs"], - argumentTypes: [ - "selectionStyle": .string, - "displayStyle": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "selectionStyle": "single (default) or multiple.", - "displayStyle": "writable (default) or all.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Array of selected calendars with identifier/title/type/allowsContentModifications." - ), - handler: { args, context in - try systemUI.pickCalendar(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.presentEvent"], - descriptor: .init( - id: .calendarUIPresentEvent, - title: "Present calendar event details", - summary: "Present system UI for an existing calendar event identifier.", - tags: ["calendar", "eventkit", "system-ui", "details"], - example: "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })", - requiredPermissions: [.calendar], - requiredArguments: ["identifier"], - optionalArguments: ["allowsEditing", "allowsCalendarPreview", "timeoutMs"], - argumentTypes: [ - "identifier": .string, - "allowsEditing": .bool, - "allowsCalendarPreview": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", - "allowsEditing": "Whether the user can edit from the detail UI; default false.", - "allowsCalendarPreview": "Whether the UI may show calendar day previews; default true.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed." - ), - handler: { args, context in - try systemUI.presentCalendarEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.presentNewEvent"], - descriptor: .init( - id: .calendarUIPresentNewEvent, - title: "Present calendar event editor", - summary: "Present system UI to let the user create or edit a new calendar event draft.", - tags: ["calendar", "eventkit", "system-ui", "picker"], - example: "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", - requiredPermissions: [.calendarWriteOnly], - optionalArguments: ["title", "start", "end", "notes", "location", "timeoutMs"], - argumentTypes: [ - "title": .string, - "start": .string, - "end": .string, - "notes": .string, - "location": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "title": "Optional event title shown in the editor.", - "start": "Optional ISO8601 start timestamp.", - "end": "Optional ISO8601 end timestamp.", - "notes": "Optional event notes/body text.", - "location": "Optional location string.", - "timeoutMs": "Optional timeout for waiting on user save/cancel.", - ], - resultSummary: "Object with action plus identifier/title when the user saves." - ), - handler: { args, context in - try systemUI.presentNewCalendarEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.reminders.listReminders"], - descriptor: .init( - id: .remindersRead, - title: "Read reminders", - summary: "Read incomplete reminders from EventKit.", - tags: ["reminders", "eventkit", "task"], - example: "await apple.reminders.listReminders({ limit: 20 })", - requiredPermissions: [.reminders], - optionalArguments: ["start", "end", "includeCompleted", "calendarIdentifier", "calendarIdentifiers", "limit"], - argumentHints: [ - "start": "Optional ISO8601 due-date lower bound.", - "end": "Optional ISO8601 due-date upper bound.", - "includeCompleted": "Whether completed reminders are included; default false.", - "calendarIdentifier": "Optional EventKit reminder calendarIdentifier to restrict results.", - "calendarIdentifiers": "Optional array of EventKit reminder calendarIdentifier strings to restrict results.", - "limit": "Max number of reminder items, default 50.", - ], - resultSummary: "Array of reminders with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." - ), - handler: { args, context in - try eventKit.readReminders(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.reminders.createReminder", "apple.reminders.updateReminder", "apple.reminders.completeReminder"], - descriptor: .init( - id: .remindersWrite, - title: "Create or update reminder", - summary: "Create a reminder or patch an existing reminder by identifier.", - tags: ["reminders", "eventkit", "task"], - example: "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })", - requiredPermissions: [.reminders], - optionalArguments: ["operation", "identifier", "title", "dueDate", "notes", "isCompleted", "priority", "calendarIdentifier"], - argumentHints: [ - "operation": "create (default without identifier), update, or complete.", - "identifier": "EventKit calendarItemIdentifier to update or complete.", - "title": "Reminder title string.", - "dueDate": "Optional ISO8601 due date timestamp.", - "notes": "Optional reminder notes.", - "isCompleted": "Completion state for update or completeReminder; default true for completeReminder.", - "priority": "EventKit reminder priority integer.", - "calendarIdentifier": "Optional destination EventKit reminders calendarIdentifier.", - ], - resultSummary: "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." - ), - handler: { args, context in - try eventKit.writeReminder(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.reminders.deleteReminder"], - descriptor: .init( - id: .remindersDelete, - title: "Delete reminder", - summary: "Delete an existing EventKit reminder by identifier.", - tags: ["reminders", "eventkit", "task", "delete"], - example: "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })", - requiredPermissions: [.reminders], - requiredArguments: ["identifier"], - argumentHints: [ - "identifier": "EventKit calendarItemIdentifier from apple.reminders.listReminders.", - ], - resultSummary: "Object with identifier/deleted." - ), - handler: { args, context in - try eventKit.deleteReminder(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: CalendarListEventsTool(eventKit: eventKit)), + CapabilityRegistration(tool: CalendarWriteEventTool(eventKit: eventKit)), + CapabilityRegistration(tool: CalendarDeleteEventTool(eventKit: eventKit)), + CapabilityRegistration(tool: CalendarPickCalendarTool(systemUI: systemUI)), + CapabilityRegistration(tool: CalendarPresentEventTool(systemUI: systemUI)), + CapabilityRegistration(tool: CalendarPresentNewEventTool(systemUI: systemUI)), + CapabilityRegistration(tool: RemindersListTool(eventKit: eventKit)), + CapabilityRegistration(tool: RemindersWriteTool(eventKit: eventKit)), + CapabilityRegistration(tool: RemindersDeleteTool(eventKit: eventKit)), ] } } diff --git a/Sources/CodeMode/Bridges/EventKitBridge.swift b/Sources/CodeMode/Bridges/EventKitBridge.swift index b393005..279c31b 100644 --- a/Sources/CodeMode/Bridges/EventKitBridge.swift +++ b/Sources/CodeMode/Bridges/EventKitBridge.swift @@ -38,8 +38,8 @@ public final class EventKitBridge: @unchecked Sendable { } public func writeEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let operation = eventOperation(arguments) - let permission: PermissionKind = operation == "update" ? .calendar : .calendarWriteOnly + let operation = try eventOperation(arguments) + let permission: PermissionKind = operation == .update ? .calendar : .calendarWriteOnly let status = resolvePermission(permission, context: context) guard status == .granted else { throw BridgeError.permissionDenied(permission) @@ -47,12 +47,10 @@ public final class EventKitBridge: @unchecked Sendable { #if canImport(EventKit) switch operation { - case "create": + case .create: return try createEvent(arguments: arguments) - case "update": + case .update: return try updateEvent(arguments: arguments) - default: - throw BridgeError.invalidArguments("calendar.write operation must be create or update") } #else _ = arguments @@ -146,13 +144,11 @@ public final class EventKitBridge: @unchecked Sendable { } #if canImport(EventKit) - switch reminderOperation(arguments) { - case "create": + switch try reminderOperation(arguments) { + case .create: return try createReminder(arguments: arguments) - case "update", "complete": + case .update, .complete: return try updateReminder(arguments: arguments) - default: - throw BridgeError.invalidArguments("reminders.write operation must be create, update, or complete") } #else _ = arguments @@ -199,18 +195,24 @@ public final class EventKitBridge: @unchecked Sendable { return ISO8601DateFormatter().date(from: text) } - private func eventOperation(_ arguments: [String: JSONValue]) -> String { - if let operation = arguments.string("operation")?.lowercased() { + private func eventOperation(_ arguments: [String: JSONValue]) throws -> CalendarWriteOperation { + if let text = arguments.string("operation") { + guard let operation = CalendarWriteOperation.codeModeValue(matching: text) else { + throw BridgeError.invalidArguments("calendar.write operation must be one of \(CalendarWriteOperation.codeModeAllowedValues.joined(separator: ", "))") + } return operation } - return arguments.string("identifier") == nil ? "create" : "update" + return arguments.string("identifier") == nil ? .create : .update } - private func reminderOperation(_ arguments: [String: JSONValue]) -> String { - if let operation = arguments.string("operation")?.lowercased() { + private func reminderOperation(_ arguments: [String: JSONValue]) throws -> ReminderWriteOperation { + if let text = arguments.string("operation") { + guard let operation = ReminderWriteOperation.codeModeValue(matching: text) else { + throw BridgeError.invalidArguments("reminders.write operation must be one of \(ReminderWriteOperation.codeModeAllowedValues.joined(separator: ", "))") + } return operation } - return arguments.string("identifier") == nil ? "create" : "update" + return arguments.string("identifier") == nil ? .create : .update } #if canImport(EventKit) @@ -469,14 +471,13 @@ public final class EventKitBridge: @unchecked Sendable { } private func eventSpan(_ text: String?) throws -> EKSpan { - switch text?.lowercased() ?? "thisevent" { - case "thisevent", "this_event", "this": + guard let text, text.isEmpty == false else { return .thisEvent - case "futureevents", "future_events", "future": - return .futureEvents - default: - throw BridgeError.invalidArguments("calendar.delete span must be thisEvent or futureEvents") } + guard let span = CalendarEventSpan.codeModeValue(matching: text) else { + throw BridgeError.invalidArguments("calendar.delete span must be one of \(CalendarEventSpan.codeModeAllowedValues.joined(separator: ", "))") + } + return span.ekSpan } private static func eventJSON(_ event: EKEvent) -> JSONValue { diff --git a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift new file mode 100644 index 0000000..8009f14 --- /dev/null +++ b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift @@ -0,0 +1,389 @@ +import Foundation +#if canImport(EventKit) +import EventKit +#endif + +// MARK: - Constrained argument values +// +// These enums are the single source of truth for their arguments: the catalog +// advertises `codeModeAllowedValues`, tool decode and the bridges parse through +// `codeModeValue(matching:)`, so advertised and accepted spellings cannot drift. + +enum CalendarEventSpan: String, CodeModeStringEnum { + case thisEvent + case futureEvents + + static let codeModeAliases: [String: Self] = [ + "this_event": .thisEvent, + "this": .thisEvent, + "future_events": .futureEvents, + "future": .futureEvents, + ] + + #if canImport(EventKit) + var ekSpan: EKSpan { + switch self { + case .thisEvent: + return .thisEvent + case .futureEvents: + return .futureEvents + } + } + #endif +} + +enum CalendarWriteOperation: String, CodeModeStringEnum { + case create + case update +} + +enum ReminderWriteOperation: String, CodeModeStringEnum { + case create + case update + case complete +} + +enum CalendarPickerSelectionStyle: String, CodeModeStringEnum { + case single + case multiple +} + +enum CalendarPickerDisplayStyle: String, CodeModeStringEnum { + case writable + case all +} + +// MARK: - Calendar tools + +struct CalendarListEventsTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .calendarRead + static let codeModePath = "apple.calendar.listEvents" + static let codeModeTitle = "Read calendar events" + static let codeModeSummary = "List events in a date range from EventKit." + static let codeModeTags = ["calendar", "eventkit", "schedule"] + static let codeModeExample = "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] + static let codeModeArguments = [ + BuiltInToolArgument("start", .string, optional: true, hint: "ISO8601 timestamp; defaults to now."), + BuiltInToolArgument("end", .string, optional: true, hint: "ISO8601 timestamp; defaults to start + 14 days."), + BuiltInToolArgument("limit", .number, optional: true, hint: "Max number of items, default 50."), + BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional EventKit calendarIdentifier to restrict results."), + BuiltInToolArgument("calendarIdentifiers", .array, optional: true, hint: "Optional array of EventKit calendarIdentifier strings to restrict results."), + ] + static let codeModeResultSummary = "Array of events with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." + + let eventKit: EventKitBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments(raw: arguments) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.readEvents(arguments: arguments.raw, context: context) + } +} + +struct CalendarWriteEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var operation: CalendarWriteOperation? + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .calendarWrite + static let codeModePath = "apple.calendar.createEvent" + static let codeModeAliasPaths = ["apple.calendar.updateEvent"] + static let codeModeTitle = "Create or update calendar event" + static let codeModeSummary = "Create a calendar event or patch an existing event by identifier." + static let codeModeTags = ["calendar", "eventkit", "schedule"] + static let codeModeExample = "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })" + static let codeModeArguments = [ + BuiltInToolArgument("operation", oneOf: CalendarWriteOperation.self, optional: true, hint: "create (default without identifier) or update (default with identifier)."), + BuiltInToolArgument("identifier", .string, optional: true, hint: "EventKit eventIdentifier to update. Updates require full calendar access at runtime."), + BuiltInToolArgument("title", .string, optional: true, hint: "Event title string."), + BuiltInToolArgument("start", .string, optional: true, hint: "ISO8601 start timestamp."), + BuiltInToolArgument("end", .string, optional: true, hint: "ISO8601 end timestamp."), + BuiltInToolArgument("notes", .string, optional: true, hint: "Optional notes/body string."), + BuiltInToolArgument("location", .string, optional: true, hint: "Optional location string."), + BuiltInToolArgument("url", .string, optional: true, hint: "Optional absolute URL string attached to the event."), + BuiltInToolArgument("isAllDay", .bool, optional: true, hint: "Whether the event is all-day."), + BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional destination EventKit calendarIdentifier."), + ] + static let codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." + + let eventKit: EventKitBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + var raw = arguments + let operation = try CodeModeArgumentDecoder.optional("operation", as: CalendarWriteOperation.self, in: arguments) + if let operation { + raw["operation"] = .string(operation.rawValue) + } + return Arguments(operation: operation, raw: raw) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.writeEvent(arguments: arguments.raw, context: context) + } +} + +struct CalendarDeleteEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var identifier: String + var span: CalendarEventSpan? + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .calendarDelete + static let codeModePath = "apple.calendar.deleteEvent" + static let codeModeTitle = "Delete calendar event" + static let codeModeSummary = "Delete an existing EventKit event by identifier." + static let codeModeTags = ["calendar", "eventkit", "schedule", "delete"] + static let codeModeExample = "await apple.calendar.deleteEvent({ identifier: 'EVENT_ID', span: 'thisEvent' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] + static let codeModeArguments = [ + BuiltInToolArgument("identifier", .string, hint: "EventKit eventIdentifier from apple.calendar.listEvents."), + BuiltInToolArgument("span", oneOf: CalendarEventSpan.self, optional: true, hint: "thisEvent (default) or futureEvents for recurring events."), + ] + static let codeModeResultSummary = "Object with identifier/deleted." + + let eventKit: EventKitBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + var raw = arguments + let span = try CodeModeArgumentDecoder.optional("span", as: CalendarEventSpan.self, in: arguments) + if let span { + raw["span"] = .string(span.rawValue) + } + return Arguments( + identifier: try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments), + span: span, + raw: raw + ) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.deleteEvent(arguments: arguments.raw, context: context) + } +} + +struct CalendarPickCalendarTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var selectionStyle: CalendarPickerSelectionStyle? + var displayStyle: CalendarPickerDisplayStyle? + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .calendarUIPickCalendar + static let codeModePath = "apple.calendar.pickCalendar" + static let codeModeTitle = "Pick calendar with system UI" + static let codeModeSummary = "Present EventKit calendar chooser UI and return the user-selected writable calendars." + static let codeModeTags = ["calendar", "eventkit", "system-ui", "picker"] + static let codeModeExample = "await apple.calendar.pickCalendar({ selectionStyle: 'single' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] + static let codeModeArguments = [ + BuiltInToolArgument("selectionStyle", oneOf: CalendarPickerSelectionStyle.self, optional: true, hint: "single (default) or multiple."), + BuiltInToolArgument("displayStyle", oneOf: CalendarPickerDisplayStyle.self, optional: true, hint: "writable (default) or all."), + BuiltInToolArgument("timeoutMs", .number, optional: true, hint: "Optional timeout for waiting on user selection."), + ] + static let codeModeResultSummary = "Array of selected calendars with identifier/title/type/allowsContentModifications." + + let systemUI: SystemUIBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + var raw = arguments + let selectionStyle = try CodeModeArgumentDecoder.optional("selectionStyle", as: CalendarPickerSelectionStyle.self, in: arguments) + let displayStyle = try CodeModeArgumentDecoder.optional("displayStyle", as: CalendarPickerDisplayStyle.self, in: arguments) + if let selectionStyle { + raw["selectionStyle"] = .string(selectionStyle.rawValue) + } + if let displayStyle { + raw["displayStyle"] = .string(displayStyle.rawValue) + } + return Arguments(selectionStyle: selectionStyle, displayStyle: displayStyle, raw: raw) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickCalendar(arguments: arguments.raw, context: context) + } +} + +struct CalendarPresentEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var identifier: String + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .calendarUIPresentEvent + static let codeModePath = "apple.calendar.presentEvent" + static let codeModeTitle = "Present calendar event details" + static let codeModeSummary = "Present system UI for an existing calendar event identifier." + static let codeModeTags = ["calendar", "eventkit", "system-ui", "details"] + static let codeModeExample = "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] + static let codeModeArguments = [ + BuiltInToolArgument("identifier", .string, hint: "EventKit eventIdentifier from apple.calendar.listEvents."), + BuiltInToolArgument("allowsEditing", .bool, optional: true, hint: "Whether the user can edit from the detail UI; default false."), + BuiltInToolArgument("allowsCalendarPreview", .bool, optional: true, hint: "Whether the UI may show calendar day previews; default true."), + BuiltInToolArgument("timeoutMs", .number, optional: true, hint: "Optional timeout for waiting on dismissal."), + ] + static let codeModeResultSummary = "Object with action dismissed." + + let systemUI: SystemUIBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments( + identifier: try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments), + raw: arguments + ) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentCalendarEvent(arguments: arguments.raw, context: context) + } +} + +struct CalendarPresentNewEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .calendarUIPresentNewEvent + static let codeModePath = "apple.calendar.presentNewEvent" + static let codeModeTitle = "Present calendar event editor" + static let codeModeSummary = "Present system UI to let the user create or edit a new calendar event draft." + static let codeModeTags = ["calendar", "eventkit", "system-ui", "picker"] + static let codeModeExample = "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] + static let codeModeArguments = [ + BuiltInToolArgument("title", .string, optional: true, hint: "Optional event title shown in the editor."), + BuiltInToolArgument("start", .string, optional: true, hint: "Optional ISO8601 start timestamp."), + BuiltInToolArgument("end", .string, optional: true, hint: "Optional ISO8601 end timestamp."), + BuiltInToolArgument("notes", .string, optional: true, hint: "Optional event notes/body text."), + BuiltInToolArgument("location", .string, optional: true, hint: "Optional location string."), + BuiltInToolArgument("timeoutMs", .number, optional: true, hint: "Optional timeout for waiting on user save/cancel."), + ] + static let codeModeResultSummary = "Object with action plus identifier/title when the user saves." + + let systemUI: SystemUIBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments(raw: arguments) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentNewCalendarEvent(arguments: arguments.raw, context: context) + } +} + +// MARK: - Reminder tools + +struct RemindersListTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .remindersRead + static let codeModePath = "apple.reminders.listReminders" + static let codeModeTitle = "Read reminders" + static let codeModeSummary = "Read incomplete reminders from EventKit." + static let codeModeTags = ["reminders", "eventkit", "task"] + static let codeModeExample = "await apple.reminders.listReminders({ limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] + static let codeModeArguments = [ + BuiltInToolArgument("start", .string, optional: true, hint: "Optional ISO8601 due-date lower bound."), + BuiltInToolArgument("end", .string, optional: true, hint: "Optional ISO8601 due-date upper bound."), + BuiltInToolArgument("includeCompleted", .bool, optional: true, hint: "Whether completed reminders are included; default false."), + BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional EventKit reminder calendarIdentifier to restrict results."), + BuiltInToolArgument("calendarIdentifiers", .array, optional: true, hint: "Optional array of EventKit reminder calendarIdentifier strings to restrict results."), + BuiltInToolArgument("limit", .number, optional: true, hint: "Max number of reminder items, default 50."), + ] + static let codeModeResultSummary = "Array of reminders with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." + + let eventKit: EventKitBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments(raw: arguments) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.readReminders(arguments: arguments.raw, context: context) + } +} + +struct RemindersWriteTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var operation: ReminderWriteOperation? + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .remindersWrite + static let codeModePath = "apple.reminders.createReminder" + static let codeModeAliasPaths = ["apple.reminders.updateReminder", "apple.reminders.completeReminder"] + static let codeModeTitle = "Create or update reminder" + static let codeModeSummary = "Create a reminder or patch an existing reminder by identifier." + static let codeModeTags = ["reminders", "eventkit", "task"] + static let codeModeExample = "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] + static let codeModeArguments = [ + BuiltInToolArgument("operation", oneOf: ReminderWriteOperation.self, optional: true, hint: "create (default without identifier), update, or complete."), + BuiltInToolArgument("identifier", .string, optional: true, hint: "EventKit calendarItemIdentifier to update or complete."), + BuiltInToolArgument("title", .string, optional: true, hint: "Reminder title string."), + BuiltInToolArgument("dueDate", .string, optional: true, hint: "Optional ISO8601 due date timestamp."), + BuiltInToolArgument("notes", .string, optional: true, hint: "Optional reminder notes."), + BuiltInToolArgument("isCompleted", .bool, optional: true, hint: "Completion state for update or completeReminder; default true for completeReminder."), + BuiltInToolArgument("priority", .number, optional: true, hint: "EventKit reminder priority integer."), + BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional destination EventKit reminders calendarIdentifier."), + ] + static let codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." + + let eventKit: EventKitBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + var raw = arguments + let operation = try CodeModeArgumentDecoder.optional("operation", as: ReminderWriteOperation.self, in: arguments) + if let operation { + raw["operation"] = .string(operation.rawValue) + } + return Arguments(operation: operation, raw: raw) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.writeReminder(arguments: arguments.raw, context: context) + } +} + +struct RemindersDeleteTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var identifier: String + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .remindersDelete + static let codeModePath = "apple.reminders.deleteReminder" + static let codeModeTitle = "Delete reminder" + static let codeModeSummary = "Delete an existing EventKit reminder by identifier." + static let codeModeTags = ["reminders", "eventkit", "task", "delete"] + static let codeModeExample = "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] + static let codeModeArguments = [ + BuiltInToolArgument("identifier", .string, hint: "EventKit calendarItemIdentifier from apple.reminders.listReminders."), + ] + static let codeModeResultSummary = "Object with identifier/deleted." + + let eventKit: EventKitBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments( + identifier: try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments), + raw: arguments + ) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.deleteReminder(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift index b9f2592..194d853 100644 --- a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift +++ b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift @@ -4,131 +4,14 @@ protocol BuiltInCodeModeProvider: Sendable { func capabilityRegistrations() -> [CapabilityRegistration] } -private struct BuiltInToolArgument { - var name: String - var type: CapabilityArgumentType - var optional: Bool - var hint: String - - init(_ name: String, _ type: CapabilityArgumentType, optional: Bool = false, hint: String) { - self.name = name - self.type = type - self.optional = optional - self.hint = hint - } -} - -private protocol BuiltInCodeModeTool: Sendable { - associatedtype Arguments: Sendable - - static var codeModePath: String { get } - static var codeModeTitle: String { get } - static var codeModeSummary: String { get } - static var codeModeTags: [String] { get } - static var codeModeExample: String { get } - static var codeModeArguments: [BuiltInToolArgument] { get } - static var codeModeResultSummary: String { get } - - func decode(arguments: [String: JSONValue]) throws -> Arguments - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue -} - -private extension BuiltInCodeModeTool { - static var requiredArguments: [String] { - codeModeArguments.filter { $0.optional == false }.map(\.name) - } - - static var optionalArguments: [String] { - codeModeArguments.filter(\.optional).map(\.name) - } - - static var argumentTypes: [String: CapabilityArgumentType] { - Dictionary(uniqueKeysWithValues: codeModeArguments.map { ($0.name, $0.type) }) - } - - static var argumentHints: [String: String] { - Dictionary(uniqueKeysWithValues: codeModeArguments.map { ($0.name, $0.hint) }) - } - - func codeModeRegistration(capabilityKey: CodeModeCapabilityKey) -> CodeModeRegistration { - CodeModeRegistration( - capabilityKey: capabilityKey, - jsPath: Self.codeModePath, - title: Self.codeModeTitle, - summary: Self.codeModeSummary, - tags: Self.codeModeTags, - example: Self.codeModeExample, - requiredArguments: Self.requiredArguments, - optionalArguments: Self.optionalArguments, - argumentTypes: Self.argumentTypes, - argumentHints: Self.argumentHints, - resultSummary: Self.codeModeResultSummary - ) { rawArguments, context in - let decodedArguments = try decode(arguments: rawArguments) - return try call(arguments: decodedArguments, context: context) - } - } -} - -extension CapabilityRegistration { - init( - builtInCapability: CapabilityID, - jsNames: [String]? = nil, - registration: CodeModeRegistration, - requiredPermissions: [PermissionKind] = [] - ) { - self.init( - jsNames: jsNames ?? [registration.jsPath], - descriptor: CapabilityDescriptor( - id: builtInCapability, - title: registration.title, - summary: registration.summary, - tags: registration.tags, - example: registration.example, - requiredPermissions: requiredPermissions, - requiredArguments: registration.requiredArguments, - optionalArguments: registration.optionalArguments, - argumentTypes: registration.argumentTypes, - argumentHints: registration.argumentHints, - argumentConstraints: registration.argumentConstraints == .none ? nil : registration.argumentConstraints, - resultSummary: registration.resultSummary - ), - handler: registration.handler - ) - } - - fileprivate init( - builtInCapability: CapabilityID, - jsNames: [String]? = nil, - tool: Tool, - requiredPermissions: [PermissionKind] = [] - ) { - self.init( - builtInCapability: builtInCapability, - jsNames: jsNames, - registration: tool.codeModeRegistration(capabilityKey: builtInCapability.codeModeKey), - requiredPermissions: requiredPermissions - ) - } -} - struct KeychainCodeModeBuiltIns: BuiltInCodeModeProvider { let keychain: KeychainBridge func capabilityRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - builtInCapability: .keychainRead, - tool: KeychainReadTool(keychain: keychain) - ), - CapabilityRegistration( - builtInCapability: .keychainWrite, - tool: KeychainWriteTool(keychain: keychain) - ), - CapabilityRegistration( - builtInCapability: .keychainDelete, - tool: KeychainDeleteTool(keychain: keychain) - ), + CapabilityRegistration(tool: KeychainReadTool(keychain: keychain)), + CapabilityRegistration(tool: KeychainWriteTool(keychain: keychain)), + CapabilityRegistration(tool: KeychainDeleteTool(keychain: keychain)), ] } } @@ -139,34 +22,9 @@ struct LocationWeatherCodeModeBuiltIns: BuiltInCodeModeProvider { func capabilityRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - builtInCapability: .locationRead, - jsNames: ["apple.location.getPermissionStatus", "apple.location.getCurrentPosition"], - registration: CodeModeRegistration( - capabilityKey: CapabilityID.locationRead.codeModeKey, - jsPath: "apple.location.getCurrentPosition", - title: "Read location state or coordinates", - summary: "Read location permission status or current coordinates.", - tags: ["location", "permission", "geospatial"], - example: "await apple.location.getCurrentPosition()", - optionalArguments: ["mode"], - argumentTypes: ["mode": .string], - argumentHints: [ - "mode": "permissionStatus or current (default current).", - ], - resultSummary: "Permission status string or coordinates object." - ) { args, context in - try location.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - builtInCapability: .locationPermissionRequest, - tool: LocationPermissionRequestTool(location: location) - ), - CapabilityRegistration( - builtInCapability: .weatherRead, - tool: WeatherReadTool(weather: weather) - ), + CapabilityRegistration(tool: LocationReadTool(location: location)), + CapabilityRegistration(tool: LocationPermissionRequestTool(location: location)), + CapabilityRegistration(tool: WeatherReadTool(weather: weather)), ] } } @@ -176,6 +34,7 @@ private struct KeychainReadTool: BuiltInCodeModeTool { var key: String } + static let codeModeCapability: CapabilityID = .keychainRead static let codeModePath = "apple.keychain.get" static let codeModeTitle = "Read Keychain value" static let codeModeSummary = "Read a string value from app-scoped Keychain storage." @@ -203,6 +62,7 @@ private struct KeychainWriteTool: BuiltInCodeModeTool { var value: String? } + static let codeModeCapability: CapabilityID = .keychainWrite static let codeModePath = "apple.keychain.set" static let codeModeTitle = "Write Keychain value" static let codeModeSummary = "Store or update a string value in app-scoped Keychain storage." @@ -237,6 +97,7 @@ private struct KeychainDeleteTool: BuiltInCodeModeTool { var key: String } + static let codeModeCapability: CapabilityID = .keychainDelete static let codeModePath = "apple.keychain.delete" static let codeModeTitle = "Delete Keychain value" static let codeModeSummary = "Delete an app-scoped Keychain value." @@ -258,9 +119,42 @@ private struct KeychainDeleteTool: BuiltInCodeModeTool { } } +private struct LocationReadTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var mode: String? + } + + static let codeModeCapability: CapabilityID = .locationRead + static let codeModePath = "apple.location.getCurrentPosition" + static let codeModeAliasPaths = ["apple.location.getPermissionStatus"] + static let codeModeTitle = "Read location state or coordinates" + static let codeModeSummary = "Read location permission status or current coordinates." + static let codeModeTags = ["location", "permission", "geospatial"] + static let codeModeExample = "await apple.location.getCurrentPosition()" + static let codeModeArguments = [ + BuiltInToolArgument("mode", .string, optional: true, hint: "permissionStatus or current (default current)."), + ] + static let codeModeResultSummary = "Permission status string or coordinates object." + + let location: LocationBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments(mode: try CodeModeArgumentDecoder.optional("mode", as: String.self, in: arguments)) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + var payload: [String: JSONValue] = [:] + if let mode = arguments.mode { + payload["mode"] = .string(mode) + } + return try location.read(arguments: payload, context: context) + } +} + private struct LocationPermissionRequestTool: BuiltInCodeModeTool { struct Arguments: Sendable {} + static let codeModeCapability: CapabilityID = .locationPermissionRequest static let codeModePath = "apple.location.requestPermission" static let codeModeTitle = "Request location permission" static let codeModeSummary = "Trigger location when-in-use permission request flow." @@ -286,6 +180,7 @@ private struct WeatherReadTool: BuiltInCodeModeTool { var longitude: Double } + static let codeModeCapability: CapabilityID = .weatherRead static let codeModePath = "apple.weather.getCurrentWeather" static let codeModeTitle = "Read WeatherKit weather" static let codeModeSummary = "Fetch current weather for a latitude/longitude pair." diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift index 77207e9..35dbbd6 100644 --- a/Sources/CodeMode/Bridges/SystemUIBridge.swift +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -172,16 +172,16 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateCalendarPickerArguments(_ arguments: [String: JSONValue]) throws { - if let selectionStyle = arguments.string("selectionStyle")?.lowercased(), - ["single", "multiple"].contains(selectionStyle) == false + if let selectionStyle = arguments.string("selectionStyle"), + CalendarPickerSelectionStyle.codeModeValue(matching: selectionStyle) == nil { - throw BridgeError.invalidArguments("calendar.ui.pickCalendar selectionStyle must be single or multiple") + throw BridgeError.invalidArguments("calendar.ui.pickCalendar selectionStyle must be one of \(CalendarPickerSelectionStyle.codeModeAllowedValues.joined(separator: ", "))") } - if let displayStyle = arguments.string("displayStyle")?.lowercased(), - ["writable", "all"].contains(displayStyle) == false + if let displayStyle = arguments.string("displayStyle"), + CalendarPickerDisplayStyle.codeModeValue(matching: displayStyle) == nil { - throw BridgeError.invalidArguments("calendar.ui.pickCalendar displayStyle must be writable or all") + throw BridgeError.invalidArguments("calendar.ui.pickCalendar displayStyle must be one of \(CalendarPickerDisplayStyle.codeModeAllowedValues.joined(separator: ", "))") } try validateTimeoutMs(arguments, capability: "calendar.ui.pickCalendar") diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 9b97ecb..526e689 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -48,23 +48,9 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { return .init(allowedStringValues: [ "options.responseEncoding": ["text", "base64"], ]) - case .calendarWrite: - return .init(allowedStringValues: [ - "operation": ["create", "update"], - ]) - case .calendarDelete: - return .init(allowedStringValues: [ - "span": ["thisEvent", "futureEvents", "this_event", "future_events", "this", "future"], - ]) - case .calendarUIPickCalendar: - return .init(allowedStringValues: [ - "selectionStyle": ["single", "multiple"], - "displayStyle": ["writable", "all"], - ]) - case .remindersWrite: - return .init(allowedStringValues: [ - "operation": ["create", "update", "complete"], - ]) + // calendarWrite / calendarDelete / calendarUIPickCalendar / remindersWrite + // constraints now come from their tools' CodeModeStringEnum arguments + // (EventKitCodeModeTools.swift), not this table. case .photosRead: return .init(allowedStringValues: [ "mediaType": ["any", "image", "photo", "video"], diff --git a/TODO.md b/TODO.md index eebd48a..b6db939 100644 --- a/TODO.md +++ b/TODO.md @@ -104,15 +104,29 @@ just the watchdog: ## STRUCTURAL IMPROVEMENTS (bridge/registry metadata drift) Biggest maintenance risk: ~115 capabilities / ~2,540 lines of hand-written -registrations with four parallel sources of truth. None addressed yet. -- [ ] Move argument types/constraints to per-capability, co-located declarations; - **fail loudly instead of degrading unknown args to `.any`** - (`CapabilityRegistry.swift:223-398`). -- [ ] Add a test asserting registration metadata matches bridge reality - (allowed-string sets vs what bridges accept; golden-test `resultSummary` - against bridge JSON encoders) — would have caught the calendar-span drift. -- [ ] Standardize on one registration idiom (three coexist across - `CapabilityRegistrations+*.swift`). +registrations with four parallel sources of truth. Phase 1 of +`PLAN-registration-macros.md` landed 2026-07-11 (owner approved swift-syntax +in the core graph, so Phase 2 is unblocked): +- [/] Move argument types/constraints to per-capability, co-located declarations + — `BuiltInCodeModeTool` protocol + `CodeModeStringEnum` (constrained string + args declared once: advertised values, decode, and bridge parsing all come + from the enum). EventKit migrated (9 registrations, 5 enums); the + calendarWrite/calendarDelete/calendarUIPickCalendar/remindersWrite rows are + deleted from the central constraint table. Remaining: 7 registration files. + - [ ] **fail loudly instead of degrading unknown args to `.any`** + (`inferArgumentTypes`) — still pending; goes away as domains migrate. +- [/] Add a test asserting registration metadata matches bridge reality — + `constrainedArgumentMetadataIsCoherentForAllRegistrations` (every constrained + arg must be declared) + span test now pins enum↔descriptor↔bridge agreement. + Golden-testing `resultSummary` against bridge JSON encoders still open. +- [/] Standardize on one registration idiom — target idiom is + `BuiltInCodeModeTool`; EventKit, Keychain, Location/Weather converged + (the raw-`CodeModeRegistration` and `builtInCapability:` glue idioms are + deleted). 7 `CapabilityRegistrations+*.swift` files still on the flat init. +- [ ] **Fifth metadata surface found during migration:** the hand-written JS + function table in `RuntimeJavaScript.swift` (e.g. `completeReminder` injects + `operation: 'complete', isCompleted: true`). Candidate for generation from + registrations in Phase 3. - [ ] Unify permission ownership — `calendarRead` checks in both registry and bridge; `calendarWrite` checks only in the bridge. - [/] Decide the fate of `Tools/CodeModeAuthoring` — investigated 2026-07-11; diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 7aafb85..f286737 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -522,24 +522,52 @@ private func jsNames(for capability: CapabilityID) -> [String] { } @Test func calendarDeleteSpanConstraintAcceptsEverySpellingTheBridgeAccepts() throws { - let constraints = CapabilityArgumentConstraints.defaults(for: .calendarDelete) - - // Keep in sync with EventKitBridge.eventSpan, which lowercases its input - // and accepts these aliases. - let bridgeAcceptedSpellings = [ - "thisEvent", "thisevent", "this_event", "this", - "futureEvents", "futureevents", "future_events", "future", - ] - for spelling in bridgeAcceptedSpellings { + // The constraint now comes from CalendarEventSpan on the tool; the bridge + // parses through the same enum, so advertised and accepted cannot drift. + let registration = try #require( + DefaultCapabilityLoader.loadAllRegistrations().first { $0.descriptor.id == .calendarDelete } + ) + let constraints = registration.descriptor.argumentConstraints + let advertised = try #require(constraints.allowedStringValues["span"]) + #expect(advertised.contains("thisEvent")) + #expect(advertised.contains("futureEvents")) + #expect(advertised.contains("this_event")) + #expect(advertised.contains("future")) + + for spelling in advertised { try constraints.validate( arguments: ["span": .string(spelling)], capabilityName: "calendar.delete" ) + #expect(CalendarEventSpan.codeModeValue(matching: spelling) != nil) + #expect(CalendarEventSpan.codeModeValue(matching: spelling.uppercased()) != nil) } #expect(throws: (any Error).self) { try constraints.validate(arguments: ["span": .string("allEvents")], capabilityName: "calendar.delete") } + #expect(CalendarEventSpan.codeModeValue(matching: "allEvents") == nil) +} + +@Test func constrainedArgumentMetadataIsCoherentForAllRegistrations() { + // Every constrained top-level argument a registration advertises must be a + // declared argument of that registration — catches metadata typos and + // constraint entries that outlive a renamed argument. + for registration in DefaultCapabilityLoader.loadAllRegistrations() { + let descriptor = registration.descriptor + let declared = Set(descriptor.requiredArguments + descriptor.optionalArguments) + for (path, allowed) in descriptor.argumentConstraints.allowedStringValues { + #expect( + allowed.isEmpty == false, + "\(descriptor.id.rawValue) advertises an empty allowed-value list for \(path)" + ) + guard path.contains(".") == false else { continue } + #expect( + declared.contains(path), + "\(descriptor.id.rawValue) constrains '\(path)' but does not declare it as an argument" + ) + } + } } @Test func registryValidationRejectsUnknownArguments() throws { From c45fb105f0c36ecf49235e35fc6bfdf392f256c4 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 16:31:58 -0700 Subject: [PATCH 16/25] Phase 2: @BuiltInCodeMode macro generates tool decode and metadata Folds Tools/CodeModeAuthoring into the root package: CodeModeMacros becomes a macro target of the main package (swift-syntax 601.0.1, prebuilts default-on; eval CLI cold build measured at 29s) and the host-facing @CodeMode surface ships as the CodeModeAuthoring product. The same compiler plugin now also provides the internal @BuiltInCodeMode + @ToolParam macros. @BuiltInCodeMode generates the mechanical members of a BuiltInCodeModeTool from its Arguments struct: identity statics, the codeModeArguments metadata list, and decode(arguments:). Curated prose, permissions, and call(arguments:context:) stay hand-written. Field types the macro does not recognize as JSON primitives are emitted through CodeModeStringEnum- constrained overloads, so the compiler enforces the semantics on the expansion and constraint metadata derives from the enum. A trailing un-annotated raw: [String: JSONValue] receives the canonicalized passthrough transitional tools need while bridges consume raw dictionaries. The EventKit domain is converted to the macro (its hand-written decode and argument lists are deleted). Adds five expansion tests (generated source + diagnostics), behavioral decode tests, and a metadata baseline test pinning the advertised surface of remindersWrite. CI xcodebuild jobs get -skipMacroValidation for the package-local plugin. 229 tests pass. --- .github/workflows/codemode-evals.yml | 3 + PLAN-registration-macros.md | 24 +- .../Package.resolved => Package.resolved | 2 +- Package.swift | 35 +- .../Bridges/BuiltInCodeModeMacros.swift | 43 +++ .../Bridges/EventKitCodeModeTools.swift | 332 +++++++----------- .../CodeModeAuthoring/CodeModeMacros.swift | 0 .../CodeModeAuthoring/README.md | 7 +- .../CodeModeMacros/BuiltInCodeModeMacro.swift | 310 ++++++++++++++++ .../CodeModeMacros/CodeModeMacro.swift | 2 + TODO.md | 13 +- .../BuiltInCodeModeMacroExpansionTests.swift | 191 ++++++++++ .../CodeModeMacroExpansionTests.swift | 0 .../BuiltInCodeModeToolTests.swift | 50 +++ Tools/CodeModeAuthoring/Package.swift | 49 --- Tools/CodeModeEval/Package.resolved | 9 + 16 files changed, 808 insertions(+), 262 deletions(-) rename Tools/CodeModeAuthoring/Package.resolved => Package.resolved (78%) create mode 100644 Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift rename {Tools/CodeModeAuthoring/Sources => Sources}/CodeModeAuthoring/CodeModeMacros.swift (100%) rename {Tools => Sources}/CodeModeAuthoring/README.md (82%) create mode 100644 Sources/CodeModeMacros/BuiltInCodeModeMacro.swift rename {Tools/CodeModeAuthoring/Sources => Sources}/CodeModeMacros/CodeModeMacro.swift (99%) create mode 100644 Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift rename {Tools/CodeModeAuthoring/Tests => Tests}/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift (100%) create mode 100644 Tests/CodeModeTests/BuiltInCodeModeToolTests.swift delete mode 100644 Tools/CodeModeAuthoring/Package.swift diff --git a/.github/workflows/codemode-evals.yml b/.github/workflows/codemode-evals.yml index 9bb078c..fe949a2 100644 --- a/.github/workflows/codemode-evals.yml +++ b/.github/workflows/codemode-evals.yml @@ -81,12 +81,15 @@ jobs: - name: List package schemes run: xcodebuild -list -json | tee schemes.json + # -skipMacroValidation: the package builds its own CodeModeMacros compiler + # plugin, and xcodebuild refuses unvalidated macros in noninteractive runs. - name: Build CodeMode for ${{ matrix.platform }} run: | set -o pipefail xcodebuild build \ -scheme CodeMode \ -destination '${{ matrix.destination }}' \ + -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO llm-plan: diff --git a/PLAN-registration-macros.md b/PLAN-registration-macros.md index 608a57e..899efa3 100644 --- a/PLAN-registration-macros.md +++ b/PLAN-registration-macros.md @@ -14,13 +14,23 @@ rejected.** Phase 1 landed the same day: `BuiltInCodeModeTool` + `CodeModeStringEnum` shipped, EventKit/Keychain/Location/Weather converged on the idiom, the four EventKit-domain rows are gone from the central constraint table, and the -coherence test guards the remainder. Two Phase-1 notes for Phase 2: -(1) transitional tools carry a `raw: [String: JSONValue]` passthrough because -bridges still consume raw dictionaries — the macro design should keep -generating that until a domain's bridge goes fully typed (Phase 3); -(2) a fifth metadata surface surfaced during migration — the hand-written JS -function table in `RuntimeJavaScript.swift` — worth folding into Phase 3 as -"generate the JS shim table from registrations". +coherence test guards the remainder. + +**Phase 2 landed 2026-07-11 as well.** `CodeModeMacros` moved into the root +package (Tools/CodeModeAuthoring is now the `CodeModeAuthoring` product); +`@BuiltInCodeMode` + `@ToolParam` generate the identity statics, +`codeModeArguments`, and `decode(arguments:)` from the `Arguments` struct. The +EventKit domain is macro-authored. Design choices that differ slightly from +the sketch below: the macro treats unrecognized field types as +`CodeModeStringEnum` and emits them through constrained overloads +(`BuiltInToolArgument(_:oneOf:)`, `CodeModeToolRawArguments.canonicalize`), so +the *compiler* enforces the semantics on the expansion rather than the macro +guessing; and the transitional `raw: [String: JSONValue]` passthrough is a +convention the macro fills (canonicalized) when declared as the last stored +property — it disappears per-domain in Phase 3 when a bridge goes typed. +Remaining Phase-2 notes: the fifth metadata surface (hand-written JS function +table in `RuntimeJavaScript.swift`) is still a Phase-3 item, and the +authoring-facing `@CodeMode` has not yet gained enum-constraint support. --- diff --git a/Tools/CodeModeAuthoring/Package.resolved b/Package.resolved similarity index 78% rename from Tools/CodeModeAuthoring/Package.resolved rename to Package.resolved index b4dc611..12ea695 100644 --- a/Tools/CodeModeAuthoring/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b74d116a7a0a89a95f1429d15cd7d38a7d0658cca5bffcf3e4d4a90e195a6be1", + "originHash" : "64f55de85e09a0b764151796f67abf830d4994a2851694e5ec36adae193822d0", "pins" : [ { "identity" : "swift-syntax", diff --git a/Package.swift b/Package.swift index 94c21c1..f34b1f1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ // swift-tools-version: 6.1 import PackageDescription +import CompilerPluginSupport let package = Package( name: "CodeMode", @@ -13,11 +14,21 @@ let package = Package( name: "CodeMode", targets: ["CodeMode"] ), + .library( + name: "CodeModeAuthoring", + targets: ["CodeModeAuthoring"] + ), .library( name: "CodeModeEvaluation", targets: ["CodeModeEvaluation"] ), ], + dependencies: [ + // Used by the CodeModeMacros compiler plugin only; consumers get the + // prebuilt swift-syntax libraries on current toolchains (measured cost + // in PLAN-registration-macros.md). + .package(url: "https://github.com/swiftlang/swift-syntax.git", exact: "601.0.1"), + ], targets: [ .target( name: "CCodeModeJSC", @@ -25,9 +36,23 @@ let package = Package( .linkedFramework("JavaScriptCore") ] ), + .macro( + name: "CodeModeMacros", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), .target( name: "CodeMode", - dependencies: ["CCodeModeJSC"] + dependencies: ["CCodeModeJSC", "CodeModeMacros"] + ), + .target( + name: "CodeModeAuthoring", + dependencies: ["CodeMode", "CodeModeMacros"] ), .target( name: "CodeModeEvaluation", @@ -37,6 +62,14 @@ let package = Package( name: "CodeModeTests", dependencies: ["CodeMode"] ), + .testTarget( + name: "CodeModeAuthoringTests", + dependencies: [ + "CodeModeAuthoring", + "CodeModeMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), .testTarget( name: "CodeModeEvalTests", dependencies: ["CodeModeEvaluation"] diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift new file mode 100644 index 0000000..a2f1311 --- /dev/null +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Generates the mechanical members of a `BuiltInCodeModeTool` — the identity +/// statics, `codeModeArguments`, and `decode(arguments:)` — from the nested +/// `Arguments` struct. Curated metadata (title, summary, tags, example, +/// permissions, result summary) and `call(arguments:context:)` stay +/// hand-written. +/// +/// Annotate each argument property with `@ToolParam("hint")`. Property types +/// may be JSON primitives (`String`, `Bool`, `Int`, `Double`, `[JSONValue]`, +/// `[String: JSONValue]`, `JSONValue`, optionals thereof) or a +/// `CodeModeStringEnum`, whose accepted values become the argument's +/// constraint metadata. A trailing un-annotated `var raw: [String: JSONValue]` +/// receives the full argument dictionary with constrained values canonicalized +/// — the passthrough transitional tools use while bridges consume raw +/// dictionaries. +@attached(member, names: named(codeModeCapability), named(codeModePath), named(codeModeAliasPaths), named(codeModeArguments), named(decode)) +macro BuiltInCodeMode( + _ capability: CapabilityID, + path: String, + aliases: [String] = [] +) = #externalMacro(module: "CodeModeMacros", type: "BuiltInCodeModeMacro") + +/// Marks one argument property of a `@BuiltInCodeMode` tool and carries its +/// LLM-facing hint. +@attached(peer) +macro ToolParam(_ hint: String) = #externalMacro(module: "CodeModeMacros", type: "ToolParamMacro") + +/// Helpers referenced by `@BuiltInCodeMode`-generated `decode` implementations. +enum CodeModeToolRawArguments { + /// Replaces a constrained value in the raw passthrough dictionary with its + /// canonical spelling, so bridges parsing the raw dictionary see exactly + /// the advertised form. + static func canonicalize(_ raw: inout [String: JSONValue], _ name: String, _ value: Value?) { + if let value { + raw[name] = .string(value.rawValue) + } + } + + static func canonicalize(_ raw: inout [String: JSONValue], _ name: String, _ value: Value) { + raw[name] = .string(value.rawValue) + } +} diff --git a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift index 8009f14..788106e 100644 --- a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift @@ -55,225 +55,180 @@ enum CalendarPickerDisplayStyle: String, CodeModeStringEnum { // MARK: - Calendar tools +@BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") struct CalendarListEventsTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .calendarRead - static let codeModePath = "apple.calendar.listEvents" static let codeModeTitle = "Read calendar events" static let codeModeSummary = "List events in a date range from EventKit." static let codeModeTags = ["calendar", "eventkit", "schedule"] static let codeModeExample = "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })" static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] - static let codeModeArguments = [ - BuiltInToolArgument("start", .string, optional: true, hint: "ISO8601 timestamp; defaults to now."), - BuiltInToolArgument("end", .string, optional: true, hint: "ISO8601 timestamp; defaults to start + 14 days."), - BuiltInToolArgument("limit", .number, optional: true, hint: "Max number of items, default 50."), - BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional EventKit calendarIdentifier to restrict results."), - BuiltInToolArgument("calendarIdentifiers", .array, optional: true, hint: "Optional array of EventKit calendarIdentifier strings to restrict results."), - ] static let codeModeResultSummary = "Array of events with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." - let eventKit: EventKitBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - Arguments(raw: arguments) + struct Arguments: Sendable { + @ToolParam("ISO8601 timestamp; defaults to now.") + var start: String? + @ToolParam("ISO8601 timestamp; defaults to start + 14 days.") + var end: String? + @ToolParam("Max number of items, default 50.") + var limit: Int? + @ToolParam("Optional EventKit calendarIdentifier to restrict results.") + var calendarIdentifier: String? + @ToolParam("Optional array of EventKit calendarIdentifier strings to restrict results.") + var calendarIdentifiers: [String]? + var raw: [String: JSONValue] } + let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try eventKit.readEvents(arguments: arguments.raw, context: context) } } +@BuiltInCodeMode(.calendarWrite, path: "apple.calendar.createEvent", aliases: ["apple.calendar.updateEvent"]) struct CalendarWriteEventTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var operation: CalendarWriteOperation? - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .calendarWrite - static let codeModePath = "apple.calendar.createEvent" - static let codeModeAliasPaths = ["apple.calendar.updateEvent"] static let codeModeTitle = "Create or update calendar event" static let codeModeSummary = "Create a calendar event or patch an existing event by identifier." static let codeModeTags = ["calendar", "eventkit", "schedule"] static let codeModeExample = "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })" - static let codeModeArguments = [ - BuiltInToolArgument("operation", oneOf: CalendarWriteOperation.self, optional: true, hint: "create (default without identifier) or update (default with identifier)."), - BuiltInToolArgument("identifier", .string, optional: true, hint: "EventKit eventIdentifier to update. Updates require full calendar access at runtime."), - BuiltInToolArgument("title", .string, optional: true, hint: "Event title string."), - BuiltInToolArgument("start", .string, optional: true, hint: "ISO8601 start timestamp."), - BuiltInToolArgument("end", .string, optional: true, hint: "ISO8601 end timestamp."), - BuiltInToolArgument("notes", .string, optional: true, hint: "Optional notes/body string."), - BuiltInToolArgument("location", .string, optional: true, hint: "Optional location string."), - BuiltInToolArgument("url", .string, optional: true, hint: "Optional absolute URL string attached to the event."), - BuiltInToolArgument("isAllDay", .bool, optional: true, hint: "Whether the event is all-day."), - BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional destination EventKit calendarIdentifier."), - ] static let codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." - let eventKit: EventKitBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - var raw = arguments - let operation = try CodeModeArgumentDecoder.optional("operation", as: CalendarWriteOperation.self, in: arguments) - if let operation { - raw["operation"] = .string(operation.rawValue) - } - return Arguments(operation: operation, raw: raw) + struct Arguments: Sendable { + @ToolParam("create (default without identifier) or update (default with identifier).") + var operation: CalendarWriteOperation? + @ToolParam("EventKit eventIdentifier to update. Updates require full calendar access at runtime.") + var identifier: String? + @ToolParam("Event title string.") + var title: String? + @ToolParam("ISO8601 start timestamp.") + var start: String? + @ToolParam("ISO8601 end timestamp.") + var end: String? + @ToolParam("Optional notes/body string.") + var notes: String? + @ToolParam("Optional location string.") + var location: String? + @ToolParam("Optional absolute URL string attached to the event.") + var url: String? + @ToolParam("Whether the event is all-day.") + var isAllDay: Bool? + @ToolParam("Optional destination EventKit calendarIdentifier.") + var calendarIdentifier: String? + var raw: [String: JSONValue] } + let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try eventKit.writeEvent(arguments: arguments.raw, context: context) } } +@BuiltInCodeMode(.calendarDelete, path: "apple.calendar.deleteEvent") struct CalendarDeleteEventTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var identifier: String - var span: CalendarEventSpan? - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .calendarDelete - static let codeModePath = "apple.calendar.deleteEvent" static let codeModeTitle = "Delete calendar event" static let codeModeSummary = "Delete an existing EventKit event by identifier." static let codeModeTags = ["calendar", "eventkit", "schedule", "delete"] static let codeModeExample = "await apple.calendar.deleteEvent({ identifier: 'EVENT_ID', span: 'thisEvent' })" static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] - static let codeModeArguments = [ - BuiltInToolArgument("identifier", .string, hint: "EventKit eventIdentifier from apple.calendar.listEvents."), - BuiltInToolArgument("span", oneOf: CalendarEventSpan.self, optional: true, hint: "thisEvent (default) or futureEvents for recurring events."), - ] static let codeModeResultSummary = "Object with identifier/deleted." - let eventKit: EventKitBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - var raw = arguments - let span = try CodeModeArgumentDecoder.optional("span", as: CalendarEventSpan.self, in: arguments) - if let span { - raw["span"] = .string(span.rawValue) - } - return Arguments( - identifier: try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments), - span: span, - raw: raw - ) + struct Arguments: Sendable { + @ToolParam("EventKit eventIdentifier from apple.calendar.listEvents.") + var identifier: String + @ToolParam("thisEvent (default) or futureEvents for recurring events.") + var span: CalendarEventSpan? + var raw: [String: JSONValue] } + let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try eventKit.deleteEvent(arguments: arguments.raw, context: context) } } +@BuiltInCodeMode(.calendarUIPickCalendar, path: "apple.calendar.pickCalendar") struct CalendarPickCalendarTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var selectionStyle: CalendarPickerSelectionStyle? - var displayStyle: CalendarPickerDisplayStyle? - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .calendarUIPickCalendar - static let codeModePath = "apple.calendar.pickCalendar" static let codeModeTitle = "Pick calendar with system UI" static let codeModeSummary = "Present EventKit calendar chooser UI and return the user-selected writable calendars." static let codeModeTags = ["calendar", "eventkit", "system-ui", "picker"] static let codeModeExample = "await apple.calendar.pickCalendar({ selectionStyle: 'single' })" static let codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] - static let codeModeArguments = [ - BuiltInToolArgument("selectionStyle", oneOf: CalendarPickerSelectionStyle.self, optional: true, hint: "single (default) or multiple."), - BuiltInToolArgument("displayStyle", oneOf: CalendarPickerDisplayStyle.self, optional: true, hint: "writable (default) or all."), - BuiltInToolArgument("timeoutMs", .number, optional: true, hint: "Optional timeout for waiting on user selection."), - ] static let codeModeResultSummary = "Array of selected calendars with identifier/title/type/allowsContentModifications." - let systemUI: SystemUIBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - var raw = arguments - let selectionStyle = try CodeModeArgumentDecoder.optional("selectionStyle", as: CalendarPickerSelectionStyle.self, in: arguments) - let displayStyle = try CodeModeArgumentDecoder.optional("displayStyle", as: CalendarPickerDisplayStyle.self, in: arguments) - if let selectionStyle { - raw["selectionStyle"] = .string(selectionStyle.rawValue) - } - if let displayStyle { - raw["displayStyle"] = .string(displayStyle.rawValue) - } - return Arguments(selectionStyle: selectionStyle, displayStyle: displayStyle, raw: raw) + struct Arguments: Sendable { + @ToolParam("single (default) or multiple.") + var selectionStyle: CalendarPickerSelectionStyle? + @ToolParam("writable (default) or all.") + var displayStyle: CalendarPickerDisplayStyle? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] } + let systemUI: SystemUIBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try systemUI.pickCalendar(arguments: arguments.raw, context: context) } } +@BuiltInCodeMode(.calendarUIPresentEvent, path: "apple.calendar.presentEvent") struct CalendarPresentEventTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var identifier: String - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .calendarUIPresentEvent - static let codeModePath = "apple.calendar.presentEvent" static let codeModeTitle = "Present calendar event details" static let codeModeSummary = "Present system UI for an existing calendar event identifier." static let codeModeTags = ["calendar", "eventkit", "system-ui", "details"] static let codeModeExample = "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })" static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] - static let codeModeArguments = [ - BuiltInToolArgument("identifier", .string, hint: "EventKit eventIdentifier from apple.calendar.listEvents."), - BuiltInToolArgument("allowsEditing", .bool, optional: true, hint: "Whether the user can edit from the detail UI; default false."), - BuiltInToolArgument("allowsCalendarPreview", .bool, optional: true, hint: "Whether the UI may show calendar day previews; default true."), - BuiltInToolArgument("timeoutMs", .number, optional: true, hint: "Optional timeout for waiting on dismissal."), - ] static let codeModeResultSummary = "Object with action dismissed." - let systemUI: SystemUIBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - Arguments( - identifier: try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments), - raw: arguments - ) + struct Arguments: Sendable { + @ToolParam("EventKit eventIdentifier from apple.calendar.listEvents.") + var identifier: String + @ToolParam("Whether the user can edit from the detail UI; default false.") + var allowsEditing: Bool? + @ToolParam("Whether the UI may show calendar day previews; default true.") + var allowsCalendarPreview: Bool? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] } + let systemUI: SystemUIBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try systemUI.presentCalendarEvent(arguments: arguments.raw, context: context) } } +@BuiltInCodeMode(.calendarUIPresentNewEvent, path: "apple.calendar.presentNewEvent") struct CalendarPresentNewEventTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .calendarUIPresentNewEvent - static let codeModePath = "apple.calendar.presentNewEvent" static let codeModeTitle = "Present calendar event editor" static let codeModeSummary = "Present system UI to let the user create or edit a new calendar event draft." static let codeModeTags = ["calendar", "eventkit", "system-ui", "picker"] static let codeModeExample = "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })" static let codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] - static let codeModeArguments = [ - BuiltInToolArgument("title", .string, optional: true, hint: "Optional event title shown in the editor."), - BuiltInToolArgument("start", .string, optional: true, hint: "Optional ISO8601 start timestamp."), - BuiltInToolArgument("end", .string, optional: true, hint: "Optional ISO8601 end timestamp."), - BuiltInToolArgument("notes", .string, optional: true, hint: "Optional event notes/body text."), - BuiltInToolArgument("location", .string, optional: true, hint: "Optional location string."), - BuiltInToolArgument("timeoutMs", .number, optional: true, hint: "Optional timeout for waiting on user save/cancel."), - ] static let codeModeResultSummary = "Object with action plus identifier/title when the user saves." - let systemUI: SystemUIBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - Arguments(raw: arguments) + struct Arguments: Sendable { + @ToolParam("Optional event title shown in the editor.") + var title: String? + @ToolParam("Optional ISO8601 start timestamp.") + var start: String? + @ToolParam("Optional ISO8601 end timestamp.") + var end: String? + @ToolParam("Optional event notes/body text.") + var notes: String? + @ToolParam("Optional location string.") + var location: String? + @ToolParam("Optional timeout for waiting on user save/cancel.") + var timeoutMs: Double? + var raw: [String: JSONValue] } + let systemUI: SystemUIBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try systemUI.presentNewCalendarEvent(arguments: arguments.raw, context: context) } @@ -281,108 +236,91 @@ struct CalendarPresentNewEventTool: BuiltInCodeModeTool { // MARK: - Reminder tools +@BuiltInCodeMode(.remindersRead, path: "apple.reminders.listReminders") struct RemindersListTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .remindersRead - static let codeModePath = "apple.reminders.listReminders" static let codeModeTitle = "Read reminders" static let codeModeSummary = "Read incomplete reminders from EventKit." static let codeModeTags = ["reminders", "eventkit", "task"] static let codeModeExample = "await apple.reminders.listReminders({ limit: 20 })" static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] - static let codeModeArguments = [ - BuiltInToolArgument("start", .string, optional: true, hint: "Optional ISO8601 due-date lower bound."), - BuiltInToolArgument("end", .string, optional: true, hint: "Optional ISO8601 due-date upper bound."), - BuiltInToolArgument("includeCompleted", .bool, optional: true, hint: "Whether completed reminders are included; default false."), - BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional EventKit reminder calendarIdentifier to restrict results."), - BuiltInToolArgument("calendarIdentifiers", .array, optional: true, hint: "Optional array of EventKit reminder calendarIdentifier strings to restrict results."), - BuiltInToolArgument("limit", .number, optional: true, hint: "Max number of reminder items, default 50."), - ] static let codeModeResultSummary = "Array of reminders with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." - let eventKit: EventKitBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - Arguments(raw: arguments) + struct Arguments: Sendable { + @ToolParam("Optional ISO8601 due-date lower bound.") + var start: String? + @ToolParam("Optional ISO8601 due-date upper bound.") + var end: String? + @ToolParam("Whether completed reminders are included; default false.") + var includeCompleted: Bool? + @ToolParam("Optional EventKit reminder calendarIdentifier to restrict results.") + var calendarIdentifier: String? + @ToolParam("Optional array of EventKit reminder calendarIdentifier strings to restrict results.") + var calendarIdentifiers: [String]? + @ToolParam("Max number of reminder items, default 50.") + var limit: Int? + var raw: [String: JSONValue] } + let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try eventKit.readReminders(arguments: arguments.raw, context: context) } } +@BuiltInCodeMode(.remindersWrite, path: "apple.reminders.createReminder", aliases: ["apple.reminders.updateReminder", "apple.reminders.completeReminder"]) struct RemindersWriteTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var operation: ReminderWriteOperation? - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .remindersWrite - static let codeModePath = "apple.reminders.createReminder" - static let codeModeAliasPaths = ["apple.reminders.updateReminder", "apple.reminders.completeReminder"] static let codeModeTitle = "Create or update reminder" static let codeModeSummary = "Create a reminder or patch an existing reminder by identifier." static let codeModeTags = ["reminders", "eventkit", "task"] static let codeModeExample = "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })" static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] - static let codeModeArguments = [ - BuiltInToolArgument("operation", oneOf: ReminderWriteOperation.self, optional: true, hint: "create (default without identifier), update, or complete."), - BuiltInToolArgument("identifier", .string, optional: true, hint: "EventKit calendarItemIdentifier to update or complete."), - BuiltInToolArgument("title", .string, optional: true, hint: "Reminder title string."), - BuiltInToolArgument("dueDate", .string, optional: true, hint: "Optional ISO8601 due date timestamp."), - BuiltInToolArgument("notes", .string, optional: true, hint: "Optional reminder notes."), - BuiltInToolArgument("isCompleted", .bool, optional: true, hint: "Completion state for update or completeReminder; default true for completeReminder."), - BuiltInToolArgument("priority", .number, optional: true, hint: "EventKit reminder priority integer."), - BuiltInToolArgument("calendarIdentifier", .string, optional: true, hint: "Optional destination EventKit reminders calendarIdentifier."), - ] static let codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." - let eventKit: EventKitBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - var raw = arguments - let operation = try CodeModeArgumentDecoder.optional("operation", as: ReminderWriteOperation.self, in: arguments) - if let operation { - raw["operation"] = .string(operation.rawValue) - } - return Arguments(operation: operation, raw: raw) + struct Arguments: Sendable { + @ToolParam("create (default without identifier), update, or complete.") + var operation: ReminderWriteOperation? + @ToolParam("EventKit calendarItemIdentifier to update or complete.") + var identifier: String? + @ToolParam("Reminder title string.") + var title: String? + @ToolParam("Optional ISO8601 due date timestamp.") + var dueDate: String? + @ToolParam("Optional reminder notes.") + var notes: String? + @ToolParam("Completion state for update or completeReminder; default true for completeReminder.") + var isCompleted: Bool? + @ToolParam("EventKit reminder priority integer.") + var priority: Int? + @ToolParam("Optional destination EventKit reminders calendarIdentifier.") + var calendarIdentifier: String? + var raw: [String: JSONValue] } + let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try eventKit.writeReminder(arguments: arguments.raw, context: context) } } +@BuiltInCodeMode(.remindersDelete, path: "apple.reminders.deleteReminder") struct RemindersDeleteTool: BuiltInCodeModeTool { - struct Arguments: Sendable { - var identifier: String - var raw: [String: JSONValue] - } - - static let codeModeCapability: CapabilityID = .remindersDelete - static let codeModePath = "apple.reminders.deleteReminder" static let codeModeTitle = "Delete reminder" static let codeModeSummary = "Delete an existing EventKit reminder by identifier." static let codeModeTags = ["reminders", "eventkit", "task", "delete"] static let codeModeExample = "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })" static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] - static let codeModeArguments = [ - BuiltInToolArgument("identifier", .string, hint: "EventKit calendarItemIdentifier from apple.reminders.listReminders."), - ] static let codeModeResultSummary = "Object with identifier/deleted." - let eventKit: EventKitBridge - - func decode(arguments: [String: JSONValue]) throws -> Arguments { - Arguments( - identifier: try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments), - raw: arguments - ) + struct Arguments: Sendable { + @ToolParam("EventKit calendarItemIdentifier from apple.reminders.listReminders.") + var identifier: String + var raw: [String: JSONValue] } + let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { try eventKit.deleteReminder(arguments: arguments.raw, context: context) } diff --git a/Tools/CodeModeAuthoring/Sources/CodeModeAuthoring/CodeModeMacros.swift b/Sources/CodeModeAuthoring/CodeModeMacros.swift similarity index 100% rename from Tools/CodeModeAuthoring/Sources/CodeModeAuthoring/CodeModeMacros.swift rename to Sources/CodeModeAuthoring/CodeModeMacros.swift diff --git a/Tools/CodeModeAuthoring/README.md b/Sources/CodeModeAuthoring/README.md similarity index 82% rename from Tools/CodeModeAuthoring/README.md rename to Sources/CodeModeAuthoring/README.md index 21a3b50..57069fa 100644 --- a/Tools/CodeModeAuthoring/README.md +++ b/Sources/CodeModeAuthoring/README.md @@ -1,6 +1,11 @@ # CodeModeAuthoring -`CodeModeAuthoring` contains the optional `@CodeMode` macro package. It lives outside the core `CodeMode` package so runtime clients and eval tooling do not inherit a `swift-syntax` dependency. +`CodeModeAuthoring` is the optional `@CodeMode` macro surface for host-authored +tools, shipped as a product of the main package (depend on the +`CodeModeAuthoring` product). The `CodeModeMacros` compiler plugin behind it is +the same one the core library uses internally for `@BuiltInCodeMode`; current +toolchains resolve swift-syntax as prebuilt libraries, so the build cost is +small (measured in `PLAN-registration-macros.md`). Hosts that want macro-authored providers can import `CodeModeAuthoring`: diff --git a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift new file mode 100644 index 0000000..f93dafd --- /dev/null +++ b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift @@ -0,0 +1,310 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// No-op marker carrying the LLM-facing hint for one argument of a built-in +/// tool; `@BuiltInCodeMode` reads it while generating `codeModeArguments`. +public struct ToolParamMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} + +/// Generates the mechanical members of a `BuiltInCodeModeTool` from its nested +/// `Arguments` struct: identity statics (`codeModeCapability`, `codeModePath`, +/// `codeModeAliasPaths`), the `codeModeArguments` metadata list, and +/// `decode(arguments:)`. Curated metadata (title, summary, tags, example, +/// permissions, result summary) and `call(arguments:context:)` stay +/// hand-written. +/// +/// Field types the macro does not recognize as JSON primitives are emitted +/// through `CodeModeStringEnum`-constrained overloads +/// (`BuiltInToolArgument(_:oneOf:...)`, `CodeModeToolRawArguments.canonicalize`), +/// so the compiler — not the macro — enforces that such a type really is a +/// `CodeModeStringEnum`, and the constraint metadata is derived from the enum. +/// +/// A stored property `var raw: [String: JSONValue]` without `@ToolParam` is +/// filled with the full argument dictionary, with constrained values replaced +/// by their canonical spelling — the passthrough transitional tools use while +/// bridges still consume raw dictionaries. +public struct BuiltInCodeModeMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self), + let capabilityExpr = arguments.first(where: { $0.label == nil })?.expression.trimmedDescription, + let pathExpr = arguments.first(where: { $0.label?.text == "path" })?.expression.trimmedDescription + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode requires a capability and a path", node: Syntax(node))) + return [] + } + let aliasesExpr = arguments.first(where: { $0.label?.text == "aliases" })?.expression.trimmedDescription + + guard let argumentsStruct = declaration.memberBlock.members + .compactMap({ $0.decl.as(StructDeclSyntax.self) }) + .first(where: { $0.name.text == "Arguments" }) + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode requires a nested Arguments struct", node: Syntax(declaration))) + return [] + } + + guard let parsed = parseFields(of: argumentsStruct, context: context) else { + return [] + } + + var members: [String] = [ + "static let codeModeCapability: CapabilityID = \(capabilityExpr)", + "static let codeModePath: String = \(pathExpr)", + ] + if let aliasesExpr { + members.append("static let codeModeAliasPaths: [String] = \(aliasesExpr)") + } + members.append(argumentsMetadataSource(for: parsed.fields)) + members.append(decodeSource(for: parsed)) + + return members.map { DeclSyntax(stringLiteral: $0) } + } + + // MARK: Field parsing + + private struct ParsedArguments { + var fields: [Field] + var hasRawPassthrough: Bool + } + + private struct Field { + var name: String + var swiftType: String + var kind: FieldKind + var optional: Bool + var hint: String + } + + fileprivate enum FieldKind { + /// A JSON primitive or collection with a direct CapabilityArgumentType. + case primitive(String) + /// An unrecognized simple type, emitted through the + /// CodeModeStringEnum-constrained overloads. + case stringEnum + } + + private static func parseFields( + of argumentsStruct: StructDeclSyntax, + context: some MacroExpansionContext + ) -> ParsedArguments? { + var fields: [Field] = [] + var hasRawPassthrough = false + var rawIsLast = true + var invalid = false + + for member in argumentsStruct.memberBlock.members { + guard let variable = member.decl.as(VariableDeclSyntax.self) else { + continue + } + guard variable.bindings.count == 1, + let binding = variable.bindings.first, + binding.accessorBlock == nil + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode supports only simple stored properties in Arguments", node: Syntax(variable))) + invalid = true + continue + } + guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self), + let type = binding.typeAnnotation?.type.trimmedDescription + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode Arguments properties need a name and an explicit type", node: Syntax(binding))) + invalid = true + continue + } + + let name = identifier.identifier.text + let hint = toolParamHint(on: variable.attributes) + + if hint == nil, name == "raw" { + guard type.replacingOccurrences(of: " ", with: "") == "[String:JSONValue]" else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode raw passthrough must be [String: JSONValue]", node: Syntax(binding))) + invalid = true + continue + } + hasRawPassthrough = true + rawIsLast = true + continue + } + rawIsLast = false + + guard let hint else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode Arguments properties need @ToolParam(\"hint\") (or be the raw passthrough)", node: Syntax(binding))) + invalid = true + continue + } + + let info = BuiltInTypeInfo(typeSyntax: type) + fields.append( + Field( + name: name, + swiftType: info.unwrappedType, + kind: info.kind, + optional: info.optional, + hint: hint + ) + ) + } + + if hasRawPassthrough, rawIsLast == false { + // decode constructs Arguments with the memberwise initializer, so the + // passthrough must be the last stored property. + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode raw passthrough must be the last stored property of Arguments", node: Syntax(argumentsStruct.name))) + return nil + } + + return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough) + } + + private static func toolParamHint(on attributes: AttributeListSyntax) -> String? { + for element in attributes { + guard case let .attribute(attribute) = element, + attribute.attributeName.trimmedDescription == "ToolParam", + let arguments = attribute.arguments?.as(LabeledExprListSyntax.self), + let first = arguments.first, + let literal = first.expression.as(StringLiteralExprSyntax.self) + else { + continue + } + return literal.segments.compactMap { segment in + segment.as(StringSegmentSyntax.self)?.content.text + }.joined() + } + return nil + } + + // MARK: Code generation + + private static func argumentsMetadataSource(for fields: [Field]) -> String { + guard fields.isEmpty == false else { + return "static let codeModeArguments: [BuiltInToolArgument] = []" + } + let entries = fields.map { field -> String in + let optionalPart = field.optional ? ", optional: true" : "" + switch field.kind { + case let .primitive(argumentType): + return "BuiltInToolArgument(\(stringLiteral(field.name)), .\(argumentType)\(optionalPart), hint: \(stringLiteral(field.hint)))," + case .stringEnum: + return "BuiltInToolArgument(\(stringLiteral(field.name)), oneOf: \(field.swiftType).self\(optionalPart), hint: \(stringLiteral(field.hint)))," + } + }.joined(separator: "\n ") + return """ + static let codeModeArguments: [BuiltInToolArgument] = [ + \(entries) + ] + """ + } + + private static func decodeSource(for parsed: ParsedArguments) -> String { + var body: [String] = [] + let enumFields = parsed.fields.filter { + if case .stringEnum = $0.kind { return true } + return false + } + + if parsed.hasRawPassthrough { + body.append(enumFields.isEmpty ? "let raw = arguments" : "var raw = arguments") + } + for field in parsed.fields { + let method = field.optional ? "optional" : "require" + body.append("let \(field.name) = try CodeModeArgumentDecoder.\(method)(\(stringLiteral(field.name)), as: \(field.swiftType).self, in: arguments)") + } + if parsed.hasRawPassthrough { + for field in enumFields { + body.append("CodeModeToolRawArguments.canonicalize(&raw, \(stringLiteral(field.name)), \(field.name))") + } + } + + var initArguments = parsed.fields.map { "\($0.name): \($0.name)" } + if parsed.hasRawPassthrough { + initArguments.append("raw: raw") + } + body.append(initArguments.isEmpty ? "return Arguments()" : "return Arguments(\(initArguments.joined(separator: ", ")))") + + return """ + func decode(arguments: [String: JSONValue]) throws -> Arguments { + \(body.joined(separator: "\n ")) + } + """ + } + + private static func stringLiteral(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" + } +} + +private struct BuiltInTypeInfo { + var unwrappedType: String + var kind: BuiltInCodeModeMacro.FieldKind + var optional: Bool + + init(typeSyntax rawType: String) { + let trimmed = rawType.replacingOccurrences(of: " ", with: "") + if trimmed.hasSuffix("?") { + optional = true + unwrappedType = String(trimmed.dropLast()) + } else if trimmed.hasPrefix("Optional<"), trimmed.hasSuffix(">") { + optional = true + unwrappedType = String(trimmed.dropFirst("Optional<".count).dropLast()) + } else { + optional = false + unwrappedType = trimmed + } + + switch unwrappedType { + case "String": + kind = .primitive("string") + case "Bool": + kind = .primitive("bool") + case "Int", "Double", "Float": + kind = .primitive("number") + case "JSONValue": + kind = .primitive("any") + default: + if unwrappedType.hasPrefix("["), unwrappedType.hasSuffix("]") { + kind = .primitive(unwrappedType.contains(":") ? "object" : "array") + } else { + // Unrecognized simple type: emitted through CodeModeStringEnum- + // constrained overloads; the compiler rejects anything else. + kind = .stringEnum + } + } + } +} + +private struct BuiltInMacroDiagnostic: DiagnosticMessage { + var message: String + var diagnosticID: MessageID + var severity: DiagnosticSeverity + var node: Syntax + + init(_ message: String, node: Syntax) { + self.message = message + self.diagnosticID = MessageID(domain: "CodeModeMacros", id: message) + self.severity = .error + self.node = node + } +} + +private extension MacroExpansionContext { + func diagnose(_ message: BuiltInMacroDiagnostic) { + diagnose(Diagnostic(node: message.node, message: message)) + } +} diff --git a/Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift b/Sources/CodeModeMacros/CodeModeMacro.swift similarity index 99% rename from Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift rename to Sources/CodeModeMacros/CodeModeMacro.swift index fe7b18b..72f6086 100644 --- a/Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift +++ b/Sources/CodeModeMacros/CodeModeMacro.swift @@ -11,6 +11,8 @@ struct CodeModePlugin: CompilerPlugin { CodeModeMacro.self, CodeModeParamMacro.self, CodeModeResultMacro.self, + BuiltInCodeModeMacro.self, + ToolParamMacro.self, ] } diff --git a/TODO.md b/TODO.md index b6db939..4c9863f 100644 --- a/TODO.md +++ b/TODO.md @@ -129,12 +129,13 @@ in the core graph, so Phase 2 is unblocked): registrations in Phase 3. - [ ] Unify permission ownership — `calendarRead` checks in both registry and bridge; `calendarWrite` checks only in the bridge. -- [/] Decide the fate of `Tools/CodeModeAuthoring` — investigated 2026-07-11; - concrete three-phase plan written up in `PLAN-registration-macros.md` - (typed-tool protocol first, then promote the macro into the core package — - swift-syntax cost measured at 24s clean/~0 incremental with prebuilts - default-on — then per-domain migration). Awaiting owner decision on - swift-syntax in the core graph; Phase 1 is worthwhile standalone. +- [x] Decide the fate of `Tools/CodeModeAuthoring` — resolved 2026-07-11: owner + approved swift-syntax in the core graph; the package is folded into the root + package as the `CodeModeAuthoring` product and its `CodeModeMacros` plugin now + also backs the internal `@BuiltInCodeMode` macro (Phases 1+2 of + `PLAN-registration-macros.md` landed; EventKit is macro-authored). Remaining: + per-domain migration (Phase 3) and enum-constraint support for the + host-facing `@CodeMode`. --- diff --git a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift new file mode 100644 index 0000000..821ffbf --- /dev/null +++ b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift @@ -0,0 +1,191 @@ +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing +import CodeModeMacros + +private func builtInTestMacros() -> [String: Macro.Type] { + [ + "BuiltInCodeMode": BuiltInCodeModeMacro.self, + "ToolParam": ToolParamMacro.self, + ] +} + +@Test func builtInCodeModeExpansionGeneratesMetadataAndDecode() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarDelete, path: "apple.calendar.deleteEvent", aliases: ["apple.calendar.removeEvent"]) + struct CalendarDeleteEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + @ToolParam("EventKit eventIdentifier.") + var identifier: String + @ToolParam("thisEvent (default) or futureEvents.") + var span: CalendarEventSpan? + var raw: [String: JSONValue] + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct CalendarDeleteEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var identifier: String + var span: CalendarEventSpan? + var raw: [String: JSONValue] + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + + static let codeModeCapability: CapabilityID = .calendarDelete + + static let codeModePath: String = "apple.calendar.deleteEvent" + + static let codeModeAliasPaths: [String] = ["apple.calendar.removeEvent"] + + static let codeModeArguments: [BuiltInToolArgument] = [ + BuiltInToolArgument("identifier", .string, hint: "EventKit eventIdentifier."), + BuiltInToolArgument("span", oneOf: CalendarEventSpan.self, optional: true, hint: "thisEvent (default) or futureEvents."), + ] + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + var raw = arguments + let identifier = try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments) + let span = try CodeModeArgumentDecoder.optional("span", as: CalendarEventSpan.self, in: arguments) + CodeModeToolRawArguments.canonicalize(&raw, "span", span) + return Arguments(identifier: identifier, span: span, raw: raw) + } + } + """, + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionSupportsNoArgumentsAndNoRaw() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.locationPermissionRequest, path: "apple.location.requestPermission") + struct LocationPermissionRequestTool: BuiltInCodeModeTool { + struct Arguments: Sendable {} + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct LocationPermissionRequestTool: BuiltInCodeModeTool { + struct Arguments: Sendable {} + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + + static let codeModeCapability: CapabilityID = .locationPermissionRequest + + static let codeModePath: String = "apple.location.requestPermission" + + static let codeModeArguments: [BuiltInToolArgument] = [] + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + return Arguments() + } + } + """, + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionDiagnosesMissingArgumentsStruct() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct BrokenTool: BuiltInCodeModeTool { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct BrokenTool: BuiltInCodeModeTool { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@BuiltInCodeMode requires a nested Arguments struct", line: 1, column: 1), + ], + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionDiagnosesUnannotatedField() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@BuiltInCodeMode Arguments properties need @ToolParam(\"hint\") (or be the raw passthrough)", line: 4, column: 9), + ], + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionDiagnosesRawNotLast() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var raw: [String: JSONValue] + @ToolParam("Max items.") + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var raw: [String: JSONValue] + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@BuiltInCodeMode raw passthrough must be the last stored property of Arguments", line: 3, column: 12), + ], + macros: builtInTestMacros() + ) +} diff --git a/Tools/CodeModeAuthoring/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift b/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift similarity index 100% rename from Tools/CodeModeAuthoring/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift rename to Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift diff --git a/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift new file mode 100644 index 0000000..9798399 --- /dev/null +++ b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func macroGeneratedDecodeCanonicalizesConstrainedValues() throws { + let tool = CalendarDeleteEventTool(eventKit: EventKitBridge()) + + let decoded = try tool.decode(arguments: [ + "identifier": .string("E1"), + "span": .string("FUTURE_EVENTS"), + ]) + #expect(decoded.identifier == "E1") + #expect(decoded.span == .futureEvents) + #expect(decoded.raw["span"] == .string("futureEvents")) + #expect(decoded.raw["identifier"] == .string("E1")) +} + +@Test func macroGeneratedDecodeRejectsMissingRequiredAndUnknownConstrainedValues() { + let tool = CalendarDeleteEventTool(eventKit: EventKitBridge()) + + #expect(throws: (any Error).self) { + _ = try tool.decode(arguments: ["span": .string("thisEvent")]) + } + #expect(throws: (any Error).self) { + _ = try tool.decode(arguments: [ + "identifier": .string("E1"), + "span": .string("allEvents"), + ]) + } +} + +@Test func macroGeneratedMetadataMatchesHandWrittenBaseline() throws { + // The EventKit domain was first migrated with hand-written metadata and + // then converted to @BuiltInCodeMode; pin the identity pieces so a macro + // regression cannot silently change the advertised surface. + let registration = try #require( + DefaultCapabilityLoader.loadAllRegistrations().first { $0.descriptor.id == .remindersWrite } + ) + #expect(registration.jsNames == [ + "apple.reminders.createReminder", + "apple.reminders.updateReminder", + "apple.reminders.completeReminder", + ]) + #expect(registration.descriptor.requiredPermissions == [.reminders]) + #expect(registration.descriptor.requiredArguments.isEmpty) + #expect(registration.descriptor.optionalArguments.first == "operation") + #expect(registration.descriptor.argumentTypes["priority"] == .number) + #expect(registration.descriptor.argumentConstraints.allowedStringValues["operation"] == ["create", "update", "complete"]) + #expect(registration.descriptor.argumentHints["identifier"] == "EventKit calendarItemIdentifier to update or complete.") +} diff --git a/Tools/CodeModeAuthoring/Package.swift b/Tools/CodeModeAuthoring/Package.swift deleted file mode 100644 index f51a879..0000000 --- a/Tools/CodeModeAuthoring/Package.swift +++ /dev/null @@ -1,49 +0,0 @@ -// swift-tools-version: 6.1 -import PackageDescription -import CompilerPluginSupport - -let package = Package( - name: "CodeModeAuthoring", - platforms: [ - .iOS(.v18), - .macOS(.v15), - .visionOS(.v2), - ], - products: [ - .library( - name: "CodeModeAuthoring", - targets: ["CodeModeAuthoring"] - ), - ], - dependencies: [ - .package(name: "codemode-ios", path: "../.."), - .package(url: "https://github.com/swiftlang/swift-syntax.git", exact: "601.0.1"), - ], - targets: [ - .target( - name: "CodeModeAuthoring", - dependencies: [ - .product(name: "CodeMode", package: "codemode-ios"), - "CodeModeMacros", - ] - ), - .macro( - name: "CodeModeMacros", - dependencies: [ - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - .product(name: "SwiftDiagnostics", package: "swift-syntax"), - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - ] - ), - .testTarget( - name: "CodeModeAuthoringTests", - dependencies: [ - "CodeModeAuthoring", - "CodeModeMacros", - .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), - ] - ), - ] -) diff --git a/Tools/CodeModeEval/Package.resolved b/Tools/CodeModeEval/Package.resolved index 8ac29a5..524d693 100644 --- a/Tools/CodeModeEval/Package.resolved +++ b/Tools/CodeModeEval/Package.resolved @@ -9,6 +9,15 @@ "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", "version" : "1.7.1" } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } } ], "version" : 3 From f5900e403776c3cc12f1df4c7d3d3ff038dba49f Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 16:47:56 -0700 Subject: [PATCH 17/25] Add golden metadata baseline and Phase 3 handoff spec CapabilityMetadataGoldenTests pins every registration's full advertised surface (jsNames, prose, permissions, argument lists/types/hints, constraints, resultSummary) against a committed 115-capability JSON baseline; CODEMODE_REGENERATE_GOLDEN=1 regenerates it, and the JSON diff becomes the review artifact for registration refactors. This is the safety rail that makes the remaining domain migrations mechanically verifiable. PHASE3-HANDOFF.md is the self-contained work spec for converting the seven remaining CapabilityRegistrations files to @BuiltInCodeMode: per-registration recipe (including the effective-type rule for inferred/.any arguments and the constrained-enum rewiring pattern), domain order with per-domain notes, the allowed golden-diff categories, and hard rules (no prose rewording, no permission-ownership changes, skip-don't-improvise). 230 tests pass. --- PHASE3-HANDOFF.md | 143 + .../CapabilityMetadataGoldenTests.swift | 92 + .../capability-metadata-golden.json | 4954 +++++++++++++++++ 3 files changed, 5189 insertions(+) create mode 100644 PHASE3-HANDOFF.md create mode 100644 Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift create mode 100644 Tests/CodeModeTests/capability-metadata-golden.json diff --git a/PHASE3-HANDOFF.md b/PHASE3-HANDOFF.md new file mode 100644 index 0000000..2e169d7 --- /dev/null +++ b/PHASE3-HANDOFF.md @@ -0,0 +1,143 @@ +# Phase 3 handoff: migrate remaining registrations to `@BuiltInCodeMode` + +Self-contained work spec for converting the remaining hand-written capability +registrations to the macro-authored tool idiom. Context: Phases 1–2 of +`PLAN-registration-macros.md` are done; EventKit is the finished reference. +This is mechanical work — the invariants below matter more than speed. + +## The one invariant + +**The advertised capability surface must not change except where this spec +says it may.** `Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift` pins +every registration's full metadata (jsNames, title, summary, tags, example, +permissions, argument lists/types/hints, constraints, resultSummary) against +`Tests/CodeModeTests/capability-metadata-golden.json`. + +Workflow per domain: +1. Convert the domain (recipe below). Run `swift test`. The golden test fails. +2. Regenerate: `CODEMODE_REGENERATE_GOLDEN=1 swift test --filter capabilityMetadata` +3. `git diff Tests/CodeModeTests/capability-metadata-golden.json` — **this diff + is the review artifact.** Every hunk must be one of the allowed diffs below; + anything else is a bug in your conversion. Fix the conversion, not the spec. +4. Full `swift test` green → one commit for the domain, golden diff included, + and the commit message lists which allowed-diff categories appear. + +Allowed golden diffs: +- `argumentHints` gaining entries for arguments that previously had no hint + (the tool idiom requires a hint per argument — write one consistent with the + bridge's actual behavior, and call it out in the commit message). +- `allowedStringValues` gaining alias spellings **only when the bridge already + accepts them** (cite the bridge line in the commit message). +- Nothing else. Not a reworded title, not a reordered jsNames array, not a + type change, not a constraint that moved keys. + +## Reference implementation (read these first) + +- `Sources/CodeMode/Bridges/EventKitCodeModeTools.swift` — 9 macro-authored + tools + 5 `CodeModeStringEnum`s. The pattern to replicate. +- `Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift` — what a + registration file looks like after conversion (a thin list). +- `Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift`, + `Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift`, + `Sources/CodeMode/API/CodeModeStringEnum.swift` — the infrastructure. +- Commits `6f5e7e8` (Phase 1) and `c45fb10` (Phase 2) show the full shape of a + domain conversion including bridge rewiring and tests. + +## Recipe per registration + +1. Create `Sources/CodeMode/Bridges/CodeModeTools.swift`. One + `@BuiltInCodeMode` struct per `CapabilityRegistration` in the old file. +2. Copy **verbatim**: title, summary, tags, example, requiredPermissions, + resultSummary. Do not improve the prose. +3. `path` = the registration's first jsName; `aliases:` = the rest, in order. +4. `Arguments` struct: one `@ToolParam("")` property per + declared argument, in the old required-then-optional order. Required args + are non-optional Swift types; optional args are optionals. +5. **Property types must reproduce the old effective type.** If the old + descriptor declared `argumentTypes`, match it. If it didn't, the effective + type came from `CapabilityDescriptor.inferArgumentTypes` + (`CapabilityRegistry.swift`) — look each name up in that table: + `.string`→`String`, `.number`→`Int` or `Double` (pick what the bridge + reads), `.bool`→`Bool`, `.array`→`[String]`/`[JSONValue]` (match bridge), + `.object`→`[String: JSONValue]`, and **names absent from the table were + `.any` — declare those as `JSONValue`**, never a tighter type. The golden + test catches mistakes here; trust it. +6. Constrained string arguments — any argument with a row in + `CapabilityArgumentConstraints.defaults(for:)` (`CapabilityRegistry.swift`): + - Define a `CodeModeStringEnum` whose **raw values are exactly the current + advertised list** (case names = raw values). Add `codeModeAliases` only + for spellings the bridge demonstrably accepts. + - Use it as the property type; the macro derives the constraint from it. + - Rewire the bridge's own parsing of that value to + `EnumType.codeModeValue(matching:)` (see `EventKitBridge.eventSpan`, + `SystemUIBridge.validateCalendarPickerArguments` for the pattern), so the + enum is the single source of truth. + - Delete the row from the `defaults(for:)` table in the same commit. + - Exception: **dotted-path constraints** (`networkFetch`'s + `options.responseEncoding`) stay in the central table — the tool argument + model is flat. Leave them and note it. +7. End every `Arguments` struct with `var raw: [String: JSONValue]` and call + the same bridge method the old handler called, passing `arguments.raw` and + the same `context`. Do not change bridge method signatures beyond the + constrained-value parsing rewiring in step 6. +8. Replace the old file's body with the thin builder-extension list (keep the + function name the builder calls, e.g. `systemUIRegistrations()` — see + `DefaultCapabilityLoader.loadAll()` for the roster). + +## Domain order and notes + +Work sequentially, one commit per domain, full suite green each time: + +1. **SystemUI** (`CapabilityRegistrations+SystemUI.swift`, 12 registrations) — + heaviest constraint user: `photosUIPick`/`contactsUIPick`/`cameraUICapture`/ + `cameraUIScanData` rows in the defaults table, and `SystemUIBridge` has + matching `lowercased()` validations to rewire (mediaType, cameraDevice, + flashMode, videoQuality, scan mode, preferredStyle…). Only enum-ify values + that have a defaults-table row today; leave other `lowercased()` checks + alone. +2. **Core** (`+Core.swift`, 11) — includes `networkFetch` (dotted-path + constraint stays in the table) and the filesystem/keychain area. Keychain is + already converted; don't touch `SimpleBuiltInCodeModeProviders.swift`. +3. **PeoplePhotosDocuments** (`+PeoplePhotosDocuments.swift`, 13) — + `photosRead` mediaType row; `PhotosBridge` lowercases mediaType, rewire it. +4. **SystemServices** (`+SystemServices.swift`, 19) — health/home/alarm/ + notifications. Do **not** add `.healthKit` to any `requiredPermissions` + (see `noBuiltInRegistrationGatesOnHealthKitPermission` test). Preserve the + existing permission declarations exactly. +5. **CloudPushSpeech** (`+CloudPushSpeech.swift`, 15) — the four CloudKit + capabilities share the `database` row; one enum, four tools. +6. **IntentsModelsActivityMaps** (`+IntentsModelsActivityMaps.swift`, 18) — + `activityEnd` dismissalPolicy and `mapsRouteEstimate`/`mapsOpen` + transportType rows. transportType is consumed in + `SystemAppleServiceClients.swift` (`SystemMapsMapping`) — rewire there. +7. **Commerce** (`+Commerce.swift`, 18) — mostly pass-through to host-supplied + clients (music/passKit/storeKit). Convert metadata + `musicPlaybackControl` + action enum (replicate the existing list; host clients stay authoritative + for semantics). Do not invent constraints for values the table doesn't + constrain today. + +## Hard rules + +- Do not touch `Sources/CodeMode/Runtime/RuntimeJavaScript.swift` (the JS + function table is a separate Phase-3 item, not this task). +- Do not reword any advertised string. Copy-paste, don't retype. +- Do not change permission ownership (some capabilities deliberately declare + `requiredPermissions: []` and check in the bridge — e.g. calendarWrite). +- Do not migrate `LocationWeather` or `EventKit` (done) and do not modify + `Tools/CodeModeEval` or CI. +- If a registration doesn't fit the recipe (unexpected handler shape, shared + state, anything surprising), **stop and leave that registration on the old + idiom in its file** with a `// PHASE3-SKIP: ` comment rather than + improvising. Mixed files are fine; wrong conversions are not. +- When all domains are done: delete any now-empty rows from `defaults(for:)`, + and update `TODO.md`'s structural-improvements section + the status block in + `PLAN-registration-macros.md`. + +## Definition of done (per domain) + +- `swift test` fully green (230+ tests). +- Golden diff contains only allowed categories, enumerated in the commit + message. +- The old registration file is a thin list; its metadata lives on tools. +- Constraint rows for the domain are deleted from the central table (except + dotted paths) and the owning bridge parses through the shared enum. diff --git a/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift new file mode 100644 index 0000000..4176c9b --- /dev/null +++ b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import CodeMode + +/// One registration's complete advertised surface, in a deterministic +/// encoding. This is everything an LLM or host sees about a capability. +private struct CapabilitySnapshot: Codable, Equatable { + var id: String + var jsNames: [String] + var title: String + var summary: String + var tags: [String] + var example: String + var requiredPermissions: [String] + var requiredArguments: [String] + var optionalArguments: [String] + var argumentTypes: [String: String] + var argumentHints: [String: String] + var allowedStringValues: [String: [String]] + var resultSummary: String + + init(_ registration: CapabilityRegistration) { + let descriptor = registration.descriptor + id = descriptor.id.rawValue + jsNames = registration.jsNames + title = descriptor.title + summary = descriptor.summary + tags = descriptor.tags + example = descriptor.example + requiredPermissions = descriptor.requiredPermissions.map(\.rawValue) + requiredArguments = descriptor.requiredArguments + optionalArguments = descriptor.optionalArguments + argumentTypes = descriptor.argumentTypes.mapValues(\.rawValue) + argumentHints = descriptor.argumentHints + allowedStringValues = descriptor.argumentConstraints.allowedStringValues + resultSummary = descriptor.resultSummary + } +} + +private let goldenFileURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("capability-metadata-golden.json") + +private func encodeSnapshots() throws -> Data { + let snapshots = DefaultCapabilityLoader.loadAllRegistrations() + .map(CapabilitySnapshot.init) + .sorted { ($0.id, $0.jsNames.first ?? "") < ($1.id, $1.jsNames.first ?? "") } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(snapshots) +} + +/// Golden test over the full advertised capability surface. +/// +/// Purpose: registration-idiom migrations (PLAN-registration-macros.md Phase 3) +/// must not change what capabilities advertise unless the change is intended +/// and reviewed. Any drift — a reworded hint, a lost constraint, a type that +/// silently tightened from `any` — fails here. +/// +/// To regenerate after an INTENDED change: +/// CODEMODE_REGENERATE_GOLDEN=1 swift test --filter capabilityMetadata +/// then review the diff of capability-metadata-golden.json in the commit — +/// the diff is the review artifact. +@Test func capabilityMetadataMatchesGoldenBaseline() throws { + let current = try encodeSnapshots() + + if ProcessInfo.processInfo.environment["CODEMODE_REGENERATE_GOLDEN"] == "1" { + try current.write(to: goldenFileURL) + return + } + + let golden = try Data(contentsOf: goldenFileURL) + if current != golden { + let decoder = JSONDecoder() + let currentSnapshots = try decoder.decode([CapabilitySnapshot].self, from: current) + let goldenSnapshots = try decoder.decode([CapabilitySnapshot].self, from: golden) + + let goldenByID = Dictionary(grouping: goldenSnapshots, by: \.id) + let currentByID = Dictionary(grouping: currentSnapshots, by: \.id) + for id in Set(goldenByID.keys).union(currentByID.keys).sorted() { + if goldenByID[id] == nil { + Issue.record("Capability \(id) is new (not in golden baseline)") + } else if currentByID[id] == nil { + Issue.record("Capability \(id) disappeared from the loaded registrations") + } else if goldenByID[id] != currentByID[id] { + Issue.record("Capability \(id) metadata changed vs golden baseline") + } + } + let advice = "Advertised capability metadata drifted from capability-metadata-golden.json. If intended, regenerate with CODEMODE_REGENERATE_GOLDEN=1 and review the JSON diff." + Issue.record("\(advice)") + } +} diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json new file mode 100644 index 0000000..7815c6f --- /dev/null +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -0,0 +1,4954 @@ +[ + { + "allowedStringValues" : { + "dismissalPolicy" : [ + "default", + "immediate" + ] + }, + "argumentHints" : { + + }, + "argumentTypes" : { + "contentState" : "object", + "dismissalPolicy" : "string", + "identifier" : "string" + }, + "example" : "await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'immediate' })", + "id" : "activity.end", + "jsNames" : [ + "apple.activity.end" + ], + "optionalArguments" : [ + "contentState", + "dismissalPolicy" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/ended\/state.", + "summary" : "End a host-registered Live Activity, optionally with final content state.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "End Live Activity" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + "activityType" : "string", + "limit" : "number" + }, + "example" : "await apple.activity.list()", + "id" : "activity.list", + "jsNames" : [ + "apple.activity.list" + ], + "optionalArguments" : [ + "activityType", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of activities with identifier\/activityType\/state\/contentState\/attributes.", + "summary" : "List host-registered ActivityKit Live Activities visible to CodeMode.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "List Live Activities" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + "identifier" : "string" + }, + "example" : "await apple.activity.getPushToken({ identifier: 'activity-1' })", + "id" : "activity.pushToken.read", + "jsNames" : [ + "apple.activity.getPushToken" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/pushToken\/environment.", + "summary" : "Read a Live Activity push token when the host adapter supports push updates.", + "tags" : [ + "activitykit", + "live-activities", + "push-token" + ], + "title" : "Read Live Activity push token" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "activityType" : "Host-registered activity adapter type.", + "attributes" : "Adapter-defined immutable attributes.", + "contentState" : "Adapter-defined mutable content state.", + "pushType" : "Optional push token request mode when the adapter supports remote updates.", + "staleDate" : "Optional ISO8601 stale date." + }, + "argumentTypes" : { + "activityType" : "string", + "attributes" : "object", + "contentState" : "object", + "pushType" : "string", + "staleDate" : "string" + }, + "example" : "await apple.activity.start({ activityType: 'delivery', attributes: {}, contentState: {} })", + "id" : "activity.start", + "jsNames" : [ + "apple.activity.start" + ], + "optionalArguments" : [ + "contentState", + "pushType", + "staleDate" + ], + "requiredArguments" : [ + "activityType", + "attributes" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/activityType\/state\/pushToken when available.", + "summary" : "Start a host-registered ActivityKit Live Activity adapter.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "Start Live Activity" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + "alert" : "object", + "contentState" : "object", + "identifier" : "string", + "staleDate" : "string" + }, + "example" : "await apple.activity.update({ identifier: 'activity-1', contentState: { progress: 0.7 } })", + "id" : "activity.update", + "jsNames" : [ + "apple.activity.update" + ], + "optionalArguments" : [ + "alert", + "staleDate" + ], + "requiredArguments" : [ + "identifier", + "contentState" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/updated\/state.", + "summary" : "Update an existing host-registered Live Activity content state.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "Update Live Activity" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Single alarm identifier UUID string.", + "identifiers" : "Array of alarm identifier UUID strings. Omit both to cancel all known alarms." + }, + "argumentTypes" : { + "identifier" : "string", + "identifiers" : "array" + }, + "example" : "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })", + "id" : "alarm.cancel", + "jsNames" : [ + "ios.alarm.cancel" + ], + "optionalArguments" : [ + "identifier", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "alarmKit" + ], + "resultSummary" : "Object with deleted\/count fields.", + "summary" : "Cancel one or more scheduled alarms by identifier, or clear all known alarms.", + "tags" : [ + "alarmkit", + "alarms", + "schedule" + ], + "title" : "Cancel scheduled alarms" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await ios.alarm.requestPermission()", + "id" : "alarm.permission.request", + "jsNames" : [ + "ios.alarm.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted fields.", + "summary" : "Request AlarmKit authorization from the user.", + "tags" : [ + "alarmkit", + "permission", + "alarms" + ], + "title" : "Request AlarmKit permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of scheduled alarms returned, default 50." + }, + "argumentTypes" : { + "limit" : "number" + }, + "example" : "await ios.alarm.list({ limit: 20 })", + "id" : "alarm.read", + "jsNames" : [ + "ios.alarm.list" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "alarmKit" + ], + "resultSummary" : "Array of scheduled alarms with identifier\/title\/timing fields.", + "summary" : "List scheduled alarms known to the bridge runtime.", + "tags" : [ + "alarmkit", + "alarms", + "schedule" + ], + "title" : "List scheduled alarms" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "fireDate" : "Optional absolute ISO8601 date; used when provided.", + "identifier" : "Optional UUID string; generated when omitted.", + "secondsFromNow" : "Fallback relative delay in seconds, default 60.", + "title" : "Alarm title shown in presentation." + }, + "argumentTypes" : { + "fireDate" : "string", + "identifier" : "string", + "secondsFromNow" : "number", + "title" : "string" + }, + "example" : "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })", + "id" : "alarm.schedule", + "jsNames" : [ + "ios.alarm.schedule" + ], + "optionalArguments" : [ + "identifier", + "secondsFromNow", + "fireDate" + ], + "requiredArguments" : [ + "title" + ], + "requiredPermissions" : [ + "alarmKit" + ], + "resultSummary" : "Object with identifier\/scheduled\/title.", + "summary" : "Schedule an AlarmKit alarm using secondsFromNow or fireDate.", + "tags" : [ + "alarmkit", + "alarms", + "schedule" + ], + "title" : "Schedule AlarmKit alarm" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Host-registered action identifier.", + "parameters" : "Structured action parameters used for the donation." + }, + "argumentTypes" : { + "identifier" : "string", + "parameters" : "object" + }, + "example" : "await apple.appIntents.donate({ identifier: 'createNote', parameters: { title: 'Draft' } })", + "id" : "appintents.donate", + "jsNames" : [ + "apple.appIntents.donate" + ], + "optionalArguments" : [ + "parameters" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with donated\/status.", + "summary" : "Ask the host to donate a supported action to Shortcuts\/Siri suggestions where feasible.", + "tags" : [ + "appintents", + "shortcuts", + "donation", + "host-adapter" + ], + "title" : "Donate host App Intent action" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "identifier" : "Optional action identifier filter.", + "limit" : "Maximum handoff events to return." + }, + "argumentTypes" : { + "afterCursor" : "string", + "identifier" : "string", + "limit" : "number" + }, + "example" : "await apple.appIntents.listHandoffs({ limit: 20 })", + "id" : "appintents.handoffs.read", + "jsNames" : [ + "apple.appIntents.listHandoffs" + ], + "optionalArguments" : [ + "identifier", + "limit", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of handoff events with cursor\/identifier\/parameters\/date\/source.", + "summary" : "Read bounded App Intent handoff events captured by the host app.", + "tags" : [ + "appintents", + "shortcuts", + "handoff", + "inbox", + "events" + ], + "title" : "Read App Intent handoff inbox" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "domain" : "Optional host-defined action domain filter.", + "limit" : "Maximum actions to return." + }, + "argumentTypes" : { + "domain" : "string", + "limit" : "number" + }, + "example" : "await apple.appIntents.list()", + "id" : "appintents.list", + "jsNames" : [ + "apple.appIntents.list" + ], + "optionalArguments" : [ + "domain", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of actions with identifier\/title\/summary\/requiredParameters.", + "summary" : "List host-registered App Intents or Shortcuts actions exposed to CodeMode.", + "tags" : [ + "appintents", + "shortcuts", + "actions", + "host-adapter" + ], + "title" : "List host App Intent adapters" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Host-registered open action identifier.", + "parameters" : "Structured parameters for the host open action." + }, + "argumentTypes" : { + "identifier" : "string", + "parameters" : "object" + }, + "example" : "await apple.appIntents.open({ identifier: 'showTask', parameters: { id: 'task-1' } })", + "id" : "appintents.open", + "jsNames" : [ + "apple.appIntents.open" + ], + "optionalArguments" : [ + "parameters" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with opened\/status.", + "summary" : "Open a host-provided App Intent, Shortcuts, or app surface for user-mediated continuation.", + "tags" : [ + "appintents", + "shortcuts", + "open", + "host-adapter" + ], + "title" : "Open host App Intent surface" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Host-registered action identifier from apple.appIntents.list.", + "parameters" : "Structured parameters validated by the host adapter.", + "timeoutMs" : "Maximum wait time in milliseconds." + }, + "argumentTypes" : { + "identifier" : "string", + "parameters" : "object", + "timeoutMs" : "number" + }, + "example" : "await apple.appIntents.run({ identifier: 'createNote', parameters: { title: 'Draft' } })", + "id" : "appintents.run", + "jsNames" : [ + "apple.appIntents.run" + ], + "optionalArguments" : [ + "parameters", + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Adapter-defined structured result.", + "summary" : "Run a host-registered App Intent adapter with structured parameters.", + "tags" : [ + "appintents", + "shortcuts", + "actions", + "host-adapter" + ], + "title" : "Run host App Intent adapter" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "callbackURLScheme" : "Optional custom URL scheme that completes the session.", + "prefersEphemeralSession" : "Whether to prefer a private browser session; default false.", + "timeoutMs" : "Optional timeout for waiting on callback\/cancellation.", + "url" : "Absolute HTTP(S) authentication URL." + }, + "argumentTypes" : { + "callbackURLScheme" : "string", + "prefersEphemeralSession" : "bool", + "timeoutMs" : "number", + "url" : "string" + }, + "example" : "await apple.auth.webAuthenticate({ url: 'https:\/\/example.com\/oauth', callbackURLScheme: 'myapp' })", + "id" : "auth.ui.webAuthenticate", + "jsNames" : [ + "apple.auth.webAuthenticate" + ], + "optionalArguments" : [ + "callbackURLScheme", + "prefersEphemeralSession", + "timeoutMs" + ], + "requiredArguments" : [ + "url" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action callback\/cancelled and callbackURL when available.", + "summary" : "Start an ASWebAuthenticationSession for OAuth-style browser authentication.", + "tags" : [ + "auth", + "oauth", + "web", + "browser", + "system-ui" + ], + "title" : "Authenticate with system web UI" + }, + { + "allowedStringValues" : { + "span" : [ + "thisEvent", + "futureEvents", + "future", + "future_events", + "this", + "this_event" + ] + }, + "argumentHints" : { + "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", + "span" : "thisEvent (default) or futureEvents for recurring events." + }, + "argumentTypes" : { + "identifier" : "string", + "span" : "string" + }, + "example" : "await apple.calendar.deleteEvent({ identifier: 'EVENT_ID', span: 'thisEvent' })", + "id" : "calendar.delete", + "jsNames" : [ + "apple.calendar.deleteEvent" + ], + "optionalArguments" : [ + "span" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "calendar" + ], + "resultSummary" : "Object with identifier\/deleted.", + "summary" : "Delete an existing EventKit event by identifier.", + "tags" : [ + "calendar", + "eventkit", + "schedule", + "delete" + ], + "title" : "Delete calendar event" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "calendarIdentifier" : "Optional EventKit calendarIdentifier to restrict results.", + "calendarIdentifiers" : "Optional array of EventKit calendarIdentifier strings to restrict results.", + "end" : "ISO8601 timestamp; defaults to start + 14 days.", + "limit" : "Max number of items, default 50.", + "start" : "ISO8601 timestamp; defaults to now." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "calendarIdentifiers" : "array", + "end" : "string", + "limit" : "number", + "start" : "string" + }, + "example" : "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })", + "id" : "calendar.read", + "jsNames" : [ + "apple.calendar.listEvents" + ], + "optionalArguments" : [ + "start", + "end", + "limit", + "calendarIdentifier", + "calendarIdentifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "calendar" + ], + "resultSummary" : "Array of events with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "summary" : "List events in a date range from EventKit.", + "tags" : [ + "calendar", + "eventkit", + "schedule" + ], + "title" : "Read calendar events" + }, + { + "allowedStringValues" : { + "displayStyle" : [ + "writable", + "all" + ], + "selectionStyle" : [ + "single", + "multiple" + ] + }, + "argumentHints" : { + "displayStyle" : "writable (default) or all.", + "selectionStyle" : "single (default) or multiple.", + "timeoutMs" : "Optional timeout for waiting on user selection." + }, + "argumentTypes" : { + "displayStyle" : "string", + "selectionStyle" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.calendar.pickCalendar({ selectionStyle: 'single' })", + "id" : "calendar.ui.pickCalendar", + "jsNames" : [ + "apple.calendar.pickCalendar" + ], + "optionalArguments" : [ + "selectionStyle", + "displayStyle", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "calendar.writeOnly" + ], + "resultSummary" : "Array of selected calendars with identifier\/title\/type\/allowsContentModifications.", + "summary" : "Present EventKit calendar chooser UI and return the user-selected writable calendars.", + "tags" : [ + "calendar", + "eventkit", + "system-ui", + "picker" + ], + "title" : "Pick calendar with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "allowsCalendarPreview" : "Whether the UI may show calendar day previews; default true.", + "allowsEditing" : "Whether the user can edit from the detail UI; default false.", + "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", + "timeoutMs" : "Optional timeout for waiting on dismissal." + }, + "argumentTypes" : { + "allowsCalendarPreview" : "bool", + "allowsEditing" : "bool", + "identifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })", + "id" : "calendar.ui.presentEvent", + "jsNames" : [ + "apple.calendar.presentEvent" + ], + "optionalArguments" : [ + "allowsEditing", + "allowsCalendarPreview", + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "calendar" + ], + "resultSummary" : "Object with action dismissed.", + "summary" : "Present system UI for an existing calendar event identifier.", + "tags" : [ + "calendar", + "eventkit", + "system-ui", + "details" + ], + "title" : "Present calendar event details" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "end" : "Optional ISO8601 end timestamp.", + "location" : "Optional location string.", + "notes" : "Optional event notes\/body text.", + "start" : "Optional ISO8601 start timestamp.", + "timeoutMs" : "Optional timeout for waiting on user save\/cancel.", + "title" : "Optional event title shown in the editor." + }, + "argumentTypes" : { + "end" : "string", + "location" : "string", + "notes" : "string", + "start" : "string", + "timeoutMs" : "number", + "title" : "string" + }, + "example" : "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + "id" : "calendar.ui.presentNewEvent", + "jsNames" : [ + "apple.calendar.presentNewEvent" + ], + "optionalArguments" : [ + "title", + "start", + "end", + "notes", + "location", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "calendar.writeOnly" + ], + "resultSummary" : "Object with action plus identifier\/title when the user saves.", + "summary" : "Present system UI to let the user create or edit a new calendar event draft.", + "tags" : [ + "calendar", + "eventkit", + "system-ui", + "picker" + ], + "title" : "Present calendar event editor" + }, + { + "allowedStringValues" : { + "operation" : [ + "create", + "update" + ] + }, + "argumentHints" : { + "calendarIdentifier" : "Optional destination EventKit calendarIdentifier.", + "end" : "ISO8601 end timestamp.", + "identifier" : "EventKit eventIdentifier to update. Updates require full calendar access at runtime.", + "isAllDay" : "Whether the event is all-day.", + "location" : "Optional location string.", + "notes" : "Optional notes\/body string.", + "operation" : "create (default without identifier) or update (default with identifier).", + "start" : "ISO8601 start timestamp.", + "title" : "Event title string.", + "url" : "Optional absolute URL string attached to the event." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "end" : "string", + "identifier" : "string", + "isAllDay" : "bool", + "location" : "string", + "notes" : "string", + "operation" : "string", + "start" : "string", + "title" : "string", + "url" : "string" + }, + "example" : "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + "id" : "calendar.write", + "jsNames" : [ + "apple.calendar.createEvent", + "apple.calendar.updateEvent" + ], + "optionalArguments" : [ + "operation", + "identifier", + "title", + "start", + "end", + "notes", + "location", + "url", + "isAllDay", + "calendarIdentifier" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "summary" : "Create a calendar event or patch an existing event by identifier.", + "tags" : [ + "calendar", + "eventkit", + "schedule" + ], + "title" : "Create or update calendar event" + }, + { + "allowedStringValues" : { + "cameraDevice" : [ + "rear", + "front" + ], + "flashMode" : [ + "auto", + "on", + "off" + ], + "mediaType" : [ + "any", + "image", + "photo", + "video" + ], + "videoQuality" : [ + "high", + "medium", + "low", + "640x480", + "iFrame1280x720", + "iFrame960x540" + ] + }, + "argumentHints" : { + "allowsEditing" : "Whether the system editor is shown before returning media; default false.", + "cameraDevice" : "rear (default) or front.", + "flashMode" : "auto (default), on, or off.", + "maximumDurationSeconds" : "Optional maximum duration for video capture.", + "mediaType" : "any (default), image\/photo, or video.", + "outputDirectory" : "Optional sandbox directory for captured media; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on capture\/export.", + "videoQuality" : "UIImagePickerController quality name such as high, medium, low, 640x480, iFrame1280x720, or iFrame960x540." + }, + "argumentTypes" : { + "allowsEditing" : "bool", + "cameraDevice" : "string", + "flashMode" : "string", + "maximumDurationSeconds" : "number", + "mediaType" : "string", + "outputDirectory" : "string", + "timeoutMs" : "number", + "videoQuality" : "string" + }, + "example" : "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })", + "id" : "camera.ui.capture", + "jsNames" : [ + "apple.camera.capture" + ], + "optionalArguments" : [ + "mediaType", + "outputDirectory", + "timeoutMs", + "allowsEditing", + "cameraDevice", + "flashMode", + "videoQuality", + "maximumDurationSeconds" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path\/artifactID\/mediaType\/uniformTypeIdentifier\/bytes.", + "summary" : "Present system camera UI and export captured media into the sandbox artifact store.", + "tags" : [ + "camera", + "capture", + "photos", + "system-ui", + "artifact" + ], + "title" : "Capture photo or video with camera UI" + }, + { + "allowedStringValues" : { + "mode" : [ + "any", + "text", + "barcode" + ], + "qualityLevel" : [ + "balanced", + "fast", + "accurate" + ] + }, + "argumentHints" : { + "isGuidanceEnabled" : "Whether VisionKit guidance UI is shown; default true.", + "isHighFrameRateTrackingEnabled" : "Whether high-frame-rate tracking is enabled; default true.", + "isHighlightingEnabled" : "Whether recognized items are highlighted; default true.", + "isPinchToZoomEnabled" : "Whether pinch-to-zoom is enabled; default true.", + "languages" : "Optional text recognition language identifiers.", + "mode" : "any (default), text, or barcode.", + "qualityLevel" : "balanced (default), fast, or accurate.", + "recognizedDataTypes" : "Optional array containing text and\/or barcode; overrides mode.", + "recognizesMultipleItems" : "Whether the scanner tracks multiple items at once; default false.", + "returnsOnFirstResult" : "Whether to dismiss as soon as data is recognized; default true.", + "timeoutMs" : "Optional timeout for waiting on a scan result or cancellation." + }, + "argumentTypes" : { + "isGuidanceEnabled" : "bool", + "isHighFrameRateTrackingEnabled" : "bool", + "isHighlightingEnabled" : "bool", + "isPinchToZoomEnabled" : "bool", + "languages" : "array", + "mode" : "string", + "qualityLevel" : "string", + "recognizedDataTypes" : "array", + "recognizesMultipleItems" : "bool", + "returnsOnFirstResult" : "bool", + "timeoutMs" : "number" + }, + "example" : "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })", + "id" : "camera.ui.scanData", + "jsNames" : [ + "apple.camera.scanData" + ], + "optionalArguments" : [ + "mode", + "recognizedDataTypes", + "languages", + "qualityLevel", + "recognizesMultipleItems", + "returnsOnFirstResult", + "isGuidanceEnabled", + "isHighlightingEnabled", + "isPinchToZoomEnabled", + "isHighFrameRateTrackingEnabled", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action and items containing text transcripts or barcode payloads.", + "summary" : "Present VisionKit live data scanner UI and return recognized text or barcode payloads.", + "tags" : [ + "camera", + "scan", + "barcode", + "text", + "visionkit", + "system-ui" + ], + "title" : "Scan text or barcodes with camera UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier; defaults to the host client's configured container." + }, + "argumentTypes" : { + "containerIdentifier" : "string" + }, + "example" : "await apple.cloudkit.getAccountStatus({ containerIdentifier: 'iCloud.com.example.app' })", + "id" : "cloudkit.account.status", + "jsNames" : [ + "apple.cloudkit.getAccountStatus" + ], + "optionalArguments" : [ + "containerIdentifier" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/accountAvailable and containerIdentifier when available.", + "summary" : "Read iCloud account availability for CloudKit-backed agent state.", + "tags" : [ + "cloudkit", + "icloud", + "sync", + "account" + ], + "title" : "Read CloudKit account status" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", + "database" : "private (default), shared, or public.", + "recordName" : "CloudKit recordName to delete.", + "zoneID" : "Optional custom zone identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "recordName" : "string", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.deleteRecord({ database: 'private', recordName: 'task-1' })", + "id" : "cloudkit.record.delete", + "jsNames" : [ + "apple.cloudkit.deleteRecord" + ], + "optionalArguments" : [ + "database", + "containerIdentifier", + "zoneID" + ], + "requiredArguments" : [ + "recordName" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with recordName\/deleted\/database.", + "summary" : "Delete a CloudKit record by recordName from a configured database.", + "tags" : [ + "cloudkit", + "icloud", + "database", + "delete" + ], + "title" : "Delete CloudKit record" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", + "database" : "private (default), shared, or public.", + "fields" : "JSON object mapped by the host client into supported CloudKit field values.", + "recordName" : "Optional CloudKit recordName; omitted means create a new record.", + "recordType" : "CloudKit record type to create or update.", + "savePolicy" : "Host-supported save policy such as changedKeys or allKeys.", + "zoneID" : "Optional custom zone identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "fields" : "object", + "recordName" : "string", + "recordType" : "string", + "savePolicy" : "string", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.saveRecord({ database: 'private', recordType: 'Task', recordName: 'task-1', fields: { title: 'Review' } })", + "id" : "cloudkit.record.save", + "jsNames" : [ + "apple.cloudkit.saveRecord" + ], + "optionalArguments" : [ + "recordName", + "database", + "containerIdentifier", + "zoneID", + "savePolicy" + ], + "requiredArguments" : [ + "recordType", + "fields" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Saved record object with recordName\/recordType\/fields\/changeTag\/database.", + "summary" : "Create or update a CloudKit record in a private\/shared\/public database.", + "tags" : [ + "cloudkit", + "icloud", + "database", + "write" + ], + "title" : "Save CloudKit record" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", + "database" : "private (default), shared, or public.", + "desiredKeys" : "Optional array of field keys to return.", + "limit" : "Max records to return; default is host-defined.", + "predicate" : "Host-supported predicate object or string; keep predicates bounded and repairable.", + "recordType" : "CloudKit record type to query.", + "sortDescriptors" : "Optional array of sort descriptor objects.", + "zoneID" : "Optional custom zone identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "desiredKeys" : "array", + "limit" : "number", + "predicate" : "any", + "recordType" : "string", + "sortDescriptors" : "array", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.queryRecords({ database: 'private', recordType: 'Task', limit: 20 })", + "id" : "cloudkit.records.query", + "jsNames" : [ + "apple.cloudkit.queryRecords" + ], + "optionalArguments" : [ + "database", + "containerIdentifier", + "zoneID", + "predicate", + "sortDescriptors", + "desiredKeys", + "limit" + ], + "requiredArguments" : [ + "recordType" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of records with recordName\/recordType\/fields\/modifiedAt\/database.", + "summary" : "Query records from a private\/shared\/public CloudKit database for synced agent state.", + "tags" : [ + "cloudkit", + "icloud", + "database", + "query" + ], + "title" : "Query CloudKit records" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "database" : "private (default), shared, or public.", + "firesOnRecordCreation" : "Whether creation events are enqueued; default true.", + "firesOnRecordDeletion" : "Whether delete events are enqueued; default true.", + "firesOnRecordUpdate" : "Whether update events are enqueued; default true.", + "predicate" : "Host-supported predicate object or string.", + "recordType" : "CloudKit record type to observe.", + "subscriptionID" : "Stable host-visible subscription identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "firesOnRecordCreation" : "bool", + "firesOnRecordDeletion" : "bool", + "firesOnRecordUpdate" : "bool", + "predicate" : "any", + "recordType" : "string", + "subscriptionID" : "string", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.subscribe({ subscriptionID: 'tasks', database: 'private', recordType: 'Task' })", + "id" : "cloudkit.subscription.save", + "jsNames" : [ + "apple.cloudkit.subscribe" + ], + "optionalArguments" : [ + "database", + "containerIdentifier", + "zoneID", + "predicate", + "firesOnRecordCreation", + "firesOnRecordUpdate", + "firesOnRecordDeletion" + ], + "requiredArguments" : [ + "subscriptionID", + "recordType" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with subscriptionID\/saved\/database.", + "summary" : "Register a bounded CloudKit query subscription so the host can enqueue subscription events later.", + "tags" : [ + "cloudkit", + "icloud", + "subscription", + "inbox" + ], + "title" : "Save CloudKit subscription" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "limit" : "Maximum number of inbox events to read.", + "subscriptionID" : "Optional subscription filter." + }, + "argumentTypes" : { + "afterCursor" : "string", + "limit" : "number", + "subscriptionID" : "string" + }, + "example" : "await apple.cloudkit.listEvents({ subscriptionID: 'tasks', limit: 20 })", + "id" : "cloudkit.subscriptionEvents.read", + "jsNames" : [ + "apple.cloudkit.listEvents" + ], + "optionalArguments" : [ + "subscriptionID", + "limit", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of subscription event objects with cursor\/subscriptionID\/recordName\/reason\/database.", + "summary" : "Read bounded CloudKit subscription events previously received by the host.", + "tags" : [ + "cloudkit", + "icloud", + "subscription", + "inbox", + "events" + ], + "title" : "Read CloudKit subscription inbox" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifiers" : "Optional array of contact identifiers for targeted read.", + "limit" : "Max number of contacts, default 50." + }, + "argumentTypes" : { + "identifiers" : "array", + "limit" : "number" + }, + "example" : "await apple.contacts.list({ limit: 25 })", + "id" : "contacts.read", + "jsNames" : [ + "apple.contacts.list" + ], + "optionalArguments" : [ + "limit", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Array of contacts with identifier\/name\/organization\/phones\/emails.", + "summary" : "Read contacts with bounded fields.", + "tags" : [ + "contacts", + "address-book", + "people" + ], + "title" : "Read contacts" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of contacts, default 20.", + "query" : "Name text to match." + }, + "argumentTypes" : { + "limit" : "number", + "query" : "string" + }, + "example" : "await apple.contacts.search({ query: 'Alex', limit: 10 })", + "id" : "contacts.search", + "jsNames" : [ + "apple.contacts.search" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + "query" + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Array of contact objects.", + "summary" : "Search contacts by name.", + "tags" : [ + "contacts", + "search", + "people" + ], + "title" : "Search contacts" + }, + { + "allowedStringValues" : { + "mode" : [ + "single", + "multiple" + ] + }, + "argumentHints" : { + "displayedPropertyKeys" : "Optional array of CNContact property key strings to display.", + "mode" : "single (default) or multiple.", + "timeoutMs" : "Optional timeout for waiting on user selection." + }, + "argumentTypes" : { + "displayedPropertyKeys" : "array", + "mode" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })", + "id" : "contacts.ui.pick", + "jsNames" : [ + "apple.contacts.pick" + ], + "optionalArguments" : [ + "mode", + "displayedPropertyKeys", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of selected contacts with identifier\/name\/organization\/phones\/emails.", + "summary" : "Present system contact picker UI and return selected contacts without requiring full Contacts permission.", + "tags" : [ + "contacts", + "people", + "system-ui", + "picker" + ], + "title" : "Pick contacts with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "allowsActions" : "Whether built-in actions like call\/message are shown; default true.", + "allowsEditing" : "Whether the user can edit the contact; default false.", + "displayedPropertyKeys" : "Optional array of CNContact property key strings to display.", + "identifier" : "Contact identifier from apple.contacts.list\/search\/pick.", + "timeoutMs" : "Optional timeout for waiting on dismissal." + }, + "argumentTypes" : { + "allowsActions" : "bool", + "allowsEditing" : "bool", + "displayedPropertyKeys" : "array", + "identifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })", + "id" : "contacts.ui.presentContact", + "jsNames" : [ + "apple.contacts.presentContact" + ], + "optionalArguments" : [ + "allowsEditing", + "allowsActions", + "displayedPropertyKeys", + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Object with action and contact when available.", + "summary" : "Present system contact card UI for a contact identifier.", + "tags" : [ + "contacts", + "people", + "system-ui", + "details" + ], + "title" : "Present contact card" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "emailAddresses" : "Optional array of email address strings.", + "familyName" : "Optional family name to prefill.", + "givenName" : "Optional given name to prefill.", + "organization" : "Optional organization to prefill.", + "phoneNumbers" : "Optional array of phone number strings.", + "timeoutMs" : "Optional timeout for waiting on user completion." + }, + "argumentTypes" : { + "emailAddresses" : "array", + "familyName" : "string", + "givenName" : "string", + "organization" : "string", + "phoneNumbers" : "array", + "timeoutMs" : "number" + }, + "example" : "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })", + "id" : "contacts.ui.presentNewContact", + "jsNames" : [ + "apple.contacts.presentNewContact" + ], + "optionalArguments" : [ + "givenName", + "familyName", + "organization", + "phoneNumbers", + "emailAddresses", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Object with action and contact when the user saves.", + "summary" : "Present system UI for creating a new contact draft.", + "tags" : [ + "contacts", + "people", + "system-ui", + "create" + ], + "title" : "Present new contact editor" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "asCopy" : "Whether to export as a copy; default true.", + "path" : "Single sandbox file path to export.", + "paths" : "Optional array of sandbox file paths to export.", + "timeoutMs" : "Optional timeout for waiting on export completion." + }, + "argumentTypes" : { + "asCopy" : "bool", + "path" : "string", + "paths" : "array", + "timeoutMs" : "number" + }, + "example" : "await apple.documents.export({ path: 'tmp:report.pdf' })", + "id" : "documents.ui.export", + "jsNames" : [ + "apple.documents.export", + "apple.documents.save" + ], + "optionalArguments" : [ + "path", + "paths", + "asCopy", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/count and destination URLs when the provider returns them.", + "summary" : "Present Files export UI to save one or more sandbox files to a user-selected destination.", + "tags" : [ + "documents", + "files", + "system-ui", + "export", + "save" + ], + "title" : "Export documents with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "name" : "Optional display name for the document interaction controller.", + "path" : "Sandbox file path to hand off.", + "timeoutMs" : "Optional timeout for waiting on the Open In menu dismissal.", + "uti" : "Optional uniform type identifier override." + }, + "argumentTypes" : { + "name" : "string", + "path" : "string", + "timeoutMs" : "number", + "uti" : "string" + }, + "example" : "await apple.documents.openIn({ path: 'tmp:report.pdf' })", + "id" : "documents.ui.openIn", + "jsNames" : [ + "apple.documents.openIn" + ], + "optionalArguments" : [ + "name", + "uti", + "timeoutMs" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action and application bundle identifier when the user hands off the file.", + "summary" : "Present the system Open In menu for a sandbox file.", + "tags" : [ + "documents", + "files", + "system-ui", + "open-in", + "handoff" + ], + "title" : "Open document in another app" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "allowMultiple" : "Whether multiple files may be selected; default false.", + "contentTypes" : "Optional array of UTType identifiers; defaults to public.item.", + "outputDirectory" : "Optional sandbox directory for copied files; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on user selection\/copy." + }, + "argumentTypes" : { + "allowMultiple" : "bool", + "contentTypes" : "array", + "outputDirectory" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })", + "id" : "documents.ui.pick", + "jsNames" : [ + "apple.documents.pick" + ], + "optionalArguments" : [ + "contentTypes", + "allowMultiple", + "outputDirectory", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of selected documents with path\/artifactID\/filename\/uniformTypeIdentifier\/bytes.", + "summary" : "Present Files document picker UI and copy selected documents into the sandbox artifact store.", + "tags" : [ + "documents", + "files", + "system-ui", + "picker", + "artifact" + ], + "title" : "Pick documents with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "outputDirectory" : "Optional sandbox directory for scanned page images; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on user scanning\/export." + }, + "argumentTypes" : { + "outputDirectory" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.documents.scan({ outputDirectory: 'tmp:scans' })", + "id" : "documents.ui.scan", + "jsNames" : [ + "apple.documents.scan" + ], + "optionalArguments" : [ + "outputDirectory", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of scanned page artifacts with path\/artifactID\/pageIndex\/mediaType\/bytes.", + "summary" : "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store.", + "tags" : [ + "documents", + "scan", + "camera", + "system-ui", + "artifact" + ], + "title" : "Scan documents with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "input" : "Input text to classify or extract from.", + "instructions" : "Optional task-specific instructions.", + "schema" : "Optional JSON schema object accepted by the host adapter.", + "schemaIdentifier" : "Preferred host-defined schema identifier.", + "timeoutMs" : "Maximum extraction wait time in milliseconds." + }, + "argumentTypes" : { + "input" : "string", + "instructions" : "string", + "schema" : "object", + "schemaIdentifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.foundationModels.extract({ input: text, schemaIdentifier: 'todo' })", + "id" : "foundationModels.extract", + "jsNames" : [ + "apple.foundationModels.extract" + ], + "optionalArguments" : [ + "schema", + "schemaIdentifier", + "instructions", + "timeoutMs" + ], + "requiredArguments" : [ + "input" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with values\/classification\/confidence\/schemaIdentifier.", + "summary" : "Run host-defined structured extraction or classification with Foundation Models.", + "tags" : [ + "foundationmodels", + "apple-intelligence", + "llm", + "structured-output" + ], + "title" : "Extract structured data with Foundation Models" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "instructions" : "Optional host-approved system instructions.", + "maxTokens" : "Optional maximum output tokens.", + "prompt" : "Prompt text sent to the host Foundation Models session.", + "schemaIdentifier" : "Optional host-defined output schema identifier.", + "temperature" : "Optional sampling temperature when supported.", + "timeoutMs" : "Maximum generation wait time in milliseconds." + }, + "argumentTypes" : { + "instructions" : "string", + "maxTokens" : "number", + "prompt" : "any", + "schemaIdentifier" : "string", + "temperature" : "number", + "timeoutMs" : "number" + }, + "example" : "await apple.foundationModels.generate({ prompt: 'Summarize this note', maxTokens: 200 })", + "id" : "foundationModels.generate", + "jsNames" : [ + "apple.foundationModels.generate" + ], + "optionalArguments" : [ + "instructions", + "temperature", + "maxTokens", + "schemaIdentifier", + "timeoutMs" + ], + "requiredArguments" : [ + "prompt" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with text\/finishReason\/usage when available.", + "summary" : "Generate text locally through the host Foundation Models adapter when available.", + "tags" : [ + "foundationmodels", + "apple-intelligence", + "llm", + "generation" + ], + "title" : "Generate text with Foundation Models" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.foundationModels.getStatus()", + "id" : "foundationModels.status", + "jsNames" : [ + "apple.foundationModels.getStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with available\/status\/reason\/modelIdentifier when available.", + "summary" : "Read host Foundation Models availability\/status before attempting local generation.", + "tags" : [ + "foundationmodels", + "apple-intelligence", + "llm", + "availability" + ], + "title" : "Read Foundation Models availability" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "File or directory path to inspect." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.access({ path: 'tmp:data.json' })", + "id" : "fs.access", + "jsNames" : [ + "apple.fs.access", + "fs.promises.access" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with readable\/writable\/path.", + "summary" : "Check read\/write access for path within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Check path access" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "from" : "Source sandbox path.", + "to" : "Destination sandbox path." + }, + "argumentTypes" : { + "from" : "string", + "to" : "string" + }, + "example" : "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", + "id" : "fs.copy", + "jsNames" : [ + "apple.fs.copy", + "fs.promises.copyFile" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "from", + "to" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with from\/to resolved paths.", + "summary" : "Copy file\/directory within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Copy file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Path to file or directory.", + "recursive" : "Required as true when deleting a directory." + }, + "argumentTypes" : { + "path" : "string", + "recursive" : "bool" + }, + "example" : "await apple.fs.delete({ path: 'tmp:data.json' })", + "id" : "fs.delete", + "jsNames" : [ + "apple.fs.delete", + "fs.promises.rm" + ], + "optionalArguments" : [ + "recursive" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with deleted flag and path.", + "summary" : "Delete file\/directory within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Delete file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "File or directory path to check." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.exists({ path: 'tmp:data.json' })", + "id" : "fs.exists", + "jsNames" : [ + "apple.fs.exists" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Boolean.", + "summary" : "Check if file\/directory exists within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Check path exists" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Directory path using allowed root prefix (tmp:, caches:, documents:)." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.list({ path: 'tmp:' })", + "id" : "fs.list", + "jsNames" : [ + "apple.fs.list", + "fs.promises.readdir" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of entry objects with name\/path\/isDirectory\/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings.", + "summary" : "List files\/directories within allowed sandbox roots as entry objects.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "List directory" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Directory path to create.", + "recursive" : "Boolean; default true." + }, + "argumentTypes" : { + "path" : "string", + "recursive" : "bool" + }, + "example" : "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })", + "id" : "fs.mkdir", + "jsNames" : [ + "apple.fs.mkdir", + "fs.promises.mkdir" + ], + "optionalArguments" : [ + "recursive" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with created flag and path.", + "summary" : "Create directories within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Create directory" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "from" : "Source sandbox path.", + "to" : "Destination sandbox path." + }, + "argumentTypes" : { + "from" : "string", + "to" : "string" + }, + "example" : "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", + "id" : "fs.move", + "jsNames" : [ + "apple.fs.move", + "fs.promises.rename" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "from", + "to" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with from\/to resolved paths.", + "summary" : "Move file\/directory within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Move file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "encoding" : "utf8 (default) or base64.", + "path" : "File path using allowed root prefix." + }, + "argumentTypes" : { + "encoding" : "string", + "path" : "string" + }, + "example" : "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })", + "id" : "fs.read", + "jsNames" : [ + "apple.fs.read", + "fs.promises.readFile" + ], + "optionalArguments" : [ + "encoding" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path plus text or base64 field.", + "summary" : "Read text\/base64 file data within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Read file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "File or directory path." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.stat({ path: 'tmp:data.json' })", + "id" : "fs.stat", + "jsNames" : [ + "apple.fs.stat", + "fs.promises.stat" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path\/isDirectory\/size\/createdAt\/modifiedAt.", + "summary" : "Read file metadata within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Stat path" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "data" : "UTF-8 text or base64 string depending on encoding.", + "encoding" : "utf8 (default) or base64.", + "path" : "File path using allowed root prefix." + }, + "argumentTypes" : { + "data" : "string", + "encoding" : "string", + "path" : "string" + }, + "example" : "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })", + "id" : "fs.write", + "jsNames" : [ + "apple.fs.write", + "fs.promises.writeFile" + ], + "optionalArguments" : [ + "data", + "encoding" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path and bytesWritten.", + "summary" : "Write text\/base64 file data within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Write file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "readTypes" : "Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.", + "writeTypes" : "Optional array of type names to write (quantity types only)." + }, + "argumentTypes" : { + "readTypes" : "array", + "writeTypes" : "array" + }, + "example" : "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })", + "id" : "health.permission.request", + "jsNames" : [ + "apple.health.requestPermission" + ], + "optionalArguments" : [ + "readTypes", + "writeTypes" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted fields and requested type arrays.", + "summary" : "Request HealthKit authorization for requested read\/write types.", + "tags" : [ + "healthkit", + "health", + "permission" + ], + "title" : "Request HealthKit permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "end" : "Optional ISO8601 end timestamp; defaults to now.", + "limit" : "Max number of samples, default 50.", + "start" : "Optional ISO8601 start timestamp; defaults to last 24h.", + "type" : "Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.", + "unit" : "Optional unit override for quantity types (count, bpm, kcal, kg, m)." + }, + "argumentTypes" : { + "end" : "string", + "limit" : "number", + "start" : "string", + "type" : "string", + "unit" : "string" + }, + "example" : "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })", + "id" : "health.read", + "jsNames" : [ + "apple.health.read" + ], + "optionalArguments" : [ + "start", + "end", + "limit", + "unit" + ], + "requiredArguments" : [ + "type" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of samples with identifier\/type\/value and timing metadata.", + "summary" : "Read HealthKit samples for a supported type and date range.", + "tags" : [ + "healthkit", + "health", + "query" + ], + "title" : "Read HealthKit samples" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "end" : "Optional ISO8601 end timestamp; defaults to start.", + "start" : "Optional ISO8601 start timestamp; defaults to now.", + "type" : "Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.", + "unit" : "Optional unit (count, bpm, kcal, kg, m).", + "value" : "Numeric sample value." + }, + "argumentTypes" : { + "end" : "string", + "start" : "string", + "type" : "string", + "unit" : "string", + "value" : "number" + }, + "example" : "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })", + "id" : "health.write", + "jsNames" : [ + "apple.health.write" + ], + "optionalArguments" : [ + "unit", + "start", + "end" + ], + "requiredArguments" : [ + "type", + "value" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/type\/value\/unit and written=true.", + "summary" : "Write a HealthKit quantity sample for supported writable quantity types.", + "tags" : [ + "healthkit", + "health", + "write" + ], + "title" : "Write HealthKit quantity sample" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "includeCharacteristics" : "Boolean; include characteristic details when true.", + "limit" : "Max number of homes to return, default 10." + }, + "argumentTypes" : { + "includeCharacteristics" : "bool", + "limit" : "number" + }, + "example" : "await apple.home.list({ includeCharacteristics: true, limit: 5 })", + "id" : "home.read", + "jsNames" : [ + "apple.home.list" + ], + "optionalArguments" : [ + "includeCharacteristics", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "homeKit" + ], + "resultSummary" : "Array of homes with accessories\/services snapshot.", + "summary" : "Read homes\/accessories\/services (and optional characteristics) from HomeKit.", + "tags" : [ + "homekit", + "iot", + "devices" + ], + "title" : "Read HomeKit graph" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "accessoryIdentifier" : "Accessory UUID string from home.read output.", + "characteristicType" : "Characteristic type identifier (e.g. HMCharacteristicTypePowerState).", + "serviceType" : "Optional service type filter for characteristic lookup.", + "value" : "Target value; string\/number\/bool\/null." + }, + "argumentTypes" : { + "accessoryIdentifier" : "string", + "characteristicType" : "string", + "serviceType" : "string", + "value" : "any" + }, + "example" : "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })", + "id" : "home.write", + "jsNames" : [ + "apple.home.writeCharacteristic" + ], + "optionalArguments" : [ + "serviceType" + ], + "requiredArguments" : [ + "accessoryIdentifier", + "characteristicType", + "value" + ], + "requiredPermissions" : [ + "homeKit" + ], + "resultSummary" : "Object with accessoryIdentifier\/characteristicType\/written.", + "summary" : "Write a value to a writable HomeKit characteristic for a target accessory.", + "tags" : [ + "homekit", + "iot", + "devices", + "control" + ], + "title" : "Write HomeKit characteristic" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "key" : "Logical key for value removal." + }, + "argumentTypes" : { + "key" : "string" + }, + "example" : "await apple.keychain.delete('auth_token')", + "id" : "keychain.delete", + "jsNames" : [ + "apple.keychain.delete" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "key" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object { key, deleted: true }.", + "summary" : "Delete an app-scoped Keychain value.", + "tags" : [ + "security", + "token", + "keychain" + ], + "title" : "Delete Keychain value" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "key" : "Logical key for this secret value." + }, + "argumentTypes" : { + "key" : "string" + }, + "example" : "await apple.keychain.get('auth_token')", + "id" : "keychain.read", + "jsNames" : [ + "apple.keychain.get" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "key" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object { key, value } or null when the key does not exist.", + "summary" : "Read a string value from app-scoped Keychain storage.", + "tags" : [ + "security", + "token", + "keychain" + ], + "title" : "Read Keychain value" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "key" : "Logical key for this secret value.", + "value" : "Secret string value. Defaults to empty string when omitted." + }, + "argumentTypes" : { + "key" : "string", + "value" : "string" + }, + "example" : "await apple.keychain.set('auth_token', token)", + "id" : "keychain.write", + "jsNames" : [ + "apple.keychain.set" + ], + "optionalArguments" : [ + "value" + ], + "requiredArguments" : [ + "key" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object { key, written: true }.", + "summary" : "Store or update a string value in app-scoped Keychain storage.", + "tags" : [ + "security", + "token", + "keychain" + ], + "title" : "Write Keychain value" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.location.requestPermission()", + "id" : "location.permission.request", + "jsNames" : [ + "apple.location.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Permission status string.", + "summary" : "Trigger location when-in-use permission request flow.", + "tags" : [ + "location", + "permission" + ], + "title" : "Request location permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "mode" : "permissionStatus or current (default current)." + }, + "argumentTypes" : { + "mode" : "string" + }, + "example" : "await apple.location.getCurrentPosition()", + "id" : "location.read", + "jsNames" : [ + "apple.location.getCurrentPosition", + "apple.location.getPermissionStatus" + ], + "optionalArguments" : [ + "mode" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Permission status string or coordinates object.", + "summary" : "Read location permission status or current coordinates.", + "tags" : [ + "location", + "permission", + "geospatial" + ], + "title" : "Read location state or coordinates" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "attachments" : "Optional array of { path, mimeType?, filename? } sandbox file attachments.", + "bcc" : "Optional array of BCC email strings.", + "body" : "Optional message body.", + "cc" : "Optional array of CC email strings.", + "isHTML" : "Whether body should be treated as HTML; default false.", + "subject" : "Optional subject.", + "timeoutMs" : "Optional timeout for waiting on user completion.", + "to" : "Optional array of recipient email strings." + }, + "argumentTypes" : { + "attachments" : "array", + "bcc" : "array", + "body" : "string", + "cc" : "array", + "isHTML" : "bool", + "subject" : "string", + "timeoutMs" : "number", + "to" : "array" + }, + "example" : "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })", + "id" : "mail.ui.compose", + "jsNames" : [ + "apple.mail.compose" + ], + "optionalArguments" : [ + "to", + "cc", + "bcc", + "subject", + "body", + "isHTML", + "attachments", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action sent\/saved\/cancelled\/failed.", + "summary" : "Present system mail compose UI with optional recipients, body, and sandbox file attachments.", + "tags" : [ + "mail", + "compose", + "system-ui", + "share" + ], + "title" : "Compose mail with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "address" : "Address or place text to geocode.", + "limit" : "Maximum coordinate candidates.", + "region" : "Optional search bias region object." + }, + "argumentTypes" : { + "address" : "string", + "limit" : "number", + "region" : "object" + }, + "example" : "await apple.maps.geocode({ address: '1 Infinite Loop, Cupertino', limit: 3 })", + "id" : "maps.geocode", + "jsNames" : [ + "apple.maps.geocode" + ], + "optionalArguments" : [ + "region", + "limit" + ], + "requiredArguments" : [ + "address" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of placemarks with name\/address\/latitude\/longitude.", + "summary" : "Resolve an address or place text to coordinate candidates through MapKit.", + "tags" : [ + "maps", + "mapkit", + "geocode", + "location" + ], + "title" : "Geocode address" + }, + { + "allowedStringValues" : { + "transportType" : [ + "automobile", + "walking", + "transit", + "any" + ] + }, + "argumentHints" : { + "destination" : "Optional destination object for directions.", + "latitude" : "Latitude when opening coordinates.", + "longitude" : "Longitude when opening coordinates.", + "query" : "Place\/search query to open.", + "url" : "Optional maps URL to open." + }, + "argumentTypes" : { + "destination" : "object", + "latitude" : "number", + "longitude" : "number", + "query" : "string", + "transportType" : "string", + "url" : "string" + }, + "example" : "await apple.maps.open({ query: 'Apple Park' })", + "id" : "maps.open", + "jsNames" : [ + "apple.maps.open" + ], + "optionalArguments" : [ + "query", + "url", + "latitude", + "longitude", + "destination", + "transportType" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with opened\/target.", + "summary" : "Open Apple Maps with coordinates, a query, URL, or directions in a user-mediated handoff.", + "tags" : [ + "maps", + "mapkit", + "open", + "directions" + ], + "title" : "Open Maps" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + "latitude" : "number", + "locale" : "string", + "longitude" : "number" + }, + "example" : "await apple.maps.reverseGeocode({ latitude: 37.3318, longitude: -122.0312 })", + "id" : "maps.reverseGeocode", + "jsNames" : [ + "apple.maps.reverseGeocode" + ], + "optionalArguments" : [ + "locale" + ], + "requiredArguments" : [ + "latitude", + "longitude" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of placemarks with name\/address\/latitude\/longitude.", + "summary" : "Resolve latitude\/longitude into address candidates through MapKit.", + "tags" : [ + "maps", + "mapkit", + "reverse-geocode", + "location" + ], + "title" : "Reverse geocode coordinates" + }, + { + "allowedStringValues" : { + "transportType" : [ + "automobile", + "walking", + "transit", + "any" + ] + }, + "argumentHints" : { + "destination" : "Coordinate or map item object.", + "origin" : "Coordinate or map item object.", + "transportType" : "automobile (default), walking, or transit when supported." + }, + "argumentTypes" : { + "arrivalDate" : "string", + "departureDate" : "string", + "destination" : "object", + "origin" : "object", + "transportType" : "string" + }, + "example" : "await apple.maps.routeEstimate({ origin: { latitude: 37.33, longitude: -122.03 }, destination: { latitude: 37.77, longitude: -122.42 }, transportType: 'automobile' })", + "id" : "maps.route.estimate", + "jsNames" : [ + "apple.maps.routeEstimate" + ], + "optionalArguments" : [ + "transportType", + "departureDate", + "arrivalDate" + ], + "requiredArguments" : [ + "origin", + "destination" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with distanceMeters\/expectedTravelTimeSeconds\/transportType.", + "summary" : "Estimate route distance and travel time between two coordinates or map items.", + "tags" : [ + "maps", + "mapkit", + "directions", + "route" + ], + "title" : "Estimate route" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Maximum map items.", + "query" : "Search query string.", + "region" : "Optional coordinate region object for local bias.", + "resultTypes" : "Optional host-supported result type filters." + }, + "argumentTypes" : { + "limit" : "number", + "query" : "string", + "region" : "object", + "resultTypes" : "array" + }, + "example" : "await apple.maps.search({ query: 'coffee near me', limit: 5 })", + "id" : "maps.search", + "jsNames" : [ + "apple.maps.search" + ], + "optionalArguments" : [ + "region", + "resultTypes", + "limit" + ], + "requiredArguments" : [ + "query" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of map items with name\/address\/category\/latitude\/longitude\/url.", + "summary" : "Search for local businesses, addresses, or points of interest through MapKit.", + "tags" : [ + "maps", + "mapkit", + "local-search", + "places" + ], + "title" : "Search local map items" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "outputPath" : "Optional output sandbox path; defaults to tmp-generated JPEG.", + "path" : "Input video sandbox path.", + "timeMs" : "Frame timestamp in milliseconds; default 0." + }, + "argumentTypes" : { + "outputPath" : "string", + "path" : "string", + "timeMs" : "number" + }, + "example" : "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })", + "id" : "media.frame.extract", + "jsNames" : [ + "apple.media.extractFrame" + ], + "optionalArguments" : [ + "timeMs", + "outputPath" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with output path and artifactID.", + "summary" : "Extract frame at time offset and persist JPEG output.", + "tags" : [ + "media", + "avfoundation", + "thumbnail" + ], + "title" : "Extract video frame" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Sandbox path like tmp:clip.mov." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.media.metadata({ path: 'tmp:video.mov' })", + "id" : "media.metadata.read", + "jsNames" : [ + "apple.media.metadata" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path\/durationSeconds\/tracks.", + "summary" : "Read duration and track metadata from media files.", + "tags" : [ + "media", + "avfoundation", + "metadata" + ], + "title" : "Read media metadata" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "outputPath" : "Optional output sandbox path; defaults to tmp-generated mp4.", + "path" : "Input media sandbox path.", + "preset" : "AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality." + }, + "argumentTypes" : { + "outputPath" : "string", + "path" : "string", + "preset" : "string" + }, + "example" : "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })", + "id" : "media.transcode", + "jsNames" : [ + "apple.media.transcode" + ], + "optionalArguments" : [ + "outputPath", + "preset" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with output path\/artifactID\/preset.", + "summary" : "Transcode media into MP4 with preset quality.", + "tags" : [ + "media", + "avfoundation", + "transcode" + ], + "title" : "Transcode media" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "attachments" : "Optional array of { path, filename? } sandbox file attachments.", + "body" : "Optional message body.", + "recipients" : "Optional array of phone number or address strings.", + "subject" : "Optional subject on devices\/accounts that support it.", + "timeoutMs" : "Optional timeout for waiting on user completion." + }, + "argumentTypes" : { + "attachments" : "array", + "body" : "string", + "recipients" : "array", + "subject" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })", + "id" : "messages.ui.compose", + "jsNames" : [ + "apple.messages.compose" + ], + "optionalArguments" : [ + "recipients", + "subject", + "body", + "attachments", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action sent\/cancelled\/failed.", + "summary" : "Present system Messages compose UI with optional recipients, body, and sandbox file attachments.", + "tags" : [ + "messages", + "sms", + "compose", + "system-ui", + "share" + ], + "title" : "Compose message with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Music catalog item identifier.", + "type" : "Catalog type such as songs, albums, artists, playlists, or stations." + }, + "argumentTypes" : { + "countryCode" : "string", + "identifier" : "string", + "type" : "string" + }, + "example" : "await apple.music.getDetails({ identifier: '123', type: 'albums' })", + "id" : "music.catalog.details", + "jsNames" : [ + "apple.music.getDetails" + ], + "optionalArguments" : [ + "type", + "countryCode" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Catalog item details object.", + "summary" : "Read details for a catalog item identifier through MusicKit.", + "tags" : [ + "music", + "musickit", + "catalog", + "details" + ], + "title" : "Read Music catalog details" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "countryCode" : "Optional storefront country code.", + "limit" : "Maximum results.", + "term" : "Catalog search term.", + "types" : "Optional array such as songs, albums, artists, playlists, stations." + }, + "argumentTypes" : { + "countryCode" : "string", + "limit" : "number", + "term" : "string", + "types" : "array" + }, + "example" : "await apple.music.search({ term: 'Miles Davis', types: ['artists', 'albums'], limit: 5 })", + "id" : "music.catalog.search", + "jsNames" : [ + "apple.music.search" + ], + "optionalArguments" : [ + "types", + "limit", + "countryCode" + ], + "requiredArguments" : [ + "term" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object grouped by result type with catalog identifiers and metadata.", + "summary" : "Search the Apple Music catalog for songs, albums, artists, playlists, or stations.", + "tags" : [ + "music", + "musickit", + "catalog", + "search" + ], + "title" : "Search Music catalog" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Maximum library items.", + "type" : "Library type such as playlists, songs, albums, or artists." + }, + "argumentTypes" : { + "limit" : "number", + "type" : "string" + }, + "example" : "await apple.music.readLibrary({ type: 'playlists', limit: 20 })", + "id" : "music.library.read", + "jsNames" : [ + "apple.music.readLibrary" + ], + "optionalArguments" : [ + "type", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "music" + ], + "resultSummary" : "Array of library items with identifiers and metadata.", + "summary" : "Read the user's Music library playlists or items after Music permission is granted.", + "tags" : [ + "music", + "musickit", + "library" + ], + "title" : "Read Music library" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.music.requestPermission()", + "id" : "music.permission.request", + "jsNames" : [ + "apple.music.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted.", + "summary" : "Request Apple Music\/media-library permission before library or playback actions.", + "tags" : [ + "music", + "musickit", + "permission" + ], + "title" : "Request Music permission" + }, + { + "allowedStringValues" : { + "action" : [ + "play", + "pause", + "stop", + "skipToNext", + "skipToPrevious", + "playCatalog", + "playLibrary" + ] + }, + "argumentHints" : { + "action" : "Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.", + "catalogID" : "Optional catalog item identifier.", + "libraryID" : "Optional library item identifier.", + "queue" : "Optional queue definition approved by the host adapter." + }, + "argumentTypes" : { + "action" : "string", + "catalogID" : "string", + "libraryID" : "string", + "queue" : "object", + "startPlaying" : "bool" + }, + "example" : "await apple.music.play({ action: 'playCatalog', catalogID: 'song-id' })", + "id" : "music.playback.control", + "jsNames" : [ + "apple.music.play" + ], + "optionalArguments" : [ + "catalogID", + "libraryID", + "queue", + "startPlaying" + ], + "requiredArguments" : [ + "action" + ], + "requiredPermissions" : [ + "music" + ], + "resultSummary" : "Object with action\/status\/currentItem when available.", + "summary" : "Control Music playback queue through a user-authorized host adapter.", + "tags" : [ + "music", + "musickit", + "playback", + "queue" + ], + "title" : "Control Music playback" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "catalogIDs" : "Optional catalog song identifiers to add.", + "libraryIDs" : "Optional library song identifiers to add.", + "name" : "Playlist name.", + "playlistID" : "Optional existing playlist identifier to update." + }, + "argumentTypes" : { + "catalogIDs" : "array", + "description" : "string", + "libraryIDs" : "array", + "name" : "any", + "playlistID" : "string" + }, + "example" : "await apple.music.writePlaylist({ name: 'Focus', catalogIDs: ['song-id'] })", + "id" : "music.playlist.write", + "jsNames" : [ + "apple.music.writePlaylist" + ], + "optionalArguments" : [ + "playlistID", + "catalogIDs", + "libraryIDs", + "description" + ], + "requiredArguments" : [ + "name" + ], + "requiredPermissions" : [ + "music" + ], + "resultSummary" : "Object with playlistID\/name\/written.", + "summary" : "Create or update a user-library playlist through a host MusicKit adapter.", + "tags" : [ + "music", + "musickit", + "library", + "playlist", + "write" + ], + "title" : "Write Music playlist" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.music.getSubscriptionStatus()", + "id" : "music.subscription.status", + "jsNames" : [ + "apple.music.getSubscriptionStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with canPlayCatalogContent\/hasCloudLibraryEnabled\/status.", + "summary" : "Read MusicKit subscription\/capability status exposed by the host.", + "tags" : [ + "music", + "musickit", + "subscription", + "catalog" + ], + "title" : "Read Music subscription status" + }, + { + "allowedStringValues" : { + "options.responseEncoding" : [ + "text", + "base64" + ] + }, + "argumentHints" : { + "options.body" : "UTF-8 request body string.", + "options.bodyBase64" : "Base64-encoded request body. Mutually exclusive with options.body.", + "options.headers" : "Object of header key\/value string pairs.", + "options.method" : "HTTP method; defaults to GET.", + "options.responseEncoding" : "text (default) or base64.", + "options.timeoutMs" : "Request and bridge wait timeout in milliseconds; defaults to 30000.", + "url" : "Absolute HTTP(S) URL string." + }, + "argumentTypes" : { + "options.body" : "string", + "options.bodyBase64" : "string", + "options.headers" : "object", + "options.method" : "string", + "options.responseEncoding" : "string", + "options.timeoutMs" : "number", + "url" : "string" + }, + "example" : "await fetch('https:\/\/api.example.com\/data').then(r => r.json())", + "id" : "network.fetch", + "jsNames" : [ + "fetch" + ], + "optionalArguments" : [ + "options.method", + "options.headers", + "options.body", + "options.bodyBase64", + "options.timeoutMs", + "options.responseEncoding" + ], + "requiredArguments" : [ + "url" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with ok\/status\/statusText\/headers\/bodyText or bodyBase64.", + "summary" : "Perform HTTP(S) requests through URLSession via a fetch-compatible API.", + "tags" : [ + "network", + "http", + "fetch" + ], + "title" : "Fetch HTTP resource" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "categories" : "Array of category definitions with identifier\/actions\/options approved by the host." + }, + "argumentTypes" : { + "categories" : "array" + }, + "example" : "await apple.notifications.setCategories({ categories: [{ identifier: 'task', actions: [{ identifier: 'done', title: 'Done' }] }] })", + "id" : "notifications.categories.set", + "jsNames" : [ + "apple.notifications.setCategories" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "categories" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with registered category identifiers.", + "summary" : "Register host-approved notification categories and actions for push\/local response handling.", + "tags" : [ + "notifications", + "apns", + "categories", + "actions" + ], + "title" : "Set notification categories" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Single delivered notification identifier to remove.", + "identifiers" : "Array of delivered notification identifiers to remove. Omit both to clear all delivered notifications." + }, + "argumentTypes" : { + "identifier" : "string", + "identifiers" : "array" + }, + "example" : "await apple.notifications.removeDelivered({ identifiers: ['codemode.1', 'codemode.2'] })", + "id" : "notifications.delivered.delete", + "jsNames" : [ + "apple.notifications.removeDelivered" + ], + "optionalArguments" : [ + "identifier", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Object with deleted\/count fields.", + "summary" : "Delete delivered notifications by identifier list or clear all.", + "tags" : [ + "notifications", + "local", + "delivered", + "delete" + ], + "title" : "Delete delivered local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of delivered notifications to return, default 50." + }, + "argumentTypes" : { + "limit" : "number" + }, + "example" : "await apple.notifications.listDelivered({ limit: 20 })", + "id" : "notifications.delivered.read", + "jsNames" : [ + "apple.notifications.listDelivered" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Array of delivered notifications with identifiers\/content\/date metadata.", + "summary" : "List notifications currently delivered in Notification Center.", + "tags" : [ + "notifications", + "local", + "delivered" + ], + "title" : "List delivered local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Single pending request identifier to remove.", + "identifiers" : "Array of pending request identifiers to remove. Omit both to clear all pending requests." + }, + "argumentTypes" : { + "identifier" : "string", + "identifiers" : "array" + }, + "example" : "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })", + "id" : "notifications.pending.delete", + "jsNames" : [ + "apple.notifications.cancelPending" + ], + "optionalArguments" : [ + "identifier", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Object with deleted\/count fields.", + "summary" : "Delete pending local notifications by identifier list or clear all.", + "tags" : [ + "notifications", + "local", + "schedule" + ], + "title" : "Delete pending local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of pending requests to return, default 50." + }, + "argumentTypes" : { + "limit" : "number" + }, + "example" : "await apple.notifications.listPending({ limit: 20 })", + "id" : "notifications.pending.read", + "jsNames" : [ + "apple.notifications.listPending" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Array of pending requests with identifiers\/content\/trigger metadata.", + "summary" : "List pending local notification requests.", + "tags" : [ + "notifications", + "local", + "schedule" + ], + "title" : "List pending local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.notifications.requestPermission()", + "id" : "notifications.permission.request", + "jsNames" : [ + "apple.notifications.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted fields.", + "summary" : "Request local notification authorization from the user.", + "tags" : [ + "notifications", + "permission" + ], + "title" : "Request notification permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "types" : "Optional host-supported notification types; APNs provider sending is intentionally out of scope." + }, + "argumentTypes" : { + "types" : "array" + }, + "example" : "await apple.notifications.registerRemote()", + "id" : "notifications.remote.register", + "jsNames" : [ + "apple.notifications.registerRemote" + ], + "optionalArguments" : [ + "types" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with registered\/status and deviceToken when already available.", + "summary" : "Ask the host app to register with APNs and record the client device-token lifecycle.", + "tags" : [ + "notifications", + "apns", + "remote", + "registration" + ], + "title" : "Register for remote notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.notifications.getRemoteToken()", + "id" : "notifications.remote.token.read", + "jsNames" : [ + "apple.notifications.getRemoteToken" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with token\/environment\/updatedAt or null token when registration has not completed.", + "summary" : "Read the latest APNs device token captured by the host app.", + "tags" : [ + "notifications", + "apns", + "remote", + "token" + ], + "title" : "Read APNs device token" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "actionIdentifier" : "Optional action filter.", + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "categoryIdentifier" : "Optional category filter.", + "limit" : "Maximum number of response events to read." + }, + "argumentTypes" : { + "actionIdentifier" : "string", + "afterCursor" : "string", + "categoryIdentifier" : "string", + "limit" : "number" + }, + "example" : "await apple.notifications.listResponses({ limit: 20 })", + "id" : "notifications.responses.read", + "jsNames" : [ + "apple.notifications.listResponses" + ], + "optionalArguments" : [ + "limit", + "categoryIdentifier", + "actionIdentifier", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of response events with cursor\/identifier\/actionIdentifier\/categoryIdentifier\/userText\/userInfo\/date.", + "summary" : "Read bounded notification action\/open responses captured by the host for later agent handling.", + "tags" : [ + "notifications", + "apns", + "response", + "inbox", + "events" + ], + "title" : "Read notification response inbox" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "badge" : "Optional app icon badge number.", + "body" : "Optional body text.", + "categoryIdentifier" : "Optional category identifier for notification actions.", + "fireDate" : "Optional ISO8601 timestamp for calendar trigger.", + "identifier" : "Optional request identifier; defaults to codemode UUID.", + "repeats" : "Boolean repeat flag (time interval requires >= 60 seconds).", + "secondsFromNow" : "Delay in seconds for time interval trigger (default 5).", + "sound" : "default (default), none, or a bundled custom sound name.", + "subtitle" : "Optional subtitle text.", + "threadIdentifier" : "Optional thread identifier for notification grouping.", + "title" : "Notification title text.", + "userInfo" : "Optional property-list-safe userInfo object." + }, + "argumentTypes" : { + "badge" : "number", + "body" : "string", + "categoryIdentifier" : "string", + "fireDate" : "string", + "identifier" : "string", + "repeats" : "bool", + "secondsFromNow" : "number", + "sound" : "string", + "subtitle" : "string", + "threadIdentifier" : "string", + "title" : "string", + "userInfo" : "object" + }, + "example" : "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })", + "id" : "notifications.schedule", + "jsNames" : [ + "apple.notifications.schedule" + ], + "optionalArguments" : [ + "identifier", + "subtitle", + "body", + "secondsFromNow", + "fireDate", + "repeats", + "sound", + "badge", + "userInfo", + "threadIdentifier", + "categoryIdentifier" + ], + "requiredArguments" : [ + "title" + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Object with identifier\/scheduled\/repeats.", + "summary" : "Schedule a local notification using time interval or fireDate trigger.", + "tags" : [ + "notifications", + "local", + "schedule" + ], + "title" : "Schedule local notification" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.notifications.getSettings()", + "id" : "notifications.settings.read", + "jsNames" : [ + "apple.notifications.getSettings" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with authorizationStatus\/alert\/badge\/sound\/criticalAlert\/providesAppNotificationSettings.", + "summary" : "Read UserNotifications\/APNs client settings exposed by the host.", + "tags" : [ + "notifications", + "apns", + "settings", + "permission" + ], + "title" : "Read notification settings" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "confirmed" : "Must be true after explicit user-visible confirmation before presenting Apple Pay.", + "paymentRequestID" : "Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript." + }, + "argumentTypes" : { + "confirmed" : "bool", + "paymentRequestID" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.wallet.presentPayment({ paymentRequestID: 'checkout-123', confirmed: true })", + "id" : "passkit.applePay.present", + "jsNames" : [ + "apple.wallet.presentPayment" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + "paymentRequestID", + "confirmed" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/authorized\/paymentRequestID\/status.", + "summary" : "Present a host-defined Apple Pay payment request using host merchant configuration after explicit user-visible confirmation.", + "tags" : [ + "passkit", + "apple-pay", + "payments", + "system-ui", + "safety" + ], + "title" : "Present Apple Pay request" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "networks" : "Optional supported payment networks to test against host merchant configuration." + }, + "argumentTypes" : { + "networks" : "array" + }, + "example" : "await apple.wallet.canMakePayments()", + "id" : "passkit.applePay.status", + "jsNames" : [ + "apple.wallet.canMakePayments" + ], + "optionalArguments" : [ + "networks" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with canMakePayments\/canMakePaymentsUsingNetworks.", + "summary" : "Read Apple Pay capability using host merchant configuration; no merchant setup is accepted from JavaScript.", + "tags" : [ + "passkit", + "apple-pay", + "payments", + "safety" + ], + "title" : "Read Apple Pay status" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Sandbox path to a .pkpass file.", + "timeoutMs" : "Optional timeout for user-mediated add-pass UI." + }, + "argumentTypes" : { + "path" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' })", + "id" : "passkit.pass.add", + "jsNames" : [ + "apple.wallet.addPass" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/added\/passTypeIdentifier\/serialNumber.", + "summary" : "Present user-mediated UI to add a sandbox .pkpass file to Wallet.", + "tags" : [ + "passkit", + "wallet", + "passes", + "add", + "system-ui" + ], + "title" : "Add Wallet pass" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + "identifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.wallet.presentPass({ identifier: 'pass-id' })", + "id" : "passkit.pass.present", + "jsNames" : [ + "apple.wallet.presentPass" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/presented\/identifier.", + "summary" : "Present user-mediated details for an accessible Wallet pass.", + "tags" : [ + "passkit", + "wallet", + "passes", + "system-ui" + ], + "title" : "Present Wallet pass" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Maximum accessible passes.", + "passTypeIdentifier" : "Optional pass type filter.", + "serialNumber" : "Optional serial number filter." + }, + "argumentTypes" : { + "limit" : "number", + "passTypeIdentifier" : "string", + "serialNumber" : "string" + }, + "example" : "await apple.wallet.listPasses({ limit: 20 })", + "id" : "passkit.passes.read", + "jsNames" : [ + "apple.wallet.listPasses" + ], + "optionalArguments" : [ + "passTypeIdentifier", + "serialNumber", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of passes with passTypeIdentifier\/serialNumber\/organizationName\/description.", + "summary" : "List Wallet passes accessible to the host app through PassKit.", + "tags" : [ + "passkit", + "wallet", + "passes", + "read" + ], + "title" : "List Wallet passes" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.wallet.getStatus()", + "id" : "passkit.wallet.status", + "jsNames" : [ + "apple.wallet.getStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with available\/canAddPasses\/canPresentPasses.", + "summary" : "Read PassKit Wallet availability and pass-library access status.", + "tags" : [ + "passkit", + "wallet", + "passes" + ], + "title" : "Read Wallet status" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "localIdentifier" : "PHAsset localIdentifier from photos.read result.", + "outputPath" : "Optional sandbox output path; defaults to tmp-generated file." + }, + "argumentTypes" : { + "localIdentifier" : "string", + "outputPath" : "string" + }, + "example" : "await apple.photos.export({ localIdentifier: 'ABC\/L0\/001', outputPath: 'tmp:exported.jpg' })", + "id" : "photos.export", + "jsNames" : [ + "apple.photos.export" + ], + "optionalArguments" : [ + "outputPath" + ], + "requiredArguments" : [ + "localIdentifier" + ], + "requiredPermissions" : [ + "photoLibrary" + ], + "resultSummary" : "Object with path\/artifactID\/localIdentifier\/mediaType\/bytes.", + "summary" : "Export a photo\/video asset to sandbox file path and register artifact handle.", + "tags" : [ + "photos", + "photo-library", + "artifact" + ], + "title" : "Export photo library asset" + }, + { + "allowedStringValues" : { + "mediaType" : [ + "any", + "image", + "photo", + "video" + ] + }, + "argumentHints" : { + "limit" : "Max number of results, default 50.", + "mediaType" : "any (default), image, or video." + }, + "argumentTypes" : { + "limit" : "number", + "mediaType" : "string" + }, + "example" : "await apple.photos.list({ mediaType: 'image', limit: 20 })", + "id" : "photos.read", + "jsNames" : [ + "apple.photos.list" + ], + "optionalArguments" : [ + "mediaType", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "photoLibrary" + ], + "resultSummary" : "Array of assets with localIdentifier\/mediaType\/dimensions\/date metadata.", + "summary" : "List photos\/videos from the user photo library.", + "tags" : [ + "photos", + "photo-library", + "media" + ], + "title" : "List photo library assets" + }, + { + "allowedStringValues" : { + "mediaType" : [ + "any", + "image", + "photo", + "video" + ] + }, + "argumentHints" : { + "limit" : "Maximum number of selectable items; default 1.", + "mediaType" : "any (default), image\/photo, or video.", + "outputDirectory" : "Optional sandbox directory for exported picker files; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on user selection\/export." + }, + "argumentTypes" : { + "limit" : "number", + "mediaType" : "string", + "outputDirectory" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })", + "id" : "photos.ui.pick", + "jsNames" : [ + "apple.photos.pick" + ], + "optionalArguments" : [ + "mediaType", + "limit", + "outputDirectory", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of selected assets with path\/artifactID\/mediaType\/uniformTypeIdentifier\/bytes.", + "summary" : "Present system photo picker UI and export selected assets into the sandbox artifact store.", + "tags" : [ + "photos", + "photo-library", + "system-ui", + "picker", + "artifact" + ], + "title" : "Pick photos with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "timeoutMs" : "Optional timeout for waiting on the limited-library picker completion." + }, + "argumentTypes" : { + "timeoutMs" : "number" + }, + "example" : "await apple.photos.presentLimitedLibraryPicker()", + "id" : "photos.ui.presentLimitedLibraryPicker", + "jsNames" : [ + "apple.photos.presentLimitedLibraryPicker" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/status and selectedIdentifiers when the limited selection changes.", + "summary" : "Present the Photos limited-library management UI so the user can update the app's selected photo set.", + "tags" : [ + "photos", + "photo-library", + "system-ui", + "permission", + "picker" + ], + "title" : "Present limited Photos picker" + }, + { + "allowedStringValues" : { + "outputType" : [ + "general", + "photo", + "grayscale" + ] + }, + "argumentHints" : { + "jobName" : "Optional print job name.", + "outputType" : "general (default), photo, or grayscale.", + "path" : "Single sandbox file path to print.", + "paths" : "Optional array of sandbox file paths to print.", + "showsNumberOfCopies" : "Whether copy count controls are shown; default true.", + "timeoutMs" : "Optional timeout for waiting on print completion\/cancellation." + }, + "argumentTypes" : { + "jobName" : "string", + "outputType" : "string", + "path" : "string", + "paths" : "array", + "showsNumberOfCopies" : "bool", + "timeoutMs" : "number" + }, + "example" : "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })", + "id" : "print.ui.present", + "jsNames" : [ + "apple.print.present" + ], + "optionalArguments" : [ + "path", + "paths", + "jobName", + "outputType", + "showsNumberOfCopies", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/completed for the print interaction.", + "summary" : "Present the system print sheet for one or more sandbox files.", + "tags" : [ + "print", + "documents", + "system-ui", + "export" + ], + "title" : "Present print UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Single sandbox file path to preview.", + "paths" : "Optional array of sandbox file paths to preview.", + "timeoutMs" : "Optional timeout for waiting on dismissal." + }, + "argumentTypes" : { + "path" : "string", + "paths" : "array", + "timeoutMs" : "number" + }, + "example" : "await apple.quicklook.preview({ path: 'tmp:report.pdf' })", + "id" : "quicklook.ui.preview", + "jsNames" : [ + "apple.quicklook.preview" + ], + "optionalArguments" : [ + "path", + "paths", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action dismissed and count.", + "summary" : "Present Quick Look preview UI for one or more sandbox file artifacts.", + "tags" : [ + "quicklook", + "preview", + "documents", + "system-ui" + ], + "title" : "Preview files with Quick Look" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "EventKit calendarItemIdentifier from apple.reminders.listReminders." + }, + "argumentTypes" : { + "identifier" : "string" + }, + "example" : "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })", + "id" : "reminders.delete", + "jsNames" : [ + "apple.reminders.deleteReminder" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Object with identifier\/deleted.", + "summary" : "Delete an existing EventKit reminder by identifier.", + "tags" : [ + "reminders", + "eventkit", + "task", + "delete" + ], + "title" : "Delete reminder" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "calendarIdentifier" : "Optional EventKit reminder calendarIdentifier to restrict results.", + "calendarIdentifiers" : "Optional array of EventKit reminder calendarIdentifier strings to restrict results.", + "end" : "Optional ISO8601 due-date upper bound.", + "includeCompleted" : "Whether completed reminders are included; default false.", + "limit" : "Max number of reminder items, default 50.", + "start" : "Optional ISO8601 due-date lower bound." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "calendarIdentifiers" : "array", + "end" : "string", + "includeCompleted" : "bool", + "limit" : "number", + "start" : "string" + }, + "example" : "await apple.reminders.listReminders({ limit: 20 })", + "id" : "reminders.read", + "jsNames" : [ + "apple.reminders.listReminders" + ], + "optionalArguments" : [ + "start", + "end", + "includeCompleted", + "calendarIdentifier", + "calendarIdentifiers", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Array of reminders with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "summary" : "Read incomplete reminders from EventKit.", + "tags" : [ + "reminders", + "eventkit", + "task" + ], + "title" : "Read reminders" + }, + { + "allowedStringValues" : { + "operation" : [ + "create", + "update", + "complete" + ] + }, + "argumentHints" : { + "calendarIdentifier" : "Optional destination EventKit reminders calendarIdentifier.", + "dueDate" : "Optional ISO8601 due date timestamp.", + "identifier" : "EventKit calendarItemIdentifier to update or complete.", + "isCompleted" : "Completion state for update or completeReminder; default true for completeReminder.", + "notes" : "Optional reminder notes.", + "operation" : "create (default without identifier), update, or complete.", + "priority" : "EventKit reminder priority integer.", + "title" : "Reminder title string." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "dueDate" : "string", + "identifier" : "string", + "isCompleted" : "bool", + "notes" : "string", + "operation" : "string", + "priority" : "number", + "title" : "string" + }, + "example" : "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })", + "id" : "reminders.write", + "jsNames" : [ + "apple.reminders.createReminder", + "apple.reminders.updateReminder", + "apple.reminders.completeReminder" + ], + "optionalArguments" : [ + "operation", + "identifier", + "title", + "dueDate", + "notes", + "isCompleted", + "priority", + "calendarIdentifier" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Object with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "summary" : "Create a reminder or patch an existing reminder by identifier.", + "tags" : [ + "reminders", + "eventkit", + "task" + ], + "title" : "Create or update reminder" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "timeoutMs" : "Optional timeout for waiting on UIApplication.open completion." + }, + "argumentTypes" : { + "timeoutMs" : "number" + }, + "example" : "await apple.settings.open()", + "id" : "settings.ui.open", + "jsNames" : [ + "apple.settings.open" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action opened\/failed and opened boolean.", + "summary" : "Open the host app's Settings page so the user can recover denied permissions.", + "tags" : [ + "settings", + "permissions", + "system-ui" + ], + "title" : "Open app settings" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "excludedActivityTypes" : "Optional array of UIActivity.ActivityType raw value strings to hide.", + "path" : "Optional single sandbox file path to share.", + "paths" : "Optional array of sandbox file paths to share.", + "subject" : "Optional subject for services that support it.", + "text" : "Optional text item to share.", + "timeoutMs" : "Optional timeout for waiting on share completion.", + "url" : "Optional absolute HTTP(S) URL to share." + }, + "argumentTypes" : { + "excludedActivityTypes" : "array", + "path" : "string", + "paths" : "array", + "subject" : "string", + "text" : "string", + "timeoutMs" : "number", + "url" : "string" + }, + "example" : "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })", + "id" : "share.ui.present", + "jsNames" : [ + "apple.share.present" + ], + "optionalArguments" : [ + "text", + "url", + "path", + "paths", + "subject", + "excludedActivityTypes", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with completed\/activityType\/action.", + "summary" : "Present system share sheet for text, URLs, and sandbox file artifacts.", + "tags" : [ + "share", + "export", + "system-ui" + ], + "title" : "Present share sheet" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "locale" : "BCP-47 locale identifier such as en-US.", + "path" : "Sandbox audio file path.", + "requiresOnDeviceRecognition" : "Whether to require on-device recognition when the host supports it.", + "taskHint" : "Speech task hint such as dictation, search, or confirmation.", + "timeoutMs" : "Maximum transcription wait time in milliseconds." + }, + "argumentTypes" : { + "locale" : "string", + "path" : "string", + "requiresOnDeviceRecognition" : "bool", + "taskHint" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.speech.transcribeFile({ path: 'tmp:meeting.m4a', locale: 'en-US', timeoutMs: 60000 })", + "id" : "speech.file.transcribe", + "jsNames" : [ + "apple.speech.transcribeFile" + ], + "optionalArguments" : [ + "locale", + "timeoutMs", + "requiresOnDeviceRecognition", + "taskHint" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + "speech.recognition" + ], + "resultSummary" : "Object with transcript\/segments\/locale\/isFinal\/durationSeconds.", + "summary" : "Transcribe a sandbox audio file through the host Speech adapter.", + "tags" : [ + "speech", + "transcription", + "audio" + ], + "title" : "Transcribe audio file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "locale" : "BCP-47 locale identifier such as en-US.", + "partialResults" : "Whether partial transcripts may be returned.", + "requiresOnDeviceRecognition" : "Whether to require on-device recognition when the host supports it.", + "taskHint" : "Speech task hint such as dictation, search, or confirmation.", + "timeoutMs" : "Maximum microphone capture\/transcription time in milliseconds." + }, + "argumentTypes" : { + "locale" : "string", + "partialResults" : "bool", + "requiresOnDeviceRecognition" : "bool", + "taskHint" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.speech.transcribeMicrophone({ locale: 'en-US', timeoutMs: 15000 })", + "id" : "speech.microphone.transcribe", + "jsNames" : [ + "apple.speech.transcribeMicrophone" + ], + "optionalArguments" : [ + "locale", + "timeoutMs", + "requiresOnDeviceRecognition", + "taskHint", + "partialResults" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "speech.recognition", + "microphone" + ], + "resultSummary" : "Object with transcript\/segments\/locale\/isFinal\/timedOut.", + "summary" : "Run live microphone transcription through a host-mediated session with an explicit timeout.", + "tags" : [ + "speech", + "transcription", + "audio", + "microphone" + ], + "title" : "Transcribe microphone audio" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.speech.requestPermission()", + "id" : "speech.permission.request", + "jsNames" : [ + "apple.speech.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted.", + "summary" : "Trigger the Speech recognition permission prompt.", + "tags" : [ + "speech", + "permission", + "transcription" + ], + "title" : "Request speech recognition permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.speech.getStatus()", + "id" : "speech.status", + "jsNames" : [ + "apple.speech.getStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted.", + "summary" : "Read Speech recognition permission status without starting capture.", + "tags" : [ + "speech", + "permission", + "transcription" + ], + "title" : "Read speech recognition permission status" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.storekit.listEntitlements()", + "id" : "storekit.entitlements.read", + "jsNames" : [ + "apple.storekit.listEntitlements" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of current entitlements with productID\/transactionID\/expirationDate.", + "summary" : "Read current StoreKit entitlements and verified transaction state.", + "tags" : [ + "storekit", + "commerce", + "entitlements" + ], + "title" : "Read StoreKit entitlements" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "productIDs" : "Array of host-configured StoreKit product identifiers." + }, + "argumentTypes" : { + "productIDs" : "array" + }, + "example" : "await apple.storekit.listProducts({ productIDs: ['pro.monthly'] })", + "id" : "storekit.products.read", + "jsNames" : [ + "apple.storekit.listProducts" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "productIDs" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of products with id\/displayName\/description\/price\/type.", + "summary" : "Read StoreKit product metadata for host-configured product identifiers.", + "tags" : [ + "storekit", + "commerce", + "products" + ], + "title" : "Read StoreKit products" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "appAccountToken" : "Optional UUID string for StoreKit appAccountToken.", + "confirmed" : "Must be true after explicit user-visible confirmation before purchase UI is presented.", + "productID" : "Host-configured StoreKit product identifier." + }, + "argumentTypes" : { + "appAccountToken" : "string", + "confirmed" : "bool", + "productID" : "string" + }, + "example" : "await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true })", + "id" : "storekit.purchase", + "jsNames" : [ + "apple.storekit.purchase" + ], + "optionalArguments" : [ + "appAccountToken" + ], + "requiredArguments" : [ + "productID", + "confirmed" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/productID\/transactionID when completed.", + "summary" : "Present StoreKit purchase UI for a host-configured product after explicit user-visible confirmation.", + "tags" : [ + "storekit", + "commerce", + "purchase", + "safety" + ], + "title" : "Purchase StoreKit product" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "confirmed" : "Must be true after explicit user-visible confirmation before restore starts." + }, + "argumentTypes" : { + "confirmed" : "bool" + }, + "example" : "await apple.storekit.restore({ confirmed: true })", + "id" : "storekit.restore", + "jsNames" : [ + "apple.storekit.restore" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "confirmed" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with restored\/status.", + "summary" : "Trigger StoreKit restore\/sync after explicit user-visible confirmation.", + "tags" : [ + "storekit", + "commerce", + "restore", + "safety" + ], + "title" : "Restore StoreKit purchases" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "limit" : "Maximum transaction updates to return.", + "productID" : "Optional product filter." + }, + "argumentTypes" : { + "afterCursor" : "string", + "limit" : "number", + "productID" : "string" + }, + "example" : "await apple.storekit.listTransactions({ limit: 20 })", + "id" : "storekit.transactions.read", + "jsNames" : [ + "apple.storekit.listTransactions" + ], + "optionalArguments" : [ + "limit", + "productID", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of transaction events with cursor\/productID\/transactionID\/status\/date.", + "summary" : "Read bounded transaction updates captured by the host StoreKit adapter.", + "tags" : [ + "storekit", + "commerce", + "transactions", + "inbox", + "events" + ], + "title" : "Read StoreKit transaction inbox" + }, + { + "allowedStringValues" : { + "preferredStyle" : [ + "alert", + "actionSheet", + "actionsheet" + ] + }, + "argumentHints" : { + "buttons" : "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "message" : "Optional alert message.", + "preferredStyle" : "alert (default) or actionSheet.", + "sourceRect" : "Optional { x, y, width, height } anchor for action sheets.", + "timeoutMs" : "Optional timeout for waiting on user selection.", + "title" : "Optional alert title." + }, + "argumentTypes" : { + "buttons" : "array", + "message" : "string", + "preferredStyle" : "string", + "sourceRect" : "object", + "timeoutMs" : "number", + "title" : "string" + }, + "example" : "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })", + "id" : "ui.alert.present", + "jsNames" : [ + "apple.ui.presentAlert" + ], + "optionalArguments" : [ + "title", + "message", + "preferredStyle", + "sourceRect", + "timeoutMs" + ], + "requiredArguments" : [ + "buttons" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/buttonID\/buttonTitle\/buttonIndex\/style for the selected button.", + "summary" : "Present a system alert or action sheet and return the button the user selects.", + "tags" : [ + "ui", + "alert", + "dialog", + "system-ui" + ], + "title" : "Present alert with custom buttons" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "buttons" : "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "fields" : "Array of { id?, placeholder?, text?\/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", + "timeoutMs" : "Optional timeout for waiting on user selection." + }, + "argumentTypes" : { + "buttons" : "array", + "fields" : "array", + "message" : "string", + "timeoutMs" : "number", + "title" : "string" + }, + "example" : "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })", + "id" : "ui.prompt.present", + "jsNames" : [ + "apple.ui.presentPrompt" + ], + "optionalArguments" : [ + "title", + "message", + "timeoutMs" + ], + "requiredArguments" : [ + "fields", + "buttons" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with selected button metadata and values keyed by field id.", + "summary" : "Present a system alert with one or more text fields and custom buttons.", + "tags" : [ + "ui", + "alert", + "prompt", + "input", + "system-ui" + ], + "title" : "Present prompt with text fields" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "features" : "Optional array including labels\/text\/barcodes.", + "maxResults" : "Max observations returned per feature, default 5.", + "path" : "Sandbox image path to analyze." + }, + "argumentTypes" : { + "features" : "array", + "maxResults" : "number", + "path" : "string" + }, + "example" : "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })", + "id" : "vision.image.analyze", + "jsNames" : [ + "apple.vision.analyzeImage" + ], + "optionalArguments" : [ + "features", + "maxResults" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object containing requested analysis sections such as labels\/text\/barcodes.", + "summary" : "Run on-device image analysis for labels\/text\/barcodes on sandbox image paths.", + "tags" : [ + "vision", + "image-analysis", + "ml" + ], + "title" : "Analyze image with Vision" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "latitude" : "Latitude in decimal degrees.", + "longitude" : "Longitude in decimal degrees." + }, + "argumentTypes" : { + "latitude" : "number", + "longitude" : "number" + }, + "example" : "await apple.weather.getCurrentWeather({ latitude: 37.77, longitude: -122.41 })", + "id" : "weather.read", + "jsNames" : [ + "apple.weather.getCurrentWeather" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "latitude", + "longitude" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with temperatureCelsius\/condition\/symbolName\/date.", + "summary" : "Fetch current weather for a latitude\/longitude pair.", + "tags" : [ + "weather", + "forecast", + "weatherkit" + ], + "title" : "Read WeatherKit weather" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "entersReaderIfAvailable" : "Whether Safari may enter Reader automatically; default false.", + "timeoutMs" : "Optional timeout for waiting on dismissal.", + "url" : "Absolute HTTP(S) URL to present." + }, + "argumentTypes" : { + "entersReaderIfAvailable" : "bool", + "timeoutMs" : "number", + "url" : "string" + }, + "example" : "await apple.web.present({ url: 'https:\/\/example.com' })", + "id" : "web.ui.present", + "jsNames" : [ + "apple.web.present" + ], + "optionalArguments" : [ + "entersReaderIfAvailable", + "timeoutMs" + ], + "requiredArguments" : [ + "url" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action dismissed.", + "summary" : "Present an HTTP(S) URL with the system Safari view controller.", + "tags" : [ + "web", + "safari", + "browser", + "system-ui" + ], + "title" : "Present web page with system UI" + } +] \ No newline at end of file From 4c6cdf984f77df568e65d205f06757c12e04af64 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 16:57:49 -0700 Subject: [PATCH 18/25] Phase 3: migrate SystemUI registrations to @BuiltInCodeMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 12 interaction-UI capabilities (share, quicklook, camera capture/scan, mail, messages, print, web, auth, alert, prompt, settings) to macro-authored tools in SystemUICodeModeTools.swift; the registration file is now a thin list. Introduces 8 CodeModeStringEnums for the constrained values (MediaTypeFilter, CameraDevice, CameraFlashMode, CameraVideoQuality, DataScannerMode, DataScannerQualityLevel, PrintOutputType, AlertPreferredStyle) and rewires SystemUIBridge's own lowercased() validations to parse through them, making each enum the single source of truth. The cameraUICapture / cameraUIScanData / printUIPresent / uiAlertPresent rows are deleted from the central CapabilityArgumentConstraints.defaults table. AlertPreferredStyle keeps the all-lowercase 'actionsheet' spelling as an alias because SystemUIBridge accepts it (SystemUIBridge.swift validateAlertArguments); CameraVideoQuality's canonical spellings survive the presenters' lowercasing (UIKitSystemUIPresenter.videoQuality/printOutputType), so raw-passthrough canonicalization is behavior-preserving. Golden diff: one allowed category only — argumentHints gained title/message entries for uiPromptPresent (which advertised no hints for them before); values match the uiAlertPresent wording. 230 tests pass. --- .../CapabilityRegistrations+SystemUI.swift | 397 +--------------- Sources/CodeMode/Bridges/SystemUIBridge.swift | 40 +- .../Bridges/SystemUICodeModeTools.swift | 435 ++++++++++++++++++ .../Registry/CapabilityRegistry.swift | 23 +- .../capability-metadata-golden.json | 4 +- 5 files changed, 472 insertions(+), 427 deletions(-) create mode 100644 Sources/CodeMode/Bridges/SystemUICodeModeTools.swift diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift index d8bfdc1..3b862fb 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift @@ -3,391 +3,18 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func interactionUIRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.share.present"], - descriptor: .init( - id: .shareUIPresent, - title: "Present share sheet", - summary: "Present system share sheet for text, URLs, and sandbox file artifacts.", - tags: ["share", "export", "system-ui"], - example: "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })", - optionalArguments: ["text", "url", "path", "paths", "subject", "excludedActivityTypes", "timeoutMs"], - argumentTypes: [ - "text": .string, - "url": .string, - "path": .string, - "paths": .array, - "subject": .string, - "excludedActivityTypes": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "text": "Optional text item to share.", - "url": "Optional absolute HTTP(S) URL to share.", - "path": "Optional single sandbox file path to share.", - "paths": "Optional array of sandbox file paths to share.", - "subject": "Optional subject for services that support it.", - "excludedActivityTypes": "Optional array of UIActivity.ActivityType raw value strings to hide.", - "timeoutMs": "Optional timeout for waiting on share completion.", - ], - resultSummary: "Object with completed/activityType/action." - ), - handler: { args, context in - try systemUI.presentShareSheet(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.quicklook.preview"], - descriptor: .init( - id: .quickLookUIPreview, - title: "Preview files with Quick Look", - summary: "Present Quick Look preview UI for one or more sandbox file artifacts.", - tags: ["quicklook", "preview", "documents", "system-ui"], - example: "await apple.quicklook.preview({ path: 'tmp:report.pdf' })", - optionalArguments: ["path", "paths", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to preview.", - "paths": "Optional array of sandbox file paths to preview.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed and count." - ), - handler: { args, context in - try systemUI.previewQuickLook(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.camera.capture"], - descriptor: .init( - id: .cameraUICapture, - title: "Capture photo or video with camera UI", - summary: "Present system camera UI and export captured media into the sandbox artifact store.", - tags: ["camera", "capture", "photos", "system-ui", "artifact"], - example: "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })", - optionalArguments: [ - "mediaType", - "outputDirectory", - "timeoutMs", - "allowsEditing", - "cameraDevice", - "flashMode", - "videoQuality", - "maximumDurationSeconds", - ], - argumentTypes: [ - "mediaType": .string, - "outputDirectory": .string, - "timeoutMs": .number, - "allowsEditing": .bool, - "cameraDevice": .string, - "flashMode": .string, - "videoQuality": .string, - "maximumDurationSeconds": .number, - ], - argumentHints: [ - "mediaType": "any (default), image/photo, or video.", - "outputDirectory": "Optional sandbox directory for captured media; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on capture/export.", - "allowsEditing": "Whether the system editor is shown before returning media; default false.", - "cameraDevice": "rear (default) or front.", - "flashMode": "auto (default), on, or off.", - "videoQuality": "UIImagePickerController quality name such as high, medium, low, 640x480, iFrame1280x720, or iFrame960x540.", - "maximumDurationSeconds": "Optional maximum duration for video capture.", - ], - resultSummary: "Object with path/artifactID/mediaType/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.captureCamera(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.camera.scanData"], - descriptor: .init( - id: .cameraUIScanData, - title: "Scan text or barcodes with camera UI", - summary: "Present VisionKit live data scanner UI and return recognized text or barcode payloads.", - tags: ["camera", "scan", "barcode", "text", "visionkit", "system-ui"], - example: "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })", - optionalArguments: [ - "mode", - "recognizedDataTypes", - "languages", - "qualityLevel", - "recognizesMultipleItems", - "returnsOnFirstResult", - "isGuidanceEnabled", - "isHighlightingEnabled", - "isPinchToZoomEnabled", - "isHighFrameRateTrackingEnabled", - "timeoutMs", - ], - argumentTypes: [ - "mode": .string, - "recognizedDataTypes": .array, - "languages": .array, - "qualityLevel": .string, - "recognizesMultipleItems": .bool, - "returnsOnFirstResult": .bool, - "isGuidanceEnabled": .bool, - "isHighlightingEnabled": .bool, - "isPinchToZoomEnabled": .bool, - "isHighFrameRateTrackingEnabled": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "mode": "any (default), text, or barcode.", - "recognizedDataTypes": "Optional array containing text and/or barcode; overrides mode.", - "languages": "Optional text recognition language identifiers.", - "qualityLevel": "balanced (default), fast, or accurate.", - "recognizesMultipleItems": "Whether the scanner tracks multiple items at once; default false.", - "returnsOnFirstResult": "Whether to dismiss as soon as data is recognized; default true.", - "isGuidanceEnabled": "Whether VisionKit guidance UI is shown; default true.", - "isHighlightingEnabled": "Whether recognized items are highlighted; default true.", - "isPinchToZoomEnabled": "Whether pinch-to-zoom is enabled; default true.", - "isHighFrameRateTrackingEnabled": "Whether high-frame-rate tracking is enabled; default true.", - "timeoutMs": "Optional timeout for waiting on a scan result or cancellation.", - ], - resultSummary: "Object with action and items containing text transcripts or barcode payloads." - ), - handler: { args, context in - try systemUI.scanData(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.mail.compose"], - descriptor: .init( - id: .mailUICompose, - title: "Compose mail with system UI", - summary: "Present system mail compose UI with optional recipients, body, and sandbox file attachments.", - tags: ["mail", "compose", "system-ui", "share"], - example: "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })", - optionalArguments: ["to", "cc", "bcc", "subject", "body", "isHTML", "attachments", "timeoutMs"], - argumentTypes: [ - "to": .array, - "cc": .array, - "bcc": .array, - "subject": .string, - "body": .string, - "isHTML": .bool, - "attachments": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "to": "Optional array of recipient email strings.", - "cc": "Optional array of CC email strings.", - "bcc": "Optional array of BCC email strings.", - "subject": "Optional subject.", - "body": "Optional message body.", - "isHTML": "Whether body should be treated as HTML; default false.", - "attachments": "Optional array of { path, mimeType?, filename? } sandbox file attachments.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action sent/saved/cancelled/failed." - ), - handler: { args, context in - try systemUI.composeMail(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.messages.compose"], - descriptor: .init( - id: .messagesUICompose, - title: "Compose message with system UI", - summary: "Present system Messages compose UI with optional recipients, body, and sandbox file attachments.", - tags: ["messages", "sms", "compose", "system-ui", "share"], - example: "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })", - optionalArguments: ["recipients", "subject", "body", "attachments", "timeoutMs"], - argumentTypes: [ - "recipients": .array, - "subject": .string, - "body": .string, - "attachments": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "recipients": "Optional array of phone number or address strings.", - "subject": "Optional subject on devices/accounts that support it.", - "body": "Optional message body.", - "attachments": "Optional array of { path, filename? } sandbox file attachments.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action sent/cancelled/failed." - ), - handler: { args, context in - try systemUI.composeMessage(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.print.present"], - descriptor: .init( - id: .printUIPresent, - title: "Present print UI", - summary: "Present the system print sheet for one or more sandbox files.", - tags: ["print", "documents", "system-ui", "export"], - example: "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })", - optionalArguments: ["path", "paths", "jobName", "outputType", "showsNumberOfCopies", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "jobName": .string, - "outputType": .string, - "showsNumberOfCopies": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to print.", - "paths": "Optional array of sandbox file paths to print.", - "jobName": "Optional print job name.", - "outputType": "general (default), photo, or grayscale.", - "showsNumberOfCopies": "Whether copy count controls are shown; default true.", - "timeoutMs": "Optional timeout for waiting on print completion/cancellation.", - ], - resultSummary: "Object with action/completed for the print interaction." - ), - handler: { args, context in - try systemUI.presentPrint(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.web.present"], - descriptor: .init( - id: .webUIPresent, - title: "Present web page with system UI", - summary: "Present an HTTP(S) URL with the system Safari view controller.", - tags: ["web", "safari", "browser", "system-ui"], - example: "await apple.web.present({ url: 'https://example.com' })", - requiredArguments: ["url"], - optionalArguments: ["entersReaderIfAvailable", "timeoutMs"], - argumentTypes: [ - "url": .string, - "entersReaderIfAvailable": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "url": "Absolute HTTP(S) URL to present.", - "entersReaderIfAvailable": "Whether Safari may enter Reader automatically; default false.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed." - ), - handler: { args, context in - try systemUI.presentWeb(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.auth.webAuthenticate"], - descriptor: .init( - id: .authUIWebAuthenticate, - title: "Authenticate with system web UI", - summary: "Start an ASWebAuthenticationSession for OAuth-style browser authentication.", - tags: ["auth", "oauth", "web", "browser", "system-ui"], - example: "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })", - requiredArguments: ["url"], - optionalArguments: ["callbackURLScheme", "prefersEphemeralSession", "timeoutMs"], - argumentTypes: [ - "url": .string, - "callbackURLScheme": .string, - "prefersEphemeralSession": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "url": "Absolute HTTP(S) authentication URL.", - "callbackURLScheme": "Optional custom URL scheme that completes the session.", - "prefersEphemeralSession": "Whether to prefer a private browser session; default false.", - "timeoutMs": "Optional timeout for waiting on callback/cancellation.", - ], - resultSummary: "Object with action callback/cancelled and callbackURL when available." - ), - handler: { args, context in - try systemUI.authenticateWeb(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.ui.presentAlert"], - descriptor: .init( - id: .uiAlertPresent, - title: "Present alert with custom buttons", - summary: "Present a system alert or action sheet and return the button the user selects.", - tags: ["ui", "alert", "dialog", "system-ui"], - example: "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })", - requiredArguments: ["buttons"], - optionalArguments: ["title", "message", "preferredStyle", "sourceRect", "timeoutMs"], - argumentTypes: [ - "title": .string, - "message": .string, - "preferredStyle": .string, - "buttons": .array, - "sourceRect": .object, - "timeoutMs": .number, - ], - argumentHints: [ - "title": "Optional alert title.", - "message": "Optional alert message.", - "preferredStyle": "alert (default) or actionSheet.", - "sourceRect": "Optional { x, y, width, height } anchor for action sheets.", - "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Object with action/buttonID/buttonTitle/buttonIndex/style for the selected button." - ), - handler: { args, context in - try systemUI.presentAlert(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.ui.presentPrompt"], - descriptor: .init( - id: .uiPromptPresent, - title: "Present prompt with text fields", - summary: "Present a system alert with one or more text fields and custom buttons.", - tags: ["ui", "alert", "prompt", "input", "system-ui"], - example: "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })", - requiredArguments: ["fields", "buttons"], - optionalArguments: ["title", "message", "timeoutMs"], - argumentTypes: [ - "title": .string, - "message": .string, - "fields": .array, - "buttons": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "fields": "Array of { id?, placeholder?, text?/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", - "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Object with selected button metadata and values keyed by field id." - ), - handler: { args, context in - try systemUI.presentPrompt(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.settings.open"], - descriptor: .init( - id: .settingsUIOpen, - title: "Open app settings", - summary: "Open the host app's Settings page so the user can recover denied permissions.", - tags: ["settings", "permissions", "system-ui"], - example: "await apple.settings.open()", - optionalArguments: ["timeoutMs"], - argumentTypes: [ - "timeoutMs": .number, - ], - argumentHints: [ - "timeoutMs": "Optional timeout for waiting on UIApplication.open completion.", - ], - resultSummary: "Object with action opened/failed and opened boolean." - ), - handler: { args, context in - try systemUI.openSettings(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: ShareUIPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: QuickLookUIPreviewTool(systemUI: systemUI)), + CapabilityRegistration(tool: CameraUICaptureTool(systemUI: systemUI)), + CapabilityRegistration(tool: CameraUIScanDataTool(systemUI: systemUI)), + CapabilityRegistration(tool: MailUIComposeTool(systemUI: systemUI)), + CapabilityRegistration(tool: MessagesUIComposeTool(systemUI: systemUI)), + CapabilityRegistration(tool: PrintUIPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: WebUIPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: AuthUIWebAuthenticateTool(systemUI: systemUI)), + CapabilityRegistration(tool: UIAlertPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: UIPromptPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: SettingsUIOpenTool(systemUI: systemUI)), ] } } diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift index 35dbbd6..21d0522 100644 --- a/Sources/CodeMode/Bridges/SystemUIBridge.swift +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -128,8 +128,8 @@ public final class SystemUIBridge: @unchecked Sendable { public func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try validatePathOrPaths(arguments, capability: "print.ui.present") - if let outputType = arguments.string("outputType")?.lowercased(), - ["general", "photo", "grayscale"].contains(outputType) == false + if let outputType = arguments.string("outputType"), + PrintOutputType.codeModeValue(matching: outputType) == nil { throw BridgeError.invalidArguments("print.ui.present outputType must be general, photo, or grayscale") } @@ -201,8 +201,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validatePhotoPickerArguments(_ arguments: [String: JSONValue], capability: String = "photos.ui.pick") throws { - if let mediaType = arguments.string("mediaType")?.lowercased(), - ["any", "image", "photo", "video"].contains(mediaType) == false + if let mediaType = arguments.string("mediaType"), + MediaTypeFilter.codeModeValue(matching: mediaType) == nil { throw BridgeError.invalidArguments("\(capability) mediaType must be any, image, photo, or video") } @@ -219,20 +219,20 @@ public final class SystemUIBridge: @unchecked Sendable { try validatePhotoPickerArguments(arguments, capability: "camera.ui.capture") try validateOptionalBool(arguments, key: "allowsEditing", capability: "camera.ui.capture") - if let cameraDevice = arguments.string("cameraDevice")?.lowercased(), - ["rear", "front"].contains(cameraDevice) == false + if let cameraDevice = arguments.string("cameraDevice"), + CameraDevice.codeModeValue(matching: cameraDevice) == nil { throw BridgeError.invalidArguments("camera.ui.capture cameraDevice must be rear or front") } - if let flashMode = arguments.string("flashMode")?.lowercased(), - ["auto", "on", "off"].contains(flashMode) == false + if let flashMode = arguments.string("flashMode"), + CameraFlashMode.codeModeValue(matching: flashMode) == nil { throw BridgeError.invalidArguments("camera.ui.capture flashMode must be auto, on, or off") } - if let videoQuality = arguments.string("videoQuality")?.lowercased(), - ["high", "medium", "low", "640x480", "iframe1280x720", "iframe960x540"].contains(videoQuality) == false + if let videoQuality = arguments.string("videoQuality"), + CameraVideoQuality.codeModeValue(matching: videoQuality) == nil { throw BridgeError.invalidArguments("camera.ui.capture videoQuality must be high, medium, low, 640x480, iFrame1280x720, or iFrame960x540") } @@ -254,8 +254,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateAlertArguments(_ arguments: [String: JSONValue]) throws { - if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), - ["alert", "actionSheet", "actionsheet"].contains(preferredStyle) == false + if let preferredStyle = arguments.string("preferredStyle"), + AlertPreferredStyle.codeModeValue(matching: preferredStyle) == nil { throw BridgeError.invalidArguments("ui.alert.present preferredStyle must be alert or actionSheet") } @@ -291,9 +291,7 @@ public final class SystemUIBridge: @unchecked Sendable { private func validatePromptArguments(_ arguments: [String: JSONValue]) throws { try validateAlertArguments(arguments, capability: "ui.prompt.present") - if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), - ["actionSheet", "actionsheet"].contains(preferredStyle) - { + if AlertPreferredStyle.codeModeValue(matching: arguments.string("preferredStyle") ?? "") == .actionSheet { throw BridgeError.invalidArguments("ui.prompt.present preferredStyle must be alert") } @@ -318,8 +316,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateAlertArguments(_ arguments: [String: JSONValue], capability: String) throws { - if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), - ["alert", "actionSheet", "actionsheet"].contains(preferredStyle) == false + if let preferredStyle = arguments.string("preferredStyle"), + AlertPreferredStyle.codeModeValue(matching: preferredStyle) == nil { throw BridgeError.invalidArguments("\(capability) preferredStyle must be alert or actionSheet") } @@ -353,8 +351,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateDataScannerArguments(_ arguments: [String: JSONValue]) throws { - if let mode = arguments.string("mode")?.lowercased(), - ["any", "text", "barcode"].contains(mode) == false + if let mode = arguments.string("mode"), + DataScannerMode.codeModeValue(matching: mode) == nil { throw BridgeError.invalidArguments("camera.ui.scanData mode must be any, text, or barcode") } @@ -369,8 +367,8 @@ public final class SystemUIBridge: @unchecked Sendable { } try validateStringArray(arguments, key: "languages", capability: "camera.ui.scanData") - if let qualityLevel = arguments.string("qualityLevel")?.lowercased(), - ["balanced", "fast", "accurate"].contains(qualityLevel) == false + if let qualityLevel = arguments.string("qualityLevel"), + DataScannerQualityLevel.codeModeValue(matching: qualityLevel) == nil { throw BridgeError.invalidArguments("camera.ui.scanData qualityLevel must be balanced, fast, or accurate") } diff --git a/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift b/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift new file mode 100644 index 0000000..b4d47d7 --- /dev/null +++ b/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift @@ -0,0 +1,435 @@ +import Foundation + +// MARK: - Constrained argument values +// +// Single source of truth for these arguments: the catalog advertises +// `codeModeAllowedValues`, tool decode and SystemUIBridge parse through +// `codeModeValue(matching:)`, so advertised and accepted spellings cannot drift. + +/// Shared by camera capture and (later) the photos pickers. +enum MediaTypeFilter: String, CodeModeStringEnum { + case any + case image + case photo + case video +} + +enum CameraDevice: String, CodeModeStringEnum { + case rear + case front +} + +enum CameraFlashMode: String, CodeModeStringEnum { + case auto + case on + case off +} + +enum CameraVideoQuality: String, CodeModeStringEnum { + case high + case medium + case low + case res640x480 = "640x480" + case iFrame1280x720 + case iFrame960x540 +} + +enum DataScannerMode: String, CodeModeStringEnum { + case any + case text + case barcode +} + +enum DataScannerQualityLevel: String, CodeModeStringEnum { + case balanced + case fast + case accurate +} + +enum PrintOutputType: String, CodeModeStringEnum { + case general + case photo + case grayscale +} + +enum AlertPreferredStyle: String, CodeModeStringEnum { + case alert + case actionSheet + + // The bridge accepts the all-lowercase spelling; keep it advertised. + static let codeModeAliases: [String: Self] = [ + "actionsheet": .actionSheet, + ] +} + +// MARK: - Share / Quick Look + +@BuiltInCodeMode(.shareUIPresent, path: "apple.share.present") +struct ShareUIPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present share sheet" + static let codeModeSummary = "Present system share sheet for text, URLs, and sandbox file artifacts." + static let codeModeTags = ["share", "export", "system-ui"] + static let codeModeExample = "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })" + static let codeModeResultSummary = "Object with completed/activityType/action." + + struct Arguments: Sendable { + @ToolParam("Optional text item to share.") + var text: String? + @ToolParam("Optional absolute HTTP(S) URL to share.") + var url: String? + @ToolParam("Optional single sandbox file path to share.") + var path: String? + @ToolParam("Optional array of sandbox file paths to share.") + var paths: [String]? + @ToolParam("Optional subject for services that support it.") + var subject: String? + @ToolParam("Optional array of UIActivity.ActivityType raw value strings to hide.") + var excludedActivityTypes: [String]? + @ToolParam("Optional timeout for waiting on share completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentShareSheet(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.quickLookUIPreview, path: "apple.quicklook.preview") +struct QuickLookUIPreviewTool: BuiltInCodeModeTool { + static let codeModeTitle = "Preview files with Quick Look" + static let codeModeSummary = "Present Quick Look preview UI for one or more sandbox file artifacts." + static let codeModeTags = ["quicklook", "preview", "documents", "system-ui"] + static let codeModeExample = "await apple.quicklook.preview({ path: 'tmp:report.pdf' })" + static let codeModeResultSummary = "Object with action dismissed and count." + + struct Arguments: Sendable { + @ToolParam("Single sandbox file path to preview.") + var path: String? + @ToolParam("Optional array of sandbox file paths to preview.") + var paths: [String]? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.previewQuickLook(arguments: arguments.raw, context: context) + } +} + +// MARK: - Camera + +@BuiltInCodeMode(.cameraUICapture, path: "apple.camera.capture") +struct CameraUICaptureTool: BuiltInCodeModeTool { + static let codeModeTitle = "Capture photo or video with camera UI" + static let codeModeSummary = "Present system camera UI and export captured media into the sandbox artifact store." + static let codeModeTags = ["camera", "capture", "photos", "system-ui", "artifact"] + static let codeModeExample = "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })" + static let codeModeResultSummary = "Object with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + + struct Arguments: Sendable { + @ToolParam("any (default), image/photo, or video.") + var mediaType: MediaTypeFilter? + @ToolParam("Optional sandbox directory for captured media; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on capture/export.") + var timeoutMs: Double? + @ToolParam("Whether the system editor is shown before returning media; default false.") + var allowsEditing: Bool? + @ToolParam("rear (default) or front.") + var cameraDevice: CameraDevice? + @ToolParam("auto (default), on, or off.") + var flashMode: CameraFlashMode? + @ToolParam("UIImagePickerController quality name such as high, medium, low, 640x480, iFrame1280x720, or iFrame960x540.") + var videoQuality: CameraVideoQuality? + @ToolParam("Optional maximum duration for video capture.") + var maximumDurationSeconds: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.captureCamera(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.cameraUIScanData, path: "apple.camera.scanData") +struct CameraUIScanDataTool: BuiltInCodeModeTool { + static let codeModeTitle = "Scan text or barcodes with camera UI" + static let codeModeSummary = "Present VisionKit live data scanner UI and return recognized text or barcode payloads." + static let codeModeTags = ["camera", "scan", "barcode", "text", "visionkit", "system-ui"] + static let codeModeExample = "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })" + static let codeModeResultSummary = "Object with action and items containing text transcripts or barcode payloads." + + struct Arguments: Sendable { + @ToolParam("any (default), text, or barcode.") + var mode: DataScannerMode? + @ToolParam("Optional array containing text and/or barcode; overrides mode.") + var recognizedDataTypes: [String]? + @ToolParam("Optional text recognition language identifiers.") + var languages: [String]? + @ToolParam("balanced (default), fast, or accurate.") + var qualityLevel: DataScannerQualityLevel? + @ToolParam("Whether the scanner tracks multiple items at once; default false.") + var recognizesMultipleItems: Bool? + @ToolParam("Whether to dismiss as soon as data is recognized; default true.") + var returnsOnFirstResult: Bool? + @ToolParam("Whether VisionKit guidance UI is shown; default true.") + var isGuidanceEnabled: Bool? + @ToolParam("Whether recognized items are highlighted; default true.") + var isHighlightingEnabled: Bool? + @ToolParam("Whether pinch-to-zoom is enabled; default true.") + var isPinchToZoomEnabled: Bool? + @ToolParam("Whether high-frame-rate tracking is enabled; default true.") + var isHighFrameRateTrackingEnabled: Bool? + @ToolParam("Optional timeout for waiting on a scan result or cancellation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.scanData(arguments: arguments.raw, context: context) + } +} + +// MARK: - Mail / Messages / Print + +@BuiltInCodeMode(.mailUICompose, path: "apple.mail.compose") +struct MailUIComposeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Compose mail with system UI" + static let codeModeSummary = "Present system mail compose UI with optional recipients, body, and sandbox file attachments." + static let codeModeTags = ["mail", "compose", "system-ui", "share"] + static let codeModeExample = "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })" + static let codeModeResultSummary = "Object with action sent/saved/cancelled/failed." + + struct Arguments: Sendable { + @ToolParam("Optional array of recipient email strings.") + var to: [String]? + @ToolParam("Optional array of CC email strings.") + var cc: [String]? + @ToolParam("Optional array of BCC email strings.") + var bcc: [String]? + @ToolParam("Optional subject.") + var subject: String? + @ToolParam("Optional message body.") + var body: String? + @ToolParam("Whether body should be treated as HTML; default false.") + var isHTML: Bool? + @ToolParam("Optional array of { path, mimeType?, filename? } sandbox file attachments.") + var attachments: [JSONValue]? + @ToolParam("Optional timeout for waiting on user completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.composeMail(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.messagesUICompose, path: "apple.messages.compose") +struct MessagesUIComposeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Compose message with system UI" + static let codeModeSummary = "Present system Messages compose UI with optional recipients, body, and sandbox file attachments." + static let codeModeTags = ["messages", "sms", "compose", "system-ui", "share"] + static let codeModeExample = "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })" + static let codeModeResultSummary = "Object with action sent/cancelled/failed." + + struct Arguments: Sendable { + @ToolParam("Optional array of phone number or address strings.") + var recipients: [String]? + @ToolParam("Optional subject on devices/accounts that support it.") + var subject: String? + @ToolParam("Optional message body.") + var body: String? + @ToolParam("Optional array of { path, filename? } sandbox file attachments.") + var attachments: [JSONValue]? + @ToolParam("Optional timeout for waiting on user completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.composeMessage(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.printUIPresent, path: "apple.print.present") +struct PrintUIPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present print UI" + static let codeModeSummary = "Present the system print sheet for one or more sandbox files." + static let codeModeTags = ["print", "documents", "system-ui", "export"] + static let codeModeExample = "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })" + static let codeModeResultSummary = "Object with action/completed for the print interaction." + + struct Arguments: Sendable { + @ToolParam("Single sandbox file path to print.") + var path: String? + @ToolParam("Optional array of sandbox file paths to print.") + var paths: [String]? + @ToolParam("Optional print job name.") + var jobName: String? + @ToolParam("general (default), photo, or grayscale.") + var outputType: PrintOutputType? + @ToolParam("Whether copy count controls are shown; default true.") + var showsNumberOfCopies: Bool? + @ToolParam("Optional timeout for waiting on print completion/cancellation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentPrint(arguments: arguments.raw, context: context) + } +} + +// MARK: - Web / Auth + +@BuiltInCodeMode(.webUIPresent, path: "apple.web.present") +struct WebUIPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present web page with system UI" + static let codeModeSummary = "Present an HTTP(S) URL with the system Safari view controller." + static let codeModeTags = ["web", "safari", "browser", "system-ui"] + static let codeModeExample = "await apple.web.present({ url: 'https://example.com' })" + static let codeModeResultSummary = "Object with action dismissed." + + struct Arguments: Sendable { + @ToolParam("Absolute HTTP(S) URL to present.") + var url: String + @ToolParam("Whether Safari may enter Reader automatically; default false.") + var entersReaderIfAvailable: Bool? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentWeb(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.authUIWebAuthenticate, path: "apple.auth.webAuthenticate") +struct AuthUIWebAuthenticateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Authenticate with system web UI" + static let codeModeSummary = "Start an ASWebAuthenticationSession for OAuth-style browser authentication." + static let codeModeTags = ["auth", "oauth", "web", "browser", "system-ui"] + static let codeModeExample = "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })" + static let codeModeResultSummary = "Object with action callback/cancelled and callbackURL when available." + + struct Arguments: Sendable { + @ToolParam("Absolute HTTP(S) authentication URL.") + var url: String + @ToolParam("Optional custom URL scheme that completes the session.") + var callbackURLScheme: String? + @ToolParam("Whether to prefer a private browser session; default false.") + var prefersEphemeralSession: Bool? + @ToolParam("Optional timeout for waiting on callback/cancellation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.authenticateWeb(arguments: arguments.raw, context: context) + } +} + +// MARK: - Alerts / Prompts / Settings + +@BuiltInCodeMode(.uiAlertPresent, path: "apple.ui.presentAlert") +struct UIAlertPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present alert with custom buttons" + static let codeModeSummary = "Present a system alert or action sheet and return the button the user selects." + static let codeModeTags = ["ui", "alert", "dialog", "system-ui"] + static let codeModeExample = "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })" + static let codeModeResultSummary = "Object with action/buttonID/buttonTitle/buttonIndex/style for the selected button." + + struct Arguments: Sendable { + @ToolParam("Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.") + var buttons: [JSONValue] + @ToolParam("Optional alert title.") + var title: String? + @ToolParam("Optional alert message.") + var message: String? + @ToolParam("alert (default) or actionSheet.") + var preferredStyle: AlertPreferredStyle? + @ToolParam("Optional { x, y, width, height } anchor for action sheets.") + var sourceRect: [String: JSONValue]? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentAlert(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.uiPromptPresent, path: "apple.ui.presentPrompt") +struct UIPromptPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present prompt with text fields" + static let codeModeSummary = "Present a system alert with one or more text fields and custom buttons." + static let codeModeTags = ["ui", "alert", "prompt", "input", "system-ui"] + static let codeModeExample = "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })" + static let codeModeResultSummary = "Object with selected button metadata and values keyed by field id." + + struct Arguments: Sendable { + @ToolParam("Array of { id?, placeholder?, text?/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.") + var fields: [JSONValue] + @ToolParam("Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.") + var buttons: [JSONValue] + @ToolParam("Optional alert title.") + var title: String? + @ToolParam("Optional alert message.") + var message: String? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentPrompt(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.settingsUIOpen, path: "apple.settings.open") +struct SettingsUIOpenTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open app settings" + static let codeModeSummary = "Open the host app's Settings page so the user can recover denied permissions." + static let codeModeTags = ["settings", "permissions", "system-ui"] + static let codeModeExample = "await apple.settings.open()" + static let codeModeResultSummary = "Object with action opened/failed and opened boolean." + + struct Arguments: Sendable { + @ToolParam("Optional timeout for waiting on UIApplication.open completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.openSettings(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 526e689..39e1f71 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -63,26 +63,9 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { return .init(allowedStringValues: [ "mode": ["single", "multiple"], ]) - case .cameraUICapture: - return .init(allowedStringValues: [ - "mediaType": ["any", "image", "photo", "video"], - "cameraDevice": ["rear", "front"], - "flashMode": ["auto", "on", "off"], - "videoQuality": ["high", "medium", "low", "640x480", "iFrame1280x720", "iFrame960x540"], - ]) - case .cameraUIScanData: - return .init(allowedStringValues: [ - "mode": ["any", "text", "barcode"], - "qualityLevel": ["balanced", "fast", "accurate"], - ]) - case .printUIPresent: - return .init(allowedStringValues: [ - "outputType": ["general", "photo", "grayscale"], - ]) - case .uiAlertPresent: - return .init(allowedStringValues: [ - "preferredStyle": ["alert", "actionSheet", "actionsheet"], - ]) + // cameraUICapture / cameraUIScanData / printUIPresent / uiAlertPresent + // constraints now come from their tools' CodeModeStringEnum arguments + // (SystemUICodeModeTools.swift), not this table. case .cloudKitRecordsQuery, .cloudKitRecordSave, .cloudKitRecordDelete, .cloudKitSubscriptionSave: return .init(allowedStringValues: [ "database": ["private", "shared", "public"], diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index 7815c6f..3e02d10 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -4801,7 +4801,9 @@ "argumentHints" : { "buttons" : "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", "fields" : "Array of { id?, placeholder?, text?\/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", - "timeoutMs" : "Optional timeout for waiting on user selection." + "message" : "Optional alert message.", + "timeoutMs" : "Optional timeout for waiting on user selection.", + "title" : "Optional alert title." }, "argumentTypes" : { "buttons" : "array", From 753826bc0618c5ea3e9913c0e2cea715a0329917 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 17:00:20 -0700 Subject: [PATCH 19/25] Phase 3: migrate filesystem registrations to @BuiltInCodeMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts the 10 filesystem capabilities (list, read, write, move, copy, delete, stat, mkdir, exists, access) to macro-authored tools in FileSystemCodeModeTools.swift; the filesystem section of +Core.swift is now a thin list. No constrained values, so no enums and no bridge rewiring. networkFetch is left on the flat-init idiom (PHASE3-SKIP comment): it declares nested dotted-path arguments (options.method, options.headers, …) that the flat @BuiltInCodeMode Arguments model cannot express without changing the advertised optionalArguments list, and its options.responseEncoding constraint stays in the central defaults table. Keychain already delegates to the Phase-1 converted builtins and is untouched. Golden diff: empty — every filesystem argument already had a hint, and types match the inferred baseline. 230 tests pass. --- .../CapabilityRegistrations+Core.swift | 207 ++-------------- .../Bridges/FileSystemCodeModeTools.swift | 228 ++++++++++++++++++ 2 files changed, 244 insertions(+), 191 deletions(-) create mode 100644 Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift index e151a66..706b4af 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift @@ -1,6 +1,12 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { + // PHASE3-SKIP: networkFetch declares nested dotted-path arguments + // (options.method, options.headers, …). The @BuiltInCodeMode Arguments + // model is flat — property names map to top-level JSON keys — so it cannot + // reproduce the advertised optionalArguments list without changing it. + // Its options.responseEncoding constraint stays in the central defaults + // table. Left on the flat-init idiom. func networkRegistrations() -> [CapabilityRegistration] { [ CapabilityRegistration( @@ -46,197 +52,16 @@ extension DefaultCapabilityRegistrationBuilder { func filesystemRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.fs.list", "fs.promises.readdir"], - descriptor: .init( - id: .fsList, - title: "List directory", - summary: "List files/directories within allowed sandbox roots as entry objects.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.list({ path: 'tmp:' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "Directory path using allowed root prefix (tmp:, caches:, documents:).", - ], - resultSummary: "Array of entry objects with name/path/isDirectory/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings." - ), - handler: { args, context in - try fs.list(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.read", "fs.promises.readFile"], - descriptor: .init( - id: .fsRead, - title: "Read file", - summary: "Read text/base64 file data within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })", - requiredArguments: ["path"], - optionalArguments: ["encoding"], - argumentHints: [ - "path": "File path using allowed root prefix.", - "encoding": "utf8 (default) or base64.", - ], - resultSummary: "Object with path plus text or base64 field." - ), - handler: { args, context in - try fs.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.write", "fs.promises.writeFile"], - descriptor: .init( - id: .fsWrite, - title: "Write file", - summary: "Write text/base64 file data within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })", - requiredArguments: ["path"], - optionalArguments: ["data", "encoding"], - argumentHints: [ - "path": "File path using allowed root prefix.", - "data": "UTF-8 text or base64 string depending on encoding.", - "encoding": "utf8 (default) or base64.", - ], - resultSummary: "Object with path and bytesWritten." - ), - handler: { args, context in - try fs.write(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.move", "fs.promises.rename"], - descriptor: .init( - id: .fsMove, - title: "Move file", - summary: "Move file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", - requiredArguments: ["from", "to"], - argumentHints: [ - "from": "Source sandbox path.", - "to": "Destination sandbox path.", - ], - resultSummary: "Object with from/to resolved paths." - ), - handler: { args, context in - try fs.move(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.copy", "fs.promises.copyFile"], - descriptor: .init( - id: .fsCopy, - title: "Copy file", - summary: "Copy file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", - requiredArguments: ["from", "to"], - argumentHints: [ - "from": "Source sandbox path.", - "to": "Destination sandbox path.", - ], - resultSummary: "Object with from/to resolved paths." - ), - handler: { args, context in - try fs.copy(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.delete", "fs.promises.rm"], - descriptor: .init( - id: .fsDelete, - title: "Delete file", - summary: "Delete file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.delete({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - optionalArguments: ["recursive"], - argumentHints: [ - "path": "Path to file or directory.", - "recursive": "Required as true when deleting a directory.", - ], - resultSummary: "Object with deleted flag and path." - ), - handler: { args, context in - try fs.delete(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.stat", "fs.promises.stat"], - descriptor: .init( - id: .fsStat, - title: "Stat path", - summary: "Read file metadata within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.stat({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path.", - ], - resultSummary: "Object with path/isDirectory/size/createdAt/modifiedAt." - ), - handler: { args, context in - try fs.stat(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.mkdir", "fs.promises.mkdir"], - descriptor: .init( - id: .fsMkdir, - title: "Create directory", - summary: "Create directories within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })", - requiredArguments: ["path"], - optionalArguments: ["recursive"], - argumentHints: [ - "path": "Directory path to create.", - "recursive": "Boolean; default true.", - ], - resultSummary: "Object with created flag and path." - ), - handler: { args, context in - try fs.mkdir(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.exists"], - descriptor: .init( - id: .fsExists, - title: "Check path exists", - summary: "Check if file/directory exists within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.exists({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path to check.", - ], - resultSummary: "Boolean." - ), - handler: { args, context in - try fs.exists(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.access", "fs.promises.access"], - descriptor: .init( - id: .fsAccess, - title: "Check path access", - summary: "Check read/write access for path within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.access({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path to inspect.", - ], - resultSummary: "Object with readable/writable/path." - ), - handler: { args, context in - try fs.access(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: FSListTool(fs: fs)), + CapabilityRegistration(tool: FSReadTool(fs: fs)), + CapabilityRegistration(tool: FSWriteTool(fs: fs)), + CapabilityRegistration(tool: FSMoveTool(fs: fs)), + CapabilityRegistration(tool: FSCopyTool(fs: fs)), + CapabilityRegistration(tool: FSDeleteTool(fs: fs)), + CapabilityRegistration(tool: FSStatTool(fs: fs)), + CapabilityRegistration(tool: FSMkdirTool(fs: fs)), + CapabilityRegistration(tool: FSExistsTool(fs: fs)), + CapabilityRegistration(tool: FSAccessTool(fs: fs)), ] } } diff --git a/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift b/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift new file mode 100644 index 0000000..650190b --- /dev/null +++ b/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift @@ -0,0 +1,228 @@ +import Foundation + +// Filesystem capabilities. Flat arguments, no constrained values, so no +// CodeModeStringEnum is needed here. + +@BuiltInCodeMode(.fsList, path: "apple.fs.list", aliases: ["fs.promises.readdir"]) +struct FSListTool: BuiltInCodeModeTool { + static let codeModeTitle = "List directory" + static let codeModeSummary = "List files/directories within allowed sandbox roots as entry objects." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.list({ path: 'tmp:' })" + static let codeModeResultSummary = "Array of entry objects with name/path/isDirectory/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings." + + struct Arguments: Sendable { + @ToolParam("Directory path using allowed root prefix (tmp:, caches:, documents:).") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.list(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsRead, path: "apple.fs.read", aliases: ["fs.promises.readFile"]) +struct FSReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read file" + static let codeModeSummary = "Read text/base64 file data within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })" + static let codeModeResultSummary = "Object with path plus text or base64 field." + + struct Arguments: Sendable { + @ToolParam("File path using allowed root prefix.") + var path: String + @ToolParam("utf8 (default) or base64.") + var encoding: String? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsWrite, path: "apple.fs.write", aliases: ["fs.promises.writeFile"]) +struct FSWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write file" + static let codeModeSummary = "Write text/base64 file data within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })" + static let codeModeResultSummary = "Object with path and bytesWritten." + + struct Arguments: Sendable { + @ToolParam("File path using allowed root prefix.") + var path: String + @ToolParam("UTF-8 text or base64 string depending on encoding.") + var data: String? + @ToolParam("utf8 (default) or base64.") + var encoding: String? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.write(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsMove, path: "apple.fs.move", aliases: ["fs.promises.rename"]) +struct FSMoveTool: BuiltInCodeModeTool { + static let codeModeTitle = "Move file" + static let codeModeSummary = "Move file/directory within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })" + static let codeModeResultSummary = "Object with from/to resolved paths." + + struct Arguments: Sendable { + @ToolParam("Source sandbox path.") + var from: String + @ToolParam("Destination sandbox path.") + var to: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.move(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsCopy, path: "apple.fs.copy", aliases: ["fs.promises.copyFile"]) +struct FSCopyTool: BuiltInCodeModeTool { + static let codeModeTitle = "Copy file" + static let codeModeSummary = "Copy file/directory within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })" + static let codeModeResultSummary = "Object with from/to resolved paths." + + struct Arguments: Sendable { + @ToolParam("Source sandbox path.") + var from: String + @ToolParam("Destination sandbox path.") + var to: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.copy(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsDelete, path: "apple.fs.delete", aliases: ["fs.promises.rm"]) +struct FSDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete file" + static let codeModeSummary = "Delete file/directory within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.delete({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Object with deleted flag and path." + + struct Arguments: Sendable { + @ToolParam("Path to file or directory.") + var path: String + @ToolParam("Required as true when deleting a directory.") + var recursive: Bool? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.delete(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsStat, path: "apple.fs.stat", aliases: ["fs.promises.stat"]) +struct FSStatTool: BuiltInCodeModeTool { + static let codeModeTitle = "Stat path" + static let codeModeSummary = "Read file metadata within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.stat({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Object with path/isDirectory/size/createdAt/modifiedAt." + + struct Arguments: Sendable { + @ToolParam("File or directory path.") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.stat(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsMkdir, path: "apple.fs.mkdir", aliases: ["fs.promises.mkdir"]) +struct FSMkdirTool: BuiltInCodeModeTool { + static let codeModeTitle = "Create directory" + static let codeModeSummary = "Create directories within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })" + static let codeModeResultSummary = "Object with created flag and path." + + struct Arguments: Sendable { + @ToolParam("Directory path to create.") + var path: String + @ToolParam("Boolean; default true.") + var recursive: Bool? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.mkdir(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsExists, path: "apple.fs.exists") +struct FSExistsTool: BuiltInCodeModeTool { + static let codeModeTitle = "Check path exists" + static let codeModeSummary = "Check if file/directory exists within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.exists({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Boolean." + + struct Arguments: Sendable { + @ToolParam("File or directory path to check.") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.exists(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsAccess, path: "apple.fs.access", aliases: ["fs.promises.access"]) +struct FSAccessTool: BuiltInCodeModeTool { + static let codeModeTitle = "Check path access" + static let codeModeSummary = "Check read/write access for path within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.access({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Object with readable/writable/path." + + struct Arguments: Sendable { + @ToolParam("File or directory path to inspect.") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.access(arguments: arguments.raw, context: context) + } +} From c96101a73ed9cba41532dbb6068f2a6857984ce5 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 17:05:47 -0700 Subject: [PATCH 20/25] Phase 3: migrate People/Photos/Documents registrations to @BuiltInCodeMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 13 capabilities (contacts read/search/pick/present/presentNew, photos read/export/pick/limited-picker, documents pick/export/openIn/scan) to macro-authored tools; both registration functions are now thin lists. Constrained values: photosRead and photosUIPick reuse MediaTypeFilter (from domain 1); contactsUIPick gets a new ContactPickerMode enum. Their three rows are deleted from the central defaults table. Rewires the owning parsers to the enums: PhotosBridge.read (mediaType -> MediaTypeFilter), SystemUIBridge validateContactPickerArguments (mode -> ContactPickerMode); photosUIPick's mediaType already validated through MediaTypeFilter via the shared validatePhotoPickerArguments rewired in domain 1. Also decouples constraintValidationMatchesCaseInsensitively from the removed contactsUIPick table row by constructing the constraints inline — it tests the generic validate() matching, which is unchanged. Golden diff: empty — advertised allowedStringValues are identical to the former table rows, all argument types match the inferred/declared baseline, and every argument already had a hint. 230 tests pass. --- ...yRegistrations+PeoplePhotosDocuments.swift | 336 +---------------- .../PeoplePhotosDocumentsCodeModeTools.swift | 349 ++++++++++++++++++ Sources/CodeMode/Bridges/PhotosBridge.swift | 8 +- Sources/CodeMode/Bridges/SystemUIBridge.swift | 4 +- .../Registry/CapabilityRegistry.swift | 16 +- .../CapabilityRegistryTests.swift | 5 +- 6 files changed, 376 insertions(+), 342 deletions(-) create mode 100644 Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift index d38daed..aa47b5f 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift @@ -3,335 +3,25 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func contactRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.contacts.list"], - descriptor: .init( - id: .contactsRead, - title: "Read contacts", - summary: "Read contacts with bounded fields.", - tags: ["contacts", "address-book", "people"], - example: "await apple.contacts.list({ limit: 25 })", - requiredPermissions: [.contacts], - optionalArguments: ["limit", "identifiers"], - argumentHints: [ - "limit": "Max number of contacts, default 50.", - "identifiers": "Optional array of contact identifiers for targeted read.", - ], - resultSummary: "Array of contacts with identifier/name/organization/phones/emails." - ), - handler: { args, context in - try contacts.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.search"], - descriptor: .init( - id: .contactsSearch, - title: "Search contacts", - summary: "Search contacts by name.", - tags: ["contacts", "search", "people"], - example: "await apple.contacts.search({ query: 'Alex', limit: 10 })", - requiredPermissions: [.contacts], - requiredArguments: ["query"], - optionalArguments: ["limit"], - argumentHints: [ - "query": "Name text to match.", - "limit": "Max number of contacts, default 20.", - ], - resultSummary: "Array of contact objects." - ), - handler: { args, context in - try contacts.search(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.pick"], - descriptor: .init( - id: .contactsUIPick, - title: "Pick contacts with system UI", - summary: "Present system contact picker UI and return selected contacts without requiring full Contacts permission.", - tags: ["contacts", "people", "system-ui", "picker"], - example: "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })", - optionalArguments: ["mode", "displayedPropertyKeys", "timeoutMs"], - argumentTypes: [ - "mode": .string, - "displayedPropertyKeys": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "mode": "single (default) or multiple.", - "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Array of selected contacts with identifier/name/organization/phones/emails." - ), - handler: { args, context in - try systemUI.pickContacts(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.presentContact"], - descriptor: .init( - id: .contactsUIPresentContact, - title: "Present contact card", - summary: "Present system contact card UI for a contact identifier.", - tags: ["contacts", "people", "system-ui", "details"], - example: "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })", - requiredPermissions: [.contacts], - requiredArguments: ["identifier"], - optionalArguments: ["allowsEditing", "allowsActions", "displayedPropertyKeys", "timeoutMs"], - argumentTypes: [ - "identifier": .string, - "allowsEditing": .bool, - "allowsActions": .bool, - "displayedPropertyKeys": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "identifier": "Contact identifier from apple.contacts.list/search/pick.", - "allowsEditing": "Whether the user can edit the contact; default false.", - "allowsActions": "Whether built-in actions like call/message are shown; default true.", - "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action and contact when available." - ), - handler: { args, context in - try systemUI.presentContact(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.presentNewContact"], - descriptor: .init( - id: .contactsUIPresentNewContact, - title: "Present new contact editor", - summary: "Present system UI for creating a new contact draft.", - tags: ["contacts", "people", "system-ui", "create"], - example: "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })", - requiredPermissions: [.contacts], - optionalArguments: ["givenName", "familyName", "organization", "phoneNumbers", "emailAddresses", "timeoutMs"], - argumentTypes: [ - "givenName": .string, - "familyName": .string, - "organization": .string, - "phoneNumbers": .array, - "emailAddresses": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "givenName": "Optional given name to prefill.", - "familyName": "Optional family name to prefill.", - "organization": "Optional organization to prefill.", - "phoneNumbers": "Optional array of phone number strings.", - "emailAddresses": "Optional array of email address strings.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action and contact when the user saves." - ), - handler: { args, context in - try systemUI.presentNewContact(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: ContactsReadTool(contacts: contacts)), + CapabilityRegistration(tool: ContactsSearchTool(contacts: contacts)), + CapabilityRegistration(tool: ContactsUIPickTool(systemUI: systemUI)), + CapabilityRegistration(tool: ContactsUIPresentContactTool(systemUI: systemUI)), + CapabilityRegistration(tool: ContactsUIPresentNewContactTool(systemUI: systemUI)), ] } func photoAndDocumentRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.photos.list"], - descriptor: .init( - id: .photosRead, - title: "List photo library assets", - summary: "List photos/videos from the user photo library.", - tags: ["photos", "photo-library", "media"], - example: "await apple.photos.list({ mediaType: 'image', limit: 20 })", - requiredPermissions: [.photoLibrary], - optionalArguments: ["mediaType", "limit"], - argumentHints: [ - "mediaType": "any (default), image, or video.", - "limit": "Max number of results, default 50.", - ], - resultSummary: "Array of assets with localIdentifier/mediaType/dimensions/date metadata." - ), - handler: { args, context in - try photos.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.photos.export"], - descriptor: .init( - id: .photosExport, - title: "Export photo library asset", - summary: "Export a photo/video asset to sandbox file path and register artifact handle.", - tags: ["photos", "photo-library", "artifact"], - example: "await apple.photos.export({ localIdentifier: 'ABC/L0/001', outputPath: 'tmp:exported.jpg' })", - requiredPermissions: [.photoLibrary], - requiredArguments: ["localIdentifier"], - optionalArguments: ["outputPath"], - argumentHints: [ - "localIdentifier": "PHAsset localIdentifier from photos.read result.", - "outputPath": "Optional sandbox output path; defaults to tmp-generated file.", - ], - resultSummary: "Object with path/artifactID/localIdentifier/mediaType/bytes." - ), - handler: { args, context in - try photos.export(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.photos.pick"], - descriptor: .init( - id: .photosUIPick, - title: "Pick photos with system UI", - summary: "Present system photo picker UI and export selected assets into the sandbox artifact store.", - tags: ["photos", "photo-library", "system-ui", "picker", "artifact"], - example: "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })", - optionalArguments: ["mediaType", "limit", "outputDirectory", "timeoutMs"], - argumentTypes: [ - "mediaType": .string, - "limit": .number, - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "mediaType": "any (default), image/photo, or video.", - "limit": "Maximum number of selectable items; default 1.", - "outputDirectory": "Optional sandbox directory for exported picker files; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user selection/export.", - ], - resultSummary: "Array of selected assets with path/artifactID/mediaType/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.pickPhotos(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.photos.presentLimitedLibraryPicker"], - descriptor: .init( - id: .photosUIPresentLimitedLibraryPicker, - title: "Present limited Photos picker", - summary: "Present the Photos limited-library management UI so the user can update the app's selected photo set.", - tags: ["photos", "photo-library", "system-ui", "permission", "picker"], - example: "await apple.photos.presentLimitedLibraryPicker()", - optionalArguments: ["timeoutMs"], - argumentTypes: [ - "timeoutMs": .number, - ], - argumentHints: [ - "timeoutMs": "Optional timeout for waiting on the limited-library picker completion.", - ], - resultSummary: "Object with action/status and selectedIdentifiers when the limited selection changes." - ), - handler: { args, context in - try systemUI.presentLimitedPhotoLibraryPicker(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.pick"], - descriptor: .init( - id: .documentsUIPick, - title: "Pick documents with system UI", - summary: "Present Files document picker UI and copy selected documents into the sandbox artifact store.", - tags: ["documents", "files", "system-ui", "picker", "artifact"], - example: "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })", - optionalArguments: ["contentTypes", "allowMultiple", "outputDirectory", "timeoutMs"], - argumentTypes: [ - "contentTypes": .array, - "allowMultiple": .bool, - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "contentTypes": "Optional array of UTType identifiers; defaults to public.item.", - "allowMultiple": "Whether multiple files may be selected; default false.", - "outputDirectory": "Optional sandbox directory for copied files; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user selection/copy.", - ], - resultSummary: "Array of selected documents with path/artifactID/filename/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.pickDocuments(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.export", "apple.documents.save"], - descriptor: .init( - id: .documentsUIExport, - title: "Export documents with system UI", - summary: "Present Files export UI to save one or more sandbox files to a user-selected destination.", - tags: ["documents", "files", "system-ui", "export", "save"], - example: "await apple.documents.export({ path: 'tmp:report.pdf' })", - optionalArguments: ["path", "paths", "asCopy", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "asCopy": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to export.", - "paths": "Optional array of sandbox file paths to export.", - "asCopy": "Whether to export as a copy; default true.", - "timeoutMs": "Optional timeout for waiting on export completion.", - ], - resultSummary: "Object with action/count and destination URLs when the provider returns them." - ), - handler: { args, context in - try systemUI.exportDocuments(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.openIn"], - descriptor: .init( - id: .documentsUIOpenIn, - title: "Open document in another app", - summary: "Present the system Open In menu for a sandbox file.", - tags: ["documents", "files", "system-ui", "open-in", "handoff"], - example: "await apple.documents.openIn({ path: 'tmp:report.pdf' })", - requiredArguments: ["path"], - optionalArguments: ["name", "uti", "timeoutMs"], - argumentTypes: [ - "path": .string, - "name": .string, - "uti": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Sandbox file path to hand off.", - "name": "Optional display name for the document interaction controller.", - "uti": "Optional uniform type identifier override.", - "timeoutMs": "Optional timeout for waiting on the Open In menu dismissal.", - ], - resultSummary: "Object with action and application bundle identifier when the user hands off the file." - ), - handler: { args, context in - try systemUI.openDocument(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.scan"], - descriptor: .init( - id: .documentsUIScan, - title: "Scan documents with system UI", - summary: "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store.", - tags: ["documents", "scan", "camera", "system-ui", "artifact"], - example: "await apple.documents.scan({ outputDirectory: 'tmp:scans' })", - optionalArguments: ["outputDirectory", "timeoutMs"], - argumentTypes: [ - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "outputDirectory": "Optional sandbox directory for scanned page images; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user scanning/export.", - ], - resultSummary: "Array of scanned page artifacts with path/artifactID/pageIndex/mediaType/bytes." - ), - handler: { args, context in - try systemUI.scanDocuments(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: PhotosReadTool(photos: photos)), + CapabilityRegistration(tool: PhotosExportTool(photos: photos)), + CapabilityRegistration(tool: PhotosUIPickTool(systemUI: systemUI)), + CapabilityRegistration(tool: PhotosUIPresentLimitedLibraryPickerTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIPickTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIExportTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIOpenInTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIScanTool(systemUI: systemUI)), ] } } diff --git a/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift b/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift new file mode 100644 index 0000000..e51e78b --- /dev/null +++ b/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift @@ -0,0 +1,349 @@ +import Foundation + +// Constrained argument value for the contacts picker; MediaTypeFilter (shared +// with camera/photos) lives in SystemUICodeModeTools.swift. +enum ContactPickerMode: String, CodeModeStringEnum { + case single + case multiple +} + +// MARK: - Contacts + +@BuiltInCodeMode(.contactsRead, path: "apple.contacts.list") +struct ContactsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read contacts" + static let codeModeSummary = "Read contacts with bounded fields." + static let codeModeTags = ["contacts", "address-book", "people"] + static let codeModeExample = "await apple.contacts.list({ limit: 25 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Array of contacts with identifier/name/organization/phones/emails." + + struct Arguments: Sendable { + @ToolParam("Max number of contacts, default 50.") + var limit: Int? + @ToolParam("Optional array of contact identifiers for targeted read.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let contacts: ContactsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try contacts.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsSearch, path: "apple.contacts.search") +struct ContactsSearchTool: BuiltInCodeModeTool { + static let codeModeTitle = "Search contacts" + static let codeModeSummary = "Search contacts by name." + static let codeModeTags = ["contacts", "search", "people"] + static let codeModeExample = "await apple.contacts.search({ query: 'Alex', limit: 10 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Array of contact objects." + + struct Arguments: Sendable { + @ToolParam("Name text to match.") + var query: String + @ToolParam("Max number of contacts, default 20.") + var limit: Int? + var raw: [String: JSONValue] + } + + let contacts: ContactsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try contacts.search(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsUIPick, path: "apple.contacts.pick") +struct ContactsUIPickTool: BuiltInCodeModeTool { + static let codeModeTitle = "Pick contacts with system UI" + static let codeModeSummary = "Present system contact picker UI and return selected contacts without requiring full Contacts permission." + static let codeModeTags = ["contacts", "people", "system-ui", "picker"] + static let codeModeExample = "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })" + static let codeModeResultSummary = "Array of selected contacts with identifier/name/organization/phones/emails." + + struct Arguments: Sendable { + @ToolParam("single (default) or multiple.") + var mode: ContactPickerMode? + @ToolParam("Optional array of CNContact property key strings to display.") + var displayedPropertyKeys: [String]? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickContacts(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsUIPresentContact, path: "apple.contacts.presentContact") +struct ContactsUIPresentContactTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present contact card" + static let codeModeSummary = "Present system contact card UI for a contact identifier." + static let codeModeTags = ["contacts", "people", "system-ui", "details"] + static let codeModeExample = "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Object with action and contact when available." + + struct Arguments: Sendable { + @ToolParam("Contact identifier from apple.contacts.list/search/pick.") + var identifier: String + @ToolParam("Whether the user can edit the contact; default false.") + var allowsEditing: Bool? + @ToolParam("Whether built-in actions like call/message are shown; default true.") + var allowsActions: Bool? + @ToolParam("Optional array of CNContact property key strings to display.") + var displayedPropertyKeys: [String]? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentContact(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsUIPresentNewContact, path: "apple.contacts.presentNewContact") +struct ContactsUIPresentNewContactTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present new contact editor" + static let codeModeSummary = "Present system UI for creating a new contact draft." + static let codeModeTags = ["contacts", "people", "system-ui", "create"] + static let codeModeExample = "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Object with action and contact when the user saves." + + struct Arguments: Sendable { + @ToolParam("Optional given name to prefill.") + var givenName: String? + @ToolParam("Optional family name to prefill.") + var familyName: String? + @ToolParam("Optional organization to prefill.") + var organization: String? + @ToolParam("Optional array of phone number strings.") + var phoneNumbers: [String]? + @ToolParam("Optional array of email address strings.") + var emailAddresses: [String]? + @ToolParam("Optional timeout for waiting on user completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentNewContact(arguments: arguments.raw, context: context) + } +} + +// MARK: - Photos + +@BuiltInCodeMode(.photosRead, path: "apple.photos.list") +struct PhotosReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List photo library assets" + static let codeModeSummary = "List photos/videos from the user photo library." + static let codeModeTags = ["photos", "photo-library", "media"] + static let codeModeExample = "await apple.photos.list({ mediaType: 'image', limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.photoLibrary] + static let codeModeResultSummary = "Array of assets with localIdentifier/mediaType/dimensions/date metadata." + + struct Arguments: Sendable { + @ToolParam("any (default), image, or video.") + var mediaType: MediaTypeFilter? + @ToolParam("Max number of results, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let photos: PhotosBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try photos.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.photosExport, path: "apple.photos.export") +struct PhotosExportTool: BuiltInCodeModeTool { + static let codeModeTitle = "Export photo library asset" + static let codeModeSummary = "Export a photo/video asset to sandbox file path and register artifact handle." + static let codeModeTags = ["photos", "photo-library", "artifact"] + static let codeModeExample = "await apple.photos.export({ localIdentifier: 'ABC/L0/001', outputPath: 'tmp:exported.jpg' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.photoLibrary] + static let codeModeResultSummary = "Object with path/artifactID/localIdentifier/mediaType/bytes." + + struct Arguments: Sendable { + @ToolParam("PHAsset localIdentifier from photos.read result.") + var localIdentifier: String + @ToolParam("Optional sandbox output path; defaults to tmp-generated file.") + var outputPath: String? + var raw: [String: JSONValue] + } + + let photos: PhotosBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try photos.export(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.photosUIPick, path: "apple.photos.pick") +struct PhotosUIPickTool: BuiltInCodeModeTool { + static let codeModeTitle = "Pick photos with system UI" + static let codeModeSummary = "Present system photo picker UI and export selected assets into the sandbox artifact store." + static let codeModeTags = ["photos", "photo-library", "system-ui", "picker", "artifact"] + static let codeModeExample = "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })" + static let codeModeResultSummary = "Array of selected assets with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + + struct Arguments: Sendable { + @ToolParam("any (default), image/photo, or video.") + var mediaType: MediaTypeFilter? + @ToolParam("Maximum number of selectable items; default 1.") + var limit: Int? + @ToolParam("Optional sandbox directory for exported picker files; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on user selection/export.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickPhotos(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.photosUIPresentLimitedLibraryPicker, path: "apple.photos.presentLimitedLibraryPicker") +struct PhotosUIPresentLimitedLibraryPickerTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present limited Photos picker" + static let codeModeSummary = "Present the Photos limited-library management UI so the user can update the app's selected photo set." + static let codeModeTags = ["photos", "photo-library", "system-ui", "permission", "picker"] + static let codeModeExample = "await apple.photos.presentLimitedLibraryPicker()" + static let codeModeResultSummary = "Object with action/status and selectedIdentifiers when the limited selection changes." + + struct Arguments: Sendable { + @ToolParam("Optional timeout for waiting on the limited-library picker completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentLimitedPhotoLibraryPicker(arguments: arguments.raw, context: context) + } +} + +// MARK: - Documents + +@BuiltInCodeMode(.documentsUIPick, path: "apple.documents.pick") +struct DocumentsUIPickTool: BuiltInCodeModeTool { + static let codeModeTitle = "Pick documents with system UI" + static let codeModeSummary = "Present Files document picker UI and copy selected documents into the sandbox artifact store." + static let codeModeTags = ["documents", "files", "system-ui", "picker", "artifact"] + static let codeModeExample = "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })" + static let codeModeResultSummary = "Array of selected documents with path/artifactID/filename/uniformTypeIdentifier/bytes." + + struct Arguments: Sendable { + @ToolParam("Optional array of UTType identifiers; defaults to public.item.") + var contentTypes: [String]? + @ToolParam("Whether multiple files may be selected; default false.") + var allowMultiple: Bool? + @ToolParam("Optional sandbox directory for copied files; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on user selection/copy.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickDocuments(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.documentsUIExport, path: "apple.documents.export", aliases: ["apple.documents.save"]) +struct DocumentsUIExportTool: BuiltInCodeModeTool { + static let codeModeTitle = "Export documents with system UI" + static let codeModeSummary = "Present Files export UI to save one or more sandbox files to a user-selected destination." + static let codeModeTags = ["documents", "files", "system-ui", "export", "save"] + static let codeModeExample = "await apple.documents.export({ path: 'tmp:report.pdf' })" + static let codeModeResultSummary = "Object with action/count and destination URLs when the provider returns them." + + struct Arguments: Sendable { + @ToolParam("Single sandbox file path to export.") + var path: String? + @ToolParam("Optional array of sandbox file paths to export.") + var paths: [String]? + @ToolParam("Whether to export as a copy; default true.") + var asCopy: Bool? + @ToolParam("Optional timeout for waiting on export completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.exportDocuments(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.documentsUIOpenIn, path: "apple.documents.openIn") +struct DocumentsUIOpenInTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open document in another app" + static let codeModeSummary = "Present the system Open In menu for a sandbox file." + static let codeModeTags = ["documents", "files", "system-ui", "open-in", "handoff"] + static let codeModeExample = "await apple.documents.openIn({ path: 'tmp:report.pdf' })" + static let codeModeResultSummary = "Object with action and application bundle identifier when the user hands off the file." + + struct Arguments: Sendable { + @ToolParam("Sandbox file path to hand off.") + var path: String + @ToolParam("Optional display name for the document interaction controller.") + var name: String? + @ToolParam("Optional uniform type identifier override.") + var uti: String? + @ToolParam("Optional timeout for waiting on the Open In menu dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.openDocument(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.documentsUIScan, path: "apple.documents.scan") +struct DocumentsUIScanTool: BuiltInCodeModeTool { + static let codeModeTitle = "Scan documents with system UI" + static let codeModeSummary = "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store." + static let codeModeTags = ["documents", "scan", "camera", "system-ui", "artifact"] + static let codeModeExample = "await apple.documents.scan({ outputDirectory: 'tmp:scans' })" + static let codeModeResultSummary = "Array of scanned page artifacts with path/artifactID/pageIndex/mediaType/bytes." + + struct Arguments: Sendable { + @ToolParam("Optional sandbox directory for scanned page images; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on user scanning/export.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.scanDocuments(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Bridges/PhotosBridge.swift b/Sources/CodeMode/Bridges/PhotosBridge.swift index 926e83f..1c25ebe 100644 --- a/Sources/CodeMode/Bridges/PhotosBridge.swift +++ b/Sources/CodeMode/Bridges/PhotosBridge.swift @@ -19,18 +19,18 @@ public final class PhotosBridge: @unchecked Sendable { #if canImport(Photos) let limit = max(1, arguments.int("limit") ?? 50) - let mediaTypeFilter = (arguments.string("mediaType") ?? "any").lowercased() + let mediaTypeFilter = MediaTypeFilter.codeModeValue(matching: arguments.string("mediaType") ?? "any") let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] let assets: PHFetchResult switch mediaTypeFilter { - case "image", "photo": + case .image, .photo: assets = PHAsset.fetchAssets(with: .image, options: options) - case "video": + case .video: assets = PHAsset.fetchAssets(with: .video, options: options) - default: + case .any, nil: assets = PHAsset.fetchAssets(with: options) } diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift index 21d0522..a5b59bb 100644 --- a/Sources/CodeMode/Bridges/SystemUIBridge.swift +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -243,8 +243,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateContactPickerArguments(_ arguments: [String: JSONValue]) throws { - if let mode = arguments.string("mode")?.lowercased(), - ["single", "multiple"].contains(mode) == false + if let mode = arguments.string("mode"), + ContactPickerMode.codeModeValue(matching: mode) == nil { throw BridgeError.invalidArguments("contacts.ui.pick mode must be single or multiple") } diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 39e1f71..bff27ac 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -51,18 +51,10 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { // calendarWrite / calendarDelete / calendarUIPickCalendar / remindersWrite // constraints now come from their tools' CodeModeStringEnum arguments // (EventKitCodeModeTools.swift), not this table. - case .photosRead: - return .init(allowedStringValues: [ - "mediaType": ["any", "image", "photo", "video"], - ]) - case .photosUIPick: - return .init(allowedStringValues: [ - "mediaType": ["any", "image", "photo", "video"], - ]) - case .contactsUIPick: - return .init(allowedStringValues: [ - "mode": ["single", "multiple"], - ]) + // photosRead / photosUIPick (MediaTypeFilter) and contactsUIPick + // (ContactPickerMode) constraints now come from their tools' + // CodeModeStringEnum arguments (SystemUICodeModeTools.swift / + // PeoplePhotosDocumentsCodeModeTools.swift), not this table. // cameraUICapture / cameraUIScanData / printUIPresent / uiAlertPresent // constraints now come from their tools' CodeModeStringEnum arguments // (SystemUICodeModeTools.swift), not this table. diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index f286737..43061a9 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -510,7 +510,10 @@ private func jsNames(for capability: CapabilityID) -> [String] { } @Test func constraintValidationMatchesCaseInsensitively() throws { - let constraints = CapabilityArgumentConstraints.defaults(for: .contactsUIPick) + // Exercises the generic CapabilityArgumentConstraints.validate matching, + // constructed inline so it does not depend on any capability's table row + // (those migrate to per-tool CodeModeStringEnums over time). + let constraints = CapabilityArgumentConstraints(allowedStringValues: ["mode": ["single", "multiple"]]) try constraints.validate(arguments: ["mode": .string("single")], capabilityName: "contacts.ui.pick") try constraints.validate(arguments: ["mode": .string("Single")], capabilityName: "contacts.ui.pick") From 76a15d249771fc175edea1a270e84dcfeab891c7 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 17:09:48 -0700 Subject: [PATCH 21/25] Phase 3: migrate SystemServices registrations to @BuiltInCodeMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 19 vision/notifications/alarm/health/home/media capabilities to macro-authored tools in SystemServicesCodeModeTools.swift; the six registration functions are now thin lists. No constrained values in this domain, so no enums, no bridge rewiring, no table changes. Permission declarations are preserved exactly: notifications (.notifications), alarms (.alarmKit), home (.homeKit); vision/media declare none; and the three health tools declare no requiredPermissions — HealthKit read authorization is opaque so HealthBridge does its own per-type auth, and the noBuiltInRegistrationGatesOnHealthKitPermission invariant test still passes. homeWrite's value keeps its .any type (declared as JSONValue). Golden diff: empty — every argument already had a hint and types match the declared/inferred baseline. 230 tests pass. --- ...pabilityRegistrations+SystemServices.swift | 386 +------------- .../Bridges/SystemServicesCodeModeTools.swift | 492 ++++++++++++++++++ 2 files changed, 511 insertions(+), 367 deletions(-) create mode 100644 Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift index 4a7fe72..810a4bb 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift @@ -3,403 +3,55 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func visionRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .visionImageAnalyze, - jsName: "apple.vision.analyzeImage", - title: "Analyze image with Vision", - summary: "Run on-device image analysis for labels/text/barcodes on sandbox image paths.", - tags: ["vision", "image-analysis", "ml"], - example: "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })", - requiredArguments: ["path"], - optionalArguments: ["features", "maxResults"], - argumentHints: [ - "path": "Sandbox image path to analyze.", - "features": "Optional array including labels/text/barcodes.", - "maxResults": "Max observations returned per feature, default 5.", - ], - resultSummary: "Object containing requested analysis sections such as labels/text/barcodes." - ) { args, context in - try vision.analyzeImage(arguments: args, context: context) - }, + CapabilityRegistration(tool: VisionImageAnalyzeTool(vision: vision)), ] } func notificationRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .notificationsPermissionRequest, - jsName: "apple.notifications.requestPermission", - title: "Request notification permission", - summary: "Request local notification authorization from the user.", - tags: ["notifications", "permission"], - example: "await apple.notifications.requestPermission()", - resultSummary: "Object with status/granted fields." - ) { _, context in - try notifications.requestPermission(context: context) - }, - CapabilityRegistration( - .notificationsSchedule, - jsName: "apple.notifications.schedule", - title: "Schedule local notification", - summary: "Schedule a local notification using time interval or fireDate trigger.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })", - requiredPermissions: [.notifications], - requiredArguments: ["title"], - optionalArguments: [ - "identifier", - "subtitle", - "body", - "secondsFromNow", - "fireDate", - "repeats", - "sound", - "badge", - "userInfo", - "threadIdentifier", - "categoryIdentifier", - ], - argumentHints: [ - "title": "Notification title text.", - "identifier": "Optional request identifier; defaults to codemode UUID.", - "subtitle": "Optional subtitle text.", - "body": "Optional body text.", - "secondsFromNow": "Delay in seconds for time interval trigger (default 5).", - "fireDate": "Optional ISO8601 timestamp for calendar trigger.", - "repeats": "Boolean repeat flag (time interval requires >= 60 seconds).", - "sound": "default (default), none, or a bundled custom sound name.", - "badge": "Optional app icon badge number.", - "userInfo": "Optional property-list-safe userInfo object.", - "threadIdentifier": "Optional thread identifier for notification grouping.", - "categoryIdentifier": "Optional category identifier for notification actions.", - ], - resultSummary: "Object with identifier/scheduled/repeats." - ) { args, context in - try notifications.schedule(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsPendingRead, - jsName: "apple.notifications.listPending", - title: "List pending local notifications", - summary: "List pending local notification requests.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.listPending({ limit: 20 })", - requiredPermissions: [.notifications], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of pending requests to return, default 50.", - ], - resultSummary: "Array of pending requests with identifiers/content/trigger metadata." - ) { args, context in - try notifications.readPending(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsPendingDelete, - jsName: "apple.notifications.cancelPending", - title: "Delete pending local notifications", - summary: "Delete pending local notifications by identifier list or clear all.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })", - requiredPermissions: [.notifications], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single pending request identifier to remove.", - "identifiers": "Array of pending request identifiers to remove. Omit both to clear all pending requests.", - ], - resultSummary: "Object with deleted/count fields." - ) { args, context in - try notifications.deletePending(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsDeliveredRead, - jsName: "apple.notifications.listDelivered", - title: "List delivered local notifications", - summary: "List notifications currently delivered in Notification Center.", - tags: ["notifications", "local", "delivered"], - example: "await apple.notifications.listDelivered({ limit: 20 })", - requiredPermissions: [.notifications], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of delivered notifications to return, default 50.", - ], - resultSummary: "Array of delivered notifications with identifiers/content/date metadata." - ) { args, context in - try notifications.readDelivered(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsDeliveredDelete, - jsName: "apple.notifications.removeDelivered", - title: "Delete delivered local notifications", - summary: "Delete delivered notifications by identifier list or clear all.", - tags: ["notifications", "local", "delivered", "delete"], - example: "await apple.notifications.removeDelivered({ identifiers: ['codemode.1', 'codemode.2'] })", - requiredPermissions: [.notifications], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single delivered notification identifier to remove.", - "identifiers": "Array of delivered notification identifiers to remove. Omit both to clear all delivered notifications.", - ], - resultSummary: "Object with deleted/count fields." - ) { args, context in - try notifications.deleteDelivered(arguments: args, context: context) - }, + CapabilityRegistration(tool: NotificationsPermissionRequestTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsScheduleTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsPendingReadTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsPendingDeleteTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsDeliveredReadTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsDeliveredDeleteTool(notifications: notifications)), ] } func alarmRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .alarmPermissionRequest, - jsName: "ios.alarm.requestPermission", - title: "Request AlarmKit permission", - summary: "Request AlarmKit authorization from the user.", - tags: ["alarmkit", "permission", "alarms"], - example: "await ios.alarm.requestPermission()", - resultSummary: "Object with status/granted fields." - ) { _, context in - try alarm.requestPermission(context: context) - }, - CapabilityRegistration( - .alarmRead, - jsName: "ios.alarm.list", - title: "List scheduled alarms", - summary: "List scheduled alarms known to the bridge runtime.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.list({ limit: 20 })", - requiredPermissions: [.alarmKit], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of scheduled alarms returned, default 50.", - ], - resultSummary: "Array of scheduled alarms with identifier/title/timing fields." - ) { args, context in - try alarm.read(arguments: args, context: context) - }, - CapabilityRegistration( - .alarmSchedule, - jsName: "ios.alarm.schedule", - title: "Schedule AlarmKit alarm", - summary: "Schedule an AlarmKit alarm using secondsFromNow or fireDate.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })", - requiredPermissions: [.alarmKit], - requiredArguments: ["title"], - optionalArguments: ["identifier", "secondsFromNow", "fireDate"], - argumentHints: [ - "title": "Alarm title shown in presentation.", - "identifier": "Optional UUID string; generated when omitted.", - "secondsFromNow": "Fallback relative delay in seconds, default 60.", - "fireDate": "Optional absolute ISO8601 date; used when provided.", - ], - resultSummary: "Object with identifier/scheduled/title." - ) { args, context in - try alarm.schedule(arguments: args, context: context) - }, - CapabilityRegistration( - .alarmCancel, - jsName: "ios.alarm.cancel", - title: "Cancel scheduled alarms", - summary: "Cancel one or more scheduled alarms by identifier, or clear all known alarms.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })", - requiredPermissions: [.alarmKit], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single alarm identifier UUID string.", - "identifiers": "Array of alarm identifier UUID strings. Omit both to cancel all known alarms.", - ], - resultSummary: "Object with deleted/count fields." - ) { args, context in - try alarm.cancel(arguments: args, context: context) - }, + CapabilityRegistration(tool: AlarmPermissionRequestTool(alarm: alarm)), + CapabilityRegistration(tool: AlarmReadTool(alarm: alarm)), + CapabilityRegistration(tool: AlarmScheduleTool(alarm: alarm)), + CapabilityRegistration(tool: AlarmCancelTool(alarm: alarm)), ] } func healthRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .healthPermissionRequest, - jsName: "apple.health.requestPermission", - title: "Request HealthKit permission", - summary: "Request HealthKit authorization for requested read/write types.", - tags: ["healthkit", "health", "permission"], - example: "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })", - optionalArguments: ["readTypes", "writeTypes"], - argumentTypes: [ - "readTypes": .array, - "writeTypes": .array, - ], - argumentHints: [ - "readTypes": "Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.", - "writeTypes": "Optional array of type names to write (quantity types only).", - ], - resultSummary: "Object with status/granted fields and requested type arrays." - ) { args, context in - try health.requestPermission(arguments: args, context: context) - }, - CapabilityRegistration( - .healthRead, - jsName: "apple.health.read", - title: "Read HealthKit samples", - summary: "Read HealthKit samples for a supported type and date range.", - tags: ["healthkit", "health", "query"], - example: "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })", - requiredArguments: ["type"], - optionalArguments: ["start", "end", "limit", "unit"], - argumentTypes: [ - "type": .string, - "start": .string, - "end": .string, - "limit": .number, - "unit": .string, - ], - argumentHints: [ - "type": "Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.", - "start": "Optional ISO8601 start timestamp; defaults to last 24h.", - "end": "Optional ISO8601 end timestamp; defaults to now.", - "limit": "Max number of samples, default 50.", - "unit": "Optional unit override for quantity types (count, bpm, kcal, kg, m).", - ], - resultSummary: "Array of samples with identifier/type/value and timing metadata." - ) { args, context in - try health.read(arguments: args, context: context) - }, - CapabilityRegistration( - .healthWrite, - jsName: "apple.health.write", - title: "Write HealthKit quantity sample", - summary: "Write a HealthKit quantity sample for supported writable quantity types.", - tags: ["healthkit", "health", "write"], - example: "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })", - requiredArguments: ["type", "value"], - optionalArguments: ["unit", "start", "end"], - argumentTypes: [ - "type": .string, - "value": .number, - "unit": .string, - "start": .string, - "end": .string, - ], - argumentHints: [ - "type": "Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.", - "value": "Numeric sample value.", - "unit": "Optional unit (count, bpm, kcal, kg, m).", - "start": "Optional ISO8601 start timestamp; defaults to now.", - "end": "Optional ISO8601 end timestamp; defaults to start.", - ], - resultSummary: "Object with identifier/type/value/unit and written=true." - ) { args, context in - try health.write(arguments: args, context: context) - }, + CapabilityRegistration(tool: HealthPermissionRequestTool(health: health)), + CapabilityRegistration(tool: HealthReadTool(health: health)), + CapabilityRegistration(tool: HealthWriteTool(health: health)), ] } func homeRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .homeRead, - jsName: "apple.home.list", - title: "Read HomeKit graph", - summary: "Read homes/accessories/services (and optional characteristics) from HomeKit.", - tags: ["homekit", "iot", "devices"], - example: "await apple.home.list({ includeCharacteristics: true, limit: 5 })", - requiredPermissions: [.homeKit], - optionalArguments: ["includeCharacteristics", "limit"], - argumentHints: [ - "includeCharacteristics": "Boolean; include characteristic details when true.", - "limit": "Max number of homes to return, default 10.", - ], - resultSummary: "Array of homes with accessories/services snapshot." - ) { args, context in - try home.read(arguments: args, context: context) - }, - CapabilityRegistration( - .homeWrite, - jsName: "apple.home.writeCharacteristic", - title: "Write HomeKit characteristic", - summary: "Write a value to a writable HomeKit characteristic for a target accessory.", - tags: ["homekit", "iot", "devices", "control"], - example: "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })", - requiredPermissions: [.homeKit], - requiredArguments: ["accessoryIdentifier", "characteristicType", "value"], - optionalArguments: ["serviceType"], - argumentTypes: [ - "accessoryIdentifier": .string, - "characteristicType": .string, - "value": .any, - "serviceType": .string, - ], - argumentHints: [ - "accessoryIdentifier": "Accessory UUID string from home.read output.", - "characteristicType": "Characteristic type identifier (e.g. HMCharacteristicTypePowerState).", - "value": "Target value; string/number/bool/null.", - "serviceType": "Optional service type filter for characteristic lookup.", - ], - resultSummary: "Object with accessoryIdentifier/characteristicType/written." - ) { args, context in - try home.write(arguments: args, context: context) - }, + CapabilityRegistration(tool: HomeReadTool(home: home)), + CapabilityRegistration(tool: HomeWriteTool(home: home)), ] } func mediaRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .mediaMetadataRead, - jsName: "apple.media.metadata", - title: "Read media metadata", - summary: "Read duration and track metadata from media files.", - tags: ["media", "avfoundation", "metadata"], - example: "await apple.media.metadata({ path: 'tmp:video.mov' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "Sandbox path like tmp:clip.mov.", - ], - resultSummary: "Object with path/durationSeconds/tracks." - ) { args, context in - try media.metadata(arguments: args, context: context) - }, - CapabilityRegistration( - .mediaFrameExtract, - jsName: "apple.media.extractFrame", - title: "Extract video frame", - summary: "Extract frame at time offset and persist JPEG output.", - tags: ["media", "avfoundation", "thumbnail"], - example: "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })", - requiredArguments: ["path"], - optionalArguments: ["timeMs", "outputPath"], - argumentHints: [ - "path": "Input video sandbox path.", - "timeMs": "Frame timestamp in milliseconds; default 0.", - "outputPath": "Optional output sandbox path; defaults to tmp-generated JPEG.", - ], - resultSummary: "Object with output path and artifactID." - ) { args, context in - try media.extractFrame(arguments: args, context: context) - }, - CapabilityRegistration( - .mediaTranscode, - jsName: "apple.media.transcode", - title: "Transcode media", - summary: "Transcode media into MP4 with preset quality.", - tags: ["media", "avfoundation", "transcode"], - example: "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })", - requiredArguments: ["path"], - optionalArguments: ["outputPath", "preset"], - argumentHints: [ - "path": "Input media sandbox path.", - "outputPath": "Optional output sandbox path; defaults to tmp-generated mp4.", - "preset": "AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality.", - ], - resultSummary: "Object with output path/artifactID/preset." - ) { args, context in - try media.transcode(arguments: args, context: context) - }, + CapabilityRegistration(tool: MediaMetadataReadTool(media: media)), + CapabilityRegistration(tool: MediaFrameExtractTool(media: media)), + CapabilityRegistration(tool: MediaTranscodeTool(media: media)), ] } } diff --git a/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift b/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift new file mode 100644 index 0000000..e5c97d4 --- /dev/null +++ b/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift @@ -0,0 +1,492 @@ +import Foundation + +// Vision / notifications / alarms / health / home / media capabilities. +// No constrained argument values in this domain, so no CodeModeStringEnums and +// no bridge rewiring. Health tools intentionally declare no requiredPermissions: +// HealthKit read authorization is opaque, so HealthBridge performs its own +// per-type authorization (see noBuiltInRegistrationGatesOnHealthKitPermission). + +// MARK: - Vision + +@BuiltInCodeMode(.visionImageAnalyze, path: "apple.vision.analyzeImage") +struct VisionImageAnalyzeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Analyze image with Vision" + static let codeModeSummary = "Run on-device image analysis for labels/text/barcodes on sandbox image paths." + static let codeModeTags = ["vision", "image-analysis", "ml"] + static let codeModeExample = "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })" + static let codeModeResultSummary = "Object containing requested analysis sections such as labels/text/barcodes." + + struct Arguments: Sendable { + @ToolParam("Sandbox image path to analyze.") + var path: String + @ToolParam("Optional array including labels/text/barcodes.") + var features: [String]? + @ToolParam("Max observations returned per feature, default 5.") + var maxResults: Int? + var raw: [String: JSONValue] + } + + let vision: VisionBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try vision.analyzeImage(arguments: arguments.raw, context: context) + } +} + +// MARK: - Notifications + +@BuiltInCodeMode(.notificationsPermissionRequest, path: "apple.notifications.requestPermission") +struct NotificationsPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request notification permission" + static let codeModeSummary = "Request local notification authorization from the user." + static let codeModeTags = ["notifications", "permission"] + static let codeModeExample = "await apple.notifications.requestPermission()" + static let codeModeResultSummary = "Object with status/granted fields." + + struct Arguments: Sendable {} + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.notificationsSchedule, path: "apple.notifications.schedule") +struct NotificationsScheduleTool: BuiltInCodeModeTool { + static let codeModeTitle = "Schedule local notification" + static let codeModeSummary = "Schedule a local notification using time interval or fireDate trigger." + static let codeModeTags = ["notifications", "local", "schedule"] + static let codeModeExample = "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Object with identifier/scheduled/repeats." + + struct Arguments: Sendable { + @ToolParam("Notification title text.") + var title: String + @ToolParam("Optional request identifier; defaults to codemode UUID.") + var identifier: String? + @ToolParam("Optional subtitle text.") + var subtitle: String? + @ToolParam("Optional body text.") + var body: String? + @ToolParam("Delay in seconds for time interval trigger (default 5).") + var secondsFromNow: Double? + @ToolParam("Optional ISO8601 timestamp for calendar trigger.") + var fireDate: String? + @ToolParam("Boolean repeat flag (time interval requires >= 60 seconds).") + var repeats: Bool? + @ToolParam("default (default), none, or a bundled custom sound name.") + var sound: String? + @ToolParam("Optional app icon badge number.") + var badge: Int? + @ToolParam("Optional property-list-safe userInfo object.") + var userInfo: [String: JSONValue]? + @ToolParam("Optional thread identifier for notification grouping.") + var threadIdentifier: String? + @ToolParam("Optional category identifier for notification actions.") + var categoryIdentifier: String? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.schedule(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsPendingRead, path: "apple.notifications.listPending") +struct NotificationsPendingReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List pending local notifications" + static let codeModeSummary = "List pending local notification requests." + static let codeModeTags = ["notifications", "local", "schedule"] + static let codeModeExample = "await apple.notifications.listPending({ limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Array of pending requests with identifiers/content/trigger metadata." + + struct Arguments: Sendable { + @ToolParam("Max number of pending requests to return, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.readPending(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsPendingDelete, path: "apple.notifications.cancelPending") +struct NotificationsPendingDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete pending local notifications" + static let codeModeSummary = "Delete pending local notifications by identifier list or clear all." + static let codeModeTags = ["notifications", "local", "schedule"] + static let codeModeExample = "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Object with deleted/count fields." + + struct Arguments: Sendable { + @ToolParam("Single pending request identifier to remove.") + var identifier: String? + @ToolParam("Array of pending request identifiers to remove. Omit both to clear all pending requests.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.deletePending(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsDeliveredRead, path: "apple.notifications.listDelivered") +struct NotificationsDeliveredReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List delivered local notifications" + static let codeModeSummary = "List notifications currently delivered in Notification Center." + static let codeModeTags = ["notifications", "local", "delivered"] + static let codeModeExample = "await apple.notifications.listDelivered({ limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Array of delivered notifications with identifiers/content/date metadata." + + struct Arguments: Sendable { + @ToolParam("Max number of delivered notifications to return, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.readDelivered(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsDeliveredDelete, path: "apple.notifications.removeDelivered") +struct NotificationsDeliveredDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete delivered local notifications" + static let codeModeSummary = "Delete delivered notifications by identifier list or clear all." + static let codeModeTags = ["notifications", "local", "delivered", "delete"] + static let codeModeExample = "await apple.notifications.removeDelivered({ identifiers: ['codemode.1', 'codemode.2'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Object with deleted/count fields." + + struct Arguments: Sendable { + @ToolParam("Single delivered notification identifier to remove.") + var identifier: String? + @ToolParam("Array of delivered notification identifiers to remove. Omit both to clear all delivered notifications.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.deleteDelivered(arguments: arguments.raw, context: context) + } +} + +// MARK: - Alarms + +@BuiltInCodeMode(.alarmPermissionRequest, path: "ios.alarm.requestPermission") +struct AlarmPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request AlarmKit permission" + static let codeModeSummary = "Request AlarmKit authorization from the user." + static let codeModeTags = ["alarmkit", "permission", "alarms"] + static let codeModeExample = "await ios.alarm.requestPermission()" + static let codeModeResultSummary = "Object with status/granted fields." + + struct Arguments: Sendable {} + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.alarmRead, path: "ios.alarm.list") +struct AlarmReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List scheduled alarms" + static let codeModeSummary = "List scheduled alarms known to the bridge runtime." + static let codeModeTags = ["alarmkit", "alarms", "schedule"] + static let codeModeExample = "await ios.alarm.list({ limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.alarmKit] + static let codeModeResultSummary = "Array of scheduled alarms with identifier/title/timing fields." + + struct Arguments: Sendable { + @ToolParam("Max number of scheduled alarms returned, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.alarmSchedule, path: "ios.alarm.schedule") +struct AlarmScheduleTool: BuiltInCodeModeTool { + static let codeModeTitle = "Schedule AlarmKit alarm" + static let codeModeSummary = "Schedule an AlarmKit alarm using secondsFromNow or fireDate." + static let codeModeTags = ["alarmkit", "alarms", "schedule"] + static let codeModeExample = "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.alarmKit] + static let codeModeResultSummary = "Object with identifier/scheduled/title." + + struct Arguments: Sendable { + @ToolParam("Alarm title shown in presentation.") + var title: String + @ToolParam("Optional UUID string; generated when omitted.") + var identifier: String? + @ToolParam("Fallback relative delay in seconds, default 60.") + var secondsFromNow: Double? + @ToolParam("Optional absolute ISO8601 date; used when provided.") + var fireDate: String? + var raw: [String: JSONValue] + } + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.schedule(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.alarmCancel, path: "ios.alarm.cancel") +struct AlarmCancelTool: BuiltInCodeModeTool { + static let codeModeTitle = "Cancel scheduled alarms" + static let codeModeSummary = "Cancel one or more scheduled alarms by identifier, or clear all known alarms." + static let codeModeTags = ["alarmkit", "alarms", "schedule"] + static let codeModeExample = "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.alarmKit] + static let codeModeResultSummary = "Object with deleted/count fields." + + struct Arguments: Sendable { + @ToolParam("Single alarm identifier UUID string.") + var identifier: String? + @ToolParam("Array of alarm identifier UUID strings. Omit both to cancel all known alarms.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.cancel(arguments: arguments.raw, context: context) + } +} + +// MARK: - Health + +@BuiltInCodeMode(.healthPermissionRequest, path: "apple.health.requestPermission") +struct HealthPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request HealthKit permission" + static let codeModeSummary = "Request HealthKit authorization for requested read/write types." + static let codeModeTags = ["healthkit", "health", "permission"] + static let codeModeExample = "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })" + static let codeModeResultSummary = "Object with status/granted fields and requested type arrays." + + struct Arguments: Sendable { + @ToolParam("Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.") + var readTypes: [String]? + @ToolParam("Optional array of type names to write (quantity types only).") + var writeTypes: [String]? + var raw: [String: JSONValue] + } + + let health: HealthBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try health.requestPermission(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.healthRead, path: "apple.health.read") +struct HealthReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read HealthKit samples" + static let codeModeSummary = "Read HealthKit samples for a supported type and date range." + static let codeModeTags = ["healthkit", "health", "query"] + static let codeModeExample = "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })" + static let codeModeResultSummary = "Array of samples with identifier/type/value and timing metadata." + + struct Arguments: Sendable { + @ToolParam("Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.") + var type: String + @ToolParam("Optional ISO8601 start timestamp; defaults to last 24h.") + var start: String? + @ToolParam("Optional ISO8601 end timestamp; defaults to now.") + var end: String? + @ToolParam("Max number of samples, default 50.") + var limit: Int? + @ToolParam("Optional unit override for quantity types (count, bpm, kcal, kg, m).") + var unit: String? + var raw: [String: JSONValue] + } + + let health: HealthBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try health.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.healthWrite, path: "apple.health.write") +struct HealthWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write HealthKit quantity sample" + static let codeModeSummary = "Write a HealthKit quantity sample for supported writable quantity types." + static let codeModeTags = ["healthkit", "health", "write"] + static let codeModeExample = "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })" + static let codeModeResultSummary = "Object with identifier/type/value/unit and written=true." + + struct Arguments: Sendable { + @ToolParam("Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.") + var type: String + @ToolParam("Numeric sample value.") + var value: Double + @ToolParam("Optional unit (count, bpm, kcal, kg, m).") + var unit: String? + @ToolParam("Optional ISO8601 start timestamp; defaults to now.") + var start: String? + @ToolParam("Optional ISO8601 end timestamp; defaults to start.") + var end: String? + var raw: [String: JSONValue] + } + + let health: HealthBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try health.write(arguments: arguments.raw, context: context) + } +} + +// MARK: - Home + +@BuiltInCodeMode(.homeRead, path: "apple.home.list") +struct HomeReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read HomeKit graph" + static let codeModeSummary = "Read homes/accessories/services (and optional characteristics) from HomeKit." + static let codeModeTags = ["homekit", "iot", "devices"] + static let codeModeExample = "await apple.home.list({ includeCharacteristics: true, limit: 5 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.homeKit] + static let codeModeResultSummary = "Array of homes with accessories/services snapshot." + + struct Arguments: Sendable { + @ToolParam("Boolean; include characteristic details when true.") + var includeCharacteristics: Bool? + @ToolParam("Max number of homes to return, default 10.") + var limit: Int? + var raw: [String: JSONValue] + } + + let home: HomeBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try home.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.homeWrite, path: "apple.home.writeCharacteristic") +struct HomeWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write HomeKit characteristic" + static let codeModeSummary = "Write a value to a writable HomeKit characteristic for a target accessory." + static let codeModeTags = ["homekit", "iot", "devices", "control"] + static let codeModeExample = "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })" + static let codeModeRequiredPermissions: [PermissionKind] = [.homeKit] + static let codeModeResultSummary = "Object with accessoryIdentifier/characteristicType/written." + + struct Arguments: Sendable { + @ToolParam("Accessory UUID string from home.read output.") + var accessoryIdentifier: String + @ToolParam("Characteristic type identifier (e.g. HMCharacteristicTypePowerState).") + var characteristicType: String + @ToolParam("Target value; string/number/bool/null.") + var value: JSONValue + @ToolParam("Optional service type filter for characteristic lookup.") + var serviceType: String? + var raw: [String: JSONValue] + } + + let home: HomeBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try home.write(arguments: arguments.raw, context: context) + } +} + +// MARK: - Media + +@BuiltInCodeMode(.mediaMetadataRead, path: "apple.media.metadata") +struct MediaMetadataReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read media metadata" + static let codeModeSummary = "Read duration and track metadata from media files." + static let codeModeTags = ["media", "avfoundation", "metadata"] + static let codeModeExample = "await apple.media.metadata({ path: 'tmp:video.mov' })" + static let codeModeResultSummary = "Object with path/durationSeconds/tracks." + + struct Arguments: Sendable { + @ToolParam("Sandbox path like tmp:clip.mov.") + var path: String + var raw: [String: JSONValue] + } + + let media: MediaBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try media.metadata(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.mediaFrameExtract, path: "apple.media.extractFrame") +struct MediaFrameExtractTool: BuiltInCodeModeTool { + static let codeModeTitle = "Extract video frame" + static let codeModeSummary = "Extract frame at time offset and persist JPEG output." + static let codeModeTags = ["media", "avfoundation", "thumbnail"] + static let codeModeExample = "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })" + static let codeModeResultSummary = "Object with output path and artifactID." + + struct Arguments: Sendable { + @ToolParam("Input video sandbox path.") + var path: String + @ToolParam("Frame timestamp in milliseconds; default 0.") + var timeMs: Double? + @ToolParam("Optional output sandbox path; defaults to tmp-generated JPEG.") + var outputPath: String? + var raw: [String: JSONValue] + } + + let media: MediaBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try media.extractFrame(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.mediaTranscode, path: "apple.media.transcode") +struct MediaTranscodeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Transcode media" + static let codeModeSummary = "Transcode media into MP4 with preset quality." + static let codeModeTags = ["media", "avfoundation", "transcode"] + static let codeModeExample = "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })" + static let codeModeResultSummary = "Object with output path/artifactID/preset." + + struct Arguments: Sendable { + @ToolParam("Input media sandbox path.") + var path: String + @ToolParam("Optional output sandbox path; defaults to tmp-generated mp4.") + var outputPath: String? + @ToolParam("AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality.") + var preset: String? + var raw: [String: JSONValue] + } + + let media: MediaBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try media.transcode(arguments: arguments.raw, context: context) + } +} From 1244a145056243bc3c022b59af94724fca18a126 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 17:15:36 -0700 Subject: [PATCH 22/25] Phase 3: migrate CloudKit/remote-notifications/speech registrations to @BuiltInCodeMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 15 capabilities in +CloudPushSpeech.swift (6 CloudKit, 5 remote notification, 4 speech) to macro-authored tools; the three sub-functions are now thin lists (the bigTicketAppleRegistrations aggregator is unchanged). The four database ops (queryRecords/saveRecord/deleteRecord/subscribe) share a new CloudKitDatabase enum (private/shared/public); its row is deleted from the central defaults table and SystemCloudKitMapping.databaseName/database now parse through the enum, so the advertised, decoded, and system-client-mapped values share one source. The mapping test (default private, shared, invalid archive) still passes. Golden diff: one allowed category — cloudKitSubscriptionSave gained containerIdentifier and zoneID hints (it advertised none for them); values are copied verbatim from the sibling CloudKit registrations. 230 tests pass. --- .../API/SystemAppleServiceClients.swift | 12 +- ...abilityRegistrations+CloudPushSpeech.swift | 266 +----------- .../CloudPushSpeechCodeModeTools.swift | 393 ++++++++++++++++++ .../Registry/CapabilityRegistry.swift | 6 +- .../capability-metadata-golden.json | 4 +- 5 files changed, 418 insertions(+), 263 deletions(-) create mode 100644 Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift diff --git a/Sources/CodeMode/API/SystemAppleServiceClients.swift b/Sources/CodeMode/API/SystemAppleServiceClients.swift index 9c5ce32..91f172f 100644 --- a/Sources/CodeMode/API/SystemAppleServiceClients.swift +++ b/Sources/CodeMode/API/SystemAppleServiceClients.swift @@ -401,14 +401,12 @@ private extension String { } enum SystemCloudKitMapping { - static let allowedDatabases = ["private", "shared", "public"] - static func databaseName(arguments: [String: JSONValue]) throws -> String { - let database = arguments.string("database") ?? "private" - guard allowedDatabases.contains(database) else { - throw BridgeError.invalidArguments("CloudKit database must be one of \(allowedDatabases.joined(separator: ", "))") + let raw = arguments.string("database") ?? "private" + guard let database = CloudKitDatabase.codeModeValue(matching: raw) else { + throw BridgeError.invalidArguments("CloudKit database must be one of \(CloudKitDatabase.codeModeAllowedValues.joined(separator: ", "))") } - return database + return database.rawValue } static func recordFields(arguments: [String: JSONValue], capability: String) throws -> [String: JSONValue] { @@ -501,7 +499,7 @@ enum SystemCloudKitMapping { case "public": return container.publicCloudDatabase default: - throw BridgeError.invalidArguments("CloudKit database must be one of \(allowedDatabases.joined(separator: ", "))") + throw BridgeError.invalidArguments("CloudKit database must be one of \(CloudKitDatabase.codeModeAllowedValues.joined(separator: ", "))") } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift index ef7661f..6d94967 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift @@ -19,269 +19,33 @@ extension DefaultCapabilityRegistrationBuilder { func cloudKitRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .cloudKitAccountStatus, - jsName: "apple.cloudkit.getAccountStatus", - title: "Read CloudKit account status", - summary: "Read iCloud account availability for CloudKit-backed agent state.", - tags: ["cloudkit", "icloud", "sync", "account"], - example: "await apple.cloudkit.getAccountStatus({ containerIdentifier: 'iCloud.com.example.app' })", - optionalArguments: ["containerIdentifier"], - argumentHints: [ - "containerIdentifier": "Optional iCloud container identifier; defaults to the host client's configured container.", - ], - resultSummary: "Object with status/accountAvailable and containerIdentifier when available." - ) { args, _ in - try cloudKit.accountStatus(arguments: args) - }, - CapabilityRegistration( - .cloudKitRecordsQuery, - jsName: "apple.cloudkit.queryRecords", - title: "Query CloudKit records", - summary: "Query records from a private/shared/public CloudKit database for synced agent state.", - tags: ["cloudkit", "icloud", "database", "query"], - example: "await apple.cloudkit.queryRecords({ database: 'private', recordType: 'Task', limit: 20 })", - requiredArguments: ["recordType"], - optionalArguments: ["database", "containerIdentifier", "zoneID", "predicate", "sortDescriptors", "desiredKeys", "limit"], - argumentHints: [ - "database": "private (default), shared, or public.", - "recordType": "CloudKit record type to query.", - "containerIdentifier": "Optional iCloud container identifier.", - "zoneID": "Optional custom zone identifier.", - "predicate": "Host-supported predicate object or string; keep predicates bounded and repairable.", - "sortDescriptors": "Optional array of sort descriptor objects.", - "desiredKeys": "Optional array of field keys to return.", - "limit": "Max records to return; default is host-defined.", - ], - resultSummary: "Array of records with recordName/recordType/fields/modifiedAt/database." - ) { args, _ in - try cloudKit.queryRecords(arguments: args) - }, - CapabilityRegistration( - .cloudKitRecordSave, - jsName: "apple.cloudkit.saveRecord", - title: "Save CloudKit record", - summary: "Create or update a CloudKit record in a private/shared/public database.", - tags: ["cloudkit", "icloud", "database", "write"], - example: "await apple.cloudkit.saveRecord({ database: 'private', recordType: 'Task', recordName: 'task-1', fields: { title: 'Review' } })", - requiredArguments: ["recordType", "fields"], - optionalArguments: ["recordName", "database", "containerIdentifier", "zoneID", "savePolicy"], - argumentHints: [ - "recordType": "CloudKit record type to create or update.", - "fields": "JSON object mapped by the host client into supported CloudKit field values.", - "recordName": "Optional CloudKit recordName; omitted means create a new record.", - "database": "private (default), shared, or public.", - "containerIdentifier": "Optional iCloud container identifier.", - "zoneID": "Optional custom zone identifier.", - "savePolicy": "Host-supported save policy such as changedKeys or allKeys.", - ], - resultSummary: "Saved record object with recordName/recordType/fields/changeTag/database." - ) { args, _ in - try cloudKit.saveRecord(arguments: args) - }, - CapabilityRegistration( - .cloudKitRecordDelete, - jsName: "apple.cloudkit.deleteRecord", - title: "Delete CloudKit record", - summary: "Delete a CloudKit record by recordName from a configured database.", - tags: ["cloudkit", "icloud", "database", "delete"], - example: "await apple.cloudkit.deleteRecord({ database: 'private', recordName: 'task-1' })", - requiredArguments: ["recordName"], - optionalArguments: ["database", "containerIdentifier", "zoneID"], - argumentHints: [ - "recordName": "CloudKit recordName to delete.", - "database": "private (default), shared, or public.", - "containerIdentifier": "Optional iCloud container identifier.", - "zoneID": "Optional custom zone identifier.", - ], - resultSummary: "Object with recordName/deleted/database." - ) { args, _ in - try cloudKit.deleteRecord(arguments: args) - }, - CapabilityRegistration( - .cloudKitSubscriptionSave, - jsName: "apple.cloudkit.subscribe", - title: "Save CloudKit subscription", - summary: "Register a bounded CloudKit query subscription so the host can enqueue subscription events later.", - tags: ["cloudkit", "icloud", "subscription", "inbox"], - example: "await apple.cloudkit.subscribe({ subscriptionID: 'tasks', database: 'private', recordType: 'Task' })", - requiredArguments: ["subscriptionID", "recordType"], - optionalArguments: ["database", "containerIdentifier", "zoneID", "predicate", "firesOnRecordCreation", "firesOnRecordUpdate", "firesOnRecordDeletion"], - argumentHints: [ - "subscriptionID": "Stable host-visible subscription identifier.", - "recordType": "CloudKit record type to observe.", - "database": "private (default), shared, or public.", - "predicate": "Host-supported predicate object or string.", - "firesOnRecordCreation": "Whether creation events are enqueued; default true.", - "firesOnRecordUpdate": "Whether update events are enqueued; default true.", - "firesOnRecordDeletion": "Whether delete events are enqueued; default true.", - ], - resultSummary: "Object with subscriptionID/saved/database." - ) { args, _ in - try cloudKit.saveSubscription(arguments: args) - }, - CapabilityRegistration( - .cloudKitSubscriptionEventsRead, - jsName: "apple.cloudkit.listEvents", - title: "Read CloudKit subscription inbox", - summary: "Read bounded CloudKit subscription events previously received by the host.", - tags: ["cloudkit", "icloud", "subscription", "inbox", "events"], - example: "await apple.cloudkit.listEvents({ subscriptionID: 'tasks', limit: 20 })", - optionalArguments: ["subscriptionID", "limit", "afterCursor"], - argumentHints: [ - "subscriptionID": "Optional subscription filter.", - "limit": "Maximum number of inbox events to read.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of subscription event objects with cursor/subscriptionID/recordName/reason/database." - ) { args, _ in - try cloudKit.readSubscriptionEvents(arguments: args) - }, + CapabilityRegistration(tool: CloudKitAccountStatusTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitRecordsQueryTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitRecordSaveTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitRecordDeleteTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitSubscriptionSaveTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitSubscriptionEventsReadTool(cloudKit: cloudKit)), ] } func remoteNotificationRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .notificationsRemoteRegister, - jsName: "apple.notifications.registerRemote", - title: "Register for remote notifications", - summary: "Ask the host app to register with APNs and record the client device-token lifecycle.", - tags: ["notifications", "apns", "remote", "registration"], - example: "await apple.notifications.registerRemote()", - optionalArguments: ["types"], - argumentHints: [ - "types": "Optional host-supported notification types; APNs provider sending is intentionally out of scope.", - ], - resultSummary: "Object with registered/status and deviceToken when already available." - ) { args, _ in - try remoteNotifications.register(arguments: args) - }, - CapabilityRegistration( - .notificationsRemoteTokenRead, - jsName: "apple.notifications.getRemoteToken", - title: "Read APNs device token", - summary: "Read the latest APNs device token captured by the host app.", - tags: ["notifications", "apns", "remote", "token"], - example: "await apple.notifications.getRemoteToken()", - resultSummary: "Object with token/environment/updatedAt or null token when registration has not completed." - ) { args, _ in - try remoteNotifications.readToken(arguments: args) - }, - CapabilityRegistration( - .notificationsSettingsRead, - jsName: "apple.notifications.getSettings", - title: "Read notification settings", - summary: "Read UserNotifications/APNs client settings exposed by the host.", - tags: ["notifications", "apns", "settings", "permission"], - example: "await apple.notifications.getSettings()", - resultSummary: "Object with authorizationStatus/alert/badge/sound/criticalAlert/providesAppNotificationSettings." - ) { args, _ in - try remoteNotifications.readSettings(arguments: args) - }, - CapabilityRegistration( - .notificationsCategoriesSet, - jsName: "apple.notifications.setCategories", - title: "Set notification categories", - summary: "Register host-approved notification categories and actions for push/local response handling.", - tags: ["notifications", "apns", "categories", "actions"], - example: "await apple.notifications.setCategories({ categories: [{ identifier: 'task', actions: [{ identifier: 'done', title: 'Done' }] }] })", - requiredArguments: ["categories"], - argumentHints: [ - "categories": "Array of category definitions with identifier/actions/options approved by the host.", - ], - resultSummary: "Object with registered category identifiers." - ) { args, _ in - try remoteNotifications.setCategories(arguments: args) - }, - CapabilityRegistration( - .notificationsResponsesRead, - jsName: "apple.notifications.listResponses", - title: "Read notification response inbox", - summary: "Read bounded notification action/open responses captured by the host for later agent handling.", - tags: ["notifications", "apns", "response", "inbox", "events"], - example: "await apple.notifications.listResponses({ limit: 20 })", - optionalArguments: ["limit", "categoryIdentifier", "actionIdentifier", "afterCursor"], - argumentHints: [ - "limit": "Maximum number of response events to read.", - "categoryIdentifier": "Optional category filter.", - "actionIdentifier": "Optional action filter.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of response events with cursor/identifier/actionIdentifier/categoryIdentifier/userText/userInfo/date." - ) { args, _ in - try remoteNotifications.readResponses(arguments: args) - }, + CapabilityRegistration(tool: NotificationsRemoteRegisterTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsRemoteTokenReadTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsSettingsReadTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsCategoriesSetTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsResponsesReadTool(remoteNotifications: remoteNotifications)), ] } func speechRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .speechPermissionRequest, - jsName: "apple.speech.requestPermission", - title: "Request speech recognition permission", - summary: "Trigger the Speech recognition permission prompt.", - tags: ["speech", "permission", "transcription"], - example: "await apple.speech.requestPermission()", - resultSummary: "Object with status/granted." - ) { _, context in - try speech.requestPermission(context: context) - }, - CapabilityRegistration( - .speechStatus, - jsName: "apple.speech.getStatus", - title: "Read speech recognition permission status", - summary: "Read Speech recognition permission status without starting capture.", - tags: ["speech", "permission", "transcription"], - example: "await apple.speech.getStatus()", - resultSummary: "Object with status/granted." - ) { _, context in - try speech.status(context: context) - }, - CapabilityRegistration( - .speechFileTranscribe, - jsName: "apple.speech.transcribeFile", - title: "Transcribe audio file", - summary: "Transcribe a sandbox audio file through the host Speech adapter.", - tags: ["speech", "transcription", "audio"], - example: "await apple.speech.transcribeFile({ path: 'tmp:meeting.m4a', locale: 'en-US', timeoutMs: 60000 })", - requiredPermissions: [.speechRecognition], - requiredArguments: ["path"], - optionalArguments: ["locale", "timeoutMs", "requiresOnDeviceRecognition", "taskHint"], - argumentHints: [ - "path": "Sandbox audio file path.", - "locale": "BCP-47 locale identifier such as en-US.", - "timeoutMs": "Maximum transcription wait time in milliseconds.", - "requiresOnDeviceRecognition": "Whether to require on-device recognition when the host supports it.", - "taskHint": "Speech task hint such as dictation, search, or confirmation.", - ], - resultSummary: "Object with transcript/segments/locale/isFinal/durationSeconds." - ) { args, context in - try speech.transcribeFile(arguments: args, context: context) - }, - CapabilityRegistration( - .speechMicrophoneTranscribe, - jsName: "apple.speech.transcribeMicrophone", - title: "Transcribe microphone audio", - summary: "Run live microphone transcription through a host-mediated session with an explicit timeout.", - tags: ["speech", "transcription", "audio", "microphone"], - example: "await apple.speech.transcribeMicrophone({ locale: 'en-US', timeoutMs: 15000 })", - requiredPermissions: [.speechRecognition, .microphone], - optionalArguments: ["locale", "timeoutMs", "requiresOnDeviceRecognition", "taskHint", "partialResults"], - argumentHints: [ - "locale": "BCP-47 locale identifier such as en-US.", - "timeoutMs": "Maximum microphone capture/transcription time in milliseconds.", - "requiresOnDeviceRecognition": "Whether to require on-device recognition when the host supports it.", - "taskHint": "Speech task hint such as dictation, search, or confirmation.", - "partialResults": "Whether partial transcripts may be returned.", - ], - resultSummary: "Object with transcript/segments/locale/isFinal/timedOut." - ) { args, context in - try speech.transcribeMicrophone(arguments: args, context: context) - }, + CapabilityRegistration(tool: SpeechPermissionRequestTool(speech: speech)), + CapabilityRegistration(tool: SpeechStatusTool(speech: speech)), + CapabilityRegistration(tool: SpeechFileTranscribeTool(speech: speech)), + CapabilityRegistration(tool: SpeechMicrophoneTranscribeTool(speech: speech)), ] } } diff --git a/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift b/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift new file mode 100644 index 0000000..c670cb6 --- /dev/null +++ b/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift @@ -0,0 +1,393 @@ +import Foundation + +/// Constrained CloudKit database selector; shared by the four database ops. +enum CloudKitDatabase: String, CodeModeStringEnum { + case `private` + case shared + case `public` +} + +// MARK: - CloudKit + +@BuiltInCodeMode(.cloudKitAccountStatus, path: "apple.cloudkit.getAccountStatus") +struct CloudKitAccountStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read CloudKit account status" + static let codeModeSummary = "Read iCloud account availability for CloudKit-backed agent state." + static let codeModeTags = ["cloudkit", "icloud", "sync", "account"] + static let codeModeExample = "await apple.cloudkit.getAccountStatus({ containerIdentifier: 'iCloud.com.example.app' })" + static let codeModeResultSummary = "Object with status/accountAvailable and containerIdentifier when available." + + struct Arguments: Sendable { + @ToolParam("Optional iCloud container identifier; defaults to the host client's configured container.") + var containerIdentifier: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.accountStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitRecordsQuery, path: "apple.cloudkit.queryRecords") +struct CloudKitRecordsQueryTool: BuiltInCodeModeTool { + static let codeModeTitle = "Query CloudKit records" + static let codeModeSummary = "Query records from a private/shared/public CloudKit database for synced agent state." + static let codeModeTags = ["cloudkit", "icloud", "database", "query"] + static let codeModeExample = "await apple.cloudkit.queryRecords({ database: 'private', recordType: 'Task', limit: 20 })" + static let codeModeResultSummary = "Array of records with recordName/recordType/fields/modifiedAt/database." + + struct Arguments: Sendable { + @ToolParam("CloudKit record type to query.") + var recordType: String + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + @ToolParam("Host-supported predicate object or string; keep predicates bounded and repairable.") + var predicate: JSONValue? + @ToolParam("Optional array of sort descriptor objects.") + var sortDescriptors: [JSONValue]? + @ToolParam("Optional array of field keys to return.") + var desiredKeys: [String]? + @ToolParam("Max records to return; default is host-defined.") + var limit: Int? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.queryRecords(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitRecordSave, path: "apple.cloudkit.saveRecord") +struct CloudKitRecordSaveTool: BuiltInCodeModeTool { + static let codeModeTitle = "Save CloudKit record" + static let codeModeSummary = "Create or update a CloudKit record in a private/shared/public database." + static let codeModeTags = ["cloudkit", "icloud", "database", "write"] + static let codeModeExample = "await apple.cloudkit.saveRecord({ database: 'private', recordType: 'Task', recordName: 'task-1', fields: { title: 'Review' } })" + static let codeModeResultSummary = "Saved record object with recordName/recordType/fields/changeTag/database." + + struct Arguments: Sendable { + @ToolParam("CloudKit record type to create or update.") + var recordType: String + @ToolParam("JSON object mapped by the host client into supported CloudKit field values.") + var fields: [String: JSONValue] + @ToolParam("Optional CloudKit recordName; omitted means create a new record.") + var recordName: String? + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + @ToolParam("Host-supported save policy such as changedKeys or allKeys.") + var savePolicy: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.saveRecord(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitRecordDelete, path: "apple.cloudkit.deleteRecord") +struct CloudKitRecordDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete CloudKit record" + static let codeModeSummary = "Delete a CloudKit record by recordName from a configured database." + static let codeModeTags = ["cloudkit", "icloud", "database", "delete"] + static let codeModeExample = "await apple.cloudkit.deleteRecord({ database: 'private', recordName: 'task-1' })" + static let codeModeResultSummary = "Object with recordName/deleted/database." + + struct Arguments: Sendable { + @ToolParam("CloudKit recordName to delete.") + var recordName: String + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.deleteRecord(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitSubscriptionSave, path: "apple.cloudkit.subscribe") +struct CloudKitSubscriptionSaveTool: BuiltInCodeModeTool { + static let codeModeTitle = "Save CloudKit subscription" + static let codeModeSummary = "Register a bounded CloudKit query subscription so the host can enqueue subscription events later." + static let codeModeTags = ["cloudkit", "icloud", "subscription", "inbox"] + static let codeModeExample = "await apple.cloudkit.subscribe({ subscriptionID: 'tasks', database: 'private', recordType: 'Task' })" + static let codeModeResultSummary = "Object with subscriptionID/saved/database." + + struct Arguments: Sendable { + @ToolParam("Stable host-visible subscription identifier.") + var subscriptionID: String + @ToolParam("CloudKit record type to observe.") + var recordType: String + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + @ToolParam("Host-supported predicate object or string.") + var predicate: JSONValue? + @ToolParam("Whether creation events are enqueued; default true.") + var firesOnRecordCreation: Bool? + @ToolParam("Whether update events are enqueued; default true.") + var firesOnRecordUpdate: Bool? + @ToolParam("Whether delete events are enqueued; default true.") + var firesOnRecordDeletion: Bool? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.saveSubscription(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitSubscriptionEventsRead, path: "apple.cloudkit.listEvents") +struct CloudKitSubscriptionEventsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read CloudKit subscription inbox" + static let codeModeSummary = "Read bounded CloudKit subscription events previously received by the host." + static let codeModeTags = ["cloudkit", "icloud", "subscription", "inbox", "events"] + static let codeModeExample = "await apple.cloudkit.listEvents({ subscriptionID: 'tasks', limit: 20 })" + static let codeModeResultSummary = "Array of subscription event objects with cursor/subscriptionID/recordName/reason/database." + + struct Arguments: Sendable { + @ToolParam("Optional subscription filter.") + var subscriptionID: String? + @ToolParam("Maximum number of inbox events to read.") + var limit: Int? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.readSubscriptionEvents(arguments: arguments.raw) + } +} + +// MARK: - Remote notifications + +@BuiltInCodeMode(.notificationsRemoteRegister, path: "apple.notifications.registerRemote") +struct NotificationsRemoteRegisterTool: BuiltInCodeModeTool { + static let codeModeTitle = "Register for remote notifications" + static let codeModeSummary = "Ask the host app to register with APNs and record the client device-token lifecycle." + static let codeModeTags = ["notifications", "apns", "remote", "registration"] + static let codeModeExample = "await apple.notifications.registerRemote()" + static let codeModeResultSummary = "Object with registered/status and deviceToken when already available." + + struct Arguments: Sendable { + @ToolParam("Optional host-supported notification types; APNs provider sending is intentionally out of scope.") + var types: [String]? + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.register(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsRemoteTokenRead, path: "apple.notifications.getRemoteToken") +struct NotificationsRemoteTokenReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read APNs device token" + static let codeModeSummary = "Read the latest APNs device token captured by the host app." + static let codeModeTags = ["notifications", "apns", "remote", "token"] + static let codeModeExample = "await apple.notifications.getRemoteToken()" + static let codeModeResultSummary = "Object with token/environment/updatedAt or null token when registration has not completed." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.readToken(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsSettingsRead, path: "apple.notifications.getSettings") +struct NotificationsSettingsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read notification settings" + static let codeModeSummary = "Read UserNotifications/APNs client settings exposed by the host." + static let codeModeTags = ["notifications", "apns", "settings", "permission"] + static let codeModeExample = "await apple.notifications.getSettings()" + static let codeModeResultSummary = "Object with authorizationStatus/alert/badge/sound/criticalAlert/providesAppNotificationSettings." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.readSettings(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsCategoriesSet, path: "apple.notifications.setCategories") +struct NotificationsCategoriesSetTool: BuiltInCodeModeTool { + static let codeModeTitle = "Set notification categories" + static let codeModeSummary = "Register host-approved notification categories and actions for push/local response handling." + static let codeModeTags = ["notifications", "apns", "categories", "actions"] + static let codeModeExample = "await apple.notifications.setCategories({ categories: [{ identifier: 'task', actions: [{ identifier: 'done', title: 'Done' }] }] })" + static let codeModeResultSummary = "Object with registered category identifiers." + + struct Arguments: Sendable { + @ToolParam("Array of category definitions with identifier/actions/options approved by the host.") + var categories: [JSONValue] + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.setCategories(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsResponsesRead, path: "apple.notifications.listResponses") +struct NotificationsResponsesReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read notification response inbox" + static let codeModeSummary = "Read bounded notification action/open responses captured by the host for later agent handling." + static let codeModeTags = ["notifications", "apns", "response", "inbox", "events"] + static let codeModeExample = "await apple.notifications.listResponses({ limit: 20 })" + static let codeModeResultSummary = "Array of response events with cursor/identifier/actionIdentifier/categoryIdentifier/userText/userInfo/date." + + struct Arguments: Sendable { + @ToolParam("Maximum number of response events to read.") + var limit: Int? + @ToolParam("Optional category filter.") + var categoryIdentifier: String? + @ToolParam("Optional action filter.") + var actionIdentifier: String? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.readResponses(arguments: arguments.raw) + } +} + +// MARK: - Speech + +@BuiltInCodeMode(.speechPermissionRequest, path: "apple.speech.requestPermission") +struct SpeechPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request speech recognition permission" + static let codeModeSummary = "Trigger the Speech recognition permission prompt." + static let codeModeTags = ["speech", "permission", "transcription"] + static let codeModeExample = "await apple.speech.requestPermission()" + static let codeModeResultSummary = "Object with status/granted." + + struct Arguments: Sendable {} + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.speechStatus, path: "apple.speech.getStatus") +struct SpeechStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read speech recognition permission status" + static let codeModeSummary = "Read Speech recognition permission status without starting capture." + static let codeModeTags = ["speech", "permission", "transcription"] + static let codeModeExample = "await apple.speech.getStatus()" + static let codeModeResultSummary = "Object with status/granted." + + struct Arguments: Sendable {} + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.status(context: context) + } +} + +@BuiltInCodeMode(.speechFileTranscribe, path: "apple.speech.transcribeFile") +struct SpeechFileTranscribeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Transcribe audio file" + static let codeModeSummary = "Transcribe a sandbox audio file through the host Speech adapter." + static let codeModeTags = ["speech", "transcription", "audio"] + static let codeModeExample = "await apple.speech.transcribeFile({ path: 'tmp:meeting.m4a', locale: 'en-US', timeoutMs: 60000 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.speechRecognition] + static let codeModeResultSummary = "Object with transcript/segments/locale/isFinal/durationSeconds." + + struct Arguments: Sendable { + @ToolParam("Sandbox audio file path.") + var path: String + @ToolParam("BCP-47 locale identifier such as en-US.") + var locale: String? + @ToolParam("Maximum transcription wait time in milliseconds.") + var timeoutMs: Double? + @ToolParam("Whether to require on-device recognition when the host supports it.") + var requiresOnDeviceRecognition: Bool? + @ToolParam("Speech task hint such as dictation, search, or confirmation.") + var taskHint: String? + var raw: [String: JSONValue] + } + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.transcribeFile(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.speechMicrophoneTranscribe, path: "apple.speech.transcribeMicrophone") +struct SpeechMicrophoneTranscribeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Transcribe microphone audio" + static let codeModeSummary = "Run live microphone transcription through a host-mediated session with an explicit timeout." + static let codeModeTags = ["speech", "transcription", "audio", "microphone"] + static let codeModeExample = "await apple.speech.transcribeMicrophone({ locale: 'en-US', timeoutMs: 15000 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.speechRecognition, .microphone] + static let codeModeResultSummary = "Object with transcript/segments/locale/isFinal/timedOut." + + struct Arguments: Sendable { + @ToolParam("BCP-47 locale identifier such as en-US.") + var locale: String? + @ToolParam("Maximum microphone capture/transcription time in milliseconds.") + var timeoutMs: Double? + @ToolParam("Whether to require on-device recognition when the host supports it.") + var requiresOnDeviceRecognition: Bool? + @ToolParam("Speech task hint such as dictation, search, or confirmation.") + var taskHint: String? + @ToolParam("Whether partial transcripts may be returned.") + var partialResults: Bool? + var raw: [String: JSONValue] + } + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.transcribeMicrophone(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index bff27ac..dfb1fde 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -58,10 +58,8 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { // cameraUICapture / cameraUIScanData / printUIPresent / uiAlertPresent // constraints now come from their tools' CodeModeStringEnum arguments // (SystemUICodeModeTools.swift), not this table. - case .cloudKitRecordsQuery, .cloudKitRecordSave, .cloudKitRecordDelete, .cloudKitSubscriptionSave: - return .init(allowedStringValues: [ - "database": ["private", "shared", "public"], - ]) + // cloudKit database constraint now comes from the tools' CloudKitDatabase + // argument (CloudPushSpeechCodeModeTools.swift), not this table. case .activityEnd: return .init(allowedStringValues: [ "dismissalPolicy": ["default", "immediate"], diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index 3e02d10..3c6a296 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -1208,13 +1208,15 @@ ] }, "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", "database" : "private (default), shared, or public.", "firesOnRecordCreation" : "Whether creation events are enqueued; default true.", "firesOnRecordDeletion" : "Whether delete events are enqueued; default true.", "firesOnRecordUpdate" : "Whether update events are enqueued; default true.", "predicate" : "Host-supported predicate object or string.", "recordType" : "CloudKit record type to observe.", - "subscriptionID" : "Stable host-visible subscription identifier." + "subscriptionID" : "Stable host-visible subscription identifier.", + "zoneID" : "Optional custom zone identifier." }, "argumentTypes" : { "containerIdentifier" : "string", From 1cf871235792428e7246cb0782aff498db2f0d15 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 17:21:05 -0700 Subject: [PATCH 23/25] Phase 3: migrate AppIntents/FoundationModels/Activity/Maps to @BuiltInCodeMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 18 capabilities in +IntentsModelsActivityMaps.swift to macro-authored tools; the four sub-functions are now thin lists. Constrained values: activityEnd gets an ActivityDismissalPolicy enum (default/immediate) and mapsRouteEstimate/mapsOpen share a MapsTransportType enum (automobile/walking/transit/any); both rows are deleted from the central defaults table. transportType parsing in SystemMapsMapping.transportTypeName is rewired through the enum (its allowedTransportTypes constant is removed); dismissalPolicy has no in-tree consumer (host ActivityClient handles it), so the enum gates it at the registry. The type-inference trap: foundationModelsGenerate.prompt is absent from the argument-type table, so its historical advertised type is 'any' — declared as JSONValue to preserve it, not String. Golden diff: one allowed category only — argumentHints gained entries for seven registrations that advertised no hints for those arguments (activityList, activityUpdate, activityEnd, activityPushTokenRead, mapsReverseGeocode, and the departureDate/arrivalDate and transportType gaps on the maps route/open tools). No types, constraints, or existing prose changed. 230 tests pass. --- .../API/SystemAppleServiceClients.swift | 7 +- ...istrations+IntentsModelsActivityMaps.swift | 312 +---------- ...tentsModelsActivityMapsCodeModeTools.swift | 485 ++++++++++++++++++ .../Registry/CapabilityRegistry.swift | 11 +- .../capability-metadata-golden.json | 21 +- 5 files changed, 525 insertions(+), 311 deletions(-) create mode 100644 Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift diff --git a/Sources/CodeMode/API/SystemAppleServiceClients.swift b/Sources/CodeMode/API/SystemAppleServiceClients.swift index 91f172f..1f3db40 100644 --- a/Sources/CodeMode/API/SystemAppleServiceClients.swift +++ b/Sources/CodeMode/API/SystemAppleServiceClients.swift @@ -636,7 +636,6 @@ private extension CKAccountStatus { #endif enum SystemMapsMapping { - static let allowedTransportTypes = ["automobile", "walking", "transit", "any"] static func coordinate(arguments: [String: JSONValue], name: String, capability: String) throws -> (latitude: Double, longitude: Double) { guard let latitude = arguments.double("latitude") else { @@ -652,10 +651,10 @@ enum SystemMapsMapping { guard let value = arguments.string("transportType") ?? defaultValue else { throw BridgeError.invalidArguments("Maps transportType is required") } - guard allowedTransportTypes.contains(value) else { - throw BridgeError.invalidArguments("Maps transportType must be one of \(allowedTransportTypes.joined(separator: ", "))") + guard let transportType = MapsTransportType.codeModeValue(matching: value) else { + throw BridgeError.invalidArguments("Maps transportType must be one of \(MapsTransportType.codeModeAllowedValues.joined(separator: ", "))") } - return value + return transportType.rawValue } static func appleMapsQueryURL(query: String) throws -> URL { diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift index 6e261f2..3f2d505 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift @@ -3,318 +3,42 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func appIntentsRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .appIntentsList, - jsName: "apple.appIntents.list", - title: "List host App Intent adapters", - summary: "List host-registered App Intents or Shortcuts actions exposed to CodeMode.", - tags: ["appintents", "shortcuts", "actions", "host-adapter"], - example: "await apple.appIntents.list()", - optionalArguments: ["domain", "limit"], - argumentHints: [ - "domain": "Optional host-defined action domain filter.", - "limit": "Maximum actions to return.", - ], - resultSummary: "Array of actions with identifier/title/summary/requiredParameters." - ) { args, _ in - try appIntents.listActions(arguments: args) - }, - CapabilityRegistration( - .appIntentsRun, - jsName: "apple.appIntents.run", - title: "Run host App Intent adapter", - summary: "Run a host-registered App Intent adapter with structured parameters.", - tags: ["appintents", "shortcuts", "actions", "host-adapter"], - example: "await apple.appIntents.run({ identifier: 'createNote', parameters: { title: 'Draft' } })", - requiredArguments: ["identifier"], - optionalArguments: ["parameters", "timeoutMs"], - argumentHints: [ - "identifier": "Host-registered action identifier from apple.appIntents.list.", - "parameters": "Structured parameters validated by the host adapter.", - "timeoutMs": "Maximum wait time in milliseconds.", - ], - resultSummary: "Adapter-defined structured result." - ) { args, _ in - try appIntents.runAction(arguments: args) - }, - CapabilityRegistration( - .appIntentsDonate, - jsName: "apple.appIntents.donate", - title: "Donate host App Intent action", - summary: "Ask the host to donate a supported action to Shortcuts/Siri suggestions where feasible.", - tags: ["appintents", "shortcuts", "donation", "host-adapter"], - example: "await apple.appIntents.donate({ identifier: 'createNote', parameters: { title: 'Draft' } })", - requiredArguments: ["identifier"], - optionalArguments: ["parameters"], - argumentHints: [ - "identifier": "Host-registered action identifier.", - "parameters": "Structured action parameters used for the donation.", - ], - resultSummary: "Object with donated/status." - ) { args, _ in - try appIntents.donateAction(arguments: args) - }, - CapabilityRegistration( - .appIntentsOpen, - jsName: "apple.appIntents.open", - title: "Open host App Intent surface", - summary: "Open a host-provided App Intent, Shortcuts, or app surface for user-mediated continuation.", - tags: ["appintents", "shortcuts", "open", "host-adapter"], - example: "await apple.appIntents.open({ identifier: 'showTask', parameters: { id: 'task-1' } })", - requiredArguments: ["identifier"], - optionalArguments: ["parameters"], - argumentHints: [ - "identifier": "Host-registered open action identifier.", - "parameters": "Structured parameters for the host open action.", - ], - resultSummary: "Object with opened/status." - ) { args, _ in - try appIntents.openAction(arguments: args) - }, - CapabilityRegistration( - .appIntentsHandoffsRead, - jsName: "apple.appIntents.listHandoffs", - title: "Read App Intent handoff inbox", - summary: "Read bounded App Intent handoff events captured by the host app.", - tags: ["appintents", "shortcuts", "handoff", "inbox", "events"], - example: "await apple.appIntents.listHandoffs({ limit: 20 })", - optionalArguments: ["identifier", "limit", "afterCursor"], - argumentHints: [ - "identifier": "Optional action identifier filter.", - "limit": "Maximum handoff events to return.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of handoff events with cursor/identifier/parameters/date/source." - ) { args, _ in - try appIntents.readHandoffs(arguments: args) - }, + CapabilityRegistration(tool: AppIntentsListTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsRunTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsDonateTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsOpenTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsHandoffsReadTool(appIntents: appIntents)), ] } func foundationModelsRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .foundationModelsStatus, - jsName: "apple.foundationModels.getStatus", - title: "Read Foundation Models availability", - summary: "Read host Foundation Models availability/status before attempting local generation.", - tags: ["foundationmodels", "apple-intelligence", "llm", "availability"], - example: "await apple.foundationModels.getStatus()", - resultSummary: "Object with available/status/reason/modelIdentifier when available." - ) { args, _ in - try foundationModels.status(arguments: args) - }, - CapabilityRegistration( - .foundationModelsGenerate, - jsName: "apple.foundationModels.generate", - title: "Generate text with Foundation Models", - summary: "Generate text locally through the host Foundation Models adapter when available.", - tags: ["foundationmodels", "apple-intelligence", "llm", "generation"], - example: "await apple.foundationModels.generate({ prompt: 'Summarize this note', maxTokens: 200 })", - requiredArguments: ["prompt"], - optionalArguments: ["instructions", "temperature", "maxTokens", "schemaIdentifier", "timeoutMs"], - argumentHints: [ - "prompt": "Prompt text sent to the host Foundation Models session.", - "instructions": "Optional host-approved system instructions.", - "temperature": "Optional sampling temperature when supported.", - "maxTokens": "Optional maximum output tokens.", - "schemaIdentifier": "Optional host-defined output schema identifier.", - "timeoutMs": "Maximum generation wait time in milliseconds.", - ], - resultSummary: "Object with text/finishReason/usage when available." - ) { args, _ in - try foundationModels.generateText(arguments: args) - }, - CapabilityRegistration( - .foundationModelsExtract, - jsName: "apple.foundationModels.extract", - title: "Extract structured data with Foundation Models", - summary: "Run host-defined structured extraction or classification with Foundation Models.", - tags: ["foundationmodels", "apple-intelligence", "llm", "structured-output"], - example: "await apple.foundationModels.extract({ input: text, schemaIdentifier: 'todo' })", - requiredArguments: ["input"], - optionalArguments: ["schema", "schemaIdentifier", "instructions", "timeoutMs"], - argumentHints: [ - "input": "Input text to classify or extract from.", - "schema": "Optional JSON schema object accepted by the host adapter.", - "schemaIdentifier": "Preferred host-defined schema identifier.", - "instructions": "Optional task-specific instructions.", - "timeoutMs": "Maximum extraction wait time in milliseconds.", - ], - resultSummary: "Object with values/classification/confidence/schemaIdentifier." - ) { args, _ in - try foundationModels.extract(arguments: args) - }, + CapabilityRegistration(tool: FoundationModelsStatusTool(foundationModels: foundationModels)), + CapabilityRegistration(tool: FoundationModelsGenerateTool(foundationModels: foundationModels)), + CapabilityRegistration(tool: FoundationModelsExtractTool(foundationModels: foundationModels)), ] } func activityRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .activityList, - jsName: "apple.activity.list", - title: "List Live Activities", - summary: "List host-registered ActivityKit Live Activities visible to CodeMode.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.list()", - optionalArguments: ["activityType", "limit"], - resultSummary: "Array of activities with identifier/activityType/state/contentState/attributes." - ) { args, _ in - try activity.listActivities(arguments: args) - }, - CapabilityRegistration( - .activityStart, - jsName: "apple.activity.start", - title: "Start Live Activity", - summary: "Start a host-registered ActivityKit Live Activity adapter.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.start({ activityType: 'delivery', attributes: {}, contentState: {} })", - requiredArguments: ["activityType", "attributes"], - optionalArguments: ["contentState", "pushType", "staleDate"], - argumentHints: [ - "activityType": "Host-registered activity adapter type.", - "attributes": "Adapter-defined immutable attributes.", - "contentState": "Adapter-defined mutable content state.", - "pushType": "Optional push token request mode when the adapter supports remote updates.", - "staleDate": "Optional ISO8601 stale date.", - ], - resultSummary: "Object with identifier/activityType/state/pushToken when available." - ) { args, _ in - try activity.startActivity(arguments: args) - }, - CapabilityRegistration( - .activityUpdate, - jsName: "apple.activity.update", - title: "Update Live Activity", - summary: "Update an existing host-registered Live Activity content state.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.update({ identifier: 'activity-1', contentState: { progress: 0.7 } })", - requiredArguments: ["identifier", "contentState"], - optionalArguments: ["alert", "staleDate"], - resultSummary: "Object with identifier/updated/state." - ) { args, _ in - try activity.updateActivity(arguments: args) - }, - CapabilityRegistration( - .activityEnd, - jsName: "apple.activity.end", - title: "End Live Activity", - summary: "End a host-registered Live Activity, optionally with final content state.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'immediate' })", - requiredArguments: ["identifier"], - optionalArguments: ["contentState", "dismissalPolicy"], - resultSummary: "Object with identifier/ended/state." - ) { args, _ in - try activity.endActivity(arguments: args) - }, - CapabilityRegistration( - .activityPushTokenRead, - jsName: "apple.activity.getPushToken", - title: "Read Live Activity push token", - summary: "Read a Live Activity push token when the host adapter supports push updates.", - tags: ["activitykit", "live-activities", "push-token"], - example: "await apple.activity.getPushToken({ identifier: 'activity-1' })", - requiredArguments: ["identifier"], - resultSummary: "Object with identifier/pushToken/environment." - ) { args, _ in - try activity.readPushToken(arguments: args) - }, + CapabilityRegistration(tool: ActivityListTool(activity: activity)), + CapabilityRegistration(tool: ActivityStartTool(activity: activity)), + CapabilityRegistration(tool: ActivityUpdateTool(activity: activity)), + CapabilityRegistration(tool: ActivityEndTool(activity: activity)), + CapabilityRegistration(tool: ActivityPushTokenReadTool(activity: activity)), ] } func mapsRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .mapsGeocode, - jsName: "apple.maps.geocode", - title: "Geocode address", - summary: "Resolve an address or place text to coordinate candidates through MapKit.", - tags: ["maps", "mapkit", "geocode", "location"], - example: "await apple.maps.geocode({ address: '1 Infinite Loop, Cupertino', limit: 3 })", - requiredArguments: ["address"], - optionalArguments: ["region", "limit"], - argumentHints: [ - "address": "Address or place text to geocode.", - "region": "Optional search bias region object.", - "limit": "Maximum coordinate candidates.", - ], - resultSummary: "Array of placemarks with name/address/latitude/longitude." - ) { args, _ in - try maps.geocode(arguments: args) - }, - CapabilityRegistration( - .mapsReverseGeocode, - jsName: "apple.maps.reverseGeocode", - title: "Reverse geocode coordinates", - summary: "Resolve latitude/longitude into address candidates through MapKit.", - tags: ["maps", "mapkit", "reverse-geocode", "location"], - example: "await apple.maps.reverseGeocode({ latitude: 37.3318, longitude: -122.0312 })", - requiredArguments: ["latitude", "longitude"], - optionalArguments: ["locale"], - resultSummary: "Array of placemarks with name/address/latitude/longitude." - ) { args, _ in - try maps.reverseGeocode(arguments: args) - }, - CapabilityRegistration( - .mapsSearch, - jsName: "apple.maps.search", - title: "Search local map items", - summary: "Search for local businesses, addresses, or points of interest through MapKit.", - tags: ["maps", "mapkit", "local-search", "places"], - example: "await apple.maps.search({ query: 'coffee near me', limit: 5 })", - requiredArguments: ["query"], - optionalArguments: ["region", "resultTypes", "limit"], - argumentHints: [ - "query": "Search query string.", - "region": "Optional coordinate region object for local bias.", - "resultTypes": "Optional host-supported result type filters.", - "limit": "Maximum map items.", - ], - resultSummary: "Array of map items with name/address/category/latitude/longitude/url." - ) { args, _ in - try maps.search(arguments: args) - }, - CapabilityRegistration( - .mapsRouteEstimate, - jsName: "apple.maps.routeEstimate", - title: "Estimate route", - summary: "Estimate route distance and travel time between two coordinates or map items.", - tags: ["maps", "mapkit", "directions", "route"], - example: "await apple.maps.routeEstimate({ origin: { latitude: 37.33, longitude: -122.03 }, destination: { latitude: 37.77, longitude: -122.42 }, transportType: 'automobile' })", - requiredArguments: ["origin", "destination"], - optionalArguments: ["transportType", "departureDate", "arrivalDate"], - argumentHints: [ - "origin": "Coordinate or map item object.", - "destination": "Coordinate or map item object.", - "transportType": "automobile (default), walking, or transit when supported.", - ], - resultSummary: "Object with distanceMeters/expectedTravelTimeSeconds/transportType." - ) { args, _ in - try maps.routeEstimate(arguments: args) - }, - CapabilityRegistration( - .mapsOpen, - jsName: "apple.maps.open", - title: "Open Maps", - summary: "Open Apple Maps with coordinates, a query, URL, or directions in a user-mediated handoff.", - tags: ["maps", "mapkit", "open", "directions"], - example: "await apple.maps.open({ query: 'Apple Park' })", - optionalArguments: ["query", "url", "latitude", "longitude", "destination", "transportType"], - argumentHints: [ - "query": "Place/search query to open.", - "url": "Optional maps URL to open.", - "latitude": "Latitude when opening coordinates.", - "longitude": "Longitude when opening coordinates.", - "destination": "Optional destination object for directions.", - ], - resultSummary: "Object with opened/target." - ) { args, _ in - try maps.open(arguments: args) - }, + CapabilityRegistration(tool: MapsGeocodeTool(maps: maps)), + CapabilityRegistration(tool: MapsReverseGeocodeTool(maps: maps)), + CapabilityRegistration(tool: MapsSearchTool(maps: maps)), + CapabilityRegistration(tool: MapsRouteEstimateTool(maps: maps)), + CapabilityRegistration(tool: MapsOpenTool(maps: maps)), ] } } diff --git a/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift b/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift new file mode 100644 index 0000000..e125b3b --- /dev/null +++ b/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift @@ -0,0 +1,485 @@ +import Foundation + +// MARK: - Constrained argument values + +enum ActivityDismissalPolicy: String, CodeModeStringEnum { + case `default` + case immediate +} + +enum MapsTransportType: String, CodeModeStringEnum { + case automobile + case walking + case transit + case any +} + +// MARK: - App Intents + +@BuiltInCodeMode(.appIntentsList, path: "apple.appIntents.list") +struct AppIntentsListTool: BuiltInCodeModeTool { + static let codeModeTitle = "List host App Intent adapters" + static let codeModeSummary = "List host-registered App Intents or Shortcuts actions exposed to CodeMode." + static let codeModeTags = ["appintents", "shortcuts", "actions", "host-adapter"] + static let codeModeExample = "await apple.appIntents.list()" + static let codeModeResultSummary = "Array of actions with identifier/title/summary/requiredParameters." + + struct Arguments: Sendable { + @ToolParam("Optional host-defined action domain filter.") + var domain: String? + @ToolParam("Maximum actions to return.") + var limit: Int? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.listActions(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsRun, path: "apple.appIntents.run") +struct AppIntentsRunTool: BuiltInCodeModeTool { + static let codeModeTitle = "Run host App Intent adapter" + static let codeModeSummary = "Run a host-registered App Intent adapter with structured parameters." + static let codeModeTags = ["appintents", "shortcuts", "actions", "host-adapter"] + static let codeModeExample = "await apple.appIntents.run({ identifier: 'createNote', parameters: { title: 'Draft' } })" + static let codeModeResultSummary = "Adapter-defined structured result." + + struct Arguments: Sendable { + @ToolParam("Host-registered action identifier from apple.appIntents.list.") + var identifier: String + @ToolParam("Structured parameters validated by the host adapter.") + var parameters: [String: JSONValue]? + @ToolParam("Maximum wait time in milliseconds.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.runAction(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsDonate, path: "apple.appIntents.donate") +struct AppIntentsDonateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Donate host App Intent action" + static let codeModeSummary = "Ask the host to donate a supported action to Shortcuts/Siri suggestions where feasible." + static let codeModeTags = ["appintents", "shortcuts", "donation", "host-adapter"] + static let codeModeExample = "await apple.appIntents.donate({ identifier: 'createNote', parameters: { title: 'Draft' } })" + static let codeModeResultSummary = "Object with donated/status." + + struct Arguments: Sendable { + @ToolParam("Host-registered action identifier.") + var identifier: String + @ToolParam("Structured action parameters used for the donation.") + var parameters: [String: JSONValue]? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.donateAction(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsOpen, path: "apple.appIntents.open") +struct AppIntentsOpenTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open host App Intent surface" + static let codeModeSummary = "Open a host-provided App Intent, Shortcuts, or app surface for user-mediated continuation." + static let codeModeTags = ["appintents", "shortcuts", "open", "host-adapter"] + static let codeModeExample = "await apple.appIntents.open({ identifier: 'showTask', parameters: { id: 'task-1' } })" + static let codeModeResultSummary = "Object with opened/status." + + struct Arguments: Sendable { + @ToolParam("Host-registered open action identifier.") + var identifier: String + @ToolParam("Structured parameters for the host open action.") + var parameters: [String: JSONValue]? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.openAction(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsHandoffsRead, path: "apple.appIntents.listHandoffs") +struct AppIntentsHandoffsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read App Intent handoff inbox" + static let codeModeSummary = "Read bounded App Intent handoff events captured by the host app." + static let codeModeTags = ["appintents", "shortcuts", "handoff", "inbox", "events"] + static let codeModeExample = "await apple.appIntents.listHandoffs({ limit: 20 })" + static let codeModeResultSummary = "Array of handoff events with cursor/identifier/parameters/date/source." + + struct Arguments: Sendable { + @ToolParam("Optional action identifier filter.") + var identifier: String? + @ToolParam("Maximum handoff events to return.") + var limit: Int? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.readHandoffs(arguments: arguments.raw) + } +} + +// MARK: - Foundation Models + +@BuiltInCodeMode(.foundationModelsStatus, path: "apple.foundationModels.getStatus") +struct FoundationModelsStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Foundation Models availability" + static let codeModeSummary = "Read host Foundation Models availability/status before attempting local generation." + static let codeModeTags = ["foundationmodels", "apple-intelligence", "llm", "availability"] + static let codeModeExample = "await apple.foundationModels.getStatus()" + static let codeModeResultSummary = "Object with available/status/reason/modelIdentifier when available." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let foundationModels: FoundationModelsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try foundationModels.status(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.foundationModelsGenerate, path: "apple.foundationModels.generate") +struct FoundationModelsGenerateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Generate text with Foundation Models" + static let codeModeSummary = "Generate text locally through the host Foundation Models adapter when available." + static let codeModeTags = ["foundationmodels", "apple-intelligence", "llm", "generation"] + static let codeModeExample = "await apple.foundationModels.generate({ prompt: 'Summarize this note', maxTokens: 200 })" + static let codeModeResultSummary = "Object with text/finishReason/usage when available." + + struct Arguments: Sendable { + // `prompt` is absent from the argument-type inference table, so its + // historical advertised type is `any` — declared as JSONValue to match. + @ToolParam("Prompt text sent to the host Foundation Models session.") + var prompt: JSONValue + @ToolParam("Optional host-approved system instructions.") + var instructions: String? + @ToolParam("Optional sampling temperature when supported.") + var temperature: Double? + @ToolParam("Optional maximum output tokens.") + var maxTokens: Int? + @ToolParam("Optional host-defined output schema identifier.") + var schemaIdentifier: String? + @ToolParam("Maximum generation wait time in milliseconds.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let foundationModels: FoundationModelsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try foundationModels.generateText(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.foundationModelsExtract, path: "apple.foundationModels.extract") +struct FoundationModelsExtractTool: BuiltInCodeModeTool { + static let codeModeTitle = "Extract structured data with Foundation Models" + static let codeModeSummary = "Run host-defined structured extraction or classification with Foundation Models." + static let codeModeTags = ["foundationmodels", "apple-intelligence", "llm", "structured-output"] + static let codeModeExample = "await apple.foundationModels.extract({ input: text, schemaIdentifier: 'todo' })" + static let codeModeResultSummary = "Object with values/classification/confidence/schemaIdentifier." + + struct Arguments: Sendable { + @ToolParam("Input text to classify or extract from.") + var input: String + @ToolParam("Optional JSON schema object accepted by the host adapter.") + var schema: [String: JSONValue]? + @ToolParam("Preferred host-defined schema identifier.") + var schemaIdentifier: String? + @ToolParam("Optional task-specific instructions.") + var instructions: String? + @ToolParam("Maximum extraction wait time in milliseconds.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let foundationModels: FoundationModelsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try foundationModels.extract(arguments: arguments.raw) + } +} + +// MARK: - Live Activities + +@BuiltInCodeMode(.activityList, path: "apple.activity.list") +struct ActivityListTool: BuiltInCodeModeTool { + static let codeModeTitle = "List Live Activities" + static let codeModeSummary = "List host-registered ActivityKit Live Activities visible to CodeMode." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.list()" + static let codeModeResultSummary = "Array of activities with identifier/activityType/state/contentState/attributes." + + struct Arguments: Sendable { + @ToolParam("Optional host-registered activity adapter type filter.") + var activityType: String? + @ToolParam("Maximum activities to return.") + var limit: Int? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.listActivities(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityStart, path: "apple.activity.start") +struct ActivityStartTool: BuiltInCodeModeTool { + static let codeModeTitle = "Start Live Activity" + static let codeModeSummary = "Start a host-registered ActivityKit Live Activity adapter." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.start({ activityType: 'delivery', attributes: {}, contentState: {} })" + static let codeModeResultSummary = "Object with identifier/activityType/state/pushToken when available." + + struct Arguments: Sendable { + @ToolParam("Host-registered activity adapter type.") + var activityType: String + @ToolParam("Adapter-defined immutable attributes.") + var attributes: [String: JSONValue] + @ToolParam("Adapter-defined mutable content state.") + var contentState: [String: JSONValue]? + @ToolParam("Optional push token request mode when the adapter supports remote updates.") + var pushType: String? + @ToolParam("Optional ISO8601 stale date.") + var staleDate: String? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.startActivity(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityUpdate, path: "apple.activity.update") +struct ActivityUpdateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Update Live Activity" + static let codeModeSummary = "Update an existing host-registered Live Activity content state." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.update({ identifier: 'activity-1', contentState: { progress: 0.7 } })" + static let codeModeResultSummary = "Object with identifier/updated/state." + + struct Arguments: Sendable { + @ToolParam("Live Activity identifier from apple.activity.list/start.") + var identifier: String + @ToolParam("Adapter-defined mutable content state.") + var contentState: [String: JSONValue] + @ToolParam("Optional alert configuration object for the update.") + var alert: [String: JSONValue]? + @ToolParam("Optional ISO8601 stale date.") + var staleDate: String? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.updateActivity(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityEnd, path: "apple.activity.end") +struct ActivityEndTool: BuiltInCodeModeTool { + static let codeModeTitle = "End Live Activity" + static let codeModeSummary = "End a host-registered Live Activity, optionally with final content state." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'immediate' })" + static let codeModeResultSummary = "Object with identifier/ended/state." + + struct Arguments: Sendable { + @ToolParam("Live Activity identifier from apple.activity.list/start.") + var identifier: String + @ToolParam("Optional final adapter-defined content state.") + var contentState: [String: JSONValue]? + @ToolParam("default (default) or immediate.") + var dismissalPolicy: ActivityDismissalPolicy? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.endActivity(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityPushTokenRead, path: "apple.activity.getPushToken") +struct ActivityPushTokenReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Live Activity push token" + static let codeModeSummary = "Read a Live Activity push token when the host adapter supports push updates." + static let codeModeTags = ["activitykit", "live-activities", "push-token"] + static let codeModeExample = "await apple.activity.getPushToken({ identifier: 'activity-1' })" + static let codeModeResultSummary = "Object with identifier/pushToken/environment." + + struct Arguments: Sendable { + @ToolParam("Live Activity identifier from apple.activity.list/start.") + var identifier: String + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.readPushToken(arguments: arguments.raw) + } +} + +// MARK: - Maps + +@BuiltInCodeMode(.mapsGeocode, path: "apple.maps.geocode") +struct MapsGeocodeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Geocode address" + static let codeModeSummary = "Resolve an address or place text to coordinate candidates through MapKit." + static let codeModeTags = ["maps", "mapkit", "geocode", "location"] + static let codeModeExample = "await apple.maps.geocode({ address: '1 Infinite Loop, Cupertino', limit: 3 })" + static let codeModeResultSummary = "Array of placemarks with name/address/latitude/longitude." + + struct Arguments: Sendable { + @ToolParam("Address or place text to geocode.") + var address: String + @ToolParam("Optional search bias region object.") + var region: [String: JSONValue]? + @ToolParam("Maximum coordinate candidates.") + var limit: Int? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.geocode(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsReverseGeocode, path: "apple.maps.reverseGeocode") +struct MapsReverseGeocodeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Reverse geocode coordinates" + static let codeModeSummary = "Resolve latitude/longitude into address candidates through MapKit." + static let codeModeTags = ["maps", "mapkit", "reverse-geocode", "location"] + static let codeModeExample = "await apple.maps.reverseGeocode({ latitude: 37.3318, longitude: -122.0312 })" + static let codeModeResultSummary = "Array of placemarks with name/address/latitude/longitude." + + struct Arguments: Sendable { + @ToolParam("Latitude in decimal degrees.") + var latitude: Double + @ToolParam("Longitude in decimal degrees.") + var longitude: Double + @ToolParam("Optional BCP-47 locale identifier for result localization.") + var locale: String? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.reverseGeocode(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsSearch, path: "apple.maps.search") +struct MapsSearchTool: BuiltInCodeModeTool { + static let codeModeTitle = "Search local map items" + static let codeModeSummary = "Search for local businesses, addresses, or points of interest through MapKit." + static let codeModeTags = ["maps", "mapkit", "local-search", "places"] + static let codeModeExample = "await apple.maps.search({ query: 'coffee near me', limit: 5 })" + static let codeModeResultSummary = "Array of map items with name/address/category/latitude/longitude/url." + + struct Arguments: Sendable { + @ToolParam("Search query string.") + var query: String + @ToolParam("Optional coordinate region object for local bias.") + var region: [String: JSONValue]? + @ToolParam("Optional host-supported result type filters.") + var resultTypes: [String]? + @ToolParam("Maximum map items.") + var limit: Int? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.search(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsRouteEstimate, path: "apple.maps.routeEstimate") +struct MapsRouteEstimateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Estimate route" + static let codeModeSummary = "Estimate route distance and travel time between two coordinates or map items." + static let codeModeTags = ["maps", "mapkit", "directions", "route"] + static let codeModeExample = "await apple.maps.routeEstimate({ origin: { latitude: 37.33, longitude: -122.03 }, destination: { latitude: 37.77, longitude: -122.42 }, transportType: 'automobile' })" + static let codeModeResultSummary = "Object with distanceMeters/expectedTravelTimeSeconds/transportType." + + struct Arguments: Sendable { + @ToolParam("Coordinate or map item object.") + var origin: [String: JSONValue] + @ToolParam("Coordinate or map item object.") + var destination: [String: JSONValue] + @ToolParam("automobile (default), walking, or transit when supported.") + var transportType: MapsTransportType? + @ToolParam("Optional ISO8601 departure date.") + var departureDate: String? + @ToolParam("Optional ISO8601 arrival date.") + var arrivalDate: String? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.routeEstimate(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsOpen, path: "apple.maps.open") +struct MapsOpenTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open Maps" + static let codeModeSummary = "Open Apple Maps with coordinates, a query, URL, or directions in a user-mediated handoff." + static let codeModeTags = ["maps", "mapkit", "open", "directions"] + static let codeModeExample = "await apple.maps.open({ query: 'Apple Park' })" + static let codeModeResultSummary = "Object with opened/target." + + struct Arguments: Sendable { + @ToolParam("Place/search query to open.") + var query: String? + @ToolParam("Optional maps URL to open.") + var url: String? + @ToolParam("Latitude when opening coordinates.") + var latitude: Double? + @ToolParam("Longitude when opening coordinates.") + var longitude: Double? + @ToolParam("Optional destination object for directions.") + var destination: [String: JSONValue]? + @ToolParam("automobile (default), walking, transit, or any.") + var transportType: MapsTransportType? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.open(arguments: arguments.raw) + } +} diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index dfb1fde..fd794ec 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -60,14 +60,9 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { // (SystemUICodeModeTools.swift), not this table. // cloudKit database constraint now comes from the tools' CloudKitDatabase // argument (CloudPushSpeechCodeModeTools.swift), not this table. - case .activityEnd: - return .init(allowedStringValues: [ - "dismissalPolicy": ["default", "immediate"], - ]) - case .mapsRouteEstimate, .mapsOpen: - return .init(allowedStringValues: [ - "transportType": ["automobile", "walking", "transit", "any"], - ]) + // activityEnd (ActivityDismissalPolicy) and mapsRouteEstimate/mapsOpen + // (MapsTransportType) constraints now come from their tools' + // CodeModeStringEnum arguments (IntentsModelsActivityMapsCodeModeTools.swift). case .musicPlaybackControl: return .init(allowedStringValues: [ "action": ["play", "pause", "stop", "skipToNext", "skipToPrevious", "playCatalog", "playLibrary"], diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index 3c6a296..c808959 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -7,7 +7,9 @@ ] }, "argumentHints" : { - + "contentState" : "Optional final adapter-defined content state.", + "dismissalPolicy" : "default (default) or immediate.", + "identifier" : "Live Activity identifier from apple.activity.list\/start." }, "argumentTypes" : { "contentState" : "object", @@ -43,7 +45,8 @@ }, "argumentHints" : { - + "activityType" : "Optional host-registered activity adapter type filter.", + "limit" : "Maximum activities to return." }, "argumentTypes" : { "activityType" : "string", @@ -78,7 +81,7 @@ }, "argumentHints" : { - + "identifier" : "Live Activity identifier from apple.activity.list\/start." }, "argumentTypes" : { "identifier" : "string" @@ -155,7 +158,10 @@ }, "argumentHints" : { - + "alert" : "Optional alert configuration object for the update.", + "contentState" : "Adapter-defined mutable content state.", + "identifier" : "Live Activity identifier from apple.activity.list\/start.", + "staleDate" : "Optional ISO8601 stale date." }, "argumentTypes" : { "alert" : "object", @@ -2639,6 +2645,7 @@ "latitude" : "Latitude when opening coordinates.", "longitude" : "Longitude when opening coordinates.", "query" : "Place\/search query to open.", + "transportType" : "automobile (default), walking, transit, or any.", "url" : "Optional maps URL to open." }, "argumentTypes" : { @@ -2683,7 +2690,9 @@ }, "argumentHints" : { - + "latitude" : "Latitude in decimal degrees.", + "locale" : "Optional BCP-47 locale identifier for result localization.", + "longitude" : "Longitude in decimal degrees." }, "argumentTypes" : { "latitude" : "number", @@ -2725,6 +2734,8 @@ ] }, "argumentHints" : { + "arrivalDate" : "Optional ISO8601 arrival date.", + "departureDate" : "Optional ISO8601 departure date.", "destination" : "Coordinate or map item object.", "origin" : "Coordinate or map item object.", "transportType" : "automobile (default), walking, or transit when supported." From 5ace990d35946340fbc3cf2a5d696d3535b5e65c Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 11 Jul 2026 20:07:18 -0700 Subject: [PATCH 24/25] Phase 3: migrate Commerce registrations to @BuiltInCodeMode (final domain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 18 music/passKit/storeKit capabilities to macro-authored tools; the three sub-functions are now thin lists. Only musicPlaybackControl.action is constrained (a new MusicPlaybackAction enum: play/pause/stop/skipToNext/ skipToPrevious/playCatalog/playLibrary); its row is deleted from the central defaults table. Host clients stay authoritative for playback semantics, so no bridge rewiring — the enum only gates the advertised value set at the registry. The type-inference trap: musicPlaylistWrite.name is absent from the argument- type table, so its historical advertised type is 'any' — declared as JSONValue to preserve it, not String. Also makes registryValidationRejectsDescriptorConstrainedValuesBeforePermissions self-contained: it built a synthetic musicPlaybackControl descriptor that relied on the removed defaults(for:) row, so it now passes an explicit argumentConstraints and still verifies constraint-before-permission ordering. Golden diff: one allowed category only — argumentHints gained entries for five registrations that advertised none for those arguments (musicCatalogDetails countryCode, musicPlaybackControl startPlaying, musicPlaylistWrite description, passKitApplePayPresent timeoutMs, passKitPassPresent identifier+timeoutMs). This completes Phase 3: 114 of 115 capabilities are now macro-authored; networkFetch alone stays on the flat init (dotted-path arguments). Updates TODO.md and PLAN-registration-macros.md to reflect completion. 230 tests pass. --- PLAN-registration-macros.md | 26 +- .../CapabilityRegistrations+Commerce.swift | 299 +----------- .../Bridges/CommerceCodeModeTools.swift | 440 ++++++++++++++++++ .../Registry/CapabilityRegistry.swift | 6 +- TODO.md | 44 +- .../CapabilityRegistryTests.swift | 5 +- .../capability-metadata-golden.json | 11 +- 7 files changed, 520 insertions(+), 311 deletions(-) create mode 100644 Sources/CodeMode/Bridges/CommerceCodeModeTools.swift diff --git a/PLAN-registration-macros.md b/PLAN-registration-macros.md index 899efa3..522a774 100644 --- a/PLAN-registration-macros.md +++ b/PLAN-registration-macros.md @@ -28,9 +28,29 @@ the *compiler* enforces the semantics on the expansion rather than the macro guessing; and the transitional `raw: [String: JSONValue]` passthrough is a convention the macro fills (canonicalized) when declared as the last stored property — it disappears per-domain in Phase 3 when a bridge goes typed. -Remaining Phase-2 notes: the fifth metadata surface (hand-written JS function -table in `RuntimeJavaScript.swift`) is still a Phase-3 item, and the -authoring-facing `@CodeMode` has not yet gained enum-constraint support. + +**Phase 3 landed 2026-07-12 (per `PHASE3-HANDOFF.md`).** All seven remaining +domains migrated: SystemUI, Core/filesystem, People/Photos/Documents, +SystemServices, CloudKit/Push/Speech, Intents/Models/Activity/Maps, and +Commerce. Net result: **114 of 115 built-in capabilities are macro-authored**; +only `networkFetch` stays on the flat init because its nested dotted-path +arguments (`options.method`, …) can't be expressed by the flat `Arguments` +model (documented PHASE3-SKIP). The central `defaults(for:)` constraint table +now holds only networkFetch's `options.responseEncoding` row; every other +constrained value moved to a per-tool `CodeModeStringEnum`, and the owning +bridge/system-client parses through that same enum (SystemUIBridge, +PhotosBridge, SystemCloudKitMapping, SystemMapsMapping). Migration was guarded +throughout by `CapabilityMetadataGoldenTests` — the only golden changes across +all seven domains were `argumentHints` gaining entries for arguments that +previously advertised none (the flat-init idiom allowed missing hints; the tool +idiom requires one per argument). Two `.any` traps were caught by the golden and +preserved as `JSONValue` rather than tightened: `foundationModelsGenerate.prompt` +and `musicPlaylistWrite.name` (both absent from the inference table). + +Remaining follow-ups (all deferred, none blocking): the fifth metadata surface +(hand-written JS function table in `RuntimeJavaScript.swift`); deleting +`inferArgumentTypes` + the last constraint row once networkFetch is handled; and +enum-constraint support for the host-facing `@CodeMode`. --- diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift index 31255d4..dae5b08 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift @@ -3,299 +3,36 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func musicRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .musicPermissionRequest, - jsName: "apple.music.requestPermission", - title: "Request Music permission", - summary: "Request Apple Music/media-library permission before library or playback actions.", - tags: ["music", "musickit", "permission"], - example: "await apple.music.requestPermission()", - resultSummary: "Object with status/granted." - ) { _, context in - try music.requestPermission(context: context) - }, - CapabilityRegistration( - .musicSubscriptionStatus, - jsName: "apple.music.getSubscriptionStatus", - title: "Read Music subscription status", - summary: "Read MusicKit subscription/capability status exposed by the host.", - tags: ["music", "musickit", "subscription", "catalog"], - example: "await apple.music.getSubscriptionStatus()", - resultSummary: "Object with canPlayCatalogContent/hasCloudLibraryEnabled/status." - ) { args, _ in - try music.subscriptionStatus(arguments: args) - }, - CapabilityRegistration( - .musicCatalogSearch, - jsName: "apple.music.search", - title: "Search Music catalog", - summary: "Search the Apple Music catalog for songs, albums, artists, playlists, or stations.", - tags: ["music", "musickit", "catalog", "search"], - example: "await apple.music.search({ term: 'Miles Davis', types: ['artists', 'albums'], limit: 5 })", - requiredArguments: ["term"], - optionalArguments: ["types", "limit", "countryCode"], - argumentHints: [ - "term": "Catalog search term.", - "types": "Optional array such as songs, albums, artists, playlists, stations.", - "limit": "Maximum results.", - "countryCode": "Optional storefront country code.", - ], - resultSummary: "Object grouped by result type with catalog identifiers and metadata." - ) { args, _ in - try music.searchCatalog(arguments: args) - }, - CapabilityRegistration( - .musicCatalogDetails, - jsName: "apple.music.getDetails", - title: "Read Music catalog details", - summary: "Read details for a catalog item identifier through MusicKit.", - tags: ["music", "musickit", "catalog", "details"], - example: "await apple.music.getDetails({ identifier: '123', type: 'albums' })", - requiredArguments: ["identifier"], - optionalArguments: ["type", "countryCode"], - argumentHints: [ - "identifier": "Music catalog item identifier.", - "type": "Catalog type such as songs, albums, artists, playlists, or stations.", - ], - resultSummary: "Catalog item details object." - ) { args, _ in - try music.catalogDetails(arguments: args) - }, - CapabilityRegistration( - .musicLibraryRead, - jsName: "apple.music.readLibrary", - title: "Read Music library", - summary: "Read the user's Music library playlists or items after Music permission is granted.", - tags: ["music", "musickit", "library"], - example: "await apple.music.readLibrary({ type: 'playlists', limit: 20 })", - requiredPermissions: [.music], - optionalArguments: ["type", "limit"], - argumentHints: [ - "type": "Library type such as playlists, songs, albums, or artists.", - "limit": "Maximum library items.", - ], - resultSummary: "Array of library items with identifiers and metadata." - ) { args, context in - try music.readLibrary(arguments: args, context: context) - }, - CapabilityRegistration( - .musicPlaylistWrite, - jsName: "apple.music.writePlaylist", - title: "Write Music playlist", - summary: "Create or update a user-library playlist through a host MusicKit adapter.", - tags: ["music", "musickit", "library", "playlist", "write"], - example: "await apple.music.writePlaylist({ name: 'Focus', catalogIDs: ['song-id'] })", - requiredPermissions: [.music], - requiredArguments: ["name"], - optionalArguments: ["playlistID", "catalogIDs", "libraryIDs", "description"], - argumentHints: [ - "name": "Playlist name.", - "playlistID": "Optional existing playlist identifier to update.", - "catalogIDs": "Optional catalog song identifiers to add.", - "libraryIDs": "Optional library song identifiers to add.", - ], - resultSummary: "Object with playlistID/name/written." - ) { args, context in - try music.writePlaylist(arguments: args, context: context) - }, - CapabilityRegistration( - .musicPlaybackControl, - jsName: "apple.music.play", - title: "Control Music playback", - summary: "Control Music playback queue through a user-authorized host adapter.", - tags: ["music", "musickit", "playback", "queue"], - example: "await apple.music.play({ action: 'playCatalog', catalogID: 'song-id' })", - requiredPermissions: [.music], - requiredArguments: ["action"], - optionalArguments: ["catalogID", "libraryID", "queue", "startPlaying"], - argumentHints: [ - "action": "Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.", - "catalogID": "Optional catalog item identifier.", - "libraryID": "Optional library item identifier.", - "queue": "Optional queue definition approved by the host adapter.", - ], - resultSummary: "Object with action/status/currentItem when available." - ) { args, context in - try music.controlPlayback(arguments: args, context: context) - }, + CapabilityRegistration(tool: MusicPermissionRequestTool(music: music)), + CapabilityRegistration(tool: MusicSubscriptionStatusTool(music: music)), + CapabilityRegistration(tool: MusicCatalogSearchTool(music: music)), + CapabilityRegistration(tool: MusicCatalogDetailsTool(music: music)), + CapabilityRegistration(tool: MusicLibraryReadTool(music: music)), + CapabilityRegistration(tool: MusicPlaylistWriteTool(music: music)), + CapabilityRegistration(tool: MusicPlaybackControlTool(music: music)), ] } func passKitRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .passKitWalletStatus, - jsName: "apple.wallet.getStatus", - title: "Read Wallet status", - summary: "Read PassKit Wallet availability and pass-library access status.", - tags: ["passkit", "wallet", "passes"], - example: "await apple.wallet.getStatus()", - resultSummary: "Object with available/canAddPasses/canPresentPasses." - ) { args, _ in - try passKit.walletStatus(arguments: args) - }, - CapabilityRegistration( - .passKitPassesRead, - jsName: "apple.wallet.listPasses", - title: "List Wallet passes", - summary: "List Wallet passes accessible to the host app through PassKit.", - tags: ["passkit", "wallet", "passes", "read"], - example: "await apple.wallet.listPasses({ limit: 20 })", - optionalArguments: ["passTypeIdentifier", "serialNumber", "limit"], - argumentHints: [ - "passTypeIdentifier": "Optional pass type filter.", - "serialNumber": "Optional serial number filter.", - "limit": "Maximum accessible passes.", - ], - resultSummary: "Array of passes with passTypeIdentifier/serialNumber/organizationName/description." - ) { args, _ in - try passKit.listPasses(arguments: args) - }, - CapabilityRegistration( - .passKitPassAdd, - jsName: "apple.wallet.addPass", - title: "Add Wallet pass", - summary: "Present user-mediated UI to add a sandbox .pkpass file to Wallet.", - tags: ["passkit", "wallet", "passes", "add", "system-ui"], - example: "await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' })", - requiredArguments: ["path"], - optionalArguments: ["timeoutMs"], - argumentHints: [ - "path": "Sandbox path to a .pkpass file.", - "timeoutMs": "Optional timeout for user-mediated add-pass UI.", - ], - resultSummary: "Object with action/added/passTypeIdentifier/serialNumber." - ) { args, context in - try passKit.addPass(arguments: args, context: context) - }, - CapabilityRegistration( - .passKitPassPresent, - jsName: "apple.wallet.presentPass", - title: "Present Wallet pass", - summary: "Present user-mediated details for an accessible Wallet pass.", - tags: ["passkit", "wallet", "passes", "system-ui"], - example: "await apple.wallet.presentPass({ identifier: 'pass-id' })", - requiredArguments: ["identifier"], - optionalArguments: ["timeoutMs"], - resultSummary: "Object with action/presented/identifier." - ) { args, _ in - try passKit.presentPass(arguments: args) - }, - CapabilityRegistration( - .passKitApplePayStatus, - jsName: "apple.wallet.canMakePayments", - title: "Read Apple Pay status", - summary: "Read Apple Pay capability using host merchant configuration; no merchant setup is accepted from JavaScript.", - tags: ["passkit", "apple-pay", "payments", "safety"], - example: "await apple.wallet.canMakePayments()", - optionalArguments: ["networks"], - argumentHints: [ - "networks": "Optional supported payment networks to test against host merchant configuration.", - ], - resultSummary: "Object with canMakePayments/canMakePaymentsUsingNetworks." - ) { args, _ in - try passKit.applePayStatus(arguments: args) - }, - CapabilityRegistration( - .passKitApplePayPresent, - jsName: "apple.wallet.presentPayment", - title: "Present Apple Pay request", - summary: "Present a host-defined Apple Pay payment request using host merchant configuration after explicit user-visible confirmation.", - tags: ["passkit", "apple-pay", "payments", "system-ui", "safety"], - example: "await apple.wallet.presentPayment({ paymentRequestID: 'checkout-123', confirmed: true })", - requiredArguments: ["paymentRequestID", "confirmed"], - optionalArguments: ["timeoutMs"], - argumentHints: [ - "paymentRequestID": "Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript.", - "confirmed": "Must be true after explicit user-visible confirmation before presenting Apple Pay.", - ], - resultSummary: "Object with action/authorized/paymentRequestID/status." - ) { args, _ in - try passKit.presentApplePay(arguments: args) - }, + CapabilityRegistration(tool: PassKitWalletStatusTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitPassesReadTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitPassAddTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitPassPresentTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitApplePayStatusTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitApplePayPresentTool(passKit: passKit)), ] } func storeKitRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .storeKitProductsRead, - jsName: "apple.storekit.listProducts", - title: "Read StoreKit products", - summary: "Read StoreKit product metadata for host-configured product identifiers.", - tags: ["storekit", "commerce", "products"], - example: "await apple.storekit.listProducts({ productIDs: ['pro.monthly'] })", - requiredArguments: ["productIDs"], - argumentHints: [ - "productIDs": "Array of host-configured StoreKit product identifiers.", - ], - resultSummary: "Array of products with id/displayName/description/price/type." - ) { args, _ in - try storeKit.products(arguments: args) - }, - CapabilityRegistration( - .storeKitEntitlementsRead, - jsName: "apple.storekit.listEntitlements", - title: "Read StoreKit entitlements", - summary: "Read current StoreKit entitlements and verified transaction state.", - tags: ["storekit", "commerce", "entitlements"], - example: "await apple.storekit.listEntitlements()", - resultSummary: "Array of current entitlements with productID/transactionID/expirationDate." - ) { args, _ in - try storeKit.currentEntitlements(arguments: args) - }, - CapabilityRegistration( - .storeKitPurchase, - jsName: "apple.storekit.purchase", - title: "Purchase StoreKit product", - summary: "Present StoreKit purchase UI for a host-configured product after explicit user-visible confirmation.", - tags: ["storekit", "commerce", "purchase", "safety"], - example: "await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true })", - requiredArguments: ["productID", "confirmed"], - optionalArguments: ["appAccountToken"], - argumentHints: [ - "productID": "Host-configured StoreKit product identifier.", - "confirmed": "Must be true after explicit user-visible confirmation before purchase UI is presented.", - "appAccountToken": "Optional UUID string for StoreKit appAccountToken.", - ], - resultSummary: "Object with status/productID/transactionID when completed." - ) { args, _ in - try storeKit.purchase(arguments: args) - }, - CapabilityRegistration( - .storeKitRestore, - jsName: "apple.storekit.restore", - title: "Restore StoreKit purchases", - summary: "Trigger StoreKit restore/sync after explicit user-visible confirmation.", - tags: ["storekit", "commerce", "restore", "safety"], - example: "await apple.storekit.restore({ confirmed: true })", - requiredArguments: ["confirmed"], - argumentHints: [ - "confirmed": "Must be true after explicit user-visible confirmation before restore starts.", - ], - resultSummary: "Object with restored/status." - ) { args, _ in - try storeKit.restore(arguments: args) - }, - CapabilityRegistration( - .storeKitTransactionsRead, - jsName: "apple.storekit.listTransactions", - title: "Read StoreKit transaction inbox", - summary: "Read bounded transaction updates captured by the host StoreKit adapter.", - tags: ["storekit", "commerce", "transactions", "inbox", "events"], - example: "await apple.storekit.listTransactions({ limit: 20 })", - optionalArguments: ["limit", "productID", "afterCursor"], - argumentHints: [ - "limit": "Maximum transaction updates to return.", - "productID": "Optional product filter.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of transaction events with cursor/productID/transactionID/status/date." - ) { args, _ in - try storeKit.transactionUpdates(arguments: args) - }, + CapabilityRegistration(tool: StoreKitProductsReadTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitEntitlementsReadTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitPurchaseTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitRestoreTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitTransactionsReadTool(storeKit: storeKit)), ] } } diff --git a/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift b/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift new file mode 100644 index 0000000..deb7c4a --- /dev/null +++ b/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift @@ -0,0 +1,440 @@ +import Foundation + +/// Constrained playback action; host MusicClient stays authoritative for +/// semantics, so only the advertised value set is enforced here. +enum MusicPlaybackAction: String, CodeModeStringEnum { + case play + case pause + case stop + case skipToNext + case skipToPrevious + case playCatalog + case playLibrary +} + +// MARK: - Music + +@BuiltInCodeMode(.musicPermissionRequest, path: "apple.music.requestPermission") +struct MusicPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request Music permission" + static let codeModeSummary = "Request Apple Music/media-library permission before library or playback actions." + static let codeModeTags = ["music", "musickit", "permission"] + static let codeModeExample = "await apple.music.requestPermission()" + static let codeModeResultSummary = "Object with status/granted." + + struct Arguments: Sendable {} + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.musicSubscriptionStatus, path: "apple.music.getSubscriptionStatus") +struct MusicSubscriptionStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Music subscription status" + static let codeModeSummary = "Read MusicKit subscription/capability status exposed by the host." + static let codeModeTags = ["music", "musickit", "subscription", "catalog"] + static let codeModeExample = "await apple.music.getSubscriptionStatus()" + static let codeModeResultSummary = "Object with canPlayCatalogContent/hasCloudLibraryEnabled/status." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.subscriptionStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.musicCatalogSearch, path: "apple.music.search") +struct MusicCatalogSearchTool: BuiltInCodeModeTool { + static let codeModeTitle = "Search Music catalog" + static let codeModeSummary = "Search the Apple Music catalog for songs, albums, artists, playlists, or stations." + static let codeModeTags = ["music", "musickit", "catalog", "search"] + static let codeModeExample = "await apple.music.search({ term: 'Miles Davis', types: ['artists', 'albums'], limit: 5 })" + static let codeModeResultSummary = "Object grouped by result type with catalog identifiers and metadata." + + struct Arguments: Sendable { + @ToolParam("Catalog search term.") + var term: String + @ToolParam("Optional array such as songs, albums, artists, playlists, stations.") + var types: [String]? + @ToolParam("Maximum results.") + var limit: Int? + @ToolParam("Optional storefront country code.") + var countryCode: String? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.searchCatalog(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.musicCatalogDetails, path: "apple.music.getDetails") +struct MusicCatalogDetailsTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Music catalog details" + static let codeModeSummary = "Read details for a catalog item identifier through MusicKit." + static let codeModeTags = ["music", "musickit", "catalog", "details"] + static let codeModeExample = "await apple.music.getDetails({ identifier: '123', type: 'albums' })" + static let codeModeResultSummary = "Catalog item details object." + + struct Arguments: Sendable { + @ToolParam("Music catalog item identifier.") + var identifier: String + @ToolParam("Catalog type such as songs, albums, artists, playlists, or stations.") + var type: String? + @ToolParam("Optional storefront country code.") + var countryCode: String? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.catalogDetails(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.musicLibraryRead, path: "apple.music.readLibrary") +struct MusicLibraryReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Music library" + static let codeModeSummary = "Read the user's Music library playlists or items after Music permission is granted." + static let codeModeTags = ["music", "musickit", "library"] + static let codeModeExample = "await apple.music.readLibrary({ type: 'playlists', limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.music] + static let codeModeResultSummary = "Array of library items with identifiers and metadata." + + struct Arguments: Sendable { + @ToolParam("Library type such as playlists, songs, albums, or artists.") + var type: String? + @ToolParam("Maximum library items.") + var limit: Int? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.readLibrary(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.musicPlaylistWrite, path: "apple.music.writePlaylist") +struct MusicPlaylistWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write Music playlist" + static let codeModeSummary = "Create or update a user-library playlist through a host MusicKit adapter." + static let codeModeTags = ["music", "musickit", "library", "playlist", "write"] + static let codeModeExample = "await apple.music.writePlaylist({ name: 'Focus', catalogIDs: ['song-id'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.music] + static let codeModeResultSummary = "Object with playlistID/name/written." + + struct Arguments: Sendable { + // `name` is absent from the argument-type inference table, so its + // historical advertised type is `any` — declared as JSONValue to match. + @ToolParam("Playlist name.") + var name: JSONValue + @ToolParam("Optional existing playlist identifier to update.") + var playlistID: String? + @ToolParam("Optional catalog song identifiers to add.") + var catalogIDs: [String]? + @ToolParam("Optional library song identifiers to add.") + var libraryIDs: [String]? + @ToolParam("Optional playlist description.") + var description: String? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.writePlaylist(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.musicPlaybackControl, path: "apple.music.play") +struct MusicPlaybackControlTool: BuiltInCodeModeTool { + static let codeModeTitle = "Control Music playback" + static let codeModeSummary = "Control Music playback queue through a user-authorized host adapter." + static let codeModeTags = ["music", "musickit", "playback", "queue"] + static let codeModeExample = "await apple.music.play({ action: 'playCatalog', catalogID: 'song-id' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.music] + static let codeModeResultSummary = "Object with action/status/currentItem when available." + + struct Arguments: Sendable { + @ToolParam("Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.") + var action: MusicPlaybackAction + @ToolParam("Optional catalog item identifier.") + var catalogID: String? + @ToolParam("Optional library item identifier.") + var libraryID: String? + @ToolParam("Optional queue definition approved by the host adapter.") + var queue: [String: JSONValue]? + @ToolParam("Whether playback should begin immediately; host-adapter defined.") + var startPlaying: Bool? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.controlPlayback(arguments: arguments.raw, context: context) + } +} + +// MARK: - PassKit / Wallet + +@BuiltInCodeMode(.passKitWalletStatus, path: "apple.wallet.getStatus") +struct PassKitWalletStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Wallet status" + static let codeModeSummary = "Read PassKit Wallet availability and pass-library access status." + static let codeModeTags = ["passkit", "wallet", "passes"] + static let codeModeExample = "await apple.wallet.getStatus()" + static let codeModeResultSummary = "Object with available/canAddPasses/canPresentPasses." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.walletStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitPassesRead, path: "apple.wallet.listPasses") +struct PassKitPassesReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List Wallet passes" + static let codeModeSummary = "List Wallet passes accessible to the host app through PassKit." + static let codeModeTags = ["passkit", "wallet", "passes", "read"] + static let codeModeExample = "await apple.wallet.listPasses({ limit: 20 })" + static let codeModeResultSummary = "Array of passes with passTypeIdentifier/serialNumber/organizationName/description." + + struct Arguments: Sendable { + @ToolParam("Optional pass type filter.") + var passTypeIdentifier: String? + @ToolParam("Optional serial number filter.") + var serialNumber: String? + @ToolParam("Maximum accessible passes.") + var limit: Int? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.listPasses(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitPassAdd, path: "apple.wallet.addPass") +struct PassKitPassAddTool: BuiltInCodeModeTool { + static let codeModeTitle = "Add Wallet pass" + static let codeModeSummary = "Present user-mediated UI to add a sandbox .pkpass file to Wallet." + static let codeModeTags = ["passkit", "wallet", "passes", "add", "system-ui"] + static let codeModeExample = "await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' })" + static let codeModeResultSummary = "Object with action/added/passTypeIdentifier/serialNumber." + + struct Arguments: Sendable { + @ToolParam("Sandbox path to a .pkpass file.") + var path: String + @ToolParam("Optional timeout for user-mediated add-pass UI.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.addPass(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.passKitPassPresent, path: "apple.wallet.presentPass") +struct PassKitPassPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present Wallet pass" + static let codeModeSummary = "Present user-mediated details for an accessible Wallet pass." + static let codeModeTags = ["passkit", "wallet", "passes", "system-ui"] + static let codeModeExample = "await apple.wallet.presentPass({ identifier: 'pass-id' })" + static let codeModeResultSummary = "Object with action/presented/identifier." + + struct Arguments: Sendable { + @ToolParam("Accessible Wallet pass identifier from apple.wallet.listPasses.") + var identifier: String + @ToolParam("Optional timeout for user-mediated pass presentation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.presentPass(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitApplePayStatus, path: "apple.wallet.canMakePayments") +struct PassKitApplePayStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Apple Pay status" + static let codeModeSummary = "Read Apple Pay capability using host merchant configuration; no merchant setup is accepted from JavaScript." + static let codeModeTags = ["passkit", "apple-pay", "payments", "safety"] + static let codeModeExample = "await apple.wallet.canMakePayments()" + static let codeModeResultSummary = "Object with canMakePayments/canMakePaymentsUsingNetworks." + + struct Arguments: Sendable { + @ToolParam("Optional supported payment networks to test against host merchant configuration.") + var networks: [String]? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.applePayStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitApplePayPresent, path: "apple.wallet.presentPayment") +struct PassKitApplePayPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present Apple Pay request" + static let codeModeSummary = "Present a host-defined Apple Pay payment request using host merchant configuration after explicit user-visible confirmation." + static let codeModeTags = ["passkit", "apple-pay", "payments", "system-ui", "safety"] + static let codeModeExample = "await apple.wallet.presentPayment({ paymentRequestID: 'checkout-123', confirmed: true })" + static let codeModeResultSummary = "Object with action/authorized/paymentRequestID/status." + + struct Arguments: Sendable { + @ToolParam("Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript.") + var paymentRequestID: String + @ToolParam("Must be true after explicit user-visible confirmation before presenting Apple Pay.") + var confirmed: Bool + @ToolParam("Optional timeout for user-mediated Apple Pay UI.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.presentApplePay(arguments: arguments.raw) + } +} + +// MARK: - StoreKit + +@BuiltInCodeMode(.storeKitProductsRead, path: "apple.storekit.listProducts") +struct StoreKitProductsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read StoreKit products" + static let codeModeSummary = "Read StoreKit product metadata for host-configured product identifiers." + static let codeModeTags = ["storekit", "commerce", "products"] + static let codeModeExample = "await apple.storekit.listProducts({ productIDs: ['pro.monthly'] })" + static let codeModeResultSummary = "Array of products with id/displayName/description/price/type." + + struct Arguments: Sendable { + @ToolParam("Array of host-configured StoreKit product identifiers.") + var productIDs: [String] + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.products(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitEntitlementsRead, path: "apple.storekit.listEntitlements") +struct StoreKitEntitlementsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read StoreKit entitlements" + static let codeModeSummary = "Read current StoreKit entitlements and verified transaction state." + static let codeModeTags = ["storekit", "commerce", "entitlements"] + static let codeModeExample = "await apple.storekit.listEntitlements()" + static let codeModeResultSummary = "Array of current entitlements with productID/transactionID/expirationDate." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.currentEntitlements(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitPurchase, path: "apple.storekit.purchase") +struct StoreKitPurchaseTool: BuiltInCodeModeTool { + static let codeModeTitle = "Purchase StoreKit product" + static let codeModeSummary = "Present StoreKit purchase UI for a host-configured product after explicit user-visible confirmation." + static let codeModeTags = ["storekit", "commerce", "purchase", "safety"] + static let codeModeExample = "await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true })" + static let codeModeResultSummary = "Object with status/productID/transactionID when completed." + + struct Arguments: Sendable { + @ToolParam("Host-configured StoreKit product identifier.") + var productID: String + @ToolParam("Must be true after explicit user-visible confirmation before purchase UI is presented.") + var confirmed: Bool + @ToolParam("Optional UUID string for StoreKit appAccountToken.") + var appAccountToken: String? + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.purchase(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitRestore, path: "apple.storekit.restore") +struct StoreKitRestoreTool: BuiltInCodeModeTool { + static let codeModeTitle = "Restore StoreKit purchases" + static let codeModeSummary = "Trigger StoreKit restore/sync after explicit user-visible confirmation." + static let codeModeTags = ["storekit", "commerce", "restore", "safety"] + static let codeModeExample = "await apple.storekit.restore({ confirmed: true })" + static let codeModeResultSummary = "Object with restored/status." + + struct Arguments: Sendable { + @ToolParam("Must be true after explicit user-visible confirmation before restore starts.") + var confirmed: Bool + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.restore(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitTransactionsRead, path: "apple.storekit.listTransactions") +struct StoreKitTransactionsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read StoreKit transaction inbox" + static let codeModeSummary = "Read bounded transaction updates captured by the host StoreKit adapter." + static let codeModeTags = ["storekit", "commerce", "transactions", "inbox", "events"] + static let codeModeExample = "await apple.storekit.listTransactions({ limit: 20 })" + static let codeModeResultSummary = "Array of transaction events with cursor/productID/transactionID/status/date." + + struct Arguments: Sendable { + @ToolParam("Maximum transaction updates to return.") + var limit: Int? + @ToolParam("Optional product filter.") + var productID: String? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.transactionUpdates(arguments: arguments.raw) + } +} diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index fd794ec..7e7d489 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -63,10 +63,8 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { // activityEnd (ActivityDismissalPolicy) and mapsRouteEstimate/mapsOpen // (MapsTransportType) constraints now come from their tools' // CodeModeStringEnum arguments (IntentsModelsActivityMapsCodeModeTools.swift). - case .musicPlaybackControl: - return .init(allowedStringValues: [ - "action": ["play", "pause", "stop", "skipToNext", "skipToPrevious", "playCatalog", "playLibrary"], - ]) + // musicPlaybackControl action constraint now comes from the tool's + // MusicPlaybackAction argument (CommerceCodeModeTools.swift). default: return .none } diff --git a/TODO.md b/TODO.md index 4c9863f..282f76a 100644 --- a/TODO.md +++ b/TODO.md @@ -103,30 +103,36 @@ just the watchdog: ## STRUCTURAL IMPROVEMENTS (bridge/registry metadata drift) -Biggest maintenance risk: ~115 capabilities / ~2,540 lines of hand-written -registrations with four parallel sources of truth. Phase 1 of -`PLAN-registration-macros.md` landed 2026-07-11 (owner approved swift-syntax -in the core graph, so Phase 2 is unblocked): -- [/] Move argument types/constraints to per-capability, co-located declarations +Biggest maintenance risk was ~115 capabilities / ~2,540 lines of hand-written +registrations with four parallel sources of truth. Phases 1–3 of +`PLAN-registration-macros.md` landed 2026-07-11/12 (owner approved swift-syntax +in the core graph): **114 of 115 capabilities are now macro-authored +`@BuiltInCodeMode` tools**; only `networkFetch` remains on the flat init +(PHASE3-SKIP — its nested dotted-path arguments can't be expressed by the flat +tool model). +- [x] Move argument types/constraints to per-capability, co-located declarations — `BuiltInCodeModeTool` protocol + `CodeModeStringEnum` (constrained string args declared once: advertised values, decode, and bridge parsing all come - from the enum). EventKit migrated (9 registrations, 5 enums); the - calendarWrite/calendarDelete/calendarUIPickCalendar/remindersWrite rows are - deleted from the central constraint table. Remaining: 7 registration files. + from the enum). All seven domains migrated; the central + `CapabilityArgumentConstraints.defaults(for:)` table now holds only + networkFetch's `options.responseEncoding` dotted-path row. - [ ] **fail loudly instead of degrading unknown args to `.any`** - (`inferArgumentTypes`) — still pending; goes away as domains migrate. -- [/] Add a test asserting registration metadata matches bridge reality — - `constrainedArgumentMetadataIsCoherentForAllRegistrations` (every constrained - arg must be declared) + span test now pins enum↔descriptor↔bridge agreement. - Golden-testing `resultSummary` against bridge JSON encoders still open. -- [/] Standardize on one registration idiom — target idiom is - `BuiltInCodeModeTool`; EventKit, Keychain, Location/Weather converged - (the raw-`CodeModeRegistration` and `builtInCapability:` glue idioms are - deleted). 7 `CapabilityRegistrations+*.swift` files still on the flat init. + (`inferArgumentTypes`) — still pending; only networkFetch depends on it now, + so the table can be deleted once that capability is handled. +- [x] Add a test asserting registration metadata matches bridge reality — + `CapabilityMetadataGoldenTests` pins the full advertised surface of all 115 + capabilities against a committed JSON baseline; + `constrainedArgumentMetadataIsCoherentForAllRegistrations` + the span test pin + enum↔descriptor↔bridge agreement. (Golden-testing `resultSummary` against + bridge JSON encoders still open — the golden pins the string, not the encoder.) +- [x] Standardize on one registration idiom — `BuiltInCodeModeTool` / + `@BuiltInCodeMode` is now the sole idiom for built-ins; the flat descriptor + init survives only for the single networkFetch skip. The + raw-`CodeModeRegistration` and `builtInCapability:` glue idioms are gone. - [ ] **Fifth metadata surface found during migration:** the hand-written JS function table in `RuntimeJavaScript.swift` (e.g. `completeReminder` injects - `operation: 'complete', isCompleted: true`). Candidate for generation from - registrations in Phase 3. + `operation: 'complete', isCompleted: true`). Untouched by Phase 3; candidate + for generation from registrations as a follow-up. - [ ] Unify permission ownership — `calendarRead` checks in both registry and bridge; `calendarWrite` checks only in the bridge. - [x] Decide the fate of `Tools/CodeModeAuthoring` — resolved 2026-07-11: owner diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 43061a9..089e4d4 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -450,7 +450,10 @@ private func jsNames(for capability: CapabilityID) -> [String] { tags: ["test"], example: "noop", requiredPermissions: [.music], - requiredArguments: ["action"] + requiredArguments: ["action"], + // Explicit constraint so the test verifies constraint-before-permission + // ordering without depending on any capability's central table row. + argumentConstraints: CapabilityArgumentConstraints(allowedStringValues: ["action": ["play", "pause"]]) ) let registry = CapabilityRegistry( diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index c808959..8e51716 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -2977,6 +2977,7 @@ }, "argumentHints" : { + "countryCode" : "Optional storefront country code.", "identifier" : "Music catalog item identifier.", "type" : "Catalog type such as songs, albums, artists, playlists, or stations." }, @@ -3137,7 +3138,8 @@ "action" : "Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.", "catalogID" : "Optional catalog item identifier.", "libraryID" : "Optional library item identifier.", - "queue" : "Optional queue definition approved by the host adapter." + "queue" : "Optional queue definition approved by the host adapter.", + "startPlaying" : "Whether playback should begin immediately; host-adapter defined." }, "argumentTypes" : { "action" : "string", @@ -3179,6 +3181,7 @@ }, "argumentHints" : { "catalogIDs" : "Optional catalog song identifiers to add.", + "description" : "Optional playlist description.", "libraryIDs" : "Optional library song identifiers to add.", "name" : "Playlist name.", "playlistID" : "Optional existing playlist identifier to update." @@ -3727,7 +3730,8 @@ }, "argumentHints" : { "confirmed" : "Must be true after explicit user-visible confirmation before presenting Apple Pay.", - "paymentRequestID" : "Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript." + "paymentRequestID" : "Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript.", + "timeoutMs" : "Optional timeout for user-mediated Apple Pay UI." }, "argumentTypes" : { "confirmed" : "bool", @@ -3836,7 +3840,8 @@ }, "argumentHints" : { - + "identifier" : "Accessible Wallet pass identifier from apple.wallet.listPasses.", + "timeoutMs" : "Optional timeout for user-mediated pass presentation." }, "argumentTypes" : { "identifier" : "string", From ab3792e9b44147d78278fbcbcee526119d42e4cc Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 12 Jul 2026 08:28:14 -0700 Subject: [PATCH 25/25] CI: drop daily eval cron; make live LLM evals a manual regression gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduled (daily) run only fired 'codemode-eval plan', which is pure arithmetic (scenarios x repeat x maxTurns) printed to a log — no live model calls, no compare, no artifact, deterministic output. It burned a macOS runner daily for zero signal. Removed the 'schedule' trigger entirely. Deterministic evals + iOS/visionOS platform builds stay continuous: PRs and pushes to main (merges), unchanged. Replaces the no-op llm-plan job with a real llm-live regression gate, manual only (workflow_dispatch): it previews the budget, then — when a WAVELIKE_API_KEY secret is present and the CLI is built with the private overlay (real codemode-eval llm vs the LLMUnavailable stub) — runs the core/failures suites live and compares each against its committed baseline, failing on pass-rate or exact-capability regression. Absent those it skips the live steps with a warning rather than pretending. This encodes the intended pipeline so it goes fully live once the private Wavelike wiring + secret land, with no further workflow edits. Updates EVALS.md CI Policy to match. --- .github/workflows/codemode-evals.yml | 93 +++++++++++++++++++++------- EVALS.md | 25 +++++--- 2 files changed, 88 insertions(+), 30 deletions(-) diff --git a/.github/workflows/codemode-evals.yml b/.github/workflows/codemode-evals.yml index fe949a2..18c7eda 100644 --- a/.github/workflows/codemode-evals.yml +++ b/.github/workflows/codemode-evals.yml @@ -15,14 +15,15 @@ on: description: "Delay before each model request" required: false default: "1000" - schedule: - - cron: "17 10 * * *" concurrency: group: codemode-evals-${{ github.ref }} cancel-in-progress: false jobs: + # Deterministic evals + platform builds are continuous: they run on every PR + # and on pushes to main (merges). No schedule — a daily cron re-running + # unchanged main added no signal for these offline checks. deterministic: name: Deterministic evals runs-on: macos-latest @@ -92,14 +93,28 @@ jobs: -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO - llm-plan: - name: LLM eval planning + # Live LLM regression gate. Manual only (workflow_dispatch) — it spends real + # provider calls, so it is never automatic. + # + # Requirements to actually execute the live run (all private, absent from this + # public repo by design): + # 1. Build the eval CLI with the private overlay so the real Wavelike-backed + # `codemode-eval llm` command is compiled in place of the LLMUnavailable + # stub (un-exclude LLM.swift + add the private Wavelike/CallableFunction + # package dependencies). + # 2. A WAVELIKE_API_KEY repository secret. + # Until both are present the job runs the budget preview and then skips the + # live+compare steps with a warning, instead of pretending or failing noisily. + llm-live: + name: LLM eval (live regression gate) runs-on: macos-latest needs: deterministic - if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + if: github.event_name == 'workflow_dispatch' env: INPUT_REPEAT_COUNT: ${{ github.event.inputs.repeat_count }} INPUT_REQUEST_DELAY_MS: ${{ github.event.inputs.request_delay_ms }} + WAVELIKE_API_KEY: ${{ secrets.WAVELIKE_API_KEY }} + WAVELIKE_MODEL_ID: ${{ vars.WAVELIKE_MODEL_ID }} steps: - name: Checkout uses: actions/checkout@v4 @@ -107,27 +122,59 @@ jobs: - name: Toolchain run: swift --version + - name: Determine whether live execution is configured + id: gate + run: | + if [ -z "${WAVELIKE_API_KEY}" ]; then + echo "::warning::WAVELIKE_API_KEY not set — live LLM evals need the private Wavelike runner and credentials. Running budget preview only." + echo "live=false" >> "$GITHUB_OUTPUT" + else + echo "live=true" >> "$GITHUB_OUTPUT" + fi + - name: Build eval CLI run: swift build --package-path Tools/CodeModeEval - - name: Preview LLM eval suites + - name: Preview LLM eval budget run: | repeat_count="${INPUT_REPEAT_COUNT:-5}" request_delay_ms="${INPUT_REQUEST_DELAY_MS:-1000}" - - swift run --package-path Tools/CodeModeEval codemode-eval plan \ - --suite core \ - --repeat "$repeat_count" \ - --request-delay-ms "$request_delay_ms" - - swift run --package-path Tools/CodeModeEval codemode-eval plan \ - --suite failures \ - --repeat "$repeat_count" \ - --request-delay-ms "$request_delay_ms" - - swift run --package-path Tools/CodeModeEval codemode-eval plan \ - --suite catalog \ - --repeat "$repeat_count" \ - --request-delay-ms "$request_delay_ms" - - echo "Live Wavelike-backed LLM execution is disabled in this workflow while the default eval package avoids private dependencies." + for suite in core failures catalog; do + swift run --package-path Tools/CodeModeEval codemode-eval plan \ + --suite "$suite" \ + --repeat "$repeat_count" \ + --request-delay-ms "$request_delay_ms" + done + + # Real regression gate: run the live suites that have committed baselines, + # then `compare` (default policy: no pass-rate or exact-capability + # regression) which fails the job on regression. `catalog` is previewed + # above but not compared until its baseline is generated and committed. + - name: Run live LLM evals and compare against baselines + if: steps.gate.outputs.live == 'true' + run: | + repeat_count="${INPUT_REPEAT_COUNT:-5}" + request_delay_ms="${INPUT_REQUEST_DELAY_MS:-1000}" + reports="Tools/CodeModeEval/.build/reports" + mkdir -p "$reports" + for suite in core failures; do + candidate="${reports}/${suite}-r${repeat_count}.json" + swift run --package-path Tools/CodeModeEval codemode-eval llm \ + --suite "$suite" \ + --repeat "$repeat_count" \ + --request-delay-ms "$request_delay_ms" \ + --output "$candidate" + swift run --package-path Tools/CodeModeEval codemode-eval compare \ + "Tools/CodeModeEval/Baselines/${suite}-r5-summary.json" \ + "$candidate" \ + --retry-tolerance 0.5 \ + --turn-tolerance 0.5 + done + + - name: Upload live eval reports + if: steps.gate.outputs.live == 'true' + uses: actions/upload-artifact@v4 + with: + name: llm-eval-reports + path: Tools/CodeModeEval/.build/reports/*.json + if-no-files-found: warn diff --git a/EVALS.md b/EVALS.md index 0d82476..cec052a 100644 --- a/EVALS.md +++ b/EVALS.md @@ -61,13 +61,24 @@ swift run --package-path Tools/CodeModeEval codemode-eval summarize \ ## CI Policy -The GitHub Actions workflow runs deterministic evals on PRs and pushes. Scheduled and manually dispatched runs build the same public eval CLI and preview `core`, `failures`, and `catalog` LLM suite budgets without making live provider calls or resolving private Wavelike dependencies. - -Scheduled/manual planning runs: - -- Preview `core`, `failures`, and `catalog` with repeat count 5 by default. -- Honor the manual `repeat_count` and `request_delay_ms` inputs for budget estimates. -- Leave baseline comparison to private live-report generation until a reviewed candidate report exists. +The GitHub Actions workflow runs the deterministic evals and the iOS/visionOS +platform builds continuously — on every pull request and on pushes to `main` +(merges). There is no schedule; a daily cron over unchanged `main` added no +signal for these offline checks. + +The live LLM regression gate (`llm-live` job) is **manual only** +(`workflow_dispatch`) because it spends real provider calls: + +- It always previews `core`, `failures`, and `catalog` budgets (honoring the + manual `repeat_count` / `request_delay_ms` inputs). +- If a `WAVELIKE_API_KEY` secret is configured **and** the CLI is built with the + private overlay (the real `codemode-eval llm` command rather than the + `LLMUnavailable` stub), it then runs the `core` and `failures` suites live and + `compare`s each against its committed baseline, failing the job on any + pass-rate or exact-capability regression. `catalog` is previewed but not + compared until its baseline is generated and committed. +- Without those, the live+compare steps skip with a warning, so the manual run + still succeeds and only reports the budget preview. ## Updating Baselines