From b01d38d6b13534a7b14536a879ed548fb8295296 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 21 Oct 2026 16:45:25 -0700 Subject: [PATCH 1/9] Point the README at the tagged releases The rewrite said no release was tagged, but 0.1.0 through 0.2.2 are. The old README had carried the same mistake. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6006bae..e5735a7 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ Platforms: iOS 18+, macOS 15+, visionOS 2+. watchOS is out of scope because the ## Installation ```swift -.package(url: "https://github.com/velos/CodeMode.swift", branch: "main") +.package(url: "https://github.com/velos/CodeMode.swift", from: "0.2.2") ``` -No release is tagged yet; once `0.1.0` is, prefer `from: "0.1.0"`. Add the product to your target: +`main` may be ahead of the latest tag; depend on `branch: "main"` for unreleased changes. Add the product to your target: ```swift .product(name: "CodeMode", package: "CodeMode") From 586fd22f882f5386b103e4ff931890c1387080c3 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 21 Oct 2026 16:45:39 -0700 Subject: [PATCH 2/9] Close the tagging item in TODO.md; the releases exist --- TODO.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index c5e8cf8..15eb772 100644 --- a/TODO.md +++ b/TODO.md @@ -64,11 +64,9 @@ just the watchdog: not just the execution transcript. - [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 - 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. +### 3. Package not installable as documented [x] +- [x] Tagged: `0.1.0` through `0.2.2` exist, and the README's install snippet + is versioned again. --- From 04b314097ae27f3e961ad663ea08c1a20301b60f Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 21 Oct 2026 16:53:42 -0700 Subject: [PATCH 3/9] Record the async native-call decision as a plan Astra reviewed the options read-only and Fable decided: go truly async, with the first phase shipping width 1 so only deadlines and cancellation change. Written down now so the branch after typed-bridges starts from it. --- PLAN-async-bridges.md | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 PLAN-async-bridges.md diff --git a/PLAN-async-bridges.md b/PLAN-async-bridges.md new file mode 100644 index 0000000..924c44c --- /dev/null +++ b/PLAN-async-bridges.md @@ -0,0 +1,92 @@ +# Plan: asynchronous native calls + +Status: decided, not started. Starts after `typed-bridges` merges, on its own branch. + +## Problem + +JS helpers already return promises. `__invokeAsync` (RuntimeJavaScript.swift) is +`Promise.resolve().then(syncInvoke)`. Inside that `.then`, though, the native call blocks the +execution thread until the Swift handler returns. Built-in tools that wrap async Apple APIs each +hand-roll a semaphore, a `Task`, a result box and a fixed timeout. There are about 15 of these +(WeatherBridge.swift is typical), plus `CompletionWait` in EventKit and the permission broker. +Host `@CodeMode` providers go through `CodeModeAsyncBridge.run`, which applies a fixed 30 s +timeout. The consequences: + +- Native waits ignore the execution's own deadline: timeouts range from 10 to 30 s, per bridge. +- `cancel()` cannot interrupt a native wait. +- `Promise.all` over several helpers runs them one after another. + +## Decision + +Go truly asynchronous, not "async handlers behind one synchronous wait point". Helpers already +return promises, so this changes timing, not the surface scripts see. The synchronous-wait +design is not a stepping stone. Its wait point, and its assumption that no native work survives +a JS turn, would both be thrown away; only the async handler signature carries over, and the +first phase below delivers that anyway. + +The first phase ships the asynchronous machinery with a concurrency width of 1. Delivery stays +sequential, and nothing observable changes except deadlines and cancellation. Turning on overlap +is a separate, deliberate change. + +Reviewed by GPT-6 Astra (read-only) and decided by Fable. The two disagreed on these points, and +here is what was settled: + +- **Outstanding native calls at settlement are drained, not cancelled.** The drain is bounded by + the remaining deadline and emits a diagnostic. Today an un-awaited + `apple.notifications.schedule(...)` still runs. Cancelling it would turn the most common + model mistake, a forgotten `await`, into silent data loss. Cancel only on timeout or host + cancel. +- **One built-in seam.** Built-in tools all go through `CapabilityRegistration.init(tool:)`, so + a single blocking-work adapter there covers every unmigrated tool. Only the public + `CapabilityHandler` needs both a sync and an async initializer. +- **Permission prompts are serialized per execution and per permission** with a lock in + `BridgeInvocationContext`. There are no shared in-flight futures; the broker's own request + already blocks. + +## Phases + +**1. Runtime-owned native waits (sequential delivery).** + +What becomes true: every native operation is bounded by +`min(execution deadline, per-operation cap)`. `cancel()` interrupts a native wait immediately. +Late completions are dropped. `Promise.all` ordering is unchanged. + +- `BridgeInvocationContext` carries an immutable deadline taken from the injected clock, the + remaining budget, and task registration so that cancel reaches registered work. +- `CapabilityHandler` gets an async form. Sync handlers and unmigrated tool bodies run on a + bounded GCD queue, bridged by a continuation. They never block inside a `Task` on the + cooperative pool, which can deadlock it. +- `BuiltInCodeModeTool.call` becomes `async throws`. The protocol is internal, so this is not a + host-facing break. The `@CodeMode` macro awaits `call` directly, and `CodeModeAsyncBridge.run` + is deprecated. +- In JS, `__bridgeStart(id, …)` returns immediately and keeps a pending map; delivery resolves + promises by id. +- In the runtime, an execution mailbox (a locked queue plus the condition `cancel()` already + broadcasts). `waitForSettlement` drains completions on the execution thread before checking + timers. It waits until the next completion, timer, deadline or cancel. It reports + "can never settle" only when no timer and no native call are pending. +- Admission width comes from `ExecutionLimits`, default 1. +- Rewrite the ExecutionWatchdog header comment and the `waitForSettlement` doc, which both state + that no native work is outstanding. +- Tests use `VirtualClock`: a deadline bounds a slow handler, cancel during a native wait, a late + completion is dropped, an un-awaited call still completes, `Promise.all` ordering is unchanged. + +**2. Overlap.** + +- Default width 4 per execution, plus a runtime-wide cap. +- A per-permission lock. +- An exclusive, runtime-wide lane for capabilities that present UI or prompt for permission. +- Size the blocking-work queue to executions × width. +- Update the docs that tell models native calls are sequential: README, `CodeModeAgentGuidance`, + `CodeModeAgentToolDescriptions`, and their tests. + +**3+. One PR per bridge family.** + +- Rewrite Weather, Health, Media, Network (async `URLSession`), Notifications, Alarm, Home and + Photos as native async bodies that honour task cancellation. Delete each family's semaphores + as it goes. +- Pair each family's rewrite with its typed-arguments migration, since the bodies are being + rewritten anyway. +- Then add async variants of `PermissionBroker` and `SystemUIPresenter`, with adapters from the + sync ones. +- Last, the service-client protocols in AppleServiceAdapters.swift. From 4f9f4f6cd41fefd2d67fab656333e28e594cdbb6 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 21 Oct 2026 17:58:49 -0700 Subject: [PATCH 4/9] Give permission checks one implementation, and let tools add argument-dependent ones The registry checks a tool's statically declared permissions before its handler runs, and the bridges then checked the same permissions again with their own copy of the logic. The registry's check, the context's `resolvedPermission`, and LocationBridge each carried the broker sequence (status, record, request if undetermined, record). That sequence now lives in one place, `BridgeInvocationContext`. `requirePermission` is the enforcing form the registry uses, and it still asks the broker and records an event on every invocation, as before. A test pins that, so a later cache is a deliberate change, not a side effect. Built-in tools gain `argumentDependentPermissions(for:)`, checked after decoding, for permissions that depend on what the call asks for. Location read needs location access only in `current` mode. Calendar create vs update follows with the EventKit migration. The audit found all 29 static declarations match what their bridges enforce, and the permission-request helpers declare none, so nothing pre-checks them. --- .../Bridges/BuiltInCodeModeTool.swift | 7 ++++ Sources/CodeMode/Bridges/LocationBridge.swift | 13 +----- .../SimpleBuiltInCodeModeProviders.swift | 4 ++ .../Registry/CapabilityRegistry.swift | 26 +----------- .../Runtime/BridgeInvocationContext.swift | 39 ++++++++++++------ .../CapabilityRegistryTests.swift | 40 ++++++++++++++++++- 6 files changed, 79 insertions(+), 50 deletions(-) diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift index e54edca..31f64a3 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -56,12 +56,16 @@ protocol BuiltInCodeModeTool: Sendable { static var codeModeResultSummary: String { get } func decode(arguments: [String: JSONValue]) throws -> Arguments + /// Additional permissions selected from decoded arguments. Static + /// requirements are enforced by the registry before the handler runs. + func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue } extension BuiltInCodeModeTool { static var codeModeAliasPaths: [String] { [] } static var codeModeRequiredPermissions: [PermissionKind] { [] } + func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] { [] } } extension CapabilityRegistration { @@ -96,6 +100,9 @@ extension CapabilityRegistration { ), handler: { rawArguments, context in let decoded = try tool.decode(arguments: rawArguments) + for permission in tool.argumentDependentPermissions(for: decoded) { + try context.requirePermission(permission) + } return try tool.call(arguments: decoded, context: context) } ) diff --git a/Sources/CodeMode/Bridges/LocationBridge.swift b/Sources/CodeMode/Bridges/LocationBridge.swift index 0e6531a..7d12a34 100644 --- a/Sources/CodeMode/Bridges/LocationBridge.swift +++ b/Sources/CodeMode/Bridges/LocationBridge.swift @@ -16,7 +16,7 @@ public final class LocationBridge: @unchecked Sendable { context.recordPermission(.locationWhenInUse, status: status) return .string(status.rawValue) case "current": - let status = resolvedPermission(for: .locationWhenInUse, context: context) + let status = context.resolvedPermission(for: .locationWhenInUse) guard status == .granted else { throw BridgeError.permissionDenied(.locationWhenInUse) } @@ -46,15 +46,4 @@ public final class LocationBridge: @unchecked Sendable { context.recordPermission(.locationWhenInUse, status: status) return .string(status.rawValue) } - - private func resolvedPermission(for permission: PermissionKind, context: BridgeInvocationContext) -> PermissionStatus { - let status = context.permissionBroker.status(for: permission) - context.recordPermission(permission, status: status) - if status == .notDetermined { - let requested = context.permissionBroker.request(for: permission) - context.recordPermission(permission, status: requested) - return requested - } - return status - } } diff --git a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift index 0066ce5..de7e0b1 100644 --- a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift +++ b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift @@ -142,6 +142,10 @@ private struct LocationReadTool: BuiltInCodeModeTool { Arguments(mode: try CodeModeArgumentDecoder.optional("mode", as: String.self, in: arguments)) } + func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] { + (arguments.mode ?? "current") == "current" ? [.locationWhenInUse] : [] + } + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { var payload: [String: JSONValue] = [:] if let mode = arguments.mode { diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index e5bb049..1ee20eb 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -709,32 +709,8 @@ public final class CapabilityRegistry: @unchecked Sendable { private func validatePermissions(_ permissions: [PermissionKind], context: BridgeInvocationContext) throws { for permission in permissions { - try context.checkCancellation() - let status = context.permissionBroker.status(for: permission) - context.recordPermission(permission, status: status) - - let resolvedStatus: PermissionStatus - if status == .notDetermined { - let requested = context.permissionBroker.request(for: permission) - context.recordPermission(permission, status: requested) - resolvedStatus = requested - } else { - resolvedStatus = status - } - - guard permissionStatus(resolvedStatus, satisfies: permission) else { - throw BridgeError.permissionDenied(permission) - } - - context.markPermissionValidated(permission) - } - } - - private func permissionStatus(_ status: PermissionStatus, satisfies permission: PermissionKind) -> Bool { - if permission == .calendarWriteOnly, status == .writeOnly { - return true + try context.requirePermission(permission) } - return status == .granted } private func value(atPath path: String, in root: [String: JSONValue]) -> JSONValue? { diff --git a/Sources/CodeMode/Runtime/BridgeInvocationContext.swift b/Sources/CodeMode/Runtime/BridgeInvocationContext.swift index 2148721..d135f4f 100644 --- a/Sources/CodeMode/Runtime/BridgeInvocationContext.swift +++ b/Sources/CodeMode/Runtime/BridgeInvocationContext.swift @@ -90,18 +90,7 @@ public final class BridgeInvocationContext: @unchecked Sendable { return .granted } - let status = permissionBroker.status(for: permission) - recordPermission(permission, status: status) - - let resolved: PermissionStatus - if status == .notDetermined { - let requested = permissionBroker.request(for: permission) - recordPermission(permission, status: requested) - resolved = requested - } else { - resolved = status - } - + let resolved = observePermission(permission) if resolved == .granted { markPermissionValidated(permission) } @@ -109,6 +98,32 @@ public final class BridgeInvocationContext: @unchecked Sendable { return resolved } + /// Enforces a permission using the same satisfaction rules for static + /// registry checks and argument-dependent built-in checks. Each invocation + /// observes and records the broker's current status, even after a previous + /// grant. Legacy bridge rechecks may still use `resolvedPermission`'s cache. + func requirePermission(_ permission: PermissionKind) throws { + try checkCancellation() + let resolvedStatus = observePermission(permission) + guard resolvedStatus == .granted || (permission == .calendarWriteOnly && resolvedStatus == .writeOnly) else { + throw BridgeError.permissionDenied(permission) + } + markPermissionValidated(permission) + } + + /// Asks the broker for the current status, requesting access when it is + /// undetermined, and records each answer in the transcript. + private func observePermission(_ permission: PermissionKind) -> PermissionStatus { + let status = permissionBroker.status(for: permission) + recordPermission(permission, status: status) + guard status == .notDetermined else { + return status + } + let requested = permissionBroker.request(for: permission) + recordPermission(permission, status: requested) + return requested + } + /// True when the request declared this identifier but the host's /// `CapabilityGrant` withheld it — a denial the model cannot repair. func isWithheldByHost(_ identifier: String) -> Bool { diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index d191fe1..914c4c3 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -3,7 +3,6 @@ import JavaScriptCore import Testing @testable import CodeMode - private func jsNames(for capability: CapabilityID) -> [String] { DefaultCapabilityLoader.loadAllRegistrations() .first { $0.descriptor.id == capability }? @@ -618,3 +617,42 @@ private func jsNames(for capability: CapabilityID) -> [String] { #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } } + +private final class StatusObservingPermissionBroker: PermissionBroker, @unchecked Sendable { + private let lock = NSLock() + private var observations: [PermissionKind] = [] + + var statusObservations: [PermissionKind] { + lock.withLock { observations } + } + + func status(for permission: PermissionKind) -> PermissionStatus { + lock.withLock { observations.append(permission) } + return .granted + } + + func request(for permission: PermissionKind) -> PermissionStatus { .denied } +} + +@Test func registryObservesStaticPermissionOnEveryInvocation() throws { + let registry = CapabilityRegistry(registrations: [CapabilityRegistration( + descriptor: CapabilityDescriptor( + id: .calendarRead, + title: "Calendar read", + summary: "Exercise static permission enforcement without a live store.", + tags: [], + example: "", + requiredPermissions: [.calendar] + ), + handler: { _, _ in .string("ok") } + )]) + let broker = StatusObservingPermissionBroker() + let (context, sandbox) = try makeInvocationContext(permissionBroker: broker) + defer { cleanup(sandbox) } + + #expect(try registry.invoke("calendar.read", arguments: [:], context: context) == .string("ok")) + #expect(try registry.invoke("calendar.read", arguments: [:], context: context) == .string("ok")) + #expect(broker.statusObservations == [.calendar, .calendar]) + #expect(context.allPermissionEvents().map(\.permission) == [.calendar, .calendar]) + #expect(context.allPermissionEvents().map(\.status) == [.granted, .granted]) +} From 651e054d181a53eae86403edf0545cd6e882f90b Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 21 Oct 2026 17:59:29 -0700 Subject: [PATCH 5/9] Give EventKit bridges typed arguments, with macro defaults and dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@BuiltInCodeMode` generated typed decoding, and then almost every tool threw it away: it handed `arguments.raw` to its bridge, which parsed every argument again by string key. That meant two declarations per argument, and defaults kept both in hint prose and in code. This is the pilot that removes the duplication for one family and sets the template for the rest. The macro gains: - Defaults written as Swift initializers. `var limit: Int = 50` makes the argument optional, decode falls back to the default, and the advertised hint ends with a generated "Default: 50." Only literals and `.enumCase` are accepted; anything else, `let`, or an optional with a default is a diagnostic. - `Date` properties, decoded through `CodeModeDate`, with the same errors the bridges produced. - `providedArguments: Set`, the keys a call supplied, so patch updates can tell which fields to touch without keeping raw values. The six EventKit tools drop `raw`. Their bridge methods take the tool's typed `Arguments`, and the re-parsing, the operation parsing, and the permission guards the registry already enforces are deleted. Calendar create and update choose their permission through `argumentDependentPermissions`. One intentional change: create now accepts a custom broker that reports `.writeOnly` for `.calendarWriteOnly`. The system broker already reported that as granted. Scripts see the same spellings, defaults, errors, and codes. The golden baseline diff is four hint lines gaining "Default: …". EventKitBridge.swift goes from 544 to 475 lines. --- PHASE3-HANDOFF.md | 2 +- .../Bridges/BuiltInCodeModeMacros.swift | 10 +- .../Bridges/BuiltInCodeModeTool.swift | 25 +- Sources/CodeMode/Bridges/EventKitBridge.swift | 317 +++++++----------- .../Bridges/EventKitCodeModeTools.swift | 61 ++-- .../CodeModeMacros/BuiltInCodeModeMacro.swift | 112 ++++++- .../BuiltInCodeModeMacroExpansionTests.swift | 188 +++++++++++ .../BuiltInCodeModeToolTests.swift | 158 ++++++++- Tests/CodeModeTests/EventKitBridgeTests.swift | 186 +++++++++- .../capability-metadata-golden.json | 8 +- 10 files changed, 814 insertions(+), 253 deletions(-) diff --git a/PHASE3-HANDOFF.md b/PHASE3-HANDOFF.md index 2e169d7..122e2a0 100644 --- a/PHASE3-HANDOFF.md +++ b/PHASE3-HANDOFF.md @@ -69,7 +69,7 @@ Allowed golden diffs: 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`, + `EnumType.codeModeValue(matching:)` (see `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. diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift index a2f1311..c48503c 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift @@ -10,7 +10,15 @@ import Foundation /// 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]` +/// constraint metadata. `Date`/`Date?` decode ISO 8601 strings through +/// `CodeModeDate`, retaining the capability and argument in date errors. +/// Nonoptional scalar properties may have literal defaults (or `.case` enum +/// defaults): omission uses the initializer, and catalog hints advertise it. +/// Dynamic defaults belong in `call` and use optional properties. +/// +/// An unannotated `var providedArguments: Set` receives the supplied +/// keys, so patch tools can distinguish omission from explicit null without +/// retaining raw values. A trailing unannotated `var raw: [String: JSONValue]` /// receives the full argument dictionary with constrained values canonicalized /// — the passthrough transitional tools use while bridges consume raw /// dictionaries. diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift index 31f64a3..c8a276a 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -6,28 +6,45 @@ struct BuiltInToolArgument { var type: CapabilityArgumentType var optional: Bool var hint: String + var defaultValue: JSONValue? /// 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) { + init(_ name: String, _ type: CapabilityArgumentType, optional: Bool = false, hint: String, defaultValue: JSONValue? = nil) { self.name = name self.type = type self.optional = optional - self.hint = hint + self.defaultValue = defaultValue + self.hint = Self.hint(hint, defaultValue: defaultValue) 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) { + init(_ name: String, oneOf: Value.Type, optional: Bool = false, hint: String, defaultValue: JSONValue? = nil) { self.name = name self.type = .string self.optional = optional - self.hint = hint + self.defaultValue = defaultValue + self.hint = Self.hint(hint, defaultValue: defaultValue) self.allowedValues = Value.codeModeAllowedValues } + + private static let defaultValueEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + return encoder + }() + + private static func hint(_ hint: String, defaultValue: JSONValue?) -> String { + guard let defaultValue else { return hint } + // JSON spelling quotes strings (including enum raw values) and preserves + // escapes; all defaults admitted by the macro are JSON scalars. + let text = String(decoding: try! defaultValueEncoder.encode(defaultValue), as: UTF8.self) + return "\(hint) Default: \(text)." + } } /// The standard idiom for defining a built-in capability: typed arguments, diff --git a/Sources/CodeMode/Bridges/EventKitBridge.swift b/Sources/CodeMode/Bridges/EventKitBridge.swift index 9750c4a..88acb64 100644 --- a/Sources/CodeMode/Bridges/EventKitBridge.swift +++ b/Sources/CodeMode/Bridges/EventKitBridge.swift @@ -9,24 +9,17 @@ public final class EventKitBridge: @unchecked Sendable { public init() {} - public func readEvents(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.calendar, context: context) - guard status == .granted else { - throw BridgeError.permissionDenied(.calendar) - } - + func readEvents(arguments: CalendarListEventsTool.Arguments) throws -> JSONValue { #if canImport(EventKit) let store = EKEventStore() - // A value that is present but unparseable is an error, not a reason to - // fall back to now/+14d: that handed the script a plausible but wrong - // window with no diagnostic to notice it by. - let start = try CodeModeDate.optional(arguments.string("start"), argument: "start", capability: "calendar.read") ?? Date() - let end = try CodeModeDate.optional(arguments.string("end"), argument: "end", capability: "calendar.read") + let start = arguments.start ?? Date() + let end = arguments.end ?? Calendar.current.date(byAdding: .day, value: 14, to: start) ?? start - let limit = arguments.int("limit") ?? 50 + let limit = arguments.limit let calendars = try resolveCalendars( - from: arguments, + identifier: arguments.calendarIdentifier, + identifiers: arguments.calendarIdentifiers, in: store, entityType: .event, capability: "calendar.read" @@ -44,16 +37,9 @@ public final class EventKitBridge: @unchecked Sendable { #endif } - public func writeEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - 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) - } - + func writeEvent(arguments: CalendarWriteEventTool.Arguments) throws -> JSONValue { #if canImport(EventKit) - switch operation { + switch arguments.resolvedOperation { case .create: return try createEvent(arguments: arguments) case .update: @@ -65,21 +51,17 @@ public final class EventKitBridge: @unchecked Sendable { #endif } - public func deleteEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.calendar, context: context) - guard status == .granted else { - throw BridgeError.permissionDenied(.calendar) - } - + func deleteEvent(arguments: CalendarDeleteEventTool.Arguments) throws -> JSONValue { #if canImport(EventKit) - guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + let identifier = arguments.identifier + guard identifier.isEmpty == false else { throw BridgeError.invalidArguments("calendar.delete requires identifier") } let store = EKEventStore() guard let event = store.event(withIdentifier: identifier) else { throw BridgeError.invalidArguments("calendar.delete could not find event identifier \(identifier)") } - let span = try eventSpan(arguments.string("span")) + let span = arguments.span.ekSpan do { try store.remove(event, span: span) @@ -96,20 +78,16 @@ public final class EventKitBridge: @unchecked Sendable { #endif } - public func readReminders(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.reminders, context: context) - guard status == .granted else { - throw BridgeError.permissionDenied(.reminders) - } - + func readReminders(arguments: RemindersListTool.Arguments) throws -> JSONValue { #if canImport(EventKit) let store = EKEventStore() - let includeCompleted = arguments.bool("includeCompleted") ?? false - let start = try CodeModeDate.optional(arguments.string("start"), argument: "start", capability: "reminders.read") - let end = try CodeModeDate.optional(arguments.string("end"), argument: "end", capability: "reminders.read") - let limit = max(1, arguments.int("limit") ?? 50) + let includeCompleted = arguments.includeCompleted + let start = arguments.start + let end = arguments.end + let limit = max(1, arguments.limit) let calendars = try resolveCalendars( - from: arguments, + identifier: arguments.calendarIdentifier, + identifiers: arguments.calendarIdentifiers, in: store, entityType: .reminder, capability: "reminders.read" @@ -149,14 +127,9 @@ public final class EventKitBridge: @unchecked Sendable { #endif } - public func writeReminder(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.reminders, context: context) - guard status == .granted else { - throw BridgeError.permissionDenied(.reminders) - } - + func writeReminder(arguments: RemindersWriteTool.Arguments) throws -> JSONValue { #if canImport(EventKit) - switch try reminderOperation(arguments) { + switch arguments.resolvedOperation { case .create: return try createReminder(arguments: arguments) case .update, .complete: @@ -168,14 +141,10 @@ public final class EventKitBridge: @unchecked Sendable { #endif } - public func deleteReminder(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.reminders, context: context) - guard status == .granted else { - throw BridgeError.permissionDenied(.reminders) - } - + func deleteReminder(arguments: RemindersDeleteTool.Arguments) throws -> JSONValue { #if canImport(EventKit) - guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + let identifier = arguments.identifier + guard identifier.isEmpty == false else { throw BridgeError.invalidArguments("reminders.delete requires identifier") } let store = EKEventStore() @@ -198,41 +167,13 @@ public final class EventKitBridge: @unchecked Sendable { #endif } - private func resolvePermission(_ permission: PermissionKind, context: BridgeInvocationContext) -> PermissionStatus { - context.resolvedPermission(for: permission) - } - - private func isoDate(_ text: String?) -> Date? { - CodeModeDate.parse(text) - } - - 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 - } - - 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 - } - #if canImport(EventKit) - private func createEvent(arguments: [String: JSONValue]) throws -> JSONValue { - guard let title = arguments.string("title"), title.isEmpty == false, - let startText = arguments.string("start"), - let endText = arguments.string("end"), - let start = isoDate(startText), - let end = isoDate(endText) + private func createEvent(arguments: CalendarWriteEventTool.Arguments) throws -> JSONValue { + guard let title = arguments.title, title.isEmpty == false, + let startText = arguments.start, + let endText = arguments.end, + let start = CodeModeDate.parse(startText), + let end = CodeModeDate.parse(endText) else { throw BridgeError.invalidArguments("calendar.write create requires title/start/end ISO8601") } @@ -242,16 +183,16 @@ public final class EventKitBridge: @unchecked Sendable { event.title = title event.startDate = start event.endDate = end - event.notes = arguments.string("notes") - event.location = arguments.string("location") - event.isAllDay = arguments.bool("isAllDay") ?? false - if let urlText = arguments.string("url"), urlText.isEmpty == false { + event.notes = arguments.notes + event.location = arguments.location + event.isAllDay = arguments.isAllDay ?? false + if let urlText = arguments.url, urlText.isEmpty == false { guard let url = URL(string: urlText) else { throw BridgeError.invalidArguments("calendar.write url must be valid when provided") } event.url = url } - if let calendarIdentifier = arguments.string("calendarIdentifier") { + if let calendarIdentifier = arguments.calendarIdentifier { event.calendar = try calendar( calendarIdentifier, in: store, @@ -270,13 +211,13 @@ public final class EventKitBridge: @unchecked Sendable { } } - private func updateEvent(arguments: [String: JSONValue]) throws -> JSONValue { - guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + private func updateEvent(arguments: CalendarWriteEventTool.Arguments) throws -> JSONValue { + guard let identifier = arguments.identifier, identifier.isEmpty == false else { throw BridgeError.invalidArguments("calendar.write update requires identifier") } let patchFields = ["title", "start", "end", "notes", "location", "url", "isAllDay", "calendarIdentifier"] - guard patchFields.contains(where: { arguments.keys.contains($0) }) else { + guard patchFields.contains(where: { arguments.providedArguments.contains($0) }) else { throw BridgeError.invalidArguments("calendar.write update requires at least one patch field") } @@ -285,48 +226,7 @@ public final class EventKitBridge: @unchecked Sendable { throw BridgeError.invalidArguments("calendar.write could not find event identifier \(identifier)") } - if arguments.keys.contains("title") { - guard let title = arguments.string("title"), title.isEmpty == false else { - throw BridgeError.invalidArguments("calendar.write title cannot be empty") - } - event.title = title - } - if arguments.keys.contains("start") { - guard let start = isoDate(arguments.string("start")) else { - throw BridgeError.invalidArguments("calendar.write start must be ISO8601 when provided") - } - event.startDate = start - } - if arguments.keys.contains("end") { - guard let end = isoDate(arguments.string("end")) else { - throw BridgeError.invalidArguments("calendar.write end must be ISO8601 when provided") - } - event.endDate = end - } - if arguments.keys.contains("notes") { - event.notes = arguments.string("notes") - } - if arguments.keys.contains("location") { - event.location = arguments.string("location") - } - if arguments.keys.contains("url") { - let urlText = arguments.string("url") ?? "" - event.url = urlText.isEmpty ? nil : URL(string: urlText) - if urlText.isEmpty == false, event.url == nil { - throw BridgeError.invalidArguments("calendar.write url must be valid when provided") - } - } - if arguments.keys.contains("isAllDay") { - event.isAllDay = arguments.bool("isAllDay") ?? false - } - if let calendarIdentifier = arguments.string("calendarIdentifier") { - event.calendar = try calendar( - calendarIdentifier, - in: store, - entityType: .event, - capability: "calendar.write" - ) - } + try applyEventPatch(arguments: arguments, to: event, in: store) do { try store.save(event, span: .thisEvent) @@ -336,20 +236,20 @@ public final class EventKitBridge: @unchecked Sendable { } } - private func createReminder(arguments: [String: JSONValue]) throws -> JSONValue { - guard let title = arguments.string("title"), title.isEmpty == false else { + private func createReminder(arguments: RemindersWriteTool.Arguments) throws -> JSONValue { + guard let title = arguments.title, title.isEmpty == false else { throw BridgeError.invalidArguments("reminders.write create requires title") } let store = EKEventStore() let reminder = EKReminder(eventStore: store) - reminder.calendar = try reminderCalendar(from: arguments, in: store) + reminder.calendar = try reminderCalendar(identifier: arguments.calendarIdentifier, in: store) reminder.title = title - reminder.notes = arguments.string("notes") - reminder.priority = arguments.int("priority") ?? 0 + reminder.notes = arguments.notes + reminder.priority = arguments.priority ?? 0 - if let dueDateText = arguments.string("dueDate"), dueDateText.isEmpty == false { - guard let dueDate = isoDate(dueDateText) else { + if let dueDateText = arguments.dueDate, dueDateText.isEmpty == false { + guard let dueDate = CodeModeDate.parse(dueDateText) else { throw BridgeError.invalidArguments("reminders.write dueDate must be ISO8601 when provided") } reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) @@ -363,13 +263,13 @@ public final class EventKitBridge: @unchecked Sendable { } } - private func updateReminder(arguments: [String: JSONValue]) throws -> JSONValue { - guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + private func updateReminder(arguments: RemindersWriteTool.Arguments) throws -> JSONValue { + guard let identifier = arguments.identifier, identifier.isEmpty == false else { throw BridgeError.invalidArguments("reminders.write update requires identifier") } let patchFields = ["title", "dueDate", "notes", "isCompleted", "priority", "calendarIdentifier"] - guard patchFields.contains(where: { arguments.keys.contains($0) }) else { + guard patchFields.contains(where: { arguments.providedArguments.contains($0) }) else { throw BridgeError.invalidArguments("reminders.write update requires at least one patch field") } @@ -378,15 +278,73 @@ public final class EventKitBridge: @unchecked Sendable { throw BridgeError.invalidArguments("reminders.write could not find reminder identifier \(identifier)") } - if arguments.keys.contains("title") { - guard let title = arguments.string("title"), title.isEmpty == false else { + try applyReminderPatch(arguments: arguments, to: reminder, in: store) + + do { + try store.save(reminder, commit: true) + return Self.reminderJSON(reminder) + } catch { + throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + } + } + + /// Applies decoded patch values independently of item lookup and persistence. + func applyEventPatch(arguments: CalendarWriteEventTool.Arguments, to event: EKEvent, in store: EKEventStore) throws { + if arguments.providedArguments.contains("title") { + guard let title = arguments.title, title.isEmpty == false else { + throw BridgeError.invalidArguments("calendar.write title cannot be empty") + } + event.title = title + } + if arguments.providedArguments.contains("start") { + guard let start = CodeModeDate.parse(arguments.start) else { + throw BridgeError.invalidArguments("calendar.write start must be ISO8601 when provided") + } + event.startDate = start + } + if arguments.providedArguments.contains("end") { + guard let end = CodeModeDate.parse(arguments.end) else { + throw BridgeError.invalidArguments("calendar.write end must be ISO8601 when provided") + } + event.endDate = end + } + if arguments.providedArguments.contains("notes") { + event.notes = arguments.notes + } + if arguments.providedArguments.contains("location") { + event.location = arguments.location + } + if arguments.providedArguments.contains("url") { + let urlText = arguments.url ?? "" + event.url = urlText.isEmpty ? nil : URL(string: urlText) + if urlText.isEmpty == false, event.url == nil { + throw BridgeError.invalidArguments("calendar.write url must be valid when provided") + } + } + if arguments.providedArguments.contains("isAllDay") { + event.isAllDay = arguments.isAllDay ?? false + } + if let calendarIdentifier = arguments.calendarIdentifier { + event.calendar = try calendar( + calendarIdentifier, + in: store, + entityType: .event, + capability: "calendar.write" + ) + } + } + + /// Applies decoded patch values independently of item lookup and persistence. + func applyReminderPatch(arguments: RemindersWriteTool.Arguments, to reminder: EKReminder, in store: EKEventStore) throws { + if arguments.providedArguments.contains("title") { + guard let title = arguments.title, title.isEmpty == false else { throw BridgeError.invalidArguments("reminders.write title cannot be empty") } reminder.title = title } - if arguments.keys.contains("dueDate") { - if let dueDateText = arguments.string("dueDate"), dueDateText.isEmpty == false { - guard let dueDate = isoDate(dueDateText) else { + if arguments.providedArguments.contains("dueDate") { + if let dueDateText = arguments.dueDate, dueDateText.isEmpty == false { + guard let dueDate = CodeModeDate.parse(dueDateText) else { throw BridgeError.invalidArguments("reminders.write dueDate must be ISO8601 when provided") } reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) @@ -394,47 +352,30 @@ public final class EventKitBridge: @unchecked Sendable { reminder.dueDateComponents = nil } } - if arguments.keys.contains("notes") { - reminder.notes = arguments.string("notes") + if arguments.providedArguments.contains("notes") { + reminder.notes = arguments.notes } - if arguments.keys.contains("isCompleted") { - let isCompleted = arguments.bool("isCompleted") ?? false + if arguments.providedArguments.contains("isCompleted") { + let isCompleted = arguments.isCompleted ?? false reminder.isCompleted = isCompleted reminder.completionDate = isCompleted ? (reminder.completionDate ?? Date()) : nil } - if let priority = arguments.int("priority") { + if let priority = arguments.priority { reminder.priority = priority } - if arguments.keys.contains("calendarIdentifier") { - reminder.calendar = try reminderCalendar(from: arguments, in: store) - } - - do { - try store.save(reminder, commit: true) - return Self.reminderJSON(reminder) - } catch { - throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + if arguments.providedArguments.contains("calendarIdentifier") { + reminder.calendar = try reminderCalendar(identifier: arguments.calendarIdentifier, in: store) } } private func resolveCalendars( - from arguments: [String: JSONValue], + identifier: String?, + identifiers: [String]?, in store: EKEventStore, entityType: EKEntityType, capability: String ) throws -> [EKCalendar]? { - var identifiers: [String] = [] - if let identifier = arguments.string("calendarIdentifier") { - identifiers.append(identifier) - } - if let values = arguments.array("calendarIdentifiers") { - for value in values { - guard let identifier = value.stringValue else { - throw BridgeError.invalidArguments("\(capability) calendarIdentifiers must contain strings") - } - identifiers.append(identifier) - } - } + let identifiers = (identifier.map { [$0] } ?? []) + (identifiers ?? []) guard identifiers.isEmpty == false else { return nil @@ -466,8 +407,8 @@ public final class EventKitBridge: @unchecked Sendable { return calendar } - private func reminderCalendar(from arguments: [String: JSONValue], in store: EKEventStore) throws -> EKCalendar { - if let calendarIdentifier = arguments.string("calendarIdentifier") { + private func reminderCalendar(identifier: String?, in store: EKEventStore) throws -> EKCalendar { + if let calendarIdentifier = identifier { return try calendar( calendarIdentifier, in: store, @@ -481,16 +422,6 @@ public final class EventKitBridge: @unchecked Sendable { return calendar } - private func eventSpan(_ text: String?) throws -> EKSpan { - guard let text, text.isEmpty == false else { - return .thisEvent - } - 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 - } - /// Test seam: these serializers are the only EventKit logic reachable /// without a live, permission-granted store, and they are where the /// implicitly-unwrapped `calendar`/`title` crashes lived. diff --git a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift index 788106e..8b40bc6 100644 --- a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift @@ -66,22 +66,21 @@ struct CalendarListEventsTool: BuiltInCodeModeTool { struct Arguments: Sendable { @ToolParam("ISO8601 timestamp; defaults to now.") - var start: String? + var start: Date? @ToolParam("ISO8601 timestamp; defaults to start + 14 days.") - var end: String? - @ToolParam("Max number of items, default 50.") - var limit: Int? + var end: Date? + @ToolParam("Max number of items.") + var limit: Int = 50 @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) + try eventKit.readEvents(arguments: arguments) } } @@ -114,13 +113,19 @@ struct CalendarWriteEventTool: BuiltInCodeModeTool { var isAllDay: Bool? @ToolParam("Optional destination EventKit calendarIdentifier.") var calendarIdentifier: String? - var raw: [String: JSONValue] + // Presence is separate from value: null clears a patch field, omission + // leaves it alone. No raw values escape the generated decoder. + var providedArguments: Set } let eventKit: EventKitBridge + func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] { + [arguments.resolvedOperation == .update ? .calendar : .calendarWriteOnly] + } + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.writeEvent(arguments: arguments.raw, context: context) + try eventKit.writeEvent(arguments: arguments) } } @@ -136,15 +141,14 @@ struct CalendarDeleteEventTool: BuiltInCodeModeTool { 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] + @ToolParam("thisEvent or futureEvents for recurring events.") + var span: CalendarEventSpan = .thisEvent } let eventKit: EventKitBridge func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.deleteEvent(arguments: arguments.raw, context: context) + try eventKit.deleteEvent(arguments: arguments) } } @@ -247,24 +251,23 @@ struct RemindersListTool: BuiltInCodeModeTool { struct Arguments: Sendable { @ToolParam("Optional ISO8601 due-date lower bound.") - var start: String? + var start: Date? @ToolParam("Optional ISO8601 due-date upper bound.") - var end: String? - @ToolParam("Whether completed reminders are included; default false.") - var includeCompleted: Bool? + var end: Date? + @ToolParam("Whether completed reminders are included.") + var includeCompleted: Bool = false @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] + @ToolParam("Max number of reminder items.") + var limit: Int = 50 } let eventKit: EventKitBridge func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.readReminders(arguments: arguments.raw, context: context) + try eventKit.readReminders(arguments: arguments) } } @@ -294,13 +297,15 @@ struct RemindersWriteTool: BuiltInCodeModeTool { var priority: Int? @ToolParam("Optional destination EventKit reminders calendarIdentifier.") var calendarIdentifier: String? - var raw: [String: JSONValue] + // Presence is separate from value: null clears a patch field, omission + // leaves it alone. No raw values escape the generated decoder. + var providedArguments: Set } let eventKit: EventKitBridge func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.writeReminder(arguments: arguments.raw, context: context) + try eventKit.writeReminder(arguments: arguments) } } @@ -316,12 +321,20 @@ struct RemindersDeleteTool: BuiltInCodeModeTool { 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) + try eventKit.deleteReminder(arguments: arguments) } } + +// Operation inference is shared by permission selection and execution. +extension CalendarWriteEventTool.Arguments { + var resolvedOperation: CalendarWriteOperation { operation ?? (identifier == nil ? .create : .update) } +} + +extension RemindersWriteTool.Arguments { + var resolvedOperation: ReminderWriteOperation { operation ?? (identifier == nil ? .create : .update) } +} diff --git a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift index f93dafd..ae7afbd 100644 --- a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift +++ b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift @@ -23,7 +23,16 @@ public struct ToolParamMacro: PeerMacro { /// permissions, result summary) and `call(arguments:context:)` stay /// hand-written. /// -/// Field types the macro does not recognize as JSON primitives are emitted +/// Nonoptional scalar properties may declare literal defaults; enum +/// properties may default to `.case`. Defaults make arguments optional in the +/// catalog, supply the decode fallback, and appear as structured metadata and +/// a uniform hint sentence. Other initializers and `let` properties are diagnosed. +/// Dynamic defaults stay optional and are resolved by the tool. +/// +/// `Date` and `Date?` decode ISO 8601 strings through the built-in `CodeModeDate` +/// helpers, preserving capability/argument names and bridge error messages. +/// +/// Field types the macro does not recognize as JSON primitives or dates are emitted /// through `CodeModeStringEnum`-constrained overloads /// (`BuiltInToolArgument(_:oneOf:...)`, `CodeModeToolRawArguments.canonicalize`), /// so the compiler — not the macro — enforces that such a type really is a @@ -79,6 +88,8 @@ public struct BuiltInCodeModeMacro: MemberMacro { private struct ParsedArguments { var fields: [Field] var hasRawPassthrough: Bool + var hasProvidedArguments: Bool + var initializerLabels: [String] } private struct Field { @@ -87,6 +98,8 @@ public struct BuiltInCodeModeMacro: MemberMacro { var kind: FieldKind var optional: Bool var hint: String + var defaultExpression: String? + var defaultJSON: String? } fileprivate enum FieldKind { @@ -95,6 +108,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { /// An unrecognized simple type, emitted through the /// CodeModeStringEnum-constrained overloads. case stringEnum + case date } private static func parseFields( @@ -102,7 +116,9 @@ public struct BuiltInCodeModeMacro: MemberMacro { context: some MacroExpansionContext ) -> ParsedArguments? { var fields: [Field] = [] + var initializerLabels: [String] = [] var hasRawPassthrough = false + var hasProvidedArguments = false var rawIsLast = true var invalid = false @@ -110,6 +126,11 @@ public struct BuiltInCodeModeMacro: MemberMacro { guard let variable = member.decl.as(VariableDeclSyntax.self) else { continue } + guard variable.bindingSpecifier.tokenKind == .keyword(.var) else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode Arguments properties must be var", node: Syntax(variable.bindingSpecifier))) + invalid = true + continue + } guard variable.bindings.count == 1, let binding = variable.bindings.first, binding.accessorBlock == nil @@ -127,8 +148,15 @@ public struct BuiltInCodeModeMacro: MemberMacro { } let name = identifier.identifier.text + initializerLabels.append(name) let hint = toolParamHint(on: variable.attributes) + if hint == nil, name == "providedArguments", type == "Set" { + rawIsLast = false + hasProvidedArguments = true + continue + } + 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))) @@ -148,13 +176,31 @@ public struct BuiltInCodeModeMacro: MemberMacro { } let info = BuiltInTypeInfo(typeSyntax: type) + var defaultExpression: String? + var defaultJSON: String? + if let initializer = binding.initializer?.value { + if info.optional { + context.diagnose(BuiltInMacroDiagnostic("@ToolParam optional properties cannot have initializers; resolve dynamic defaults in call", node: Syntax(initializer))) + invalid = true + continue + } + guard let json = defaultValueSource(initializer, info: info) else { + context.diagnose(BuiltInMacroDiagnostic("@ToolParam defaults must be a matching integer, float, bool, or string literal, or .case for a CodeModeStringEnum", node: Syntax(initializer))) + invalid = true + continue + } + defaultExpression = initializer.trimmedDescription + defaultJSON = json + } fields.append( Field( name: name, swiftType: info.unwrappedType, kind: info.kind, optional: info.optional, - hint: hint + hint: hint, + defaultExpression: defaultExpression, + defaultJSON: defaultJSON ) ) } @@ -166,7 +212,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { return nil } - return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough) + return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough, hasProvidedArguments: hasProvidedArguments, initializerLabels: initializerLabels) } private static func toolParamHint(on attributes: AttributeListSyntax) -> String? { @@ -186,6 +232,33 @@ public struct BuiltInCodeModeMacro: MemberMacro { return nil } + private static func defaultValueSource(_ expression: ExprSyntax, info: BuiltInTypeInfo) -> String? { + let source = expression.trimmedDescription + var literal = expression + if let prefix = expression.as(PrefixOperatorExprSyntax.self), ["-", "+"].contains(prefix.operator.text) { + literal = prefix.expression + } + switch info.kind { + case .primitive("number"): + if literal.is(IntegerLiteralExprSyntax.self) || (info.unwrappedType != "Int" && literal.is(FloatLiteralExprSyntax.self)) { + return ".number(Double(\(source)))" + } + case .primitive("bool") where expression.is(BooleanLiteralExprSyntax.self): + return ".bool(\(source))" + case .primitive("string"): + if let string = expression.as(StringLiteralExprSyntax.self), string.segments.allSatisfy({ $0.is(StringSegmentSyntax.self) }) { + return ".string(\(source))" + } + case .stringEnum: + if let member = expression.as(MemberAccessExprSyntax.self), member.base == nil { + return ".string(\(info.unwrappedType)\(source).rawValue)" + } + default: + break + } + return nil + } + // MARK: Code generation private static func argumentsMetadataSource(for fields: [Field]) -> String { @@ -193,12 +266,15 @@ public struct BuiltInCodeModeMacro: MemberMacro { return "static let codeModeArguments: [BuiltInToolArgument] = []" } let entries = fields.map { field -> String in - let optionalPart = field.optional ? ", optional: true" : "" + let optionalPart = (field.optional || field.defaultExpression != nil) ? ", optional: true" : "" + let defaultPart = field.defaultJSON.map { ", defaultValue: \($0)" } ?? "" switch field.kind { + case .date: + return "BuiltInToolArgument(\(stringLiteral(field.name)), .string\(optionalPart), hint: \(stringLiteral(field.hint))\(defaultPart))," case let .primitive(argumentType): - return "BuiltInToolArgument(\(stringLiteral(field.name)), .\(argumentType)\(optionalPart), hint: \(stringLiteral(field.hint)))," + return "BuiltInToolArgument(\(stringLiteral(field.name)), .\(argumentType)\(optionalPart), hint: \(stringLiteral(field.hint))\(defaultPart))," case .stringEnum: - return "BuiltInToolArgument(\(stringLiteral(field.name)), oneOf: \(field.swiftType).self\(optionalPart), hint: \(stringLiteral(field.hint)))," + return "BuiltInToolArgument(\(stringLiteral(field.name)), oneOf: \(field.swiftType).self\(optionalPart), hint: \(stringLiteral(field.hint))\(defaultPart))," } }.joined(separator: "\n ") return """ @@ -216,21 +292,31 @@ public struct BuiltInCodeModeMacro: MemberMacro { } if parsed.hasRawPassthrough { - body.append(enumFields.isEmpty ? "let raw = arguments" : "var raw = arguments") + body.append(enumFields.isEmpty && parsed.fields.allSatisfy { $0.defaultExpression == nil } ? "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)") + let method = field.optional || field.defaultExpression != nil ? "optional" : "require" + if case .date = field.kind { + body.append("let \(field.name) = try CodeModeDate.\(method)(arguments.string(\(stringLiteral(field.name))), argument: \(stringLiteral(field.name)), capability: Self.codeModeCapability.rawValue)") + } else { + let fallback = field.defaultExpression.map { " ?? \($0)" } ?? "" + body.append("let \(field.name) = try CodeModeArgumentDecoder.\(method)(\(stringLiteral(field.name)), as: \(field.swiftType).self, in: arguments)\(fallback)") + } } if parsed.hasRawPassthrough { + for field in parsed.fields where field.defaultExpression != nil { + if case .stringEnum = field.kind { continue } + body.append("raw[\(stringLiteral(field.name))] = CodeModeValueEncoder.encode(\(field.name))") + } 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") + let initArguments = parsed.initializerLabels.map { name in + if name == "raw", parsed.hasRawPassthrough { return "raw: raw" } + if name == "providedArguments", parsed.hasProvidedArguments { return "providedArguments: Set(arguments.keys)" } + return "\(name): \(name)" } body.append(initArguments.isEmpty ? "return Arguments()" : "return Arguments(\(initArguments.joined(separator: ", ")))") @@ -269,6 +355,8 @@ private struct BuiltInTypeInfo { } switch unwrappedType { + case "Date", "Foundation.Date": + kind = .date case "String": kind = .primitive("string") case "Bool": diff --git a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift index fd0f850..5350f88 100644 --- a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift +++ b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift @@ -189,3 +189,191 @@ private func builtInTestMacros() -> [String: Macro.Type] { macros: builtInTestMacros() ) } + +@Test func builtInCodeModeExpansionSupportsScalarAndEnumDefaults() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct DefaultsTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + @ToolParam("limit hint.") + var limit: Int = 50 + @ToolParam("offset hint.") + var offset: Int = -2 + @ToolParam("ratio hint.") + var ratio: Double = 0.5 + @ToolParam("fraction hint.") + var fraction: Float = 0.1 + @ToolParam("enabled hint.") + var enabled: Bool = true + @ToolParam("label hint.") + var label: String = "hello" + @ToolParam("span hint.") + var span: CalendarEventSpan = .thisEvent + } + } + """, + expandedSource: """ + struct DefaultsTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var limit: Int = 50 + var offset: Int = -2 + var ratio: Double = 0.5 + var fraction: Float = 0.1 + var enabled: Bool = true + var label: String = "hello" + var span: CalendarEventSpan = .thisEvent + } + + static let codeModeCapability: CapabilityID = .calendarRead + + static let codeModePath: String = "apple.calendar.listEvents" + + static let codeModeArguments: [BuiltInToolArgument] = [ + BuiltInToolArgument("limit", .number, optional: true, hint: "limit hint.", defaultValue: .number(Double(50))), + BuiltInToolArgument("offset", .number, optional: true, hint: "offset hint.", defaultValue: .number(Double(-2))), + BuiltInToolArgument("ratio", .number, optional: true, hint: "ratio hint.", defaultValue: .number(Double(0.5))), + BuiltInToolArgument("fraction", .number, optional: true, hint: "fraction hint.", defaultValue: .number(Double(0.1))), + BuiltInToolArgument("enabled", .bool, optional: true, hint: "enabled hint.", defaultValue: .bool(true)), + BuiltInToolArgument("label", .string, optional: true, hint: "label hint.", defaultValue: .string("hello")), + BuiltInToolArgument("span", oneOf: CalendarEventSpan.self, optional: true, hint: "span hint.", defaultValue: .string(CalendarEventSpan.thisEvent.rawValue)), + ] + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + let limit = try CodeModeArgumentDecoder.optional("limit", as: Int.self, in: arguments) ?? 50 + let offset = try CodeModeArgumentDecoder.optional("offset", as: Int.self, in: arguments) ?? -2 + let ratio = try CodeModeArgumentDecoder.optional("ratio", as: Double.self, in: arguments) ?? 0.5 + let fraction = try CodeModeArgumentDecoder.optional("fraction", as: Float.self, in: arguments) ?? 0.1 + let enabled = try CodeModeArgumentDecoder.optional("enabled", as: Bool.self, in: arguments) ?? true + let label = try CodeModeArgumentDecoder.optional("label", as: String.self, in: arguments) ?? "hello" + let span = try CodeModeArgumentDecoder.optional("span", as: CalendarEventSpan.self, in: arguments) ?? .thisEvent + return Arguments(limit: limit, offset: offset, ratio: ratio, fraction: fraction, enabled: enabled, label: label, span: span) + } + } + """, + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionSupportsDates() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct DatesTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + @ToolParam("Start timestamp.") + var start: Date + @ToolParam("End timestamp.") + var end: Foundation.Date? + var raw: [String: JSONValue] + } + } + """, + expandedSource: """ + struct DatesTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var start: Date + var end: Foundation.Date? + var raw: [String: JSONValue] + } + + static let codeModeCapability: CapabilityID = .calendarRead + + static let codeModePath: String = "apple.calendar.listEvents" + + static let codeModeArguments: [BuiltInToolArgument] = [ + BuiltInToolArgument("start", .string, hint: "Start timestamp."), + BuiltInToolArgument("end", .string, optional: true, hint: "End timestamp."), + ] + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + let raw = arguments + let start = try CodeModeDate.require(arguments.string("start"), argument: "start", capability: Self.codeModeCapability.rawValue) + let end = try CodeModeDate.optional(arguments.string("end"), argument: "end", capability: Self.codeModeCapability.rawValue) + return Arguments(start: start, end: end, raw: raw) + } + } + """, + macros: builtInTestMacros() + ) +} + +@Test(arguments: [ + ("Int?", "5", true, "var"), + ("Optional", "5", true, "var"), + ("Date", "Date()", false, "var"), + ("Int", "makeLimit()", false, "var"), + ("Int", "1 + 2", false, "var"), + ("Int", "1.5", false, "var"), + ("String", "5", false, "var"), + ("[Int]", "[1]", false, "var"), + ("String", #""\(value)""#, false, "var"), + ("Int", "50", false, "let"), +]) +func builtInCodeModeExpansionDiagnosesUnsupportedDefaults(type: String, initializer: String, optional: Bool, binding: String) { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + @ToolParam("Value hint.") + \(binding) value: \(type) = \(initializer) + } + } + """, + expandedSource: """ + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + \(binding) value: \(type) = \(initializer) + } + } + """, + diagnostics: [DiagnosticSpec( + message: binding == "let" + ? "@BuiltInCodeMode Arguments properties must be var" + : optional + ? "@ToolParam optional properties cannot have initializers; resolve dynamic defaults in call" + : "@ToolParam defaults must be a matching integer, float, bool, or string literal, or .case for a CodeModeStringEnum", + line: 5, + column: binding == "let" ? 9 : 9 + "\(binding) value: \(type) = ".count + )], + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionTracksSuppliedFieldsWithoutRawValues() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarWrite, path: "apple.calendar.updateEvent") + struct PatchTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var providedArguments: Set + @ToolParam("Notes patch.") + var notes: String? + } + } + """, + expandedSource: """ + struct PatchTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var providedArguments: Set + var notes: String? + } + + static let codeModeCapability: CapabilityID = .calendarWrite + + static let codeModePath: String = "apple.calendar.updateEvent" + + static let codeModeArguments: [BuiltInToolArgument] = [ + BuiltInToolArgument("notes", .string, optional: true, hint: "Notes patch."), + ] + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + let notes = try CodeModeArgumentDecoder.optional("notes", as: String.self, in: arguments) + return Arguments(providedArguments: Set(arguments.keys), notes: notes) + } + } + """, + macros: builtInTestMacros() + ) +} diff --git a/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift index 9798399..3d82b2d 100644 --- a/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift +++ b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift @@ -11,8 +11,6 @@ import Testing ]) #expect(decoded.identifier == "E1") #expect(decoded.span == .futureEvents) - #expect(decoded.raw["span"] == .string("futureEvents")) - #expect(decoded.raw["identifier"] == .string("E1")) } @Test func macroGeneratedDecodeRejectsMissingRequiredAndUnknownConstrainedValues() { @@ -48,3 +46,159 @@ import Testing #expect(registration.descriptor.argumentConstraints.allowedStringValues["operation"] == ["create", "update", "complete"]) #expect(registration.descriptor.argumentHints["identifier"] == "EventKit calendarItemIdentifier to update or complete.") } + +private enum DefaultFixtureMode: String, CodeModeStringEnum { + case selected = "wire-value" + static let codeModeAliases: [String: Self] = ["alias": .selected] +} + +// A compiled consumer of every default kind. Expansion snapshots cannot catch +// type inference failures or incorrect advertised defaults. +@BuiltInCodeMode(.calendarRead, path: "apple.calendar.defaultFixture") +private struct DefaultFixtureTool: BuiltInCodeModeTool { + static let codeModeTitle = "Defaults fixture" + static let codeModeSummary = "Exercise generated decoding." + static let codeModeTags: [String] = [] + static let codeModeExample = "" + static let codeModeResultSummary = "JSON value" + + struct Arguments: Sendable { + @ToolParam("Limit.") var limit: Int = -5 + @ToolParam("Ratio.") var ratio: Double = 0.5 + @ToolParam("Fraction.") var fraction: Float = 0.1 + @ToolParam("Enabled.") var enabled: Bool = true + @ToolParam("Label.") var label: String = "hello" + @ToolParam("Media type.") var mediaType: String = "image/png" + @ToolParam("Mode.") var mode: DefaultFixtureMode = .selected + @ToolParam("Start.") var start: Date + @ToolParam("End.") var end: Date? + var raw: [String: JSONValue] + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { .null } +} + +@Test func generatedDefaultsStayTypedAndAdvertiseTheirJSONValues() throws { + let tool = DefaultFixtureTool() + let decoded = try tool.decode(arguments: ["start": .string("2026-07-24T10:00:00.000Z")]) + #expect(decoded.limit == -5) + #expect(decoded.ratio == 0.5) + #expect(decoded.fraction == 0.1) + #expect(decoded.enabled) + #expect(decoded.label == "hello") + #expect(decoded.mediaType == "image/png") + #expect(decoded.mode == .selected) + #expect(decoded.start == Date(timeIntervalSince1970: 1_784_887_200)) + #expect(decoded.end == nil) + // Defaults must reach transitional raw bridges as well as typed consumers. + #expect(decoded.raw["limit"] == .number(-5)) + #expect(decoded.raw["mode"] == .string("wire-value")) + + let fields = Dictionary(uniqueKeysWithValues: DefaultFixtureTool.codeModeArguments.map { ($0.name, $0) }) + let expected: [String: JSONValue] = [ + "limit": .number(-5), "ratio": .number(0.5), "fraction": .number(0.1), + "enabled": .bool(true), "label": .string("hello"), "mode": .string("wire-value"), + "mediaType": .string("image/png"), + ] + for (name, value) in expected { + #expect(fields[name]?.defaultValue == value) + #expect(fields[name]?.optional == true) + } + #expect(fields["label"]?.hint == "Label. Default: \"hello\".") + #expect(fields["mode"]?.hint == "Mode. Default: \"wire-value\".") + #expect(fields["limit"]?.hint == "Limit. Default: -5.") + #expect(fields["fraction"]?.hint == "Fraction. Default: 0.1.") + #expect(fields["mediaType"]?.hint == "Media type. Default: \"image/png\".") + #expect(CapabilityRegistration(tool: tool).descriptor.requiredArguments == ["start"]) + + let overridden = try tool.decode(arguments: [ + "start": .string("2026-07-24T11:00:00+01:00"), "limit": .number(8), + "enabled": .bool(false), "label": .string("override"), "mode": .string("ALIAS"), + ]) + #expect(overridden.start == decoded.start) + #expect(overridden.limit == 8) + #expect(overridden.enabled == false) + #expect(overridden.label == "override") + #expect(overridden.mode == .selected) +} + +@Test func generatedRequiredDateUsesTheBridgeErrorPath() throws { + for value: JSONValue? in [nil, .string(""), .string("soon")] { + let arguments = value.map { ["start": $0] } ?? [:] + do { + _ = try DefaultFixtureTool().decode(arguments: arguments) + Issue.record("Expected a required date error") + } catch let error as BridgeError { + #expect(error.diagnosticCode == "INVALID_ARGUMENTS") + #expect(error.localizedDescription == (value == .string("soon") + ? "calendar.read could not parse 'start' as an ISO 8601 timestamp: \"soon\". Use a form like 2026-07-24T10:00:00Z." + : "calendar.read requires 'start'")) + } + } +} + +@Test func invalidDynamicPermissionArgumentsFailBeforeRequestingAccess() throws { + let tool = CalendarWriteEventTool(eventKit: EventKitBridge()) + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker( + statuses: [.calendarWriteOnly: .notDetermined], requestStatuses: [.calendarWriteOnly: .granted] + )) + defer { cleanup(sandbox) } + // Exercise the generated handler directly: registry validation already + // rejects this, but argument-dependent hooks must remain safe on their own. + #expect(throws: CodeModeFunctionError.invalidArguments("Argument operation must be one of create, update")) { + _ = try CapabilityRegistration(tool: tool).handler(["operation": .string("invalid")], context) + } + #expect(context.allPermissionEvents().isEmpty) +} + +@Test func resolvingWriteOnlyStatusDoesNotChangeItsMeaningUntilRequired() throws { + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker(statuses: [ + .calendarWriteOnly: .writeOnly, .calendar: .writeOnly, + ])) + defer { cleanup(sandbox) } + #expect(context.resolvedPermission(for: .calendarWriteOnly) == .writeOnly) + #expect(context.isPermissionValidated(.calendarWriteOnly) == false) + try context.requirePermission(.calendarWriteOnly) + #expect(context.isPermissionValidated(.calendarWriteOnly)) + // Requiring a permission must always observe and record its current status. + try context.requirePermission(.calendarWriteOnly) + #expect(context.allPermissionEvents().map(\.status) == [.writeOnly, .writeOnly, .writeOnly]) + + do { + try context.requirePermission(.calendar) + Issue.record("Write-only access must not satisfy full calendar access") + } catch let error as BridgeError { + #expect(error.diagnosticCode == "PERMISSION_DENIED") + #expect(error.localizedDescription == "Permission denied: calendar") + #expect(context.isPermissionValidated(.calendar) == false) + } +} + +@Test(arguments: [ + (CapabilityID.locationPermissionRequest, PermissionKind.locationWhenInUse), + (.notificationsPermissionRequest, .notifications), + (.alarmPermissionRequest, .alarmKit), + (.musicPermissionRequest, .music), + (.speechPermissionRequest, .speechRecognition), +]) +func permissionRequestHelpersReportDenialWithoutBeingPrechecked(capability: CapabilityID, permission: PermissionKind) throws { + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker( + statuses: [permission: .denied], requestStatuses: [permission: .denied] + )) + defer { cleanup(sandbox) } + let result = try registry.invoke(capability.rawValue, arguments: [:], context: context) + #expect((result.stringValue ?? result.objectValue?.string("status")) == "denied") + #expect(context.allPermissionEvents().map(\.status) == [.denied]) +} + +@Test func speechStatusDoesNotRequestOrRequirePermission() throws { + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker( + statuses: [.speechRecognition: .notDetermined], requestStatuses: [.speechRecognition: .granted] + )) + defer { cleanup(sandbox) } + let result = try registry.invoke(CapabilityID.speechStatus.rawValue, arguments: [:], context: context) + #expect(result.objectValue?.string("status") == "notDetermined") + #expect(context.allPermissionEvents().map(\.status) == [.notDetermined]) +} diff --git a/Tests/CodeModeTests/EventKitBridgeTests.swift b/Tests/CodeModeTests/EventKitBridgeTests.swift index da6c32a..cb8c77d 100644 --- a/Tests/CodeModeTests/EventKitBridgeTests.swift +++ b/Tests/CodeModeTests/EventKitBridgeTests.swift @@ -3,7 +3,7 @@ import Testing @testable import CodeMode @Test func eventKitCalendarOperationsRequirePermission() throws { - let bridge = EventKitBridge() + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) let broker = FixedPermissionBroker(statuses: [ .calendar: .denied, .calendarWriteOnly: .denied, @@ -12,21 +12,21 @@ import Testing defer { cleanup(sandbox) } do { - _ = try bridge.readEvents(arguments: [:], context: context) + _ = try registry.invoke("calendar.read", arguments: [:], context: context) Issue.record("Expected permission denial for calendar.read") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } do { - _ = try bridge.writeEvent(arguments: [:], context: context) + _ = try registry.invoke("calendar.write", arguments: [:], context: context) Issue.record("Expected permission denial for calendar.write") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } do { - _ = try bridge.writeEvent(arguments: [ + _ = try registry.invoke("calendar.write", arguments: [ "operation": .string("update"), "identifier": .string("event-1"), "title": .string("Updated"), @@ -37,7 +37,7 @@ import Testing } do { - _ = try bridge.deleteEvent(arguments: ["identifier": .string("event-1")], context: context) + _ = try registry.invoke("calendar.delete", arguments: ["identifier": .string("event-1")], context: context) Issue.record("Expected permission denial for calendar.delete") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") @@ -45,27 +45,27 @@ import Testing } @Test func eventKitReminderOperationsRequirePermission() throws { - let bridge = EventKitBridge() + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) let broker = FixedPermissionBroker(statuses: [.reminders: .denied]) let (context, sandbox) = try makeInvocationContext(permissionBroker: broker) defer { cleanup(sandbox) } do { - _ = try bridge.readReminders(arguments: [:], context: context) + _ = try registry.invoke("reminders.read", arguments: [:], context: context) Issue.record("Expected permission denial for reminders.read") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } do { - _ = try bridge.writeReminder(arguments: [:], context: context) + _ = try registry.invoke("reminders.write", arguments: [:], context: context) Issue.record("Expected permission denial for reminders.write") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } do { - _ = try bridge.deleteReminder(arguments: ["identifier": .string("reminder-1")], context: context) + _ = try registry.invoke("reminders.delete", arguments: ["identifier": .string("reminder-1")], context: context) Issue.record("Expected permission denial for reminders.delete") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") @@ -91,10 +91,11 @@ import Testing #expect(observed.error?.code == "PERMISSION_DENIED") } -@Test func executeCalendarWriteUsesWriteOnlyPermissionPath() async throws { +@Test(arguments: [PermissionStatus.writeOnly, .notDetermined]) +func executeCalendarWriteUsesWriteOnlyPermissionPath(initialStatus: PermissionStatus) async throws { let broker = FixedPermissionBroker( - statuses: [.calendarWriteOnly: .notDetermined], - requestStatuses: [.calendarWriteOnly: .granted] + statuses: [.calendarWriteOnly: initialStatus], + requestStatuses: [.calendarWriteOnly: .writeOnly] ) let (tools, sandbox) = try makeTools(permissionBroker: broker) defer { cleanup(sandbox) } @@ -112,6 +113,7 @@ import Testing #if canImport(EventKit) #expect(observed.error?.code == "INVALID_ARGUMENTS") + #expect(observed.error?.message == "calendar.write create requires title/start/end ISO8601") #else #expect(observed.error?.code == "UNSUPPORTED_PLATFORM") #endif @@ -176,3 +178,163 @@ import EventKit #expect(json.bool("isCompleted") == false) } #endif + +@Test func eventKitOmittedArgumentsUseDeclaredDefaults() throws { + let bridge = EventKitBridge() + let events = try CalendarListEventsTool(eventKit: bridge).decode(arguments: [:]) + #expect(events.limit == 50) + #expect(events.start == nil) + #expect(events.end == nil) + let reminders = try RemindersListTool(eventKit: bridge).decode(arguments: [:]) + #expect(reminders.limit == 50) + #expect(reminders.includeCompleted == false) + let deletion = try CalendarDeleteEventTool(eventKit: bridge).decode(arguments: ["identifier": .string("E1")]) + #expect(deletion.span == .thisEvent) + + let overrides = try RemindersListTool(eventKit: bridge).decode(arguments: ["limit": .number(3), "includeCompleted": .bool(true)]) + #expect(overrides.limit == 3) + #expect(overrides.includeCompleted) + let blank = try CalendarListEventsTool(eventKit: bridge).decode(arguments: ["start": .string(" "), "end": .string("")]) + #expect(blank.start == nil) + #expect(blank.end == nil) +} + +@Test(arguments: [ + ("calendar.read", "start"), ("calendar.read", "end"), + ("reminders.read", "start"), ("reminders.read", "end"), +]) +func eventKitReadDateErrorsRetainScriptMessages(capability: String, argument: String) throws { + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker(statuses: [ + .calendar: .granted, .reminders: .granted, + ])) + defer { cleanup(sandbox) } + do { + _ = try registry.invoke(capability, arguments: [argument: .string("soon")], context: context) + Issue.record("Expected an invalid timestamp error") + } catch let error as BridgeError { + #expect(error.diagnosticCode == "INVALID_ARGUMENTS") + #expect(error.localizedDescription == "\(capability) could not parse '\(argument)' as an ISO 8601 timestamp: \"soon\". Use a form like 2026-07-24T10:00:00Z.") + } +} + +@Test(arguments: [ + (nil, nil, PermissionKind.calendarWriteOnly), + (nil, "E1", .calendar), + (nil, "", .calendar), + ("create", "E1", .calendarWriteOnly), + ("update", nil, .calendar), + ("UPDATE", "E1", .calendar), +]) +func eventKitWriteSelectsPermissionFromOperation(operation: String?, identifier: String?, expected: PermissionKind) throws { + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker(statuses: [ + .calendar: .denied, .calendarWriteOnly: .denied, + ])) + defer { cleanup(sandbox) } + var arguments: [String: JSONValue] = [:] + arguments["operation"] = operation.map(JSONValue.string) + arguments["identifier"] = identifier.map(JSONValue.string) + do { + _ = try registry.invoke("calendar.write", arguments: arguments, context: context) + Issue.record("Expected operation-specific permission denial") + } catch let error as BridgeError { + #expect(error.diagnosticCode == "PERMISSION_DENIED") + #expect(error.localizedDescription == "Permission denied: \(expected.rawValue)") + #expect(context.allPermissionEvents().map(\.permission) == [expected]) + } +} + +#if canImport(EventKit) +@Test func eventUpdatePatchDistinguishesNullFromOmission() throws { + let bridge = EventKitBridge() + let tool = CalendarWriteEventTool(eventKit: bridge) + let store = EKEventStore() + let event = EKEvent(eventStore: store) + event.title = "Keep title" + event.notes = "Keep notes" + event.location = "Keep location" + event.url = URL(string: "https://example.com") + event.isAllDay = true + let start = Date(timeIntervalSince1970: 1_700_000_000) + event.startDate = start + event.endDate = start.addingTimeInterval(3_600) + // EventKit normalizes all-day dates itself; compare its stored values. + let originalStart = event.startDate + let originalEnd = event.endDate + + let omitted = try tool.decode(arguments: ["identifier": .string("E1")]) + try bridge.applyEventPatch(arguments: omitted, to: event, in: store) + #expect(event.notes == "Keep notes") + #expect(event.location == "Keep location") + #expect(event.url?.absoluteString == "https://example.com") + #expect(event.isAllDay) + #expect(event.startDate == originalStart) + #expect(event.endDate == originalEnd) + + let cleared = try tool.decode(arguments: [ + "identifier": .string("E1"), "notes": .null, "location": .null, + "url": .null, "isAllDay": .null, + ]) + try bridge.applyEventPatch(arguments: cleared, to: event, in: store) + #expect(event.notes == nil) + #expect(event.location == nil) + #expect(event.url == nil) + #expect(event.isAllDay == false) + #expect(event.title == "Keep title") +} + +@Test func reminderUpdatePatchDistinguishesNullFromOmission() throws { + let bridge = EventKitBridge() + let tool = RemindersWriteTool(eventKit: bridge) + let store = EKEventStore() + let reminder = EKReminder(eventStore: store) + reminder.title = "Keep title" + reminder.notes = "Keep notes" + reminder.priority = 7 + let date = Date(timeIntervalSince1970: 1_700_000_000) + reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: date) + reminder.isCompleted = true + reminder.completionDate = date + + let omitted = try tool.decode(arguments: ["identifier": .string("R1")]) + try bridge.applyReminderPatch(arguments: omitted, to: reminder, in: store) + #expect(reminder.notes == "Keep notes") + #expect(reminder.dueDateComponents != nil) + #expect(reminder.isCompleted) + #expect(reminder.completionDate == date) + #expect(reminder.priority == 7) + + let cleared = try tool.decode(arguments: [ + "identifier": .string("R1"), "notes": .null, "dueDate": .null, + "isCompleted": .null, "priority": .null, + ]) + try bridge.applyReminderPatch(arguments: cleared, to: reminder, in: store) + #expect(reminder.notes == nil) + #expect(reminder.dueDateComponents == nil) + #expect(reminder.isCompleted == false) + #expect(reminder.completionDate == nil) + #expect(reminder.priority == 7) + #expect(reminder.title == "Keep title") +} + +@Test(arguments: ["start", "end", "dueDate"]) +func eventKitWriteDateErrorsRetainTheirOperationSpecificMessages(argument: String) throws { + let bridge = EventKitBridge() + let store = EKEventStore() + do { + if argument == "dueDate" { + let decoded = try RemindersWriteTool(eventKit: bridge).decode(arguments: [argument: .string("soon")]) + try bridge.applyReminderPatch(arguments: decoded, to: EKReminder(eventStore: store), in: store) + } else { + let decoded = try CalendarWriteEventTool(eventKit: bridge).decode(arguments: [argument: .string("soon")]) + try bridge.applyEventPatch(arguments: decoded, to: EKEvent(eventStore: store), in: store) + } + Issue.record("Expected an invalid patch date") + } catch let error as BridgeError { + let capability = argument == "dueDate" ? "reminders.write" : "calendar.write" + #expect(error.diagnosticCode == "INVALID_ARGUMENTS") + #expect(error.localizedDescription == "\(capability) \(argument) must be ISO8601 when provided") + } +} +#endif diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index d28571b..68cd6db 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -582,7 +582,7 @@ }, "argumentHints" : { "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", - "span" : "thisEvent (default) or futureEvents for recurring events." + "span" : "thisEvent or futureEvents for recurring events. Default: \"thisEvent\"." }, "argumentTypes" : { "identifier" : "string", @@ -620,7 +620,7 @@ "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.", + "limit" : "Max number of items. Default: 50.", "start" : "ISO8601 timestamp; defaults to now." }, "argumentTypes" : { @@ -4257,8 +4257,8 @@ "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.", + "includeCompleted" : "Whether completed reminders are included. Default: false.", + "limit" : "Max number of reminder items. Default: 50.", "start" : "Optional ISO8601 due-date lower bound." }, "argumentTypes" : { From 682874ac4353cf2ac90da462232fa28b34e5be3d Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 21 Oct 2026 23:39:34 -0700 Subject: [PATCH 6/9] Record the typed-results design for built-in tools Schema derives from Swift types at runtime so shared and nested result structs compose; a @ToolResult macro generates the encoder and the schema from one declaration; the declarations render a real Promise type. JSON a script receives is unchanged. --- PLAN-typed-results.md | 88 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 PLAN-typed-results.md diff --git a/PLAN-typed-results.md b/PLAN-typed-results.md new file mode 100644 index 0000000..2506291 --- /dev/null +++ b/PLAN-typed-results.md @@ -0,0 +1,88 @@ +# Plan: typed results for built-in tools + +Status: decided. Implemented on `typed-bridges` for the EventKit family first, then with each +family's typed-arguments migration. + +## Problem + +Every helper is declared `(…): Promise`. The only description of what comes back +is `codeModeResultSummary`, a hand-written sentence (120 of them, 111 distinct) that nothing +checks against the JSON the bridge actually builds. Models guess field names from prose, a +host's `tsc` cannot catch a wrong one, and the summary drifts from the code the first time +someone renames a key. + +## Shapes in use today + +From the 120 summaries and the bridges behind them: + +- an object with fixed fields, some present only "when available" (the common case); +- an array of such objects (every list helper); +- a string-keyed map of values (Vision's per-feature sections, form values keyed by field id); +- a nullable object (`keychain.get` returns null for a missing key); +- a handful of genuine unions (`location.read`: a status string or a coordinates object). + +Unions stay `CodeModeValue` with prose. Everything else gets a schema. + +## Design + +**Schema is derived from Swift types at runtime, not parsed by the macro.** The macro is +syntactic and cannot see a struct declared elsewhere, but results are shared across tools +(`CalendarEvent` is returned by list, create and update) and nested (an array of events). So: + +```swift +indirect enum CodeModeResultSchema: Sendable, Codable, Equatable { + case string, number, bool, any + case nullable(CodeModeResultSchema) + case array(CodeModeResultSchema) + case dictionary(CodeModeResultSchema) + case object([Field]) + struct Field: Sendable, Codable, Equatable { var name: String; var schema: CodeModeResultSchema; var hint: String? } +} + +protocol CodeModeResultDescribable: CodeModeJSONEncodable { + static var codeModeResultSchema: CodeModeResultSchema { get } +} +``` + +Conformances: `String`, `Int`, `Double`, `Float`, `Bool`, `Date` (an ISO 8601 string), `JSONValue` +(`.any`), `Optional` (`.nullable`), `Array`, `Dictionary`. Composition is by the type +system, so `[CalendarEvent]?` describes itself. + +**`@ToolResult` marks a result struct.** It generates `codeModeJSONValue` (an object of the +encoded stored properties, in declaration order) and `codeModeResultSchema` (an `.object` of +fields). An optional `@ToolField("hint")` on a property becomes the field's doc comment. Field +types must themselves be `CodeModeResultDescribable`; the compiler enforces it. One struct can +serve several tools. + +**The tool protocol gains a typed result.** + +```swift +protocol BuiltInCodeModeTool { + associatedtype Result: CodeModeResultDescribable + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> Result +} +``` + +The registration handler encodes `result.codeModeJSONValue`. The descriptor carries +`Result.codeModeResultSchema` and a summary derived from it. `codeModeResultSummary` becomes a +protocol extension default rendered from the schema; a tool still returning `JSONValue` +(schema `.any`) overrides it with its existing prose. That is the migration seam: unmigrated +tools compile unchanged, apart from the derived default they already override. + +**The catalog and the public surface.** `CapabilityDescriptor` and `JavaScriptAPIReference` gain +`resultSchema: CodeModeResultSchema?`, decoded with a default so earlier encodings still load. +`TypeScriptDeclarations` renders it as the promise's type argument: an object literal type with +`?` for nullable fields, `Array<…>`, `{ [key: string]: … }`, `| null`. The derived summary is a +one-line rendering of the same type, for search results. + +**Behaviour preservation.** The JSON a script receives does not change: same keys, same values, +same `""` where the bridge coalesces a missing string today. Whether those should become `null` +(and therefore `string | null` in the declaration) is a separate, model-facing decision; it is +deferred, and the schema makes it a one-line change per field when taken. + +## What this is not + +- Not a change to the host-facing `@CodeMode` macro, which already has a `Result` struct. Aligning + the two macros on one implementation is desirable and later. +- Not result validation at runtime. The encoder is generated from the same struct the schema + comes from, so they cannot disagree. From 5b2e90f434955778c4d66f9dd1df708d85158a9b Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 22 Oct 2026 00:47:47 -0700 Subject: [PATCH 7/9] Derive tool titles from the path; drop codeModeTitle Every built-in tool wrote a `codeModeTitle`, 120 of them, and no model ever saw one: `JavaScriptAPIReference` carries no title, and neither search nor the TypeScript declarations use it. The descriptor's title is now the last path segment, as the host-facing macro already does, and the protocol no longer asks for one. The golden baseline changes by exactly those 120 lines. --- .../Bridges/BuiltInCodeModeMacros.swift | 2 +- .../Bridges/BuiltInCodeModeTool.swift | 5 +- .../CloudPushSpeechCodeModeTools.swift | 15 -- .../Bridges/CommerceCodeModeTools.swift | 18 -- .../Bridges/FileSystemCodeModeTools.swift | 10 - ...tentsModelsActivityMapsCodeModeTools.swift | 18 -- .../PeoplePhotosDocumentsCodeModeTools.swift | 13 - .../SimpleBuiltInCodeModeProviders.swift | 6 - .../Bridges/SystemServicesCodeModeTools.swift | 19 -- .../Bridges/SystemUICodeModeTools.swift | 12 - .../capability-metadata-golden.json | 240 +++++++++--------- 11 files changed, 123 insertions(+), 235 deletions(-) diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift index c48503c..4f3e6be 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift @@ -2,7 +2,7 @@ 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, +/// `Arguments` struct. Curated metadata (summary, tags, example, /// permissions, result summary) and `call(arguments:context:)` stay /// hand-written. /// diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift index c8a276a..80b3880 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -62,9 +62,8 @@ protocol BuiltInCodeModeTool: 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`. + /// metadata). 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 } @@ -99,7 +98,7 @@ extension CapabilityRegistration { jsNames: [Tool.codeModePath] + Tool.codeModeAliasPaths, descriptor: CapabilityDescriptor( id: Tool.codeModeCapability, - title: Tool.codeModeTitle, + title: Tool.codeModePath.split(separator: ".").last.map(String.init) ?? Tool.codeModePath, summary: Tool.codeModeSummary, tags: Tool.codeModeTags, example: Tool.codeModeExample, diff --git a/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift b/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift index c670cb6..ac9cedd 100644 --- a/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift @@ -11,7 +11,6 @@ enum CloudKitDatabase: String, CodeModeStringEnum { @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' })" @@ -32,7 +31,6 @@ struct CloudKitAccountStatusTool: BuiltInCodeModeTool { @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 })" @@ -67,7 +65,6 @@ struct CloudKitRecordsQueryTool: BuiltInCodeModeTool { @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' } })" @@ -100,7 +97,6 @@ struct CloudKitRecordSaveTool: BuiltInCodeModeTool { @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' })" @@ -127,7 +123,6 @@ struct CloudKitRecordDeleteTool: BuiltInCodeModeTool { @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' })" @@ -164,7 +159,6 @@ struct CloudKitSubscriptionSaveTool: BuiltInCodeModeTool { @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 })" @@ -191,7 +185,6 @@ struct CloudKitSubscriptionEventsReadTool: BuiltInCodeModeTool { @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()" @@ -212,7 +205,6 @@ struct NotificationsRemoteRegisterTool: BuiltInCodeModeTool { @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()" @@ -231,7 +223,6 @@ struct NotificationsRemoteTokenReadTool: BuiltInCodeModeTool { @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()" @@ -250,7 +241,6 @@ struct NotificationsSettingsReadTool: BuiltInCodeModeTool { @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' }] }] })" @@ -271,7 +261,6 @@ struct NotificationsCategoriesSetTool: BuiltInCodeModeTool { @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 })" @@ -300,7 +289,6 @@ struct NotificationsResponsesReadTool: BuiltInCodeModeTool { @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()" @@ -317,7 +305,6 @@ struct SpeechPermissionRequestTool: BuiltInCodeModeTool { @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()" @@ -334,7 +321,6 @@ struct SpeechStatusTool: BuiltInCodeModeTool { @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 })" @@ -364,7 +350,6 @@ struct SpeechFileTranscribeTool: BuiltInCodeModeTool { @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 })" diff --git a/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift b/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift index deb7c4a..8f974f7 100644 --- a/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift @@ -16,7 +16,6 @@ enum MusicPlaybackAction: String, CodeModeStringEnum { @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()" @@ -33,7 +32,6 @@ struct MusicPermissionRequestTool: BuiltInCodeModeTool { @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()" @@ -52,7 +50,6 @@ struct MusicSubscriptionStatusTool: BuiltInCodeModeTool { @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 })" @@ -79,7 +76,6 @@ struct MusicCatalogSearchTool: BuiltInCodeModeTool { @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' })" @@ -104,7 +100,6 @@ struct MusicCatalogDetailsTool: BuiltInCodeModeTool { @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 })" @@ -128,7 +123,6 @@ struct MusicLibraryReadTool: BuiltInCodeModeTool { @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'] })" @@ -160,7 +154,6 @@ struct MusicPlaylistWriteTool: BuiltInCodeModeTool { @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' })" @@ -192,7 +185,6 @@ struct MusicPlaybackControlTool: BuiltInCodeModeTool { @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()" @@ -211,7 +203,6 @@ struct PassKitWalletStatusTool: BuiltInCodeModeTool { @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 })" @@ -236,7 +227,6 @@ struct PassKitPassesReadTool: BuiltInCodeModeTool { @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' })" @@ -259,7 +249,6 @@ struct PassKitPassAddTool: BuiltInCodeModeTool { @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' })" @@ -282,7 +271,6 @@ struct PassKitPassPresentTool: BuiltInCodeModeTool { @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()" @@ -303,7 +291,6 @@ struct PassKitApplePayStatusTool: BuiltInCodeModeTool { @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 })" @@ -330,7 +317,6 @@ struct PassKitApplePayPresentTool: BuiltInCodeModeTool { @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'] })" @@ -351,7 +337,6 @@ struct StoreKitProductsReadTool: BuiltInCodeModeTool { @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()" @@ -370,7 +355,6 @@ struct StoreKitEntitlementsReadTool: BuiltInCodeModeTool { @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 })" @@ -395,7 +379,6 @@ struct StoreKitPurchaseTool: BuiltInCodeModeTool { @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 })" @@ -416,7 +399,6 @@ struct StoreKitRestoreTool: BuiltInCodeModeTool { @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 })" diff --git a/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift b/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift index ca296cb..daaec97 100644 --- a/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift @@ -14,7 +14,6 @@ enum FileEncoding: String, CodeModeStringEnum { @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:' })" @@ -35,7 +34,6 @@ struct FSListTool: BuiltInCodeModeTool { @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' })" @@ -58,7 +56,6 @@ struct FSReadTool: BuiltInCodeModeTool { @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}' })" @@ -83,7 +80,6 @@ struct FSWriteTool: BuiltInCodeModeTool { @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. Fails rather than clobbering an existing destination unless overwrite is set, and never accepts a sandbox root as the destination." static let codeModeTags = ["filesystem", "io", "fs"] static let codeModeExample = "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })" @@ -110,7 +106,6 @@ struct FSMoveTool: BuiltInCodeModeTool { @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. Fails rather than clobbering an existing destination unless overwrite is set, and never accepts a sandbox root as the destination." static let codeModeTags = ["filesystem", "io", "fs"] static let codeModeExample = "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })" @@ -137,7 +132,6 @@ struct FSCopyTool: BuiltInCodeModeTool { @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' })" @@ -160,7 +154,6 @@ struct FSDeleteTool: BuiltInCodeModeTool { @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' })" @@ -181,7 +174,6 @@ struct FSStatTool: BuiltInCodeModeTool { @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 })" @@ -204,7 +196,6 @@ struct FSMkdirTool: BuiltInCodeModeTool { @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' })" @@ -225,7 +216,6 @@ struct FSExistsTool: BuiltInCodeModeTool { @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' })" diff --git a/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift b/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift index e125b3b..fe1bd08 100644 --- a/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift @@ -18,7 +18,6 @@ enum MapsTransportType: String, CodeModeStringEnum { @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()" @@ -41,7 +40,6 @@ struct AppIntentsListTool: BuiltInCodeModeTool { @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' } })" @@ -66,7 +64,6 @@ struct AppIntentsRunTool: BuiltInCodeModeTool { @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' } })" @@ -89,7 +86,6 @@ struct AppIntentsDonateTool: BuiltInCodeModeTool { @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' } })" @@ -112,7 +108,6 @@ struct AppIntentsOpenTool: BuiltInCodeModeTool { @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 })" @@ -139,7 +134,6 @@ struct AppIntentsHandoffsReadTool: BuiltInCodeModeTool { @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()" @@ -158,7 +152,6 @@ struct FoundationModelsStatusTool: BuiltInCodeModeTool { @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 })" @@ -191,7 +184,6 @@ struct FoundationModelsGenerateTool: BuiltInCodeModeTool { @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' })" @@ -222,7 +214,6 @@ struct FoundationModelsExtractTool: BuiltInCodeModeTool { @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()" @@ -245,7 +236,6 @@ struct ActivityListTool: BuiltInCodeModeTool { @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: {} })" @@ -274,7 +264,6 @@ struct ActivityStartTool: BuiltInCodeModeTool { @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 } })" @@ -301,7 +290,6 @@ struct ActivityUpdateTool: BuiltInCodeModeTool { @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' })" @@ -326,7 +314,6 @@ struct ActivityEndTool: BuiltInCodeModeTool { @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' })" @@ -349,7 +336,6 @@ struct ActivityPushTokenReadTool: BuiltInCodeModeTool { @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 })" @@ -374,7 +360,6 @@ struct MapsGeocodeTool: BuiltInCodeModeTool { @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 })" @@ -399,7 +384,6 @@ struct MapsReverseGeocodeTool: BuiltInCodeModeTool { @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 })" @@ -426,7 +410,6 @@ struct MapsSearchTool: BuiltInCodeModeTool { @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' })" @@ -455,7 +438,6 @@ struct MapsRouteEstimateTool: BuiltInCodeModeTool { @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' })" diff --git a/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift b/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift index e51e78b..e36538e 100644 --- a/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift @@ -11,7 +11,6 @@ enum ContactPickerMode: String, CodeModeStringEnum { @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 })" @@ -35,7 +34,6 @@ struct ContactsReadTool: BuiltInCodeModeTool { @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 })" @@ -59,7 +57,6 @@ struct ContactsSearchTool: BuiltInCodeModeTool { @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'] })" @@ -84,7 +81,6 @@ struct ContactsUIPickTool: BuiltInCodeModeTool { @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 })" @@ -114,7 +110,6 @@ struct ContactsUIPresentContactTool: BuiltInCodeModeTool { @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'] })" @@ -148,7 +143,6 @@ struct ContactsUIPresentNewContactTool: BuiltInCodeModeTool { @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 })" @@ -172,7 +166,6 @@ struct PhotosReadTool: BuiltInCodeModeTool { @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' })" @@ -196,7 +189,6 @@ struct PhotosExportTool: BuiltInCodeModeTool { @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' })" @@ -223,7 +215,6 @@ struct PhotosUIPickTool: BuiltInCodeModeTool { @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()" @@ -246,7 +237,6 @@ struct PhotosUIPresentLimitedLibraryPickerTool: BuiltInCodeModeTool { @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' })" @@ -273,7 +263,6 @@ struct DocumentsUIPickTool: BuiltInCodeModeTool { @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' })" @@ -300,7 +289,6 @@ struct DocumentsUIExportTool: BuiltInCodeModeTool { @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' })" @@ -327,7 +315,6 @@ struct DocumentsUIOpenInTool: BuiltInCodeModeTool { @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' })" diff --git a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift index de7e0b1..94276ab 100644 --- a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift +++ b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift @@ -36,7 +36,6 @@ private struct KeychainReadTool: BuiltInCodeModeTool { 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." static let codeModeTags = ["security", "token", "keychain"] static let codeModeExample = "await apple.keychain.get({ key: 'auth_token' })" @@ -64,7 +63,6 @@ private struct KeychainWriteTool: BuiltInCodeModeTool { 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." static let codeModeTags = ["security", "token", "keychain"] static let codeModeExample = "await apple.keychain.set({ key: 'auth_token', value: token })" @@ -99,7 +97,6 @@ private struct KeychainDeleteTool: BuiltInCodeModeTool { 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." static let codeModeTags = ["security", "token", "keychain"] static let codeModeExample = "await apple.keychain.delete({ key: 'auth_token' })" @@ -127,7 +124,6 @@ private struct LocationReadTool: BuiltInCodeModeTool { 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()" @@ -160,7 +156,6 @@ private struct LocationPermissionRequestTool: BuiltInCodeModeTool { 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." static let codeModeTags = ["location", "permission"] static let codeModeExample = "await apple.location.requestPermission()" @@ -186,7 +181,6 @@ private struct WeatherReadTool: BuiltInCodeModeTool { 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." static let codeModeTags = ["weather", "forecast", "weatherkit"] static let codeModeExample = "await apple.weather.getCurrentWeather({ latitude: 37.77, longitude: -122.41 })" diff --git a/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift b/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift index 0258ef7..61afe69 100644 --- a/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift @@ -10,7 +10,6 @@ import Foundation @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 })" @@ -37,7 +36,6 @@ struct VisionImageAnalyzeTool: BuiltInCodeModeTool { @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()" @@ -54,7 +52,6 @@ struct NotificationsPermissionRequestTool: BuiltInCodeModeTool { @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 })" @@ -98,7 +95,6 @@ struct NotificationsScheduleTool: BuiltInCodeModeTool { @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 })" @@ -120,7 +116,6 @@ struct NotificationsPendingReadTool: BuiltInCodeModeTool { @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'] })" @@ -144,7 +139,6 @@ struct NotificationsPendingDeleteTool: BuiltInCodeModeTool { @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 })" @@ -166,7 +160,6 @@ struct NotificationsDeliveredReadTool: BuiltInCodeModeTool { @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'] })" @@ -192,7 +185,6 @@ struct NotificationsDeliveredDeleteTool: BuiltInCodeModeTool { @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()" @@ -209,7 +201,6 @@ struct AlarmPermissionRequestTool: BuiltInCodeModeTool { @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 })" @@ -231,7 +222,6 @@ struct AlarmReadTool: BuiltInCodeModeTool { @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 })" @@ -259,7 +249,6 @@ struct AlarmScheduleTool: BuiltInCodeModeTool { @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'] })" @@ -285,7 +274,6 @@ struct AlarmCancelTool: BuiltInCodeModeTool { @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'] })" @@ -308,7 +296,6 @@ struct HealthPermissionRequestTool: BuiltInCodeModeTool { @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' })" @@ -337,7 +324,6 @@ struct HealthReadTool: BuiltInCodeModeTool { @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' })" @@ -368,7 +354,6 @@ struct HealthWriteTool: BuiltInCodeModeTool { @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 })" @@ -392,7 +377,6 @@ struct HomeReadTool: BuiltInCodeModeTool { @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 })" @@ -422,7 +406,6 @@ struct HomeWriteTool: BuiltInCodeModeTool { @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' })" @@ -443,7 +426,6 @@ struct MediaMetadataReadTool: BuiltInCodeModeTool { @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 })" @@ -468,7 +450,6 @@ struct MediaFrameExtractTool: BuiltInCodeModeTool { @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' })" diff --git a/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift b/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift index 398af2d..bbfe3f8 100644 --- a/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift +++ b/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift @@ -66,7 +66,6 @@ enum AlertPreferredStyle: String, CodeModeStringEnum { @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'] })" @@ -99,7 +98,6 @@ struct ShareUIPresentTool: BuiltInCodeModeTool { @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' })" @@ -126,7 +124,6 @@ struct QuickLookUIPreviewTool: BuiltInCodeModeTool { @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' })" @@ -161,7 +158,6 @@ struct CameraUICaptureTool: BuiltInCodeModeTool { @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 })" @@ -204,7 +200,6 @@ struct CameraUIScanDataTool: BuiltInCodeModeTool { @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' }] })" @@ -239,7 +234,6 @@ struct MailUIComposeTool: BuiltInCodeModeTool { @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' })" @@ -268,7 +262,6 @@ struct MessagesUIComposeTool: BuiltInCodeModeTool { @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' })" @@ -301,7 +294,6 @@ struct PrintUIPresentTool: BuiltInCodeModeTool { @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' })" @@ -326,7 +318,6 @@ struct WebUIPresentTool: BuiltInCodeModeTool { @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. Runs in a private browser session by default and the URL must satisfy the host's network access policy." static let codeModeTags = ["auth", "oauth", "web", "browser", "system-ui"] static let codeModeExample = "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })" @@ -355,7 +346,6 @@ struct AuthUIWebAuthenticateTool: BuiltInCodeModeTool { @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' }] })" @@ -386,7 +376,6 @@ struct UIAlertPresentTool: BuiltInCodeModeTool { @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' }] })" @@ -415,7 +404,6 @@ struct UIPromptPresentTool: BuiltInCodeModeTool { @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()" diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index 68cd6db..53b4dc3 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -38,7 +38,7 @@ "live-activities", "host-adapter" ], - "title" : "End Live Activity" + "title" : "end" }, { "allowedStringValues" : { @@ -74,7 +74,7 @@ "live-activities", "host-adapter" ], - "title" : "List Live Activities" + "title" : "list" }, { "allowedStringValues" : { @@ -107,7 +107,7 @@ "live-activities", "push-token" ], - "title" : "Read Live Activity push token" + "title" : "getPushToken" }, { "allowedStringValues" : { @@ -151,7 +151,7 @@ "live-activities", "host-adapter" ], - "title" : "Start Live Activity" + "title" : "start" }, { "allowedStringValues" : { @@ -192,7 +192,7 @@ "live-activities", "host-adapter" ], - "title" : "Update Live Activity" + "title" : "update" }, { "allowedStringValues" : { @@ -228,7 +228,7 @@ "alarms", "schedule" ], - "title" : "Cancel scheduled alarms" + "title" : "cancel" }, { "allowedStringValues" : { @@ -261,7 +261,7 @@ "permission", "alarms" ], - "title" : "Request AlarmKit permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -294,7 +294,7 @@ "alarms", "schedule" ], - "title" : "List scheduled alarms" + "title" : "list" }, { "allowedStringValues" : { @@ -335,7 +335,7 @@ "alarms", "schedule" ], - "title" : "Schedule AlarmKit alarm" + "title" : "schedule" }, { "allowedStringValues" : { @@ -371,7 +371,7 @@ "donation", "host-adapter" ], - "title" : "Donate host App Intent action" + "title" : "donate" }, { "allowedStringValues" : { @@ -412,7 +412,7 @@ "inbox", "events" ], - "title" : "Read App Intent handoff inbox" + "title" : "listHandoffs" }, { "allowedStringValues" : { @@ -449,7 +449,7 @@ "actions", "host-adapter" ], - "title" : "List host App Intent adapters" + "title" : "list" }, { "allowedStringValues" : { @@ -485,7 +485,7 @@ "open", "host-adapter" ], - "title" : "Open host App Intent surface" + "title" : "open" }, { "allowedStringValues" : { @@ -524,7 +524,7 @@ "actions", "host-adapter" ], - "title" : "Run host App Intent adapter" + "title" : "run" }, { "allowedStringValues" : { @@ -567,7 +567,7 @@ "browser", "system-ui" ], - "title" : "Authenticate with system web UI" + "title" : "webAuthenticate" }, { "allowedStringValues" : { @@ -610,7 +610,7 @@ "schedule", "delete" ], - "title" : "Delete calendar event" + "title" : "deleteEvent" }, { "allowedStringValues" : { @@ -655,7 +655,7 @@ "eventkit", "schedule" ], - "title" : "Read calendar events" + "title" : "listEvents" }, { "allowedStringValues" : { @@ -702,7 +702,7 @@ "system-ui", "picker" ], - "title" : "Pick calendar with system UI" + "title" : "pickCalendar" }, { "allowedStringValues" : { @@ -744,7 +744,7 @@ "system-ui", "details" ], - "title" : "Present calendar event details" + "title" : "presentEvent" }, { "allowedStringValues" : { @@ -793,7 +793,7 @@ "system-ui", "picker" ], - "title" : "Present calendar event editor" + "title" : "presentNewEvent" }, { "allowedStringValues" : { @@ -857,7 +857,7 @@ "eventkit", "schedule" ], - "title" : "Create or update calendar event" + "title" : "createEvent" }, { "allowedStringValues" : { @@ -935,7 +935,7 @@ "system-ui", "artifact" ], - "title" : "Capture photo or video with camera UI" + "title" : "capture" }, { "allowedStringValues" : { @@ -1010,7 +1010,7 @@ "visionkit", "system-ui" ], - "title" : "Scan text or barcodes with camera UI" + "title" : "scanData" }, { "allowedStringValues" : { @@ -1044,7 +1044,7 @@ "sync", "account" ], - "title" : "Read CloudKit account status" + "title" : "getAccountStatus" }, { "allowedStringValues" : { @@ -1090,7 +1090,7 @@ "database", "delete" ], - "title" : "Delete CloudKit record" + "title" : "deleteRecord" }, { "allowedStringValues" : { @@ -1145,7 +1145,7 @@ "database", "write" ], - "title" : "Save CloudKit record" + "title" : "saveRecord" }, { "allowedStringValues" : { @@ -1203,7 +1203,7 @@ "database", "query" ], - "title" : "Query CloudKit records" + "title" : "queryRecords" }, { "allowedStringValues" : { @@ -1264,7 +1264,7 @@ "subscription", "inbox" ], - "title" : "Save CloudKit subscription" + "title" : "subscribe" }, { "allowedStringValues" : { @@ -1305,7 +1305,7 @@ "inbox", "events" ], - "title" : "Read CloudKit subscription inbox" + "title" : "listEvents" }, { "allowedStringValues" : { @@ -1341,7 +1341,7 @@ "address-book", "people" ], - "title" : "Read contacts" + "title" : "list" }, { "allowedStringValues" : { @@ -1376,7 +1376,7 @@ "search", "people" ], - "title" : "Search contacts" + "title" : "search" }, { "allowedStringValues" : { @@ -1419,7 +1419,7 @@ "system-ui", "picker" ], - "title" : "Pick contacts with system UI" + "title" : "pick" }, { "allowedStringValues" : { @@ -1464,7 +1464,7 @@ "system-ui", "details" ], - "title" : "Present contact card" + "title" : "presentContact" }, { "allowedStringValues" : { @@ -1513,7 +1513,7 @@ "system-ui", "create" ], - "title" : "Present new contact editor" + "title" : "presentNewContact" }, { "allowedStringValues" : { @@ -1558,7 +1558,7 @@ "export", "save" ], - "title" : "Export documents with system UI" + "title" : "export" }, { "allowedStringValues" : { @@ -1601,7 +1601,7 @@ "open-in", "handoff" ], - "title" : "Open document in another app" + "title" : "openIn" }, { "allowedStringValues" : { @@ -1645,7 +1645,7 @@ "picker", "artifact" ], - "title" : "Pick documents with system UI" + "title" : "pick" }, { "allowedStringValues" : { @@ -1683,7 +1683,7 @@ "system-ui", "artifact" ], - "title" : "Scan documents with system UI" + "title" : "scan" }, { "allowedStringValues" : { @@ -1728,7 +1728,7 @@ "llm", "structured-output" ], - "title" : "Extract structured data with Foundation Models" + "title" : "extract" }, { "allowedStringValues" : { @@ -1776,7 +1776,7 @@ "llm", "generation" ], - "title" : "Generate text with Foundation Models" + "title" : "generate" }, { "allowedStringValues" : { @@ -1810,7 +1810,7 @@ "llm", "availability" ], - "title" : "Read Foundation Models availability" + "title" : "getStatus" }, { "allowedStringValues" : { @@ -1844,7 +1844,7 @@ "io", "fs" ], - "title" : "Check path access" + "title" : "access" }, { "allowedStringValues" : { @@ -1886,7 +1886,7 @@ "io", "fs" ], - "title" : "Copy file" + "title" : "copy" }, { "allowedStringValues" : { @@ -1922,7 +1922,7 @@ "io", "fs" ], - "title" : "Delete file" + "title" : "delete" }, { "allowedStringValues" : { @@ -1955,7 +1955,7 @@ "io", "fs" ], - "title" : "Check path exists" + "title" : "exists" }, { "allowedStringValues" : { @@ -1989,7 +1989,7 @@ "io", "fs" ], - "title" : "List directory" + "title" : "list" }, { "allowedStringValues" : { @@ -2025,7 +2025,7 @@ "io", "fs" ], - "title" : "Create directory" + "title" : "mkdir" }, { "allowedStringValues" : { @@ -2067,7 +2067,7 @@ "io", "fs" ], - "title" : "Move file" + "title" : "move" }, { "allowedStringValues" : { @@ -2107,7 +2107,7 @@ "io", "fs" ], - "title" : "Read file" + "title" : "read" }, { "allowedStringValues" : { @@ -2141,7 +2141,7 @@ "io", "fs" ], - "title" : "Stat path" + "title" : "stat" }, { "allowedStringValues" : { @@ -2184,7 +2184,7 @@ "io", "fs" ], - "title" : "Write file" + "title" : "write" }, { "allowedStringValues" : { @@ -2220,7 +2220,7 @@ "health", "permission" ], - "title" : "Request HealthKit permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -2264,7 +2264,7 @@ "health", "query" ], - "title" : "Read HealthKit samples" + "title" : "read" }, { "allowedStringValues" : { @@ -2308,7 +2308,7 @@ "health", "write" ], - "title" : "Write HealthKit quantity sample" + "title" : "write" }, { "allowedStringValues" : { @@ -2344,7 +2344,7 @@ "iot", "devices" ], - "title" : "Read HomeKit graph" + "title" : "list" }, { "allowedStringValues" : { @@ -2386,7 +2386,7 @@ "devices", "control" ], - "title" : "Write HomeKit characteristic" + "title" : "writeCharacteristic" }, { "allowedStringValues" : { @@ -2419,7 +2419,7 @@ "token", "keychain" ], - "title" : "Delete Keychain value" + "title" : "delete" }, { "allowedStringValues" : { @@ -2452,7 +2452,7 @@ "token", "keychain" ], - "title" : "Read Keychain value" + "title" : "get" }, { "allowedStringValues" : { @@ -2487,7 +2487,7 @@ "token", "keychain" ], - "title" : "Write Keychain value" + "title" : "set" }, { "allowedStringValues" : { @@ -2519,7 +2519,7 @@ "location", "permission" ], - "title" : "Request location permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -2553,7 +2553,7 @@ "permission", "geospatial" ], - "title" : "Read location state or coordinates" + "title" : "getCurrentPosition" }, { "allowedStringValues" : { @@ -2608,7 +2608,7 @@ "system-ui", "share" ], - "title" : "Compose mail with system UI" + "title" : "compose" }, { "allowedStringValues" : { @@ -2647,7 +2647,7 @@ "geocode", "location" ], - "title" : "Geocode address" + "title" : "geocode" }, { "allowedStringValues" : { @@ -2701,7 +2701,7 @@ "open", "directions" ], - "title" : "Open Maps" + "title" : "open" }, { "allowedStringValues" : { @@ -2740,7 +2740,7 @@ "reverse-geocode", "location" ], - "title" : "Reverse geocode coordinates" + "title" : "reverseGeocode" }, { "allowedStringValues" : { @@ -2790,7 +2790,7 @@ "directions", "route" ], - "title" : "Estimate route" + "title" : "routeEstimate" }, { "allowedStringValues" : { @@ -2832,7 +2832,7 @@ "local-search", "places" ], - "title" : "Search local map items" + "title" : "search" }, { "allowedStringValues" : { @@ -2870,7 +2870,7 @@ "avfoundation", "thumbnail" ], - "title" : "Extract video frame" + "title" : "extractFrame" }, { "allowedStringValues" : { @@ -2903,7 +2903,7 @@ "avfoundation", "metadata" ], - "title" : "Read media metadata" + "title" : "metadata" }, { "allowedStringValues" : { @@ -2941,7 +2941,7 @@ "avfoundation", "transcode" ], - "title" : "Transcode media" + "title" : "transcode" }, { "allowedStringValues" : { @@ -2988,7 +2988,7 @@ "system-ui", "share" ], - "title" : "Compose message with system UI" + "title" : "compose" }, { "allowedStringValues" : { @@ -3027,7 +3027,7 @@ "catalog", "details" ], - "title" : "Read Music catalog details" + "title" : "getDetails" }, { "allowedStringValues" : { @@ -3069,7 +3069,7 @@ "catalog", "search" ], - "title" : "Search Music catalog" + "title" : "search" }, { "allowedStringValues" : { @@ -3105,7 +3105,7 @@ "musickit", "library" ], - "title" : "Read Music library" + "title" : "readLibrary" }, { "allowedStringValues" : { @@ -3138,7 +3138,7 @@ "musickit", "permission" ], - "title" : "Request Music permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -3191,7 +3191,7 @@ "playback", "queue" ], - "title" : "Control Music playback" + "title" : "play" }, { "allowedStringValues" : { @@ -3237,7 +3237,7 @@ "playlist", "write" ], - "title" : "Write Music playlist" + "title" : "writePlaylist" }, { "allowedStringValues" : { @@ -3271,7 +3271,7 @@ "subscription", "catalog" ], - "title" : "Read Music subscription status" + "title" : "getSubscriptionStatus" }, { "allowedStringValues" : { @@ -3358,7 +3358,7 @@ "categories", "actions" ], - "title" : "Set notification categories" + "title" : "setCategories" }, { "allowedStringValues" : { @@ -3395,7 +3395,7 @@ "delivered", "delete" ], - "title" : "Delete delivered local notifications" + "title" : "removeDelivered" }, { "allowedStringValues" : { @@ -3428,7 +3428,7 @@ "local", "delivered" ], - "title" : "List delivered local notifications" + "title" : "listDelivered" }, { "allowedStringValues" : { @@ -3464,7 +3464,7 @@ "local", "schedule" ], - "title" : "Delete pending local notifications" + "title" : "cancelPending" }, { "allowedStringValues" : { @@ -3497,7 +3497,7 @@ "local", "schedule" ], - "title" : "List pending local notifications" + "title" : "listPending" }, { "allowedStringValues" : { @@ -3529,7 +3529,7 @@ "notifications", "permission" ], - "title" : "Request notification permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -3563,7 +3563,7 @@ "remote", "registration" ], - "title" : "Register for remote notifications" + "title" : "registerRemote" }, { "allowedStringValues" : { @@ -3597,7 +3597,7 @@ "remote", "token" ], - "title" : "Read APNs device token" + "title" : "getRemoteToken" }, { "allowedStringValues" : { @@ -3641,7 +3641,7 @@ "inbox", "events" ], - "title" : "Read notification response inbox" + "title" : "listResponses" }, { "allowedStringValues" : { @@ -3706,7 +3706,7 @@ "local", "schedule" ], - "title" : "Schedule local notification" + "title" : "schedule" }, { "allowedStringValues" : { @@ -3740,7 +3740,7 @@ "settings", "permission" ], - "title" : "Read notification settings" + "title" : "getSettings" }, { "allowedStringValues" : { @@ -3780,7 +3780,7 @@ "system-ui", "safety" ], - "title" : "Present Apple Pay request" + "title" : "presentPayment" }, { "allowedStringValues" : { @@ -3814,7 +3814,7 @@ "payments", "safety" ], - "title" : "Read Apple Pay status" + "title" : "canMakePayments" }, { "allowedStringValues" : { @@ -3851,7 +3851,7 @@ "add", "system-ui" ], - "title" : "Add Wallet pass" + "title" : "addPass" }, { "allowedStringValues" : { @@ -3887,7 +3887,7 @@ "passes", "system-ui" ], - "title" : "Present Wallet pass" + "title" : "presentPass" }, { "allowedStringValues" : { @@ -3927,7 +3927,7 @@ "passes", "read" ], - "title" : "List Wallet passes" + "title" : "listPasses" }, { "allowedStringValues" : { @@ -3960,7 +3960,7 @@ "wallet", "passes" ], - "title" : "Read Wallet status" + "title" : "getStatus" }, { "allowedStringValues" : { @@ -3995,7 +3995,7 @@ "photo-library", "artifact" ], - "title" : "Export photo library asset" + "title" : "export" }, { "allowedStringValues" : { @@ -4036,7 +4036,7 @@ "photo-library", "media" ], - "title" : "List photo library assets" + "title" : "list" }, { "allowedStringValues" : { @@ -4085,7 +4085,7 @@ "picker", "artifact" ], - "title" : "Pick photos with system UI" + "title" : "pick" }, { "allowedStringValues" : { @@ -4120,7 +4120,7 @@ "permission", "picker" ], - "title" : "Present limited Photos picker" + "title" : "presentLimitedLibraryPicker" }, { "allowedStringValues" : { @@ -4173,7 +4173,7 @@ "system-ui", "export" ], - "title" : "Present print UI" + "title" : "present" }, { "allowedStringValues" : { @@ -4213,7 +4213,7 @@ "documents", "system-ui" ], - "title" : "Preview files with Quick Look" + "title" : "preview" }, { "allowedStringValues" : { @@ -4247,7 +4247,7 @@ "task", "delete" ], - "title" : "Delete reminder" + "title" : "deleteReminder" }, { "allowedStringValues" : { @@ -4295,7 +4295,7 @@ "eventkit", "task" ], - "title" : "Read reminders" + "title" : "listReminders" }, { "allowedStringValues" : { @@ -4355,7 +4355,7 @@ "eventkit", "task" ], - "title" : "Create or update reminder" + "title" : "createReminder" }, { "allowedStringValues" : { @@ -4388,7 +4388,7 @@ "permissions", "system-ui" ], - "title" : "Open app settings" + "title" : "open" }, { "allowedStringValues" : { @@ -4439,7 +4439,7 @@ "export", "system-ui" ], - "title" : "Present share sheet" + "title" : "present" }, { "allowedStringValues" : { @@ -4483,7 +4483,7 @@ "transcription", "audio" ], - "title" : "Transcribe audio file" + "title" : "transcribeFile" }, { "allowedStringValues" : { @@ -4530,7 +4530,7 @@ "audio", "microphone" ], - "title" : "Transcribe microphone audio" + "title" : "transcribeMicrophone" }, { "allowedStringValues" : { @@ -4563,7 +4563,7 @@ "permission", "transcription" ], - "title" : "Request speech recognition permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -4596,7 +4596,7 @@ "permission", "transcription" ], - "title" : "Read speech recognition permission status" + "title" : "getStatus" }, { "allowedStringValues" : { @@ -4629,7 +4629,7 @@ "commerce", "entitlements" ], - "title" : "Read StoreKit entitlements" + "title" : "listEntitlements" }, { "allowedStringValues" : { @@ -4662,7 +4662,7 @@ "commerce", "products" ], - "title" : "Read StoreKit products" + "title" : "listProducts" }, { "allowedStringValues" : { @@ -4701,7 +4701,7 @@ "purchase", "safety" ], - "title" : "Purchase StoreKit product" + "title" : "purchase" }, { "allowedStringValues" : { @@ -4735,7 +4735,7 @@ "restore", "safety" ], - "title" : "Restore StoreKit purchases" + "title" : "restore" }, { "allowedStringValues" : { @@ -4776,7 +4776,7 @@ "inbox", "events" ], - "title" : "Read StoreKit transaction inbox" + "title" : "listTransactions" }, { "allowedStringValues" : { @@ -4828,7 +4828,7 @@ "dialog", "system-ui" ], - "title" : "Present alert with custom buttons" + "title" : "presentAlert" }, { "allowedStringValues" : { @@ -4874,7 +4874,7 @@ "input", "system-ui" ], - "title" : "Present prompt with text fields" + "title" : "presentPrompt" }, { "allowedStringValues" : { @@ -4912,7 +4912,7 @@ "image-analysis", "ml" ], - "title" : "Analyze image with Vision" + "title" : "analyzeImage" }, { "allowedStringValues" : { @@ -4948,7 +4948,7 @@ "forecast", "weatherkit" ], - "title" : "Read WeatherKit weather" + "title" : "getCurrentWeather" }, { "allowedStringValues" : { @@ -4987,6 +4987,6 @@ "browser", "system-ui" ], - "title" : "Present web page with system UI" + "title" : "present" } ] \ No newline at end of file From b516c5663c0e88f688a3a93db32284ea8ae33317 Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 22 Oct 2026 00:48:08 -0700 Subject: [PATCH 8/9] Split the aliased EventKit write tools, add Patch for clearing fields, and move the logic into call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the EventKit family that set the template for the remaining bridge families. **One tool per helper.** `createEvent` also answered to `updateEvent`, and `createReminder` to `updateReminder` and `completeReminder`, through aliases on one tool. Hand-written JavaScript in the bootstrap injected an `operation` argument (and `isCompleted: true` for complete) before the call reached Swift, so scripts behaved, but the catalog and the declarations could not tell the helpers apart: `updateEvent` showed `identifier` as optional, and a failed `updateEvent` got `createEvent`'s repair suggestions. They are now five tools with their own `Arguments` (create requires title/start/end as `Date`; update requires `identifier`; complete requires `identifier` with `isCompleted` defaulting to true). The hand-written bindings are deleted; the generated bootstrap is the only binding for these names. Several tools now share one `CapabilityID`, so the registry keeps a list per ID, JavaScript dispatch passes the called helper name through to the registry and to error enrichment (suggestions are looked up by name first), and a host registration replaces every existing one whose names overlap, or the whole ID when it names none. `byCapability` in search holds the first helper; `byJSName` is exact. **`Patch`.** Scripts could never clear a field: the registry rejected `null` for typed arguments before any handler ran, so the "null clears" branches, and the presence tracking added for them, were unreachable. A `Patch` property is `.unchanged` when the key is missing, `.clear` on `null`, `.set` otherwise; it is advertised as nullable through the descriptor, `JavaScriptAPIReference` (decoded with a default) and the declaration (`notes?: string | null`), and the registry accepts `null` only there. Fields that cannot be cleared (title, start, end, an event's calendar) stay plain optionals, so the declaration is honest. For an `Arguments` struct with any `Patch` field the macro generates `hasChanges(_:)`, so an update with nothing to change is rejected without a hand-kept field list that would drift. **Logic in `call`.** Each tool's body moved from `EventKitBridge` into the tool; the bridge is now a namespace of the genuinely shared helpers (calendar lookup, completion, serializers). The loader no longer holds an instance. EventKitBridge.swift: 544 lines on main, 111 now. Also: hints say only what the type cannot (the macro adds "ISO 8601 timestamp." for `Date` arguments; dynamic defaults read "… when omitted"), pinned by a test; every catalog example is parsed against the live catalog; `RemindersCreateTool.isCompleted` is honoured instead of ignored; a capability-specific branch in the shared date parser is gone, so write-date errors use the standard repairable message. --- Sources/CodeMode/API/BridgeModels.swift | 6 +- .../Bridges/BuiltInCodeModeMacros.swift | 9 +- .../Bridges/BuiltInCodeModeTool.swift | 8 +- .../CapabilityRegistrations+EventKit.swift | 15 +- .../Bridges/DefaultCapabilityLoader.swift | 2 - Sources/CodeMode/Bridges/EventKitBridge.swift | 386 +---------- .../Bridges/EventKitCodeModeTools.swift | 526 ++++++++++++--- Sources/CodeMode/Catalog/BridgeCatalog.swift | 9 +- .../Catalog/TypeScriptDeclarations.swift | 2 +- .../Host/CodeModeAgentToolDescriptions.swift | 3 + .../Registry/CapabilityRegistry.swift | 70 +- Sources/CodeMode/Runtime/BridgeRuntime.swift | 24 +- .../CodeMode/Runtime/RuntimeJavaScript.swift | 37 +- Sources/CodeMode/Support/Patch.swift | 28 + .../CodeModeMacros/BuiltInCodeModeMacro.swift | 61 +- TODO.md | 18 +- .../BuiltInCodeModeMacroExpansionTests.swift | 39 +- Tests/CodeModeTests/AgentGuidanceTests.swift | 71 ++ .../BuiltInCodeModeToolTests.swift | 129 ++-- .../CapabilityMetadataGoldenTests.swift | 2 + .../CapabilityRegistryTests.swift | 55 +- .../CodeModeTests/CodeModeProviderTests.swift | 9 +- Tests/CodeModeTests/EventKitBridgeTests.swift | 186 ++++-- Tests/CodeModeTests/HostRuntimeTests.swift | 42 +- .../TypeScriptDeclarationsTests.swift | 3 +- .../capability-metadata-golden.json | 626 ++++++++++++++++-- 26 files changed, 1607 insertions(+), 759 deletions(-) create mode 100644 Sources/CodeMode/Support/Patch.swift diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index f3432ef..4f2c16c 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -100,6 +100,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { public var requiredArguments: [String] public var optionalArguments: [String] public var argumentTypes: [String: CapabilityArgumentType] + public var nullableArguments: [String] public var argumentHints: [String: String] public var argumentConstraints: CapabilityArgumentConstraints public var resultSummary: String @@ -119,6 +120,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { requiredArguments: [String], optionalArguments: [String], argumentTypes: [String: CapabilityArgumentType], + nullableArguments: [String] = [], argumentHints: [String: String], argumentConstraints: CapabilityArgumentConstraints = .none, resultSummary: String, @@ -134,6 +136,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { self.requiredArguments = requiredArguments self.optionalArguments = optionalArguments self.argumentTypes = argumentTypes + self.nullableArguments = nullableArguments self.argumentHints = argumentHints self.argumentConstraints = argumentConstraints self.resultSummary = resultSummary @@ -143,7 +146,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { private enum CodingKeys: String, CodingKey { case capability, capabilityKey, builtInCapability, jsNames, summary, tags, example case requiredArguments, optionalArguments, argumentTypes, argumentHints - case argumentConstraints, resultSummary, dts + case nullableArguments, argumentConstraints, resultSummary, dts } /// `dts` is optional on the way in. References encoded before it existed — @@ -162,6 +165,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { self.requiredArguments = try container.decode([String].self, forKey: .requiredArguments) self.optionalArguments = try container.decode([String].self, forKey: .optionalArguments) self.argumentTypes = try container.decode([String: CapabilityArgumentType].self, forKey: .argumentTypes) + self.nullableArguments = try container.decodeIfPresent([String].self, forKey: .nullableArguments) ?? [] self.argumentHints = try container.decode([String: String].self, forKey: .argumentHints) self.argumentConstraints = try container.decode(CapabilityArgumentConstraints.self, forKey: .argumentConstraints) self.resultSummary = try container.decode(String.self, forKey: .resultSummary) diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift index 4f3e6be..c06f5a0 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift @@ -16,13 +16,14 @@ import Foundation /// defaults): omission uses the initializer, and catalog hints advertise it. /// Dynamic defaults belong in `call` and use optional properties. /// -/// An unannotated `var providedArguments: Set` receives the supplied -/// keys, so patch tools can distinguish omission from explicit null without -/// retaining raw values. A trailing unannotated `var raw: [String: JSONValue]` +/// `Patch` arguments distinguish omission, null, and a decoded value. Tools +/// with Patch fields get `static hasChanges(_:)`, which checks their Patch and +/// optional fields and skips required and defaulted fields. A +/// trailing unannotated `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)) +@attached(member, names: named(codeModeCapability), named(codeModePath), named(codeModeAliasPaths), named(codeModeArguments), named(decode), named(hasChanges)) macro BuiltInCodeMode( _ capability: CapabilityID, path: String, diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift index 80b3880..c8be660 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -5,16 +5,18 @@ struct BuiltInToolArgument { var name: String var type: CapabilityArgumentType var optional: Bool + var nullable: Bool var hint: String var defaultValue: JSONValue? /// 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, defaultValue: JSONValue? = nil) { + init(_ name: String, _ type: CapabilityArgumentType, optional: Bool = false, nullable: Bool = false, hint: String, defaultValue: JSONValue? = nil) { self.name = name self.type = type self.optional = optional + self.nullable = nullable self.defaultValue = defaultValue self.hint = Self.hint(hint, defaultValue: defaultValue) self.allowedValues = nil @@ -23,10 +25,11 @@ struct BuiltInToolArgument { /// 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, defaultValue: JSONValue? = nil) { + init(_ name: String, oneOf: Value.Type, optional: Bool = false, nullable: Bool = false, hint: String, defaultValue: JSONValue? = nil) { self.name = name self.type = .string self.optional = optional + self.nullable = nullable self.defaultValue = defaultValue self.hint = Self.hint(hint, defaultValue: defaultValue) self.allowedValues = Value.codeModeAllowedValues @@ -106,6 +109,7 @@ extension CapabilityRegistration { requiredArguments: arguments.filter { $0.optional == false }.map(\.name), optionalArguments: arguments.filter(\.optional).map(\.name), argumentTypes: Dictionary(uniqueKeysWithValues: arguments.map { ($0.name, $0.type) }), + nullableArguments: arguments.filter(\.nullable).map(\.name), 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. diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift index 07f1f6b..6295fb5 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift @@ -3,15 +3,18 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func calendarAndReminderRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration(tool: CalendarListEventsTool(eventKit: eventKit)), - CapabilityRegistration(tool: CalendarWriteEventTool(eventKit: eventKit)), - CapabilityRegistration(tool: CalendarDeleteEventTool(eventKit: eventKit)), + CapabilityRegistration(tool: CalendarListEventsTool()), + CapabilityRegistration(tool: CalendarCreateEventTool()), + CapabilityRegistration(tool: CalendarUpdateEventTool()), + CapabilityRegistration(tool: CalendarDeleteEventTool()), 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)), + CapabilityRegistration(tool: RemindersListTool()), + CapabilityRegistration(tool: RemindersCreateTool()), + CapabilityRegistration(tool: RemindersUpdateTool()), + CapabilityRegistration(tool: RemindersCompleteTool()), + CapabilityRegistration(tool: RemindersDeleteTool()), ] } } diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index 3985f55..de2b0ff 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -42,7 +42,6 @@ struct DefaultCapabilityRegistrationBuilder { let keychain: KeychainBridge let location: LocationBridge let weather: WeatherBridge - let eventKit: EventKitBridge let contacts: ContactsBridge let photos: PhotosBridge let vision: VisionBridge @@ -85,7 +84,6 @@ struct DefaultCapabilityRegistrationBuilder { self.keychain = KeychainBridge() self.location = LocationBridge() self.weather = WeatherBridge() - self.eventKit = EventKitBridge() self.contacts = ContactsBridge() self.photos = PhotosBridge() self.vision = VisionBridge() diff --git a/Sources/CodeMode/Bridges/EventKitBridge.swift b/Sources/CodeMode/Bridges/EventKitBridge.swift index 88acb64..9c4948e 100644 --- a/Sources/CodeMode/Bridges/EventKitBridge.swift +++ b/Sources/CodeMode/Bridges/EventKitBridge.swift @@ -1,374 +1,21 @@ import Foundation - #if canImport(EventKit) import EventKit #endif -public final class EventKitBridge: @unchecked Sendable { +/// Shared EventKit lookup, patching, and serialization helpers. +enum EventKitBridge { static let reminderFetchTimeoutSeconds: TimeInterval = 15 - public init() {} - - func readEvents(arguments: CalendarListEventsTool.Arguments) throws -> JSONValue { - #if canImport(EventKit) - let store = EKEventStore() - let start = arguments.start ?? Date() - let end = arguments.end - ?? Calendar.current.date(byAdding: .day, value: 14, to: start) - ?? start - let limit = arguments.limit - let calendars = try resolveCalendars( - identifier: arguments.calendarIdentifier, - identifiers: arguments.calendarIdentifiers, - in: store, - entityType: .event, - capability: "calendar.read" - ) - - let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars) - let events = store.events(matching: predicate).prefix(max(1, limit)).map { event in - Self.eventJSON(event) - } - - return .array(Array(events)) - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #endif - } - - func writeEvent(arguments: CalendarWriteEventTool.Arguments) throws -> JSONValue { - #if canImport(EventKit) - switch arguments.resolvedOperation { - case .create: - return try createEvent(arguments: arguments) - case .update: - return try updateEvent(arguments: arguments) - } - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #endif - } - - func deleteEvent(arguments: CalendarDeleteEventTool.Arguments) throws -> JSONValue { - #if canImport(EventKit) - let identifier = arguments.identifier - guard identifier.isEmpty == false else { - throw BridgeError.invalidArguments("calendar.delete requires identifier") - } - let store = EKEventStore() - guard let event = store.event(withIdentifier: identifier) else { - throw BridgeError.invalidArguments("calendar.delete could not find event identifier \(identifier)") - } - let span = arguments.span.ekSpan - - do { - try store.remove(event, span: span) - return .object([ - "identifier": .string(identifier), - "deleted": .bool(true), - ]) - } catch { - throw BridgeError.nativeFailure("calendar.delete failed: \(error.localizedDescription)") - } - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #endif - } - - func readReminders(arguments: RemindersListTool.Arguments) throws -> JSONValue { - #if canImport(EventKit) - let store = EKEventStore() - let includeCompleted = arguments.includeCompleted - let start = arguments.start - let end = arguments.end - let limit = max(1, arguments.limit) - let calendars = try resolveCalendars( - identifier: arguments.calendarIdentifier, - identifiers: arguments.calendarIdentifiers, - in: store, - entityType: .reminder, - capability: "reminders.read" - ) - - let predicate = includeCompleted - ? store.predicateForReminders(in: calendars) - : store.predicateForIncompleteReminders(withDueDateStarting: start, ending: end, calendars: calendars) - // Previously a `var` written from EventKit's completion queue, read after - // a semaphore wait whose timeout result was discarded — a data race with - // the late callback, and an empty array on timeout that the script could - // not tell apart from "no reminders". - let result = try CompletionWait.value( - timeout: Self.reminderFetchTimeoutSeconds, - operationName: "reminders.read" - ) { complete in - store.fetchReminders(matching: predicate) { reminders in - let filtered = (reminders ?? []) - .filter { reminder in - guard includeCompleted else { return true } - guard let dueDate = reminder.dueDateComponents?.date else { - return start == nil && end == nil - } - if let start, dueDate < start { return false } - if let end, dueDate > end { return false } - return true - } - .prefix(limit) - complete(filtered.map { Self.reminderJSON($0) }) - } - } - - return .array(result) - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #endif - } - - func writeReminder(arguments: RemindersWriteTool.Arguments) throws -> JSONValue { - #if canImport(EventKit) - switch arguments.resolvedOperation { - case .create: - return try createReminder(arguments: arguments) - case .update, .complete: - return try updateReminder(arguments: arguments) - } - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #endif - } - - func deleteReminder(arguments: RemindersDeleteTool.Arguments) throws -> JSONValue { - #if canImport(EventKit) - let identifier = arguments.identifier - guard identifier.isEmpty == false else { - throw BridgeError.invalidArguments("reminders.delete requires identifier") - } - let store = EKEventStore() - guard let reminder = store.calendarItem(withIdentifier: identifier) as? EKReminder else { - throw BridgeError.invalidArguments("reminders.delete could not find reminder identifier \(identifier)") - } - - do { - try store.remove(reminder, commit: true) - return .object([ - "identifier": .string(identifier), - "deleted": .bool(true), - ]) - } catch { - throw BridgeError.nativeFailure("reminders.delete failed: \(error.localizedDescription)") - } - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #endif - } - #if canImport(EventKit) - private func createEvent(arguments: CalendarWriteEventTool.Arguments) throws -> JSONValue { - guard let title = arguments.title, title.isEmpty == false, - let startText = arguments.start, - let endText = arguments.end, - let start = CodeModeDate.parse(startText), - let end = CodeModeDate.parse(endText) - else { - throw BridgeError.invalidArguments("calendar.write create requires title/start/end ISO8601") - } - - let store = EKEventStore() - let event = EKEvent(eventStore: store) - event.title = title - event.startDate = start - event.endDate = end - event.notes = arguments.notes - event.location = arguments.location - event.isAllDay = arguments.isAllDay ?? false - if let urlText = arguments.url, urlText.isEmpty == false { - guard let url = URL(string: urlText) else { - throw BridgeError.invalidArguments("calendar.write url must be valid when provided") - } - event.url = url - } - if let calendarIdentifier = arguments.calendarIdentifier { - event.calendar = try calendar( - calendarIdentifier, - in: store, - entityType: .event, - capability: "calendar.write" - ) - } else { - event.calendar = store.defaultCalendarForNewEvents - } - - do { - try store.save(event, span: .thisEvent) - return Self.eventJSON(event) - } catch { - throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") - } - } - - private func updateEvent(arguments: CalendarWriteEventTool.Arguments) throws -> JSONValue { - guard let identifier = arguments.identifier, identifier.isEmpty == false else { - throw BridgeError.invalidArguments("calendar.write update requires identifier") - } - - let patchFields = ["title", "start", "end", "notes", "location", "url", "isAllDay", "calendarIdentifier"] - guard patchFields.contains(where: { arguments.providedArguments.contains($0) }) else { - throw BridgeError.invalidArguments("calendar.write update requires at least one patch field") - } - - let store = EKEventStore() - guard let event = store.event(withIdentifier: identifier) else { - throw BridgeError.invalidArguments("calendar.write could not find event identifier \(identifier)") - } - - try applyEventPatch(arguments: arguments, to: event, in: store) - - do { - try store.save(event, span: .thisEvent) - return Self.eventJSON(event) - } catch { - throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") - } - } - - private func createReminder(arguments: RemindersWriteTool.Arguments) throws -> JSONValue { - guard let title = arguments.title, title.isEmpty == false else { - throw BridgeError.invalidArguments("reminders.write create requires title") - } - - let store = EKEventStore() - let reminder = EKReminder(eventStore: store) - reminder.calendar = try reminderCalendar(identifier: arguments.calendarIdentifier, in: store) - reminder.title = title - reminder.notes = arguments.notes - reminder.priority = arguments.priority ?? 0 - - if let dueDateText = arguments.dueDate, dueDateText.isEmpty == false { - guard let dueDate = CodeModeDate.parse(dueDateText) else { - throw BridgeError.invalidArguments("reminders.write dueDate must be ISO8601 when provided") - } - reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) - } - - do { - try store.save(reminder, commit: true) - return Self.reminderJSON(reminder) - } catch { - throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + static func applyCompletion(_ patch: Patch, to reminder: EKReminder) { + patch.apply { isCompleted in + reminder.isCompleted = isCompleted ?? false + reminder.completionDate = reminder.isCompleted ? (reminder.completionDate ?? Date()) : nil } } - private func updateReminder(arguments: RemindersWriteTool.Arguments) throws -> JSONValue { - guard let identifier = arguments.identifier, identifier.isEmpty == false else { - throw BridgeError.invalidArguments("reminders.write update requires identifier") - } - - let patchFields = ["title", "dueDate", "notes", "isCompleted", "priority", "calendarIdentifier"] - guard patchFields.contains(where: { arguments.providedArguments.contains($0) }) else { - throw BridgeError.invalidArguments("reminders.write update requires at least one patch field") - } - - let store = EKEventStore() - guard let reminder = store.calendarItem(withIdentifier: identifier) as? EKReminder else { - throw BridgeError.invalidArguments("reminders.write could not find reminder identifier \(identifier)") - } - - try applyReminderPatch(arguments: arguments, to: reminder, in: store) - - do { - try store.save(reminder, commit: true) - return Self.reminderJSON(reminder) - } catch { - throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") - } - } - - /// Applies decoded patch values independently of item lookup and persistence. - func applyEventPatch(arguments: CalendarWriteEventTool.Arguments, to event: EKEvent, in store: EKEventStore) throws { - if arguments.providedArguments.contains("title") { - guard let title = arguments.title, title.isEmpty == false else { - throw BridgeError.invalidArguments("calendar.write title cannot be empty") - } - event.title = title - } - if arguments.providedArguments.contains("start") { - guard let start = CodeModeDate.parse(arguments.start) else { - throw BridgeError.invalidArguments("calendar.write start must be ISO8601 when provided") - } - event.startDate = start - } - if arguments.providedArguments.contains("end") { - guard let end = CodeModeDate.parse(arguments.end) else { - throw BridgeError.invalidArguments("calendar.write end must be ISO8601 when provided") - } - event.endDate = end - } - if arguments.providedArguments.contains("notes") { - event.notes = arguments.notes - } - if arguments.providedArguments.contains("location") { - event.location = arguments.location - } - if arguments.providedArguments.contains("url") { - let urlText = arguments.url ?? "" - event.url = urlText.isEmpty ? nil : URL(string: urlText) - if urlText.isEmpty == false, event.url == nil { - throw BridgeError.invalidArguments("calendar.write url must be valid when provided") - } - } - if arguments.providedArguments.contains("isAllDay") { - event.isAllDay = arguments.isAllDay ?? false - } - if let calendarIdentifier = arguments.calendarIdentifier { - event.calendar = try calendar( - calendarIdentifier, - in: store, - entityType: .event, - capability: "calendar.write" - ) - } - } - - /// Applies decoded patch values independently of item lookup and persistence. - func applyReminderPatch(arguments: RemindersWriteTool.Arguments, to reminder: EKReminder, in store: EKEventStore) throws { - if arguments.providedArguments.contains("title") { - guard let title = arguments.title, title.isEmpty == false else { - throw BridgeError.invalidArguments("reminders.write title cannot be empty") - } - reminder.title = title - } - if arguments.providedArguments.contains("dueDate") { - if let dueDateText = arguments.dueDate, dueDateText.isEmpty == false { - guard let dueDate = CodeModeDate.parse(dueDateText) else { - throw BridgeError.invalidArguments("reminders.write dueDate must be ISO8601 when provided") - } - reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) - } else { - reminder.dueDateComponents = nil - } - } - if arguments.providedArguments.contains("notes") { - reminder.notes = arguments.notes - } - if arguments.providedArguments.contains("isCompleted") { - let isCompleted = arguments.isCompleted ?? false - reminder.isCompleted = isCompleted - reminder.completionDate = isCompleted ? (reminder.completionDate ?? Date()) : nil - } - if let priority = arguments.priority { - reminder.priority = priority - } - if arguments.providedArguments.contains("calendarIdentifier") { - reminder.calendar = try reminderCalendar(identifier: arguments.calendarIdentifier, in: store) - } - } - - private func resolveCalendars( + static func resolveCalendars( identifier: String?, identifiers: [String]?, in store: EKEventStore, @@ -392,7 +39,7 @@ public final class EventKitBridge: @unchecked Sendable { return matches } - private func calendar( + static func calendar( _ identifier: String, in store: EKEventStore, entityType: EKEntityType, @@ -407,7 +54,7 @@ public final class EventKitBridge: @unchecked Sendable { return calendar } - private func reminderCalendar(identifier: String?, in store: EKEventStore) throws -> EKCalendar { + static func reminderCalendar(identifier: String?, in store: EKEventStore) throws -> EKCalendar { if let calendarIdentifier = identifier { return try calendar( calendarIdentifier, @@ -422,18 +69,7 @@ public final class EventKitBridge: @unchecked Sendable { return calendar } - /// Test seam: these serializers are the only EventKit logic reachable - /// without a live, permission-granted store, and they are where the - /// implicitly-unwrapped `calendar`/`title` crashes lived. - static func eventJSONForTesting(_ event: EKEvent) -> JSONValue { - eventJSON(event) - } - - static func reminderJSONForTesting(_ reminder: EKReminder) -> JSONValue { - reminderJSON(reminder) - } - - private static func eventJSON(_ event: EKEvent) -> JSONValue { + static func eventJSON(_ event: EKEvent) -> JSONValue { .object([ "identifier": .string(event.eventIdentifier ?? ""), "title": .string(event.title ?? ""), @@ -451,7 +87,7 @@ public final class EventKitBridge: @unchecked Sendable { ]) } - private static func reminderJSON(_ reminder: EKReminder) -> JSONValue { + static func reminderJSON(_ reminder: EKReminder) -> JSONValue { var object: [String: JSONValue] = [ "identifier": .string(reminder.calendarItemIdentifier), // `EKReminder.title` is `String!`. diff --git a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift index 8b40bc6..211f11a 100644 --- a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift @@ -32,17 +32,6 @@ enum CalendarEventSpan: String, CodeModeStringEnum { #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 @@ -57,7 +46,6 @@ enum CalendarPickerDisplayStyle: String, CodeModeStringEnum { @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") struct CalendarListEventsTool: BuiltInCodeModeTool { - 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' })" @@ -65,73 +53,205 @@ struct CalendarListEventsTool: BuiltInCodeModeTool { static let codeModeResultSummary = "Array of events with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." struct Arguments: Sendable { - @ToolParam("ISO8601 timestamp; defaults to now.") + @ToolParam("Range start; uses now when omitted.") var start: Date? - @ToolParam("ISO8601 timestamp; defaults to start + 14 days.") + @ToolParam("Range end; uses start + 14 days when omitted.") var end: Date? @ToolParam("Max number of items.") var limit: Int = 50 - @ToolParam("Optional EventKit calendarIdentifier to restrict results.") + @ToolParam("EventKit calendarIdentifier to restrict results.") var calendarIdentifier: String? - @ToolParam("Optional array of EventKit calendarIdentifier strings to restrict results.") + @ToolParam("EventKit calendarIdentifier strings to restrict results.") var calendarIdentifiers: [String]? } - let eventKit: EventKitBridge - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.readEvents(arguments: arguments) + #if canImport(EventKit) + let store = EKEventStore() + let start = arguments.start ?? Date() + let end = arguments.end + ?? Calendar.current.date(byAdding: .day, value: 14, to: start) + ?? start + let limit = arguments.limit + let calendars = try EventKitBridge.resolveCalendars( + identifier: arguments.calendarIdentifier, + identifiers: arguments.calendarIdentifiers, + in: store, + entityType: .event, + capability: "calendar.read" + ) + + let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars) + let events = store.events(matching: predicate).prefix(max(1, limit)).map { event in + EventKitBridge.eventJSON(event) + } + + return .array(Array(events)) + #else + _ = arguments + throw BridgeError.unsupportedPlatform("EventKit") + #endif } } -@BuiltInCodeMode(.calendarWrite, path: "apple.calendar.createEvent", aliases: ["apple.calendar.updateEvent"]) -struct CalendarWriteEventTool: BuiltInCodeModeTool { - static let codeModeTitle = "Create or update calendar event" - static let codeModeSummary = "Create a calendar event or patch an existing event by identifier." +@BuiltInCodeMode(.calendarWrite, path: "apple.calendar.createEvent") +struct CalendarCreateEventTool: BuiltInCodeModeTool { + static let codeModeSummary = "Create a calendar event." 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 codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] static let codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." 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.") + @ToolParam("Event title.") + var title: String + @ToolParam("Event start.") + var start: Date + @ToolParam("Event end.") + var end: Date + @ToolParam("Notes/body text.") var notes: String? - @ToolParam("Optional location string.") + @ToolParam("Event location.") var location: String? - @ToolParam("Optional absolute URL string attached to the event.") + @ToolParam("Absolute URL attached to the event.") var url: String? @ToolParam("Whether the event is all-day.") var isAllDay: Bool? - @ToolParam("Optional destination EventKit calendarIdentifier.") + @ToolParam("Destination EventKit calendarIdentifier.") var calendarIdentifier: String? - // Presence is separate from value: null clears a patch field, omission - // leaves it alone. No raw values escape the generated decoder. - var providedArguments: Set } - let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(EventKit) + guard arguments.title.isEmpty == false else { + throw BridgeError.invalidArguments("calendar.write title cannot be empty") + } - func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] { - [arguments.resolvedOperation == .update ? .calendar : .calendarWriteOnly] + let store = EKEventStore() + let event = EKEvent(eventStore: store) + event.title = arguments.title + event.startDate = arguments.start + event.endDate = arguments.end + event.notes = arguments.notes + event.location = arguments.location + event.isAllDay = arguments.isAllDay ?? false + if let urlText = arguments.url, urlText.isEmpty == false { + guard let url = URL(string: urlText) else { + throw BridgeError.invalidArguments("calendar.write url must be valid when provided") + } + event.url = url + } + if let calendarIdentifier = arguments.calendarIdentifier { + event.calendar = try EventKitBridge.calendar( + calendarIdentifier, + in: store, + entityType: .event, + capability: "calendar.write" + ) + } else { + event.calendar = store.defaultCalendarForNewEvents + } + + do { + try store.save(event, span: .thisEvent) + return EventKitBridge.eventJSON(event) + } catch { + throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") + } + #else + throw BridgeError.unsupportedPlatform("EventKit") + #endif + } +} + +@BuiltInCodeMode(.calendarWrite, path: "apple.calendar.updateEvent") +struct CalendarUpdateEventTool: BuiltInCodeModeTool { + static let codeModeSummary = "Patch an existing event by identifier." + static let codeModeTags = ["calendar", "eventkit", "schedule"] + static let codeModeExample = "await apple.calendar.updateEvent({ identifier: 'EVENT_ID', notes: null })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] + static let codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." + + struct Arguments: Sendable { + @ToolParam("EventKit eventIdentifier to update.") + var identifier: String + @ToolParam("Event title.") + var title: String? + @ToolParam("Event start.") + var start: Date? + @ToolParam("Event end.") + var end: Date? + @ToolParam("Notes/body text.") + var notes: Patch + @ToolParam("Event location.") + var location: Patch + @ToolParam("Absolute URL attached to the event.") + var url: Patch + @ToolParam("Whether the event is all-day.") + var isAllDay: Patch + @ToolParam("Destination EventKit calendarIdentifier.") + var calendarIdentifier: String? } func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.writeEvent(arguments: arguments) + #if canImport(EventKit) + let identifier = arguments.identifier + guard identifier.isEmpty == false else { + throw BridgeError.invalidArguments("calendar.write update requires identifier") + } + + guard Self.hasChanges(arguments) else { + throw BridgeError.invalidArguments("calendar.write update requires at least one patch field") + } + + let store = EKEventStore() + guard let event = store.event(withIdentifier: identifier) else { + throw BridgeError.invalidArguments("calendar.write could not find event identifier \(identifier)") + } + + try arguments.apply(to: event, in: store) + + do { + try store.save(event, span: .thisEvent) + return EventKitBridge.eventJSON(event) + } catch { + throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") + } + #else + throw BridgeError.unsupportedPlatform("EventKit") + #endif + } +} + +extension CalendarUpdateEventTool.Arguments { + #if canImport(EventKit) + func apply(to event: EKEvent, in store: EKEventStore) throws { + if let title { + guard !title.isEmpty else { throw BridgeError.invalidArguments("calendar.write title cannot be empty") } + event.title = title + } + if let start { event.startDate = start } + if let end { event.endDate = end } + notes.apply { event.notes = $0 } + location.apply { event.location = $0 } + try url.apply { text in + let text = text ?? "" + let parsedURL = text.isEmpty ? nil : URL(string: text) + guard text.isEmpty || parsedURL != nil else { + throw BridgeError.invalidArguments("calendar.write url must be valid when provided") + } + event.url = parsedURL + } + isAllDay.apply { event.isAllDay = $0 ?? false } + if let calendarIdentifier { + event.calendar = try EventKitBridge.calendar(calendarIdentifier, in: store, entityType: .event, capability: "calendar.write") + } } + #endif } @BuiltInCodeMode(.calendarDelete, path: "apple.calendar.deleteEvent") struct CalendarDeleteEventTool: BuiltInCodeModeTool { - 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' })" @@ -141,20 +261,40 @@ struct CalendarDeleteEventTool: BuiltInCodeModeTool { struct Arguments: Sendable { @ToolParam("EventKit eventIdentifier from apple.calendar.listEvents.") var identifier: String - @ToolParam("thisEvent or futureEvents for recurring events.") + @ToolParam("Scope for recurring events.") var span: CalendarEventSpan = .thisEvent } - let eventKit: EventKitBridge - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.deleteEvent(arguments: arguments) + #if canImport(EventKit) + let identifier = arguments.identifier + guard identifier.isEmpty == false else { + throw BridgeError.invalidArguments("calendar.delete requires identifier") + } + let store = EKEventStore() + guard let event = store.event(withIdentifier: identifier) else { + throw BridgeError.invalidArguments("calendar.delete could not find event identifier \(identifier)") + } + let span = arguments.span.ekSpan + + do { + try store.remove(event, span: span) + return .object([ + "identifier": .string(identifier), + "deleted": .bool(true), + ]) + } catch { + throw BridgeError.nativeFailure("calendar.delete failed: \(error.localizedDescription)") + } + #else + _ = arguments + throw BridgeError.unsupportedPlatform("EventKit") + #endif } } @BuiltInCodeMode(.calendarUIPickCalendar, path: "apple.calendar.pickCalendar") struct CalendarPickCalendarTool: BuiltInCodeModeTool { - 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' })" @@ -162,11 +302,11 @@ struct CalendarPickCalendarTool: BuiltInCodeModeTool { static let codeModeResultSummary = "Array of selected calendars with identifier/title/type/allowsContentModifications." struct Arguments: Sendable { - @ToolParam("single (default) or multiple.") + @ToolParam("Calendar selection style; uses single when omitted.") var selectionStyle: CalendarPickerSelectionStyle? - @ToolParam("writable (default) or all.") + @ToolParam("Calendars to display; uses writable when omitted.") var displayStyle: CalendarPickerDisplayStyle? - @ToolParam("Optional timeout for waiting on user selection.") + @ToolParam("Timeout in milliseconds for waiting on user selection.") var timeoutMs: Double? var raw: [String: JSONValue] } @@ -180,7 +320,6 @@ struct CalendarPickCalendarTool: BuiltInCodeModeTool { @BuiltInCodeMode(.calendarUIPresentEvent, path: "apple.calendar.presentEvent") struct CalendarPresentEventTool: BuiltInCodeModeTool { - 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 })" @@ -190,11 +329,11 @@ struct CalendarPresentEventTool: BuiltInCodeModeTool { 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.") + @ToolParam("Whether the user can edit from the detail UI; uses false when omitted.") var allowsEditing: Bool? - @ToolParam("Whether the UI may show calendar day previews; default true.") + @ToolParam("Whether the UI may show calendar day previews; uses true when omitted.") var allowsCalendarPreview: Bool? - @ToolParam("Optional timeout for waiting on dismissal.") + @ToolParam("Timeout in milliseconds for waiting on dismissal.") var timeoutMs: Double? var raw: [String: JSONValue] } @@ -208,7 +347,6 @@ struct CalendarPresentEventTool: BuiltInCodeModeTool { @BuiltInCodeMode(.calendarUIPresentNewEvent, path: "apple.calendar.presentNewEvent") struct CalendarPresentNewEventTool: BuiltInCodeModeTool { - 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' })" @@ -216,17 +354,17 @@ struct CalendarPresentNewEventTool: BuiltInCodeModeTool { static let codeModeResultSummary = "Object with action plus identifier/title when the user saves." struct Arguments: Sendable { - @ToolParam("Optional event title shown in the editor.") + @ToolParam("Event title shown in the editor.") var title: String? - @ToolParam("Optional ISO8601 start timestamp.") + @ToolParam("ISO 8601 start timestamp.") var start: String? - @ToolParam("Optional ISO8601 end timestamp.") + @ToolParam("ISO 8601 end timestamp.") var end: String? - @ToolParam("Optional event notes/body text.") + @ToolParam("Event notes/body text.") var notes: String? - @ToolParam("Optional location string.") + @ToolParam("Location string.") var location: String? - @ToolParam("Optional timeout for waiting on user save/cancel.") + @ToolParam("Timeout in milliseconds for waiting on user save/cancel.") var timeoutMs: Double? var raw: [String: JSONValue] } @@ -242,7 +380,6 @@ struct CalendarPresentNewEventTool: BuiltInCodeModeTool { @BuiltInCodeMode(.remindersRead, path: "apple.reminders.listReminders") struct RemindersListTool: BuiltInCodeModeTool { - 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 })" @@ -250,68 +387,236 @@ struct RemindersListTool: BuiltInCodeModeTool { static let codeModeResultSummary = "Array of reminders with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." struct Arguments: Sendable { - @ToolParam("Optional ISO8601 due-date lower bound.") + @ToolParam("Due-date lower bound.") var start: Date? - @ToolParam("Optional ISO8601 due-date upper bound.") + @ToolParam("Due-date upper bound.") var end: Date? @ToolParam("Whether completed reminders are included.") var includeCompleted: Bool = false - @ToolParam("Optional EventKit reminder calendarIdentifier to restrict results.") + @ToolParam("EventKit reminder calendarIdentifier to restrict results.") var calendarIdentifier: String? - @ToolParam("Optional array of EventKit reminder calendarIdentifier strings to restrict results.") + @ToolParam("EventKit reminder calendarIdentifier strings to restrict results.") var calendarIdentifiers: [String]? @ToolParam("Max number of reminder items.") var limit: Int = 50 } - let eventKit: EventKitBridge - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.readReminders(arguments: arguments) + #if canImport(EventKit) + let store = EKEventStore() + let includeCompleted = arguments.includeCompleted + let start = arguments.start + let end = arguments.end + let limit = max(1, arguments.limit) + let calendars = try EventKitBridge.resolveCalendars( + identifier: arguments.calendarIdentifier, + identifiers: arguments.calendarIdentifiers, + in: store, + entityType: .reminder, + capability: "reminders.read" + ) + + let predicate = includeCompleted + ? store.predicateForReminders(in: calendars) + : store.predicateForIncompleteReminders(withDueDateStarting: start, ending: end, calendars: calendars) + // Previously a `var` written from EventKit's completion queue, read after + // a semaphore wait whose timeout result was discarded — a data race with + // the late callback, and an empty array on timeout that the script could + // not tell apart from "no reminders". + let result = try CompletionWait.value( + timeout: EventKitBridge.reminderFetchTimeoutSeconds, + operationName: "reminders.read" + ) { complete in + store.fetchReminders(matching: predicate) { reminders in + let filtered = (reminders ?? []) + .filter { reminder in + guard includeCompleted else { return true } + guard let dueDate = reminder.dueDateComponents?.date else { + return start == nil && end == nil + } + if let start, dueDate < start { return false } + if let end, dueDate > end { return false } + return true + } + .prefix(limit) + complete(filtered.map { EventKitBridge.reminderJSON($0) }) + } + } + + return .array(result) + #else + _ = arguments + throw BridgeError.unsupportedPlatform("EventKit") + #endif } } -@BuiltInCodeMode(.remindersWrite, path: "apple.reminders.createReminder", aliases: ["apple.reminders.updateReminder", "apple.reminders.completeReminder"]) -struct RemindersWriteTool: BuiltInCodeModeTool { - static let codeModeTitle = "Create or update reminder" - static let codeModeSummary = "Create a reminder or patch an existing reminder by identifier." +@BuiltInCodeMode(.remindersWrite, path: "apple.reminders.createReminder") +struct RemindersCreateTool: BuiltInCodeModeTool { + static let codeModeSummary = "Create a reminder." 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 codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." 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.") + @ToolParam("Reminder title.") + var title: String + @ToolParam("Reminder due date.") + var dueDate: Date? + @ToolParam("Reminder notes.") var notes: String? - @ToolParam("Completion state for update or completeReminder; default true for completeReminder.") + @ToolParam("Completion state.") var isCompleted: Bool? @ToolParam("EventKit reminder priority integer.") var priority: Int? - @ToolParam("Optional destination EventKit reminders calendarIdentifier.") + @ToolParam("Destination EventKit reminders calendarIdentifier.") var calendarIdentifier: String? - // Presence is separate from value: null clears a patch field, omission - // leaves it alone. No raw values escape the generated decoder. - var providedArguments: Set } - let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(EventKit) + guard arguments.title.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.write title cannot be empty") + } + + let store = EKEventStore() + let reminder = EKReminder(eventStore: store) + reminder.calendar = try EventKitBridge.reminderCalendar(identifier: arguments.calendarIdentifier, in: store) + reminder.title = arguments.title + reminder.notes = arguments.notes + reminder.priority = arguments.priority ?? 0 + if let isCompleted = arguments.isCompleted { + EventKitBridge.applyCompletion(.set(isCompleted), to: reminder) + } + + if let dueDate = arguments.dueDate { + reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) + } + + do { + try store.save(reminder, commit: true) + return EventKitBridge.reminderJSON(reminder) + } catch { + throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + } + #else + throw BridgeError.unsupportedPlatform("EventKit") + #endif + } +} + +@BuiltInCodeMode(.remindersWrite, path: "apple.reminders.updateReminder") +struct RemindersUpdateTool: BuiltInCodeModeTool { + static let codeModeSummary = "Patch an existing reminder by identifier." + static let codeModeTags = ["reminders", "eventkit", "task"] + static let codeModeExample = "await apple.reminders.updateReminder({ identifier: 'REMINDER_ID', dueDate: null })" + static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] + static let codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." + + struct Arguments: Sendable { + @ToolParam("EventKit calendarItemIdentifier to update.") + var identifier: String + @ToolParam("Reminder title.") + var title: String? + @ToolParam("Reminder due date.") + var dueDate: Patch + @ToolParam("Reminder notes.") + var notes: Patch + @ToolParam("Completion state.") + var isCompleted: Patch + @ToolParam("EventKit reminder priority integer.") + var priority: Patch + @ToolParam("Destination EventKit reminders calendarIdentifier.") + var calendarIdentifier: Patch + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(EventKit) + let identifier = arguments.identifier + guard identifier.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.write update requires identifier") + } + + guard Self.hasChanges(arguments) else { + throw BridgeError.invalidArguments("reminders.write update requires at least one patch field") + } + + let store = EKEventStore() + guard let reminder = store.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw BridgeError.invalidArguments("reminders.write could not find reminder identifier \(identifier)") + } + + try arguments.apply(to: reminder, in: store) + + do { + try store.save(reminder, commit: true) + return EventKitBridge.reminderJSON(reminder) + } catch { + throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + } + #else + throw BridgeError.unsupportedPlatform("EventKit") + #endif + } +} + +extension RemindersUpdateTool.Arguments { + #if canImport(EventKit) + func apply(to reminder: EKReminder, in store: EKEventStore) throws { + if let title { + guard !title.isEmpty else { throw BridgeError.invalidArguments("reminders.write title cannot be empty") } + reminder.title = title + } + dueDate.apply { reminder.dueDateComponents = $0.map { Calendar.current.dateComponents(in: .current, from: $0) } } + notes.apply { reminder.notes = $0 } + EventKitBridge.applyCompletion(isCompleted, to: reminder) + priority.apply { reminder.priority = $0 ?? 0 } + try calendarIdentifier.apply { reminder.calendar = try EventKitBridge.reminderCalendar(identifier: $0, in: store) } + } + #endif +} + +@BuiltInCodeMode(.remindersWrite, path: "apple.reminders.completeReminder") +struct RemindersCompleteTool: BuiltInCodeModeTool { + static let codeModeSummary = "Set an existing reminder's completion state." + static let codeModeTags = ["reminders", "eventkit", "task"] + static let codeModeExample = "await apple.reminders.completeReminder({ identifier: 'REMINDER_ID' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] + static let codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." + + struct Arguments: Sendable { + @ToolParam("EventKit calendarItemIdentifier to complete.") + var identifier: String + @ToolParam("Completion state.") + var isCompleted: Bool = true + } func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.writeReminder(arguments: arguments) + #if canImport(EventKit) + let identifier = arguments.identifier + guard identifier.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.write complete requires identifier") + } + let store = EKEventStore() + guard let reminder = store.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw BridgeError.invalidArguments("reminders.write could not find reminder identifier \(identifier)") + } + EventKitBridge.applyCompletion(.set(arguments.isCompleted), to: reminder) + do { + try store.save(reminder, commit: true) + return EventKitBridge.reminderJSON(reminder) + } catch { + throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + } + #else + throw BridgeError.unsupportedPlatform("EventKit") + #endif } } @BuiltInCodeMode(.remindersDelete, path: "apple.reminders.deleteReminder") struct RemindersDeleteTool: BuiltInCodeModeTool { - 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' })" @@ -323,18 +628,29 @@ struct RemindersDeleteTool: BuiltInCodeModeTool { var identifier: String } - let eventKit: EventKitBridge - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.deleteReminder(arguments: arguments) - } -} - -// Operation inference is shared by permission selection and execution. -extension CalendarWriteEventTool.Arguments { - var resolvedOperation: CalendarWriteOperation { operation ?? (identifier == nil ? .create : .update) } -} + #if canImport(EventKit) + let identifier = arguments.identifier + guard identifier.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.delete requires identifier") + } + let store = EKEventStore() + guard let reminder = store.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw BridgeError.invalidArguments("reminders.delete could not find reminder identifier \(identifier)") + } -extension RemindersWriteTool.Arguments { - var resolvedOperation: ReminderWriteOperation { operation ?? (identifier == nil ? .create : .update) } + do { + try store.remove(reminder, commit: true) + return .object([ + "identifier": .string(identifier), + "deleted": .bool(true), + ]) + } catch { + throw BridgeError.nativeFailure("reminders.delete failed: \(error.localizedDescription)") + } + #else + _ = arguments + throw BridgeError.unsupportedPlatform("EventKit") + #endif + } } diff --git a/Sources/CodeMode/Catalog/BridgeCatalog.swift b/Sources/CodeMode/Catalog/BridgeCatalog.swift index 8ec69f1..fe28309 100644 --- a/Sources/CodeMode/Catalog/BridgeCatalog.swift +++ b/Sources/CodeMode/Catalog/BridgeCatalog.swift @@ -17,6 +17,7 @@ final class BridgeCatalog: @unchecked Sendable { private struct Snapshot { var generation: Int var references: [JavaScriptAPIReference] + var referencesByJSName: [String: JavaScriptAPIReference] var referencesByCapability: [CodeModeCapabilityKey: JavaScriptAPIReference] var searchCatalog: JSONValue var allJavaScriptNames: [String] @@ -46,7 +47,7 @@ final class BridgeCatalog: @unchecked Sendable { private static func makeSnapshot(registry: CapabilityRegistry) -> Snapshot { let generation = registry.generation - let functions = registry.allRegisteredFunctions().sorted { $0.catalogCapability < $1.catalogCapability } + let functions = registry.allRegisteredFunctions().sorted { ($0.catalogCapability, $0.jsNames.first ?? "") < ($1.catalogCapability, $1.jsNames.first ?? "") } let references = functions.map(Self.reference(from:)) var byJSName: [String: JavaScriptAPIReference] = [:] @@ -59,6 +60,7 @@ final class BridgeCatalog: @unchecked Sendable { return Snapshot( generation: generation, references: references, + referencesByJSName: byJSName, referencesByCapability: Dictionary(references.map { ($0.capabilityKey, $0) }, uniquingKeysWith: { first, _ in first }), searchCatalog: Self.jsonValue( from: SearchCatalogPayload( @@ -79,6 +81,10 @@ final class BridgeCatalog: @unchecked Sendable { current().referencesByCapability[capabilityKey] } + func reference(forJavaScriptName jsName: String) -> JavaScriptAPIReference? { + current().referencesByJSName[jsName] + } + func allReferences() -> [JavaScriptAPIReference] { current().references } @@ -151,6 +157,7 @@ final class BridgeCatalog: @unchecked Sendable { requiredArguments: function.requiredArguments, optionalArguments: function.optionalArguments, argumentTypes: function.argumentTypes, + nullableArguments: function.nullableArguments, argumentHints: function.argumentHints, argumentConstraints: function.argumentConstraints, resultSummary: function.resultSummary diff --git a/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift b/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift index 29cfe77..ecf1849 100644 --- a/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift +++ b/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift @@ -228,7 +228,7 @@ public enum TypeScriptDeclarations { type: TypeScriptDeclarations.tsType( for: reference.argumentTypes[path] ?? .any, allowedValues: reference.argumentConstraints.allowedStringValues[path] - ), + ) + (reference.nullableArguments.contains(path) ? " | null" : ""), // A nested path is optional unless the exact path is // listed as required. optional: required.contains(path) == false, diff --git a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift index 84e53b3..29d8c9f 100644 --- a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift +++ b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift @@ -72,6 +72,7 @@ public enum CodeModeAgentToolDescriptions { requiredArguments: string[]; optionalArguments: string[]; argumentTypes: Record; + nullableArguments: string[]; argumentHints: Record; argumentConstraints: { allowedStringValues: Record }; resultSummary: string; @@ -80,6 +81,8 @@ public enum CodeModeAgentToolDescriptions { declare const api: { references: JavaScriptAPIReference[]; + // First helper when several share a capability; use byJSName or + // references.filter(ref => ref.capability === id) to select others. byCapability: Record; byJSName: Record; }; diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 1ee20eb..8b360dc 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -51,8 +51,8 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { return .init(allowedStringValues: [ "options.responseEncoding": ["text", "base64"], ]) - // calendarWrite / calendarDelete / calendarUIPickCalendar / remindersWrite - // constraints now come from their tools' CodeModeStringEnum arguments + // calendarDelete / calendarUIPickCalendar constraints now come + // from their tools' CodeModeStringEnum arguments // (EventKitCodeModeTools.swift), not this table. // photosRead / photosUIPick (MediaTypeFilter) and contactsUIPick // (ContactPickerMode) constraints now come from their tools' @@ -77,11 +77,12 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { try validate(arguments: arguments, capabilityName: capability.rawValue) } - func validate(arguments: [String: JSONValue], capabilityName: String) throws { + func validate(arguments: [String: JSONValue], capabilityName: String, nullableArguments: [String] = []) throws { for (path, allowed) in allowedStringValues.sorted(by: { $0.key < $1.key }) { guard let value = Self.value(atPath: path, in: arguments) else { continue } + if value == .null, nullableArguments.contains(path) { continue } guard let string = value.stringValue else { throw BridgeError.invalidArguments("\(capabilityName) expected '\(path)' as string, received \(Self.jsonTypeName(for: value))") } @@ -146,6 +147,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { public var requiredArguments: [String] public var optionalArguments: [String] public var argumentTypes: [String: CapabilityArgumentType] + public var nullableArguments: [String] public var argumentHints: [String: String] public var argumentConstraints: CapabilityArgumentConstraints public var resultSummary: String @@ -160,6 +162,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { requiredArguments: [String] = [], optionalArguments: [String] = [], argumentTypes: [String: CapabilityArgumentType] = [:], + nullableArguments: [String] = [], argumentHints: [String: String] = [:], argumentConstraints: CapabilityArgumentConstraints? = nil, resultSummary: String = "JSON value" @@ -173,6 +176,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { self.requiredArguments = requiredArguments self.optionalArguments = optionalArguments self.argumentTypes = argumentTypes.isEmpty ? CapabilityDescriptor.inferArgumentTypes(required: requiredArguments, optional: optionalArguments) : argumentTypes + self.nullableArguments = nullableArguments self.argumentHints = argumentHints self.argumentConstraints = argumentConstraints ?? CapabilityArgumentConstraints.defaults(for: id) self.resultSummary = resultSummary @@ -380,6 +384,7 @@ struct RegisteredCodeModeFunction: Sendable { var requiredArguments: [String] var optionalArguments: [String] var argumentTypes: [String: CapabilityArgumentType] + var nullableArguments: [String] var argumentHints: [String: String] var argumentConstraints: CapabilityArgumentConstraints var resultSummary: String @@ -406,6 +411,7 @@ struct RegisteredCodeModeFunction: Sendable { self.requiredArguments = descriptor.requiredArguments self.optionalArguments = descriptor.optionalArguments self.argumentTypes = descriptor.argumentTypes + self.nullableArguments = descriptor.nullableArguments self.argumentHints = descriptor.argumentHints self.argumentConstraints = descriptor.argumentConstraints self.resultSummary = descriptor.resultSummary @@ -424,6 +430,7 @@ struct RegisteredCodeModeFunction: Sendable { self.requiredArguments = registration.requiredArguments self.optionalArguments = registration.optionalArguments self.argumentTypes = registration.argumentTypes + self.nullableArguments = [] self.argumentHints = registration.argumentHints self.argumentConstraints = registration.argumentConstraints self.resultSummary = registration.resultSummary @@ -433,7 +440,7 @@ struct RegisteredCodeModeFunction: Sendable { public final class CapabilityRegistry: @unchecked Sendable { private let lock = NSLock() - private var registrations: [CapabilityID: CapabilityRegistration] = [:] + private var registrations: [CapabilityID: [CapabilityRegistration]] = [:] private var codeModeRegistrations: [CodeModeCapabilityKey: CodeModeRegistration] = [:] /// Bumped on every mutation so `BridgeCatalog` can tell whether its snapshot /// is still current. Without it the catalog was frozen at init, which made @@ -443,13 +450,29 @@ public final class CapabilityRegistry: @unchecked Sendable { public init(registrations: [CapabilityRegistration] = [], codeModeRegistrations: [CodeModeRegistration] = []) { for registration in registrations { - self.registrations[registration.descriptor.id] = registration + self.store(registration) } for registration in codeModeRegistrations { self.codeModeRegistrations[registration.capabilityKey] = registration } } + /// Replace all intersecting helper registrations, preserving the first + /// replaced slot. An unnamed registration overrides the entire capability. + private func store(_ registration: CapabilityRegistration) { + let id = registration.descriptor.id + guard !registration.jsNames.isEmpty else { + registrations[id] = [registration] + return + } + let names = Set(registration.jsNames) + var existing = registrations[id] ?? [] + let index = existing.firstIndex { !names.isDisjoint(with: $0.jsNames) } + existing.removeAll { !names.isDisjoint(with: $0.jsNames) } + existing.insert(registration, at: index ?? existing.endIndex) + registrations[id] = existing + } + /// Monotonic version of the registry's contents. public var generation: Int { lock.lock() @@ -459,7 +482,7 @@ public final class CapabilityRegistry: @unchecked Sendable { public func register(_ registration: CapabilityRegistration) { lock.lock() - registrations[registration.descriptor.id] = registration + store(registration) generationValue += 1 lock.unlock() } @@ -467,7 +490,7 @@ public final class CapabilityRegistry: @unchecked Sendable { public func register(_ registrations: [CapabilityRegistration]) { lock.lock() for registration in registrations { - self.registrations[registration.descriptor.id] = registration + self.store(registration) } generationValue += 1 lock.unlock() @@ -489,28 +512,31 @@ public final class CapabilityRegistry: @unchecked Sendable { lock.unlock() } + /// Returns the first registration's descriptor when tools share an ID. public func descriptor(for capability: CapabilityID) -> CapabilityDescriptor? { lock.lock() defer { lock.unlock() } - return registrations[capability]?.descriptor + return registrations[capability]?.first?.descriptor } - public func registration(for capability: CapabilityID) -> CapabilityRegistration? { + /// Selects the named helper, or the first registration when jsName is omitted. + public func registration(for capability: CapabilityID, jsName: String? = nil) -> CapabilityRegistration? { lock.lock() defer { lock.unlock() } - return registrations[capability] + if let jsName { return registrations[capability]?.first { $0.jsNames.contains(jsName) } } + return registrations[capability]?.first } public func allCapabilityRegistrations() -> [CapabilityRegistration] { lock.lock() defer { lock.unlock() } - return registrations.values.map { $0 } + return registrations.values.flatMap { $0 } } public func allDescriptors() -> [CapabilityDescriptor] { lock.lock() defer { lock.unlock() } - return registrations.values.map(\.descriptor) + return registrations.values.flatMap { $0 }.map(\.descriptor) } public func allCodeModeRegistrations() -> [CodeModeRegistration] { @@ -519,10 +545,13 @@ public final class CapabilityRegistry: @unchecked Sendable { return codeModeRegistrations.values.map { $0 } } - func registeredFunction(for capability: CapabilityID) -> RegisteredCodeModeFunction? { + func registeredFunction(for capability: CapabilityID, jsName: String? = nil) -> RegisteredCodeModeFunction? { lock.lock() defer { lock.unlock() } - return registrations[capability].map(RegisteredCodeModeFunction.init) + if let jsName { + return registrations[capability]?.first { $0.jsNames.contains(jsName) }.map(RegisteredCodeModeFunction.init) + } + return registrations[capability]?.first.map(RegisteredCodeModeFunction.init) } /// Resolves a *custom provider* key only. Built-ins are deliberately not @@ -538,21 +567,21 @@ public final class CapabilityRegistry: @unchecked Sendable { func allRegisteredFunctions() -> [RegisteredCodeModeFunction] { lock.lock() defer { lock.unlock() } - return registrations.values.map(RegisteredCodeModeFunction.init) + return registrations.values.flatMap { $0 }.map(RegisteredCodeModeFunction.init) + codeModeRegistrations.values.map(RegisteredCodeModeFunction.init) } - public func invoke(_ capabilityID: String, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + public func invoke(_ capabilityID: String, jsName: String? = nil, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try context.checkCancellation() if let capability = CapabilityID(rawValue: capabilityID) { - return try invokeBuiltIn(capability, arguments: arguments, context: context) + return try invokeBuiltIn(capability, jsName: jsName, arguments: arguments, context: context) } return try invokeCodeMode(CodeModeCapabilityKey(rawValue: capabilityID), arguments: arguments, context: context) } - private func invokeBuiltIn(_ capability: CapabilityID, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + private func invokeBuiltIn(_ capability: CapabilityID, jsName: String?, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { // Built-ins are gated by `allowedCapabilities` alone. `allowedCapabilityKeys` // accepts arbitrary strings and is not validated against `CapabilityID` at // decode time, so honouring it here would bypass any host that vets only @@ -561,7 +590,7 @@ public final class CapabilityRegistry: @unchecked Sendable { throw BridgeError.capabilityDenied(capability) } - guard let function = registeredFunction(for: capability) else { + guard let function = registeredFunction(for: capability, jsName: jsName) else { throw BridgeError.capabilityNotFound(capability.rawValue) } @@ -688,6 +717,7 @@ public final class CapabilityRegistry: @unchecked Sendable { continue } + if value == .null, function.nullableArguments.contains(path) { continue } guard expectedType.matches(value) else { throw BridgeError.invalidArguments( "\(name) expected '\(path)' as \(expectedType.rawValue), received \(jsonTypeName(for: value))" @@ -695,7 +725,7 @@ public final class CapabilityRegistry: @unchecked Sendable { } } - try function.argumentConstraints.validate(arguments: arguments, capabilityName: name) + try function.argumentConstraints.validate(arguments: arguments, capabilityName: name, nullableArguments: function.nullableArguments) let allowedNames = Set(required + optional + Array(typed.keys)) if allowedNames.isEmpty == false { diff --git a/Sources/CodeMode/Runtime/BridgeRuntime.swift b/Sources/CodeMode/Runtime/BridgeRuntime.swift index 2c8f9cc..61f9ae1 100644 --- a/Sources/CodeMode/Runtime/BridgeRuntime.swift +++ b/Sources/CodeMode/Runtime/BridgeRuntime.swift @@ -307,7 +307,7 @@ final class BridgeRuntime: @unchecked Sendable { invocationContext: BridgeInvocationContext, lastException: LockedBox ) throws { - let invokeBlock: @convention(block) (String, String) -> String = { capability, payload in + let invokeBlock: @convention(block) (String, String, String) -> String = { capability, payload, jsName in do { try invocationContext.checkCancellation() let data = Data(payload.utf8) @@ -316,7 +316,7 @@ final class BridgeRuntime: @unchecked Sendable { throw BridgeError.invalidRequest("Bridge payload must be a JSON object") } - let result = try self.registry.invoke(capability, arguments: parsed, context: invocationContext) + let result = try self.registry.invoke(capability, jsName: jsName.isEmpty ? nil : jsName, arguments: parsed, context: invocationContext) try invocationContext.checkCancellation() invocationContext.log(.debug, message: "Capability executed: \(capability)") invocationContext.auditLogger.log(AuditEvent(capability: capability, message: "success")) @@ -335,6 +335,7 @@ final class BridgeRuntime: @unchecked Sendable { for: bridgeError, capability: capabilityID, capabilityKey: capabilityKey, + jsName: jsName.isEmpty ? nil : jsName, invocationContext: invocationContext ) invocationContext.recordCapabilityFailure(capability: capability, code: errorPayload.code) @@ -347,6 +348,7 @@ final class BridgeRuntime: @unchecked Sendable { "code": .string(errorPayload.code), "message": .string(errorPayload.message), "capability": .string(capabilityKey.rawValue), + "functionName": jsName.isEmpty ? .null : .string(jsName), "suggestions": .array(errorPayload.suggestions.map { .string($0) }), ]), ]) @@ -1001,17 +1003,20 @@ final class BridgeRuntime: @unchecked Sendable { for error: BridgeError, capability: CapabilityID?, capabilityKey: CodeModeCapabilityKey?, + jsName: String? = nil, invocationContext: BridgeInvocationContext? = nil ) -> CodeModeToolError { let (message, suggestions) = enrichedBridgeFailure( for: error, capability: capability, capabilityKey: capabilityKey, + jsName: jsName, invocationContext: invocationContext ) return CodeModeToolError( code: error.diagnosticCode, message: message, + functionName: jsName, capability: capability, capabilityKey: capabilityKey, suggestions: suggestions @@ -1033,7 +1038,7 @@ final class BridgeRuntime: @unchecked Sendable { if isToolFailureCode(code) { let suggestions = payload.suggestions.isEmpty - ? bridgeSuggestions(for: payload.capability, capabilityKey: payload.capabilityKey) + ? bridgeSuggestions(for: payload.capability, capabilityKey: payload.capabilityKey, jsName: payload.functionName) : payload.suggestions return CodeModeToolError( code: code, @@ -1189,9 +1194,11 @@ final class BridgeRuntime: @unchecked Sendable { bridgeSuggestions(for: capability, capabilityKey: capability?.codeModeKey) } - private func bridgeSuggestions(for capability: CapabilityID?, capabilityKey: CodeModeCapabilityKey?) -> [String] { + private func bridgeSuggestions(for capability: CapabilityID?, capabilityKey: CodeModeCapabilityKey?, jsName: String? = nil) -> [String] { let reference: JavaScriptAPIReference? - if let capability { + if let jsName, let namedReference = catalog.reference(forJavaScriptName: jsName) { + reference = namedReference + } else if let capability { reference = catalog.reference(for: capability) } else if let capabilityKey { reference = catalog.reference(for: capabilityKey) @@ -1217,6 +1224,7 @@ final class BridgeRuntime: @unchecked Sendable { for error: BridgeError, capability: CapabilityID?, capabilityKey: CodeModeCapabilityKey?, + jsName: String? = nil, invocationContext: BridgeInvocationContext? = nil ) -> (String, [String]) { // A host-withheld denial reads the same whichever allowlist it came from, @@ -1244,12 +1252,12 @@ final class BridgeRuntime: @unchecked Sendable { suggestions = [ "Add \"\(capability.rawValue)\" to allowedCapabilities and retry.", "Built-in capabilities are granted only by allowedCapabilities; listing one in allowedCapabilityKeys has no effect.", - ] + bridgeSuggestions(for: capability, capabilityKey: capability.codeModeKey) + ] + bridgeSuggestions(for: capability, capabilityKey: capability.codeModeKey, jsName: jsName) case let .capabilityKeyDenied(capabilityKey): suggestions = [ "Add \"\(capabilityKey.rawValue)\" to allowedCapabilityKeys and retry.", "Custom provider capabilities are not enabled by allowedCapabilities.", - ] + bridgeSuggestions(for: nil, capabilityKey: capabilityKey) + ] + bridgeSuggestions(for: nil, capabilityKey: capabilityKey, jsName: jsName) case let .permissionDenied(permission): suggestions = permissionDeniedSuggestions(for: permission) case .customPermissionDenied: @@ -1269,7 +1277,7 @@ final class BridgeRuntime: @unchecked Sendable { "Only public HTTP(S) destinations permitted by CodeModeConfiguration.networkAccessPolicy are reachable.", ] default: - suggestions = bridgeSuggestions(for: capability, capabilityKey: capabilityKey) + suggestions = bridgeSuggestions(for: capability, capabilityKey: capabilityKey, jsName: jsName) } return (error.localizedDescription, suggestions) diff --git a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift index ddce142..702a37f 100644 --- a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift +++ b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift @@ -161,7 +161,7 @@ enum RuntimeJavaScript { static func builtInBootstrap(for registrations: [CapabilityRegistration]) -> String { let commands = registrations - .sorted { $0.descriptor.id.rawValue < $1.descriptor.id.rawValue } + .sorted { ($0.descriptor.id.rawValue, $0.jsNames.first ?? "") < ($1.descriptor.id.rawValue, $1.jsNames.first ?? "") } .flatMap { registration in registration.jsNames.sorted().map { jsName in "__codemodeInstallBindingIfMissing(\(jsonString(jsName)), \(jsonString(registration.descriptor.id.codeModeKey.rawValue)));" @@ -227,13 +227,14 @@ enum RuntimeJavaScript { globalThis.__codemode.result = null; globalThis.__codemode.error = null; - function __invoke(capability, args) { + function __invoke(capability, args, jsName) { const payload = JSON.stringify(args ?? {}); - const raw = __codemodeInvokeSync(String(capability), payload); + const raw = __codemodeInvokeSync(String(capability), payload, jsName || ''); const envelope = JSON.parse(String(raw || '{}')); if (!envelope.ok) { const error = new Error(envelope.error && envelope.error.message ? envelope.error.message : 'Bridge call failed'); error.code = envelope.error && envelope.error.code ? envelope.error.code : 'BRIDGE_ERROR'; + error.functionName = envelope.error && envelope.error.functionName ? envelope.error.functionName : null; error.capability = envelope.error && envelope.error.capability ? envelope.error.capability : null; error.suggestions = envelope.error && Array.isArray(envelope.error.suggestions) ? envelope.error.suggestions.map(function(value){ return String(value); }) : []; throw error; @@ -241,8 +242,8 @@ enum RuntimeJavaScript { return envelope.value; } - function __invokeAsync(capability, args) { - return Promise.resolve().then(function(){ return __invoke(capability, args); }); + function __invokeAsync(capability, args, jsName) { + return Promise.resolve().then(function(){ return __invoke(capability, args, jsName); }); } function __codemodeResolveBinding(jsPath) { @@ -261,20 +262,20 @@ enum RuntimeJavaScript { return { target: target, property: segments[segments.length - 1] }; } - function __codemodeInstallResolvedBinding(binding, capability) { + function __codemodeInstallResolvedBinding(binding, capability, jsPath) { binding.target[binding.property] = function(args) { - return __invokeAsync(capability, args || {}); + return __invokeAsync(capability, args || {}, jsPath); }; } function __codemodeInstallBinding(jsPath, capability) { - __codemodeInstallResolvedBinding(__codemodeResolveBinding(jsPath), capability); + __codemodeInstallResolvedBinding(__codemodeResolveBinding(jsPath), capability, jsPath); } function __codemodeInstallBindingIfMissing(jsPath, capability) { const binding = __codemodeResolveBinding(jsPath); if (typeof binding.target[binding.property] === 'undefined') { - __codemodeInstallResolvedBinding(binding, capability); + __codemodeInstallResolvedBinding(binding, capability, jsPath); } } @@ -365,24 +366,6 @@ enum RuntimeJavaScript { getCurrentPosition: function(args) { return __invokeAsync('location.read', Object.assign({ mode: 'current' }, args || {})); } }; - globalThis.apple.calendar = { - listEvents: function(args) { return __invokeAsync('calendar.read', args || {}); }, - createEvent: function(args) { return __invokeAsync('calendar.write', Object.assign({}, args || {}, { operation: 'create' })); }, - updateEvent: function(args) { return __invokeAsync('calendar.write', Object.assign({}, args || {}, { operation: 'update' })); }, - deleteEvent: function(args) { return __invokeAsync('calendar.delete', args || {}); }, - pickCalendar: function(args) { return __invokeAsync('calendar.ui.pickCalendar', args || {}); }, - presentEvent: function(args) { return __invokeAsync('calendar.ui.presentEvent', args || {}); }, - presentNewEvent: function(args) { return __invokeAsync('calendar.ui.presentNewEvent', args || {}); } - }; - - globalThis.apple.reminders = { - listReminders: function(args) { return __invokeAsync('reminders.read', args || {}); }, - createReminder: function(args) { return __invokeAsync('reminders.write', Object.assign({}, args || {}, { operation: 'create' })); }, - updateReminder: function(args) { return __invokeAsync('reminders.write', Object.assign({}, args || {}, { operation: 'update' })); }, - completeReminder: function(args) { return __invokeAsync('reminders.write', Object.assign({ isCompleted: true }, args || {}, { operation: 'complete' })); }, - deleteReminder: function(args) { return __invokeAsync('reminders.delete', args || {}); } - }; - globalThis.apple.contacts = { list: function(args) { return __invokeAsync('contacts.read', args || {}); }, search: function(args) { return __invokeAsync('contacts.search', args || {}); }, diff --git a/Sources/CodeMode/Support/Patch.swift b/Sources/CodeMode/Support/Patch.swift new file mode 100644 index 0000000..64f2035 --- /dev/null +++ b/Sources/CodeMode/Support/Patch.swift @@ -0,0 +1,28 @@ +/// An update argument: omission preserves the field; null clears it. +enum Patch: Sendable { + case unchanged + case clear + case set(Value) + + var isUnchanged: Bool { + if case .unchanged = self { return true } + return false + } + + /// Apply a clear or replacement value; omission never calls the setter. + func apply(_ update: (Value?) throws -> Void) rethrows { + switch self { + case .unchanged: break + case .clear: try update(nil) + case let .set(value): try update(value) + } + } + + static func decode(_ value: JSONValue?, using decode: () throws -> Value?) throws -> Self { + guard let value else { return .unchanged } + if case .null = value { return .clear } + // Match optional decoding, including its treatment of blank dates. + guard let decoded = try decode() else { return .clear } + return .set(decoded) + } +} diff --git a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift index ae7afbd..e215e69 100644 --- a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift +++ b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift @@ -19,7 +19,8 @@ public struct ToolParamMacro: PeerMacro { /// 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, +/// `decode(arguments:)`. Tools with Patch fields also get `hasChanges(_:)` +/// over their Patch and optional fields. Curated metadata (summary, tags, example, /// permissions, result summary) and `call(arguments:context:)` stay /// hand-written. /// @@ -79,6 +80,18 @@ public struct BuiltInCodeModeMacro: MemberMacro { } members.append(argumentsMetadataSource(for: parsed.fields)) members.append(decodeSource(for: parsed)) + if parsed.fields.contains(where: \.patch) { + let changes = parsed.fields.compactMap { field -> String? in + guard field.defaultExpression == nil else { return nil } + if field.patch { return "!arguments.\(field.name).isUnchanged" } + return field.optional ? "arguments.\(field.name) != nil" : nil + } + members.append(""" + static func hasChanges(_ arguments: Arguments) -> Bool { + \(changes.joined(separator: " || ")) + } + """) + } return members.map { DeclSyntax(stringLiteral: $0) } } @@ -88,7 +101,6 @@ public struct BuiltInCodeModeMacro: MemberMacro { private struct ParsedArguments { var fields: [Field] var hasRawPassthrough: Bool - var hasProvidedArguments: Bool var initializerLabels: [String] } @@ -97,12 +109,13 @@ public struct BuiltInCodeModeMacro: MemberMacro { var swiftType: String var kind: FieldKind var optional: Bool + var patch: Bool var hint: String var defaultExpression: String? var defaultJSON: String? } - fileprivate enum FieldKind { + fileprivate enum FieldKind: Equatable { /// A JSON primitive or collection with a direct CapabilityArgumentType. case primitive(String) /// An unrecognized simple type, emitted through the @@ -118,7 +131,6 @@ public struct BuiltInCodeModeMacro: MemberMacro { var fields: [Field] = [] var initializerLabels: [String] = [] var hasRawPassthrough = false - var hasProvidedArguments = false var rawIsLast = true var invalid = false @@ -151,12 +163,6 @@ public struct BuiltInCodeModeMacro: MemberMacro { initializerLabels.append(name) let hint = toolParamHint(on: variable.attributes) - if hint == nil, name == "providedArguments", type == "Set" { - rawIsLast = false - hasProvidedArguments = true - continue - } - 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))) @@ -179,7 +185,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { var defaultExpression: String? var defaultJSON: String? if let initializer = binding.initializer?.value { - if info.optional { + if info.optional || info.patch { context.diagnose(BuiltInMacroDiagnostic("@ToolParam optional properties cannot have initializers; resolve dynamic defaults in call", node: Syntax(initializer))) invalid = true continue @@ -198,6 +204,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { swiftType: info.unwrappedType, kind: info.kind, optional: info.optional, + patch: info.patch, hint: hint, defaultExpression: defaultExpression, defaultJSON: defaultJSON @@ -212,7 +219,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { return nil } - return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough, hasProvidedArguments: hasProvidedArguments, initializerLabels: initializerLabels) + return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough, initializerLabels: initializerLabels) } private static func toolParamHint(on attributes: AttributeListSyntax) -> String? { @@ -266,15 +273,17 @@ public struct BuiltInCodeModeMacro: MemberMacro { return "static let codeModeArguments: [BuiltInToolArgument] = []" } let entries = fields.map { field -> String in - let optionalPart = (field.optional || field.defaultExpression != nil) ? ", optional: true" : "" + let optionalPart = (field.optional || field.patch || field.defaultExpression != nil) ? ", optional: true" : "" + let nullablePart = field.patch ? ", nullable: true" : "" + let hint = field.kind == .date ? [field.hint, "ISO 8601 timestamp."].filter { !$0.isEmpty }.joined(separator: " ") : field.hint let defaultPart = field.defaultJSON.map { ", defaultValue: \($0)" } ?? "" switch field.kind { case .date: - return "BuiltInToolArgument(\(stringLiteral(field.name)), .string\(optionalPart), hint: \(stringLiteral(field.hint))\(defaultPart))," + return "BuiltInToolArgument(\(stringLiteral(field.name)), .string\(optionalPart)\(nullablePart), hint: \(stringLiteral(hint))\(defaultPart))," case let .primitive(argumentType): - return "BuiltInToolArgument(\(stringLiteral(field.name)), .\(argumentType)\(optionalPart), hint: \(stringLiteral(field.hint))\(defaultPart))," + return "BuiltInToolArgument(\(stringLiteral(field.name)), .\(argumentType)\(optionalPart)\(nullablePart), hint: \(stringLiteral(hint))\(defaultPart))," case .stringEnum: - return "BuiltInToolArgument(\(stringLiteral(field.name)), oneOf: \(field.swiftType).self\(optionalPart), hint: \(stringLiteral(field.hint))\(defaultPart))," + return "BuiltInToolArgument(\(stringLiteral(field.name)), oneOf: \(field.swiftType).self\(optionalPart)\(nullablePart), hint: \(stringLiteral(hint))\(defaultPart))," } }.joined(separator: "\n ") return """ @@ -295,12 +304,18 @@ public struct BuiltInCodeModeMacro: MemberMacro { body.append(enumFields.isEmpty && parsed.fields.allSatisfy { $0.defaultExpression == nil } ? "let raw = arguments" : "var raw = arguments") } for field in parsed.fields { - let method = field.optional || field.defaultExpression != nil ? "optional" : "require" + let method = field.optional || field.patch || field.defaultExpression != nil ? "optional" : "require" + let expression: String if case .date = field.kind { - body.append("let \(field.name) = try CodeModeDate.\(method)(arguments.string(\(stringLiteral(field.name))), argument: \(stringLiteral(field.name)), capability: Self.codeModeCapability.rawValue)") + expression = "try CodeModeDate.\(method)(arguments.string(\(stringLiteral(field.name))), argument: \(stringLiteral(field.name)), capability: Self.codeModeCapability.rawValue)" + } else { + expression = "try CodeModeArgumentDecoder.\(method)(\(stringLiteral(field.name)), as: \(field.swiftType).self, in: arguments)" + } + if field.patch { + body.append("let \(field.name): Patch<\(field.swiftType)> = try Patch.decode(arguments[\(stringLiteral(field.name))]) { \(expression) }") } else { let fallback = field.defaultExpression.map { " ?? \($0)" } ?? "" - body.append("let \(field.name) = try CodeModeArgumentDecoder.\(method)(\(stringLiteral(field.name)), as: \(field.swiftType).self, in: arguments)\(fallback)") + body.append("let \(field.name) = \(expression)\(fallback)") } } if parsed.hasRawPassthrough { @@ -315,7 +330,6 @@ public struct BuiltInCodeModeMacro: MemberMacro { let initArguments = parsed.initializerLabels.map { name in if name == "raw", parsed.hasRawPassthrough { return "raw: raw" } - if name == "providedArguments", parsed.hasProvidedArguments { return "providedArguments: Set(arguments.keys)" } return "\(name): \(name)" } body.append(initArguments.isEmpty ? "return Arguments()" : "return Arguments(\(initArguments.joined(separator: ", ")))") @@ -340,10 +354,15 @@ private struct BuiltInTypeInfo { var unwrappedType: String var kind: BuiltInCodeModeMacro.FieldKind var optional: Bool + var patch: Bool init(typeSyntax rawType: String) { let trimmed = rawType.replacingOccurrences(of: " ", with: "") - if trimmed.hasSuffix("?") { + patch = trimmed.hasPrefix("Patch<") && trimmed.hasSuffix(">") + if patch { + optional = false + unwrappedType = String(trimmed.dropFirst("Patch<".count).dropLast()) + } else if trimmed.hasSuffix("?") { optional = true unwrappedType = String(trimmed.dropLast()) } else if trimmed.hasPrefix("Optional<"), trimmed.hasSuffix(">") { diff --git a/TODO.md b/TODO.md index 15eb772..c3e167f 100644 --- a/TODO.md +++ b/TODO.md @@ -135,11 +135,11 @@ tool model). 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`). 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. + function table in `RuntimeJavaScript.swift`. EventKit now uses independent + registration-generated bindings, with no hidden `operation` injection; + generating the remaining families is still a follow-up. +- [ ] Unify permission ownership across the remaining bridge families. + EventKit requirements are now declared statically and enforced by the registry. - [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 @@ -277,10 +277,10 @@ Done: the concrete drift found while generating declarations is fixed (keychain object/positional, `fs.promises.rename`/`copyFile` options, location argument pass-through, tool-description calling conventions). - [ ] `RuntimeJavaScript.bootstrap` is still ~250 hand-written lines that can - drift from the catalog again. Wrappers that inject hidden arguments - (`createEvent` forces `operation: 'create'`) still cannot be expressed in - catalog metadata, so the generated `.d.ts` advertises an argument the wrapper - overrides. + drift from the catalog again. EventKit's create/update/complete helpers now + have separate typed registrations and matching declarations; their hidden + `operation` injection is resolved. Migrate the remaining hand-written + families to registration-generated bindings. ### #13 — provider ergonomics + MCP Done: catalog unfrozen (post-init `register(provider:)` works and stays in sync), diff --git a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift index 5350f88..4258d55 100644 --- a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift +++ b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift @@ -282,8 +282,8 @@ private func builtInTestMacros() -> [String: Macro.Type] { static let codeModePath: String = "apple.calendar.listEvents" static let codeModeArguments: [BuiltInToolArgument] = [ - BuiltInToolArgument("start", .string, hint: "Start timestamp."), - BuiltInToolArgument("end", .string, optional: true, hint: "End timestamp."), + BuiltInToolArgument("start", .string, hint: "Start timestamp. ISO 8601 timestamp."), + BuiltInToolArgument("end", .string, optional: true, hint: "End timestamp. ISO 8601 timestamp."), ] func decode(arguments: [String: JSONValue]) throws -> Arguments { @@ -341,23 +341,30 @@ func builtInCodeModeExpansionDiagnosesUnsupportedDefaults(type: String, initiali ) } -@Test func builtInCodeModeExpansionTracksSuppliedFieldsWithoutRawValues() { +@Test func builtInCodeModeExpansionDetectsPatchAndOptionalChanges() { assertMacroExpansion( """ @BuiltInCodeMode(.calendarWrite, path: "apple.calendar.updateEvent") struct PatchTool: BuiltInCodeModeTool { struct Arguments: Sendable { - var providedArguments: Set + @ToolParam("Identifier.") + var identifier: String @ToolParam("Notes patch.") - var notes: String? + var notes: Patch + @ToolParam("Time zone.") + var timeZone: String? + @ToolParam("Limit.") + var limit: Int = 50 } } """, expandedSource: """ struct PatchTool: BuiltInCodeModeTool { struct Arguments: Sendable { - var providedArguments: Set - var notes: String? + var identifier: String + var notes: Patch + var timeZone: String? + var limit: Int = 50 } static let codeModeCapability: CapabilityID = .calendarWrite @@ -365,12 +372,24 @@ func builtInCodeModeExpansionDiagnosesUnsupportedDefaults(type: String, initiali static let codeModePath: String = "apple.calendar.updateEvent" static let codeModeArguments: [BuiltInToolArgument] = [ - BuiltInToolArgument("notes", .string, optional: true, hint: "Notes patch."), + BuiltInToolArgument("identifier", .string, hint: "Identifier."), + BuiltInToolArgument("notes", .string, optional: true, nullable: true, hint: "Notes patch."), + BuiltInToolArgument("timeZone", .string, optional: true, hint: "Time zone."), + BuiltInToolArgument("limit", .number, optional: true, hint: "Limit.", defaultValue: .number(Double(50))), ] func decode(arguments: [String: JSONValue]) throws -> Arguments { - let notes = try CodeModeArgumentDecoder.optional("notes", as: String.self, in: arguments) - return Arguments(providedArguments: Set(arguments.keys), notes: notes) + let identifier = try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments) + let notes: Patch = try Patch.decode(arguments["notes"]) { + try CodeModeArgumentDecoder.optional("notes", as: String.self, in: arguments) + } + let timeZone = try CodeModeArgumentDecoder.optional("timeZone", as: String.self, in: arguments) + let limit = try CodeModeArgumentDecoder.optional("limit", as: Int.self, in: arguments) ?? 50 + return Arguments(identifier: identifier, notes: notes, timeZone: timeZone, limit: limit) + } + + static func hasChanges(_ arguments: Arguments) -> Bool { + !arguments.notes.isUnchanged || arguments.timeZone != nil } } """, diff --git a/Tests/CodeModeTests/AgentGuidanceTests.swift b/Tests/CodeModeTests/AgentGuidanceTests.swift index 9dbbd94..aae0200 100644 --- a/Tests/CodeModeTests/AgentGuidanceTests.swift +++ b/Tests/CodeModeTests/AgentGuidanceTests.swift @@ -169,3 +169,74 @@ import Testing #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.count < 4_800) #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("const results = []") == false) } + +@Test func everyCatalogExampleNamesRealHelpersAndArguments() async throws { + let (tools, sandbox) = try makeTools(hostPlatform: .iOS) + defer { cleanup(sandbox) } + let references = tools.capabilities() + let byName = Dictionary(references.flatMap { ref in ref.jsNames.map { ($0, ref) } }, uniquingKeysWith: { first, _ in first }) + // Scan object keys at each declared level, skipping quoted strings and + // nested literals. Open objects (fields, parameters, etc.) own their keys; + // only objects with catalogued children constrain the nested names. + func keys(in source: Substring) throws -> [(String, Substring?)] { + let chars = Array(source) + var entries: [(String, Substring?)] = [] + var depth = 0 + var quote: Character? + var escaped = false + var start = 0 + var pieces: [String] = [] + for (index, char) in chars.enumerated() { + if let current = quote { + if escaped { escaped = false } + else if char == "\\" { escaped = true } + else if char == current { quote = nil } + continue + } + if char == "\"" || char == "'" || char == "`" { quote = char } + else if char == "{" || char == "[" || char == "(" { depth += 1 } + else if char == "}" || char == "]" || char == ")" { depth -= 1 } + else if char == ",", depth == 0 { + pieces.append(String(chars[start.. 30) +} + +@BuiltInCodeMode(.calendarRead, path: "apple.calendar.patchFixture") +private struct PatchFixtureTool: BuiltInCodeModeTool { + static let codeModeSummary = "Exercise typed patches." + static let codeModeTags: [String] = [] + static let codeModeExample = "" + static let codeModeResultSummary = "JSON value" + struct Arguments: Sendable { + @ToolParam("Mode.") var mode: Patch + @ToolParam("Date.") var date: Patch + } + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + switch arguments.mode { + case .unchanged: return .string("unchanged") + case .clear: return .null + case let .set(mode): return .string(mode.rawValue) + } + } +} + +@Test func patchesDecodeThroughRegistryIncludingConstrainedNullAndDates() throws { + let registry = CapabilityRegistry(registrations: [CapabilityRegistration(tool: PatchFixtureTool())]) + let (context, sandbox) = try makeInvocationContext() + defer { cleanup(sandbox) } + #expect(try registry.invoke("calendar.read", arguments: [:], context: context) == .string("unchanged")) + #expect(try registry.invoke("calendar.read", arguments: ["mode": .null], context: context) == .null) + #expect(try registry.invoke("calendar.read", arguments: ["mode": .string("ALIAS")], context: context) == .string("wire-value")) + let decoded = try PatchFixtureTool().decode(arguments: ["date": .string("2026-07-24T10:00:00.000Z")]) + guard case let .set(date) = decoded.date else { Issue.record("Expected a date patch"); return } + #expect(date == Date(timeIntervalSince1970: 1_784_887_200)) + #expect(throws: (any Error).self) { + _ = try registry.invoke("calendar.read", arguments: ["mode": .string("unknown")], context: context) + } + let reference = try #require(BridgeCatalog(registry: registry).allReferences().first) + #expect(reference.nullableArguments == ["mode", "date"]) + #expect(reference.dts.contains("date?: string | null;")) + #expect(reference.dts.contains("mode?: \"wire-value\" | \"alias\" | null;")) +} + +@BuiltInCodeMode(.calendarWrite, path: "apple.calendar.dynamicPermissionFixture") +private struct DynamicPermissionFixtureTool: BuiltInCodeModeTool { + static let codeModeSummary = "Exercise permission selection after decoding." + static let codeModeTags: [String] = [] + static let codeModeExample = "" + static let codeModeResultSummary = "JSON value" + struct Arguments: Sendable { + @ToolParam("Mode.") var mode: DefaultFixtureMode + } + func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] { [.calendarWriteOnly] } + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { .null } +} + +@Test func invalidDynamicPermissionArgumentsFailBeforeRequestingAccess() throws { + let registration = CapabilityRegistration(tool: DynamicPermissionFixtureTool()) + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker( + statuses: [.calendarWriteOnly: .notDetermined], requestStatuses: [.calendarWriteOnly: .granted] + )) + defer { cleanup(sandbox) } + // Invoke the generated handler directly: registry validation cannot mask a + // regression that prompts before the generated tool decoder runs. + #expect(throws: CodeModeFunctionError.invalidArguments("Argument mode must be one of wire-value, alias")) { + _ = try registration.handler(["mode": .string("invalid")], context) + } + #expect(context.allPermissionEvents().isEmpty) + _ = try registration.handler(["mode": .string("alias")], context) + #expect(context.allPermissionEvents().contains { $0.permission == .calendarWriteOnly && $0.status == .granted }) +} diff --git a/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift index 4176c9b..6b0c337 100644 --- a/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift +++ b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift @@ -15,6 +15,7 @@ private struct CapabilitySnapshot: Codable, Equatable { var requiredArguments: [String] var optionalArguments: [String] var argumentTypes: [String: String] + var nullableArguments: [String] var argumentHints: [String: String] var allowedStringValues: [String: [String]] var resultSummary: String @@ -31,6 +32,7 @@ private struct CapabilitySnapshot: Codable, Equatable { requiredArguments = descriptor.requiredArguments optionalArguments = descriptor.optionalArguments argumentTypes = descriptor.argumentTypes.mapValues(\.rawValue) + nullableArguments = descriptor.nullableArguments argumentHints = descriptor.argumentHints allowedStringValues = descriptor.argumentConstraints.allowedStringValues resultSummary = descriptor.resultSummary diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 914c4c3..5f89bce 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -5,8 +5,8 @@ import Testing private func jsNames(for capability: CapabilityID) -> [String] { DefaultCapabilityLoader.loadAllRegistrations() - .first { $0.descriptor.id == capability }? - .jsNames ?? [] + .filter { $0.descriptor.id == capability } + .flatMap(\.jsNames) } @Test func defaultCapabilityLoaderCoversAllCapabilityIDs() { @@ -113,7 +113,8 @@ private func jsNames(for capability: CapabilityID) -> [String] { @Test func systemUIDescriptorsExposeExpectedJavaScriptNames() throws { let descriptors = Dictionary( - uniqueKeysWithValues: DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) } + DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) }, + uniquingKeysWith: { first, _ in first } ) let calendar = try #require(descriptors[.calendarUIPresentNewEvent]) @@ -160,11 +161,12 @@ private func jsNames(for capability: CapabilityID) -> [String] { @Test func expandedAPIDescriptorsExposeExpectedJavaScriptNames() throws { let descriptors = Dictionary( - uniqueKeysWithValues: DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) } + DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) }, + uniquingKeysWith: { first, _ in first } ) let calendarWrite = try #require(descriptors[.calendarWrite]) - #expect(calendarWrite.requiredPermissions.isEmpty) + #expect(calendarWrite.requiredPermissions == [.calendarWriteOnly]) #expect(calendarWrite.optionalArguments.contains("calendarIdentifier")) #expect(jsNames(for: .calendarWrite) == ["apple.calendar.createEvent", "apple.calendar.updateEvent"]) @@ -209,7 +211,8 @@ private func jsNames(for capability: CapabilityID) -> [String] { @Test func bigTicketAPIDescriptorsExposeExpectedJavaScriptNames() throws { let descriptors = Dictionary( - uniqueKeysWithValues: DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) } + DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) }, + uniquingKeysWith: { first, _ in first } ) let cloudKit = try #require(descriptors[.cloudKitRecordsQuery]) @@ -656,3 +659,43 @@ private final class StatusObservingPermissionBroker: PermissionBroker, @unchecke #expect(context.allPermissionEvents().map(\.permission) == [.calendar, .calendar]) #expect(context.allPermissionEvents().map(\.status) == [.granted, .granted]) } + +@Test(arguments: ["matching", "empty", "split"]) +func builtInOverridesReplaceIntersectingNamesAndPreserveOtherTools(mode: String) throws { + var create = CapabilityRegistration(tool: CalendarCreateEventTool()) + create.jsNames.append("apple.calendar.legacyCreate") + create.handler = { _, _ in .string("create") } + var update = CapabilityRegistration(tool: CalendarUpdateEventTool()) + update.handler = { _, _ in .string("update") } + let registry = CapabilityRegistry(registrations: [create, update]) + let catalog = BridgeCatalog(registry: registry) + #expect(catalog.allReferences().count == 2) + var replacement = create + replacement.descriptor.requiredPermissions = [] + replacement.descriptor.title = "Override" + replacement.jsNames = switch mode { + case "matching": ["apple.calendar.legacyCreate", "apple.calendar.updateEvent"] + case "empty": [] + default: ["apple.calendar.updateEvent"] + } + replacement.handler = { _, _ in .string("override") } + registry.register(replacement) + let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker(statuses: [.calendarWriteOnly: .granted])) + defer { cleanup(sandbox) } + let args: [String: JSONValue] = [ + "title": .string("T"), "start": .string("2026-02-22T16:00:00Z"), "end": .string("2026-02-22T17:00:00Z"), + ] + #expect(registry.allCapabilityRegistrations().count == (mode == "split" ? 2 : 1)) + #expect(catalog.allReferences().count == (mode == "split" ? 2 : 1)) + if mode == "split" { + #expect(try registry.invoke("calendar.write", jsName: "apple.calendar.createEvent", arguments: args, context: context) == .string("create")) + #expect(try registry.invoke("calendar.write", jsName: "apple.calendar.updateEvent", arguments: args, context: context) == .string("override")) + #expect(registry.descriptor(for: .calendarWrite)?.title == "createEvent") + } else { + #expect(try registry.invoke("calendar.write", arguments: args, context: context) == .string("override")) + #expect(registry.registration(for: .calendarWrite)?.descriptor.title == "Override") + if mode == "matching" { + #expect(try registry.invoke("calendar.write", jsName: "apple.calendar.updateEvent", arguments: args, context: context) == .string("override")) + } + } +} diff --git a/Tests/CodeModeTests/CodeModeProviderTests.swift b/Tests/CodeModeTests/CodeModeProviderTests.swift index 1574388..ae8cd55 100644 --- a/Tests/CodeModeTests/CodeModeProviderTests.swift +++ b/Tests/CodeModeTests/CodeModeProviderTests.swift @@ -232,7 +232,7 @@ private struct ManualProvider: CodeModeProvider { jsNames: registration.jsNames, descriptor: registration.descriptor ) { _, _ in - invocations.mutate { $0.append(registration.descriptor.id.rawValue) } + invocations.mutate { $0.append(registration.jsNames.first ?? registration.descriptor.id.rawValue) } return fakeRuntimeValue(for: registration.descriptor.id) } } @@ -287,7 +287,12 @@ private struct ManualProvider: CodeModeProvider { let observed = await observe(call) let output = try #require(observed.result?.output?.objectValue) #expect(output.array("failures") == []) - #expect(invocations.get() == checks.map { $0.descriptor.id.rawValue }) + // Shared capability IDs must still invoke the registration for the called + // helper, including every alias of that registration. + let owners = Dictionary(fakeRegistrations.flatMap { registration in + registration.jsNames.map { ($0, registration.jsNames.first!) } + }, uniquingKeysWith: { first, _ in first }) + #expect(invocations.get() == checks.map { owners[$0.jsName]! }) let macRegistrations = CapabilityPlatformSupport.filter(fakeRegistrations, for: .macOS) let unsupportedMacNames = CapabilityPlatformSupport.unsupportedJavaScriptNames(from: fakeRegistrations, for: .macOS) diff --git a/Tests/CodeModeTests/EventKitBridgeTests.swift b/Tests/CodeModeTests/EventKitBridgeTests.swift index cb8c77d..654559f 100644 --- a/Tests/CodeModeTests/EventKitBridgeTests.swift +++ b/Tests/CodeModeTests/EventKitBridgeTests.swift @@ -19,15 +19,14 @@ import Testing } do { - _ = try registry.invoke("calendar.write", arguments: [:], context: context) + _ = try registry.invoke("calendar.write", arguments: ["title": .string("T"), "start": .string("2026-02-22T16:00:00Z"), "end": .string("2026-02-22T17:00:00Z")], context: context) Issue.record("Expected permission denial for calendar.write") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } do { - _ = try registry.invoke("calendar.write", arguments: [ - "operation": .string("update"), + _ = try registry.invoke("calendar.write", jsName: "apple.calendar.updateEvent", arguments: [ "identifier": .string("event-1"), "title": .string("Updated"), ], context: context) @@ -58,7 +57,7 @@ import Testing } do { - _ = try registry.invoke("reminders.write", arguments: [:], context: context) + _ = try registry.invoke("reminders.write", arguments: ["title": .string("T")], context: context) Issue.record("Expected permission denial for reminders.write") } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") @@ -104,7 +103,7 @@ func executeCalendarWriteUsesWriteOnlyPermissionPath(initialStatus: PermissionSt tools, request: JavaScriptExecutionRequest( code: """ - await apple.calendar.createEvent({}); + await apple.calendar.createEvent({ title: '', start: '2026-02-22T16:00:00Z', end: '2026-02-22T17:00:00Z' }); return { ok: true }; """, allowedCapabilities: [.calendarWrite] @@ -113,7 +112,7 @@ func executeCalendarWriteUsesWriteOnlyPermissionPath(initialStatus: PermissionSt #if canImport(EventKit) #expect(observed.error?.code == "INVALID_ARGUMENTS") - #expect(observed.error?.message == "calendar.write create requires title/start/end ISO8601") + #expect(observed.error?.message == "calendar.write title cannot be empty") #else #expect(observed.error?.code == "UNSUPPORTED_PLATFORM") #endif @@ -160,7 +159,7 @@ import EventKit // every neighbouring field was nil-coalesced. #expect(event.calendar == nil) - let json = try #require(EventKitBridge.eventJSONForTesting(event).objectValue) + let json = try #require(EventKitBridge.eventJSON(event).objectValue) #expect(json.string("title") == "Orphaned") #expect(json.string("calendarIdentifier") == "") #expect(json.string("calendarTitle") == "") @@ -172,7 +171,7 @@ import EventKit let reminder = EKReminder(eventStore: store) // `EKReminder.title` is `String!`. - let json = try #require(EventKitBridge.reminderJSONForTesting(reminder).objectValue) + let json = try #require(EventKitBridge.reminderJSON(reminder).objectValue) #expect(json.string("title") == "") #expect(json.string("calendarIdentifier") == "") #expect(json.bool("isCompleted") == false) @@ -180,21 +179,20 @@ import EventKit #endif @Test func eventKitOmittedArgumentsUseDeclaredDefaults() throws { - let bridge = EventKitBridge() - let events = try CalendarListEventsTool(eventKit: bridge).decode(arguments: [:]) + let events = try CalendarListEventsTool().decode(arguments: [:]) #expect(events.limit == 50) #expect(events.start == nil) #expect(events.end == nil) - let reminders = try RemindersListTool(eventKit: bridge).decode(arguments: [:]) + let reminders = try RemindersListTool().decode(arguments: [:]) #expect(reminders.limit == 50) #expect(reminders.includeCompleted == false) - let deletion = try CalendarDeleteEventTool(eventKit: bridge).decode(arguments: ["identifier": .string("E1")]) + let deletion = try CalendarDeleteEventTool().decode(arguments: ["identifier": .string("E1")]) #expect(deletion.span == .thisEvent) - let overrides = try RemindersListTool(eventKit: bridge).decode(arguments: ["limit": .number(3), "includeCompleted": .bool(true)]) + let overrides = try RemindersListTool().decode(arguments: ["limit": .number(3), "includeCompleted": .bool(true)]) #expect(overrides.limit == 3) #expect(overrides.includeCompleted) - let blank = try CalendarListEventsTool(eventKit: bridge).decode(arguments: ["start": .string(" "), "end": .string("")]) + let blank = try CalendarListEventsTool().decode(arguments: ["start": .string(" "), "end": .string("")]) #expect(blank.start == nil) #expect(blank.end == nil) } @@ -219,36 +217,27 @@ func eventKitReadDateErrorsRetainScriptMessages(capability: String, argument: St } @Test(arguments: [ - (nil, nil, PermissionKind.calendarWriteOnly), - (nil, "E1", .calendar), - (nil, "", .calendar), - ("create", "E1", .calendarWriteOnly), - ("update", nil, .calendar), - ("UPDATE", "E1", .calendar), + ("apple.calendar.createEvent", PermissionKind.calendarWriteOnly), + ("apple.calendar.updateEvent", .calendar), ]) -func eventKitWriteSelectsPermissionFromOperation(operation: String?, identifier: String?, expected: PermissionKind) throws { - let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) - let (context, sandbox) = try makeInvocationContext(permissionBroker: FixedPermissionBroker(statuses: [ +func eventKitWritesRequireTheirOwnStaticPermissions(helper: String, expected: PermissionKind) async throws { + let (tools, sandbox) = try makeTools(permissionBroker: FixedPermissionBroker(statuses: [ .calendar: .denied, .calendarWriteOnly: .denied, ])) defer { cleanup(sandbox) } - var arguments: [String: JSONValue] = [:] - arguments["operation"] = operation.map(JSONValue.string) - arguments["identifier"] = identifier.map(JSONValue.string) - do { - _ = try registry.invoke("calendar.write", arguments: arguments, context: context) - Issue.record("Expected operation-specific permission denial") - } catch let error as BridgeError { - #expect(error.diagnosticCode == "PERMISSION_DENIED") - #expect(error.localizedDescription == "Permission denied: \(expected.rawValue)") - #expect(context.allPermissionEvents().map(\.permission) == [expected]) - } + let args = helper.hasSuffix("createEvent") + ? "{ title: 'T', start: '2026-02-22T16:00:00Z', end: '2026-02-22T17:00:00Z' }" + : "{ identifier: 'E1', notes: null }" + let observed = try await execute(tools, request: JavaScriptExecutionRequest( + code: "await \(helper)(\(args));", allowedCapabilities: [.calendarWrite] + )) + #expect(observed.error?.code == "PERMISSION_DENIED") + #expect(observed.error?.message == "Permission denied: \(expected.rawValue)") } #if canImport(EventKit) @Test func eventUpdatePatchDistinguishesNullFromOmission() throws { - let bridge = EventKitBridge() - let tool = CalendarWriteEventTool(eventKit: bridge) + let tool = CalendarUpdateEventTool() let store = EKEventStore() let event = EKEvent(eventStore: store) event.title = "Keep title" @@ -264,7 +253,7 @@ func eventKitWriteSelectsPermissionFromOperation(operation: String?, identifier: let originalEnd = event.endDate let omitted = try tool.decode(arguments: ["identifier": .string("E1")]) - try bridge.applyEventPatch(arguments: omitted, to: event, in: store) + try omitted.apply(to: event, in: store) #expect(event.notes == "Keep notes") #expect(event.location == "Keep location") #expect(event.url?.absoluteString == "https://example.com") @@ -276,17 +265,30 @@ func eventKitWriteSelectsPermissionFromOperation(operation: String?, identifier: "identifier": .string("E1"), "notes": .null, "location": .null, "url": .null, "isAllDay": .null, ]) - try bridge.applyEventPatch(arguments: cleared, to: event, in: store) + try cleared.apply(to: event, in: store) #expect(event.notes == nil) #expect(event.location == nil) #expect(event.url == nil) #expect(event.isAllDay == false) #expect(event.title == "Keep title") + + let updated = try tool.decode(arguments: [ + "identifier": .string("E1"), "title": .string("New title"), + "start": .string("2026-02-22T16:00:00Z"), "end": .string("2026-02-22T17:00:00Z"), + "notes": .string("New notes"), "location": .string("New location"), + "url": .string("https://example.org"), + ]) + try updated.apply(to: event, in: store) + #expect(event.title == "New title") + #expect(event.startDate == CodeModeDate.parse("2026-02-22T16:00:00Z")) + #expect(event.endDate == CodeModeDate.parse("2026-02-22T17:00:00Z")) + #expect(event.notes == "New notes") + #expect(event.location == "New location") + #expect(event.url?.absoluteString == "https://example.org") } @Test func reminderUpdatePatchDistinguishesNullFromOmission() throws { - let bridge = EventKitBridge() - let tool = RemindersWriteTool(eventKit: bridge) + let tool = RemindersUpdateTool() let store = EKEventStore() let reminder = EKReminder(eventStore: store) reminder.title = "Keep title" @@ -298,7 +300,7 @@ func eventKitWriteSelectsPermissionFromOperation(operation: String?, identifier: reminder.completionDate = date let omitted = try tool.decode(arguments: ["identifier": .string("R1")]) - try bridge.applyReminderPatch(arguments: omitted, to: reminder, in: store) + try omitted.apply(to: reminder, in: store) #expect(reminder.notes == "Keep notes") #expect(reminder.dueDateComponents != nil) #expect(reminder.isCompleted) @@ -309,32 +311,116 @@ func eventKitWriteSelectsPermissionFromOperation(operation: String?, identifier: "identifier": .string("R1"), "notes": .null, "dueDate": .null, "isCompleted": .null, "priority": .null, ]) - try bridge.applyReminderPatch(arguments: cleared, to: reminder, in: store) + try cleared.apply(to: reminder, in: store) #expect(reminder.notes == nil) #expect(reminder.dueDateComponents == nil) #expect(reminder.isCompleted == false) #expect(reminder.completionDate == nil) - #expect(reminder.priority == 7) + #expect(reminder.priority == 0) #expect(reminder.title == "Keep title") + + let updated = try tool.decode(arguments: [ + "identifier": .string("R1"), "title": .string("New title"), "notes": .string("New notes"), + "dueDate": .string("2026-02-22T16:00:00Z"), "isCompleted": .bool(true), "priority": .number(9), + ]) + try updated.apply(to: reminder, in: store) + #expect(reminder.title == "New title") + #expect(reminder.notes == "New notes") + #expect(reminder.dueDateComponents?.date == CodeModeDate.parse("2026-02-22T16:00:00Z")) + #expect(reminder.isCompleted) + #expect(reminder.completionDate != nil) + #expect(reminder.priority == 9) } @Test(arguments: ["start", "end", "dueDate"]) -func eventKitWriteDateErrorsRetainTheirOperationSpecificMessages(argument: String) throws { - let bridge = EventKitBridge() +func eventKitWriteDateErrorsUseStandardRepairableMessages(argument: String) throws { let store = EKEventStore() do { if argument == "dueDate" { - let decoded = try RemindersWriteTool(eventKit: bridge).decode(arguments: [argument: .string("soon")]) - try bridge.applyReminderPatch(arguments: decoded, to: EKReminder(eventStore: store), in: store) + let decoded = try RemindersUpdateTool().decode(arguments: ["identifier": .string("ID"), argument: .string("soon")]) + try decoded.apply(to: EKReminder(eventStore: store), in: store) } else { - let decoded = try CalendarWriteEventTool(eventKit: bridge).decode(arguments: [argument: .string("soon")]) - try bridge.applyEventPatch(arguments: decoded, to: EKEvent(eventStore: store), in: store) + let decoded = try CalendarUpdateEventTool().decode(arguments: ["identifier": .string("ID"), argument: .string("soon")]) + try decoded.apply(to: EKEvent(eventStore: store), in: store) } Issue.record("Expected an invalid patch date") } catch let error as BridgeError { let capability = argument == "dueDate" ? "reminders.write" : "calendar.write" #expect(error.diagnosticCode == "INVALID_ARGUMENTS") - #expect(error.localizedDescription == "\(capability) \(argument) must be ISO8601 when provided") + #expect(error.localizedDescription == "\(capability) could not parse '\(argument)' as an ISO 8601 timestamp: \"soon\". Use a form like 2026-07-24T10:00:00Z.") + } +} +#endif + +@Test(arguments: ["apple.calendar.updateEvent", "apple.reminders.updateReminder", "apple.reminders.completeReminder"]) +func writeHelpersRequireIdentifiersBeforePermissions(helper: String) async throws { + let (tools, sandbox) = try makeTools(permissionBroker: FixedPermissionBroker(statuses: [ + .calendar: .denied, .calendarWriteOnly: .denied, .reminders: .denied, + ])) + defer { cleanup(sandbox) } + let observed = try await execute(tools, request: JavaScriptExecutionRequest( + code: "await \(helper)({ title: 'T', start: '2026-02-22T16:00:00Z', end: '2026-02-22T17:00:00Z' });", + allowedCapabilities: [.calendarWrite, .remindersWrite] + )) + #expect(observed.error?.code == "INVALID_ARGUMENTS") + #expect(observed.error?.message.contains("missing required arguments: identifier") == true) +} + +@Test func completeReminderDefaultsToCompletedAndAllowsReopening() throws { + let tool = RemindersCompleteTool() + #expect(try tool.decode(arguments: ["identifier": .string("R1")]).isCompleted) + #expect(try tool.decode(arguments: ["identifier": .string("R1"), "isCompleted": .bool(false)]).isCompleted == false) +} + +@Test(arguments: [ + ("apple.calendar.updateEvent", "title"), + ("apple.calendar.updateEvent", "start"), + ("apple.calendar.updateEvent", "end"), + ("apple.calendar.updateEvent", "calendarIdentifier"), + ("apple.reminders.updateReminder", "title"), +]) +func updateFieldsThatCannotBeClearedRejectNullInJavaScript(helper: String, field: String) async throws { + let (tools, sandbox) = try makeTools(permissionBroker: FixedPermissionBroker(statuses: [ + .calendar: .denied, .reminders: .denied, + ])) + defer { cleanup(sandbox) } + let observed = try await execute(tools, request: JavaScriptExecutionRequest( + code: "await \(helper)({ identifier: 'ID', \(field): null });", + allowedCapabilities: [.calendarWrite, .remindersWrite] + )) + let capability = helper.contains("calendar") ? "calendar.write" : "reminders.write" + #expect(observed.error?.code == "INVALID_ARGUMENTS") + #expect(observed.error?.message == "\(capability) expected '\(field)' as string, received null") + #expect(observed.error?.permissionEvents.isEmpty == true) + let reference = try #require(tools.capabilities().first { $0.jsNames.contains(helper) }) + #expect(!reference.nullableArguments.contains(field)) + #expect(reference.dts.contains("\(field)?: string;")) + #expect(!reference.dts.contains("\(field)?: string | null;")) +} + +#if canImport(EventKit) +@Test(arguments: ["calendar", "reminders"]) +func updateHelpersRequireAChangeAndCountPlainOptionalFields(family: String) async throws { + let (tools, sandbox) = try makeTools(permissionBroker: FixedPermissionBroker(statuses: [ + .calendar: .granted, .reminders: .granted, + ])) + defer { cleanup(sandbox) } + let isCalendar = family == "calendar" + let helper = isCalendar ? "apple.calendar.updateEvent" : "apple.reminders.updateReminder" + let identifier = isCalendar ? "E1" : "R1" + let item = isCalendar ? "event" : "reminder" + for title in ["", ", title: 'T'"] { + let observed = try await execute(tools, request: JavaScriptExecutionRequest( + code: "await \(helper)({ identifier: '\(identifier)'\(title) });", + allowedCapabilities: [.calendarWrite, .remindersWrite] + )) + #expect(observed.error?.code == "INVALID_ARGUMENTS") + // These identifiers cannot resolve to native items. A title-only update + // must pass change detection and reach lookup, without saving anything. + let expected = title.isEmpty + ? "\(family).write update requires at least one patch field" + : "\(family).write could not find \(item) identifier \(identifier)" + #expect(observed.error?.message == expected) } } #endif diff --git a/Tests/CodeModeTests/HostRuntimeTests.swift b/Tests/CodeModeTests/HostRuntimeTests.swift index 489db5d..62fc8a9 100644 --- a/Tests/CodeModeTests/HostRuntimeTests.swift +++ b/Tests/CodeModeTests/HostRuntimeTests.swift @@ -179,18 +179,26 @@ import Testing JavaScriptAPISearchRequest( code: """ async () => { - return api.byCapability["calendar.write"]; + return { + create: api.byCapability["calendar.write"], + update: api.byJSName["apple.calendar.updateEvent"], + writes: api.references.filter(ref => ref.capability === "calendar.write") + }; } """ ) ) let result = try #require(response.result?.objectValue) - #expect(result.string("capability") == CapabilityID.calendarWrite.rawValue) - #expect(result.array("optionalArguments")?.contains(.string("title")) == true) - #expect(result.array("optionalArguments")?.contains(.string("start")) == true) - #expect(result.array("optionalArguments")?.contains(.string("end")) == true) - #expect(result.array("jsNames")?.contains(.string("apple.calendar.updateEvent")) == true) + let create = try #require(result["create"]?.objectValue) + let update = try #require(result["update"]?.objectValue) + #expect(create.string("capability") == CapabilityID.calendarWrite.rawValue) + #expect(create.array("requiredArguments") == [.string("title"), .string("start"), .string("end")]) + #expect(create.array("jsNames") == [.string("apple.calendar.createEvent")]) + #expect(update.array("requiredArguments") == [.string("identifier")]) + #expect(update.array("jsNames") == [.string("apple.calendar.updateEvent")]) + #expect(update.array("nullableArguments")?.contains(.string("notes")) == true) + #expect(result.array("writes")?.count == 2) } @Test func searchReturnsProjectedResults() async throws { @@ -755,3 +763,25 @@ import Testing #expect(observed.result == nil) #expect(observed.error?.code == "CANCELLED") } + +@Test(arguments: [false, true]) +func updateEventFailuresSuggestTheCalledHelper(rethrowWithoutSuggestions: Bool) async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + let code = rethrowWithoutSuggestions ? """ + try { await apple.calendar.updateEvent({ title: 'T' }); } + catch (error) { + throw { code: error.code, message: error.message, capability: error.capability, + functionName: 'apple.calendar.updateEvent' }; + } + """ : "await apple.calendar.updateEvent({ title: 'T' });" + let observed = try await execute(tools, request: JavaScriptExecutionRequest( + code: code, allowedCapabilities: [.calendarWrite] + )) + let error = try #require(observed.error) + #expect(error.code == "INVALID_ARGUMENTS") + #expect(error.message == "calendar.write missing required arguments: identifier") + #expect(error.suggestions.contains("Required arguments: identifier:string")) + #expect(error.suggestions.contains("Example: await apple.calendar.updateEvent({ identifier: 'EVENT_ID', notes: null })")) + #expect(!error.suggestions.contains { $0.contains("createEvent") || $0.contains("Required arguments: title") }) +} diff --git a/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift b/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift index e7e9f9a..e980779 100644 --- a/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift +++ b/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift @@ -271,6 +271,7 @@ private func reference( let legacy = #"{"capability":"fs.read","capabilityKey":"fs.read","builtInCapability":"fs.read","jsNames":["apple.fs.read"],"summary":"Read a file.","tags":[],"example":"","requiredArguments":["path"],"optionalArguments":[],"argumentTypes":{"path":"string"},"argumentHints":{},"argumentConstraints":{"allowedStringValues":{}},"resultSummary":"Object."}"# let decoded = try JSONDecoder().decode(JavaScriptAPIReference.self, from: Data(legacy.utf8)) #expect(decoded.capability == "fs.read") + #expect(decoded.nullableArguments.isEmpty) // Regenerated rather than left empty. #expect(decoded.dts.contains("read(args: {")) #expect(decoded.dts.contains("path: string;")) @@ -317,7 +318,7 @@ func writeTypeDeclarationsForCompilerCheck() async throws { let references = tools.capabilities() for reference in references { try reference.dts.write( - to: fragments.appendingPathComponent("\(reference.capability).d.ts"), atomically: true, encoding: .utf8 + to: fragments.appendingPathComponent("\(reference.jsNames.first ?? reference.capability).d.ts"), atomically: true, encoding: .utf8 ) } #expect(references.count > 100, "expected the full iOS catalog") diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index 53b4dc3..9140d4d 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -20,6 +20,9 @@ "id" : "activity.end", "jsNames" : [ "apple.activity.end" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "contentState", @@ -56,6 +59,9 @@ "id" : "activity.list", "jsNames" : [ "apple.activity.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "activityType", @@ -90,6 +96,9 @@ "id" : "activity.pushToken.read", "jsNames" : [ "apple.activity.getPushToken" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -131,6 +140,9 @@ "id" : "activity.start", "jsNames" : [ "apple.activity.start" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "contentState", @@ -173,6 +185,9 @@ "id" : "activity.update", "jsNames" : [ "apple.activity.update" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "alert", @@ -210,6 +225,9 @@ "id" : "alarm.cancel", "jsNames" : [ "ios.alarm.cancel" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -244,6 +262,9 @@ "id" : "alarm.permission.request", "jsNames" : [ "ios.alarm.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -277,6 +298,9 @@ "id" : "alarm.read", "jsNames" : [ "ios.alarm.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -316,6 +340,9 @@ "id" : "alarm.schedule", "jsNames" : [ "ios.alarm.schedule" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -353,6 +380,9 @@ "id" : "appintents.donate", "jsNames" : [ "apple.appIntents.donate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "parameters" @@ -391,6 +421,9 @@ "id" : "appintents.handoffs.read", "jsNames" : [ "apple.appIntents.listHandoffs" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -430,6 +463,9 @@ "id" : "appintents.list", "jsNames" : [ "apple.appIntents.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "domain", @@ -467,6 +503,9 @@ "id" : "appintents.open", "jsNames" : [ "apple.appIntents.open" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "parameters" @@ -505,6 +544,9 @@ "id" : "appintents.run", "jsNames" : [ "apple.appIntents.run" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "parameters", @@ -546,6 +588,9 @@ "id" : "auth.ui.webAuthenticate", "jsNames" : [ "apple.auth.webAuthenticate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "callbackURLScheme", @@ -582,7 +627,7 @@ }, "argumentHints" : { "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", - "span" : "thisEvent or futureEvents for recurring events. Default: \"thisEvent\"." + "span" : "Scope for recurring events. Default: \"thisEvent\"." }, "argumentTypes" : { "identifier" : "string", @@ -592,6 +637,9 @@ "id" : "calendar.delete", "jsNames" : [ "apple.calendar.deleteEvent" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "span" @@ -617,11 +665,11 @@ }, "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.", + "calendarIdentifier" : "EventKit calendarIdentifier to restrict results.", + "calendarIdentifiers" : "EventKit calendarIdentifier strings to restrict results.", + "end" : "Range end; uses start + 14 days when omitted. ISO 8601 timestamp.", "limit" : "Max number of items. Default: 50.", - "start" : "ISO8601 timestamp; defaults to now." + "start" : "Range start; uses now when omitted. ISO 8601 timestamp." }, "argumentTypes" : { "calendarIdentifier" : "string", @@ -634,6 +682,9 @@ "id" : "calendar.read", "jsNames" : [ "apple.calendar.listEvents" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "start", @@ -669,9 +720,9 @@ ] }, "argumentHints" : { - "displayStyle" : "writable (default) or all.", - "selectionStyle" : "single (default) or multiple.", - "timeoutMs" : "Optional timeout for waiting on user selection." + "displayStyle" : "Calendars to display; uses writable when omitted.", + "selectionStyle" : "Calendar selection style; uses single when omitted.", + "timeoutMs" : "Timeout in milliseconds for waiting on user selection." }, "argumentTypes" : { "displayStyle" : "string", @@ -682,6 +733,9 @@ "id" : "calendar.ui.pickCalendar", "jsNames" : [ "apple.calendar.pickCalendar" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "selectionStyle", @@ -709,10 +763,10 @@ }, "argumentHints" : { - "allowsCalendarPreview" : "Whether the UI may show calendar day previews; default true.", - "allowsEditing" : "Whether the user can edit from the detail UI; default false.", + "allowsCalendarPreview" : "Whether the UI may show calendar day previews; uses true when omitted.", + "allowsEditing" : "Whether the user can edit from the detail UI; uses false when omitted.", "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", - "timeoutMs" : "Optional timeout for waiting on dismissal." + "timeoutMs" : "Timeout in milliseconds for waiting on dismissal." }, "argumentTypes" : { "allowsCalendarPreview" : "bool", @@ -724,6 +778,9 @@ "id" : "calendar.ui.presentEvent", "jsNames" : [ "apple.calendar.presentEvent" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "allowsEditing", @@ -751,12 +808,12 @@ }, "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." + "end" : "ISO 8601 end timestamp.", + "location" : "Location string.", + "notes" : "Event notes\/body text.", + "start" : "ISO 8601 start timestamp.", + "timeoutMs" : "Timeout in milliseconds for waiting on user save\/cancel.", + "title" : "Event title shown in the editor." }, "argumentTypes" : { "end" : "string", @@ -770,6 +827,9 @@ "id" : "calendar.ui.presentNewEvent", "jsNames" : [ "apple.calendar.presentNewEvent" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "title", @@ -797,31 +857,24 @@ }, { "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.", + "calendarIdentifier" : "Destination EventKit calendarIdentifier.", + "end" : "Event end. ISO 8601 timestamp.", "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." + "location" : "Event location.", + "notes" : "Notes\/body text.", + "start" : "Event start. ISO 8601 timestamp.", + "title" : "Event title.", + "url" : "Absolute URL 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" @@ -829,12 +882,73 @@ "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.createEvent" + ], + "nullableArguments" : [ + + ], + "optionalArguments" : [ + "notes", + "location", + "url", + "isAllDay", + "calendarIdentifier" + ], + "requiredArguments" : [ + "title", + "start", + "end" + ], + "requiredPermissions" : [ + "calendar.writeOnly" + ], + "resultSummary" : "Object with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "summary" : "Create a calendar event.", + "tags" : [ + "calendar", + "eventkit", + "schedule" + ], + "title" : "createEvent" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "calendarIdentifier" : "Destination EventKit calendarIdentifier.", + "end" : "Event end. ISO 8601 timestamp.", + "identifier" : "EventKit eventIdentifier to update.", + "isAllDay" : "Whether the event is all-day.", + "location" : "Event location.", + "notes" : "Notes\/body text.", + "start" : "Event start. ISO 8601 timestamp.", + "title" : "Event title.", + "url" : "Absolute URL attached to the event." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "end" : "string", + "identifier" : "string", + "isAllDay" : "bool", + "location" : "string", + "notes" : "string", + "start" : "string", + "title" : "string", + "url" : "string" + }, + "example" : "await apple.calendar.updateEvent({ identifier: 'EVENT_ID', notes: null })", + "id" : "calendar.write", + "jsNames" : [ "apple.calendar.updateEvent" ], + "nullableArguments" : [ + "notes", + "location", + "url", + "isAllDay" + ], "optionalArguments" : [ - "operation", - "identifier", "title", "start", "end", @@ -845,19 +959,19 @@ "calendarIdentifier" ], "requiredArguments" : [ - + "identifier" ], "requiredPermissions" : [ - + "calendar" ], "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.", + "summary" : "Patch an existing event by identifier.", "tags" : [ "calendar", "eventkit", "schedule" ], - "title" : "createEvent" + "title" : "updateEvent" }, { "allowedStringValues" : { @@ -909,6 +1023,9 @@ "id" : "camera.ui.capture", "jsNames" : [ "apple.camera.capture" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mediaType", @@ -980,6 +1097,9 @@ "id" : "camera.ui.scanData", "jsNames" : [ "apple.camera.scanData" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mode", @@ -1026,6 +1146,9 @@ "id" : "cloudkit.account.status", "jsNames" : [ "apple.cloudkit.getAccountStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "containerIdentifier" @@ -1070,6 +1193,9 @@ "id" : "cloudkit.record.delete", "jsNames" : [ "apple.cloudkit.deleteRecord" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "database", @@ -1122,6 +1248,9 @@ "id" : "cloudkit.record.save", "jsNames" : [ "apple.cloudkit.saveRecord" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recordName", @@ -1179,6 +1308,9 @@ "id" : "cloudkit.records.query", "jsNames" : [ "apple.cloudkit.queryRecords" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "database", @@ -1239,6 +1371,9 @@ "id" : "cloudkit.subscription.save", "jsNames" : [ "apple.cloudkit.subscribe" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "database", @@ -1284,6 +1419,9 @@ "id" : "cloudkit.subscriptionEvents.read", "jsNames" : [ "apple.cloudkit.listEvents" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "subscriptionID", @@ -1323,6 +1461,9 @@ "id" : "contacts.read", "jsNames" : [ "apple.contacts.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit", @@ -1359,6 +1500,9 @@ "id" : "contacts.search", "jsNames" : [ "apple.contacts.search" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -1399,6 +1543,9 @@ "id" : "contacts.ui.pick", "jsNames" : [ "apple.contacts.pick" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mode", @@ -1443,6 +1590,9 @@ "id" : "contacts.ui.presentContact", "jsNames" : [ "apple.contacts.presentContact" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "allowsEditing", @@ -1490,6 +1640,9 @@ "id" : "contacts.ui.presentNewContact", "jsNames" : [ "apple.contacts.presentNewContact" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "givenName", @@ -1536,6 +1689,9 @@ "jsNames" : [ "apple.documents.export", "apple.documents.save" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "path", @@ -1580,6 +1736,9 @@ "id" : "documents.ui.openIn", "jsNames" : [ "apple.documents.openIn" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "name", @@ -1623,6 +1782,9 @@ "id" : "documents.ui.pick", "jsNames" : [ "apple.documents.pick" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "contentTypes", @@ -1663,6 +1825,9 @@ "id" : "documents.ui.scan", "jsNames" : [ "apple.documents.scan" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "outputDirectory", @@ -1707,6 +1872,9 @@ "id" : "foundationModels.extract", "jsNames" : [ "apple.foundationModels.extract" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "schema", @@ -1754,6 +1922,9 @@ "id" : "foundationModels.generate", "jsNames" : [ "apple.foundationModels.generate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "instructions", @@ -1792,6 +1963,9 @@ "id" : "foundationModels.status", "jsNames" : [ "apple.foundationModels.getStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -1827,6 +2001,9 @@ "jsNames" : [ "apple.fs.access", "fs.promises.access" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -1867,6 +2044,9 @@ "jsNames" : [ "apple.fs.copy", "fs.promises.copyFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "overwrite", @@ -1905,6 +2085,9 @@ "jsNames" : [ "apple.fs.delete", "fs.promises.rm" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recursive" @@ -1938,6 +2121,9 @@ "id" : "fs.exists", "jsNames" : [ "apple.fs.exists" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -1972,6 +2158,9 @@ "jsNames" : [ "apple.fs.list", "fs.promises.readdir" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2008,6 +2197,9 @@ "jsNames" : [ "apple.fs.mkdir", "fs.promises.mkdir" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recursive" @@ -2048,6 +2240,9 @@ "jsNames" : [ "apple.fs.move", "fs.promises.rename" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "overwrite", @@ -2090,6 +2285,9 @@ "jsNames" : [ "apple.fs.read", "fs.promises.readFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "encoding" @@ -2124,6 +2322,9 @@ "jsNames" : [ "apple.fs.stat", "fs.promises.stat" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2166,6 +2367,9 @@ "jsNames" : [ "apple.fs.write", "fs.promises.writeFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "data", @@ -2202,6 +2406,9 @@ "id" : "health.permission.request", "jsNames" : [ "apple.health.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "readTypes", @@ -2244,6 +2451,9 @@ "id" : "health.read", "jsNames" : [ "apple.health.read" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "start", @@ -2288,6 +2498,9 @@ "id" : "health.write", "jsNames" : [ "apple.health.write" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "unit", @@ -2326,6 +2539,9 @@ "id" : "home.read", "jsNames" : [ "apple.home.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "includeCharacteristics", @@ -2366,6 +2582,9 @@ "id" : "home.write", "jsNames" : [ "apple.home.writeCharacteristic" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "serviceType" @@ -2402,6 +2621,9 @@ "id" : "keychain.delete", "jsNames" : [ "apple.keychain.delete" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2435,6 +2657,9 @@ "id" : "keychain.read", "jsNames" : [ "apple.keychain.get" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2470,6 +2695,9 @@ "id" : "keychain.write", "jsNames" : [ "apple.keychain.set" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "value" @@ -2503,6 +2731,9 @@ "id" : "location.permission.request", "jsNames" : [ "apple.location.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2536,6 +2767,9 @@ "jsNames" : [ "apple.location.getCurrentPosition", "apple.location.getPermissionStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mode" @@ -2583,6 +2817,9 @@ "id" : "mail.ui.compose", "jsNames" : [ "apple.mail.compose" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "to", @@ -2628,6 +2865,9 @@ "id" : "maps.geocode", "jsNames" : [ "apple.maps.geocode" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "region", @@ -2678,6 +2918,9 @@ "id" : "maps.open", "jsNames" : [ "apple.maps.open" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "query", @@ -2721,6 +2964,9 @@ "id" : "maps.reverseGeocode", "jsNames" : [ "apple.maps.reverseGeocode" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "locale" @@ -2769,6 +3015,9 @@ "id" : "maps.route.estimate", "jsNames" : [ "apple.maps.routeEstimate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "transportType", @@ -2812,6 +3061,9 @@ "id" : "maps.search", "jsNames" : [ "apple.maps.search" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "region", @@ -2852,6 +3104,9 @@ "id" : "media.frame.extract", "jsNames" : [ "apple.media.extractFrame" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeMs", @@ -2886,6 +3141,9 @@ "id" : "media.metadata.read", "jsNames" : [ "apple.media.metadata" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2923,6 +3181,9 @@ "id" : "media.transcode", "jsNames" : [ "apple.media.transcode" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "outputPath", @@ -2965,6 +3226,9 @@ "id" : "messages.ui.compose", "jsNames" : [ "apple.messages.compose" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recipients", @@ -3008,6 +3272,9 @@ "id" : "music.catalog.details", "jsNames" : [ "apple.music.getDetails" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "type", @@ -3049,6 +3316,9 @@ "id" : "music.catalog.search", "jsNames" : [ "apple.music.search" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "types", @@ -3087,6 +3357,9 @@ "id" : "music.library.read", "jsNames" : [ "apple.music.readLibrary" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "type", @@ -3121,6 +3394,9 @@ "id" : "music.permission.request", "jsNames" : [ "apple.music.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3170,6 +3446,9 @@ "id" : "music.playback.control", "jsNames" : [ "apple.music.play" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "catalogID", @@ -3215,6 +3494,9 @@ "id" : "music.playlist.write", "jsNames" : [ "apple.music.writePlaylist" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "playlistID", @@ -3253,6 +3535,9 @@ "id" : "music.subscription.status", "jsNames" : [ "apple.music.getSubscriptionStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3302,6 +3587,9 @@ "id" : "network.fetch", "jsNames" : [ "fetch" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "options.method", @@ -3340,6 +3628,9 @@ "id" : "notifications.categories.set", "jsNames" : [ "apple.notifications.setCategories" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3376,6 +3667,9 @@ "id" : "notifications.delivered.delete", "jsNames" : [ "apple.notifications.removeDelivered" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -3411,6 +3705,9 @@ "id" : "notifications.delivered.read", "jsNames" : [ "apple.notifications.listDelivered" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -3446,6 +3743,9 @@ "id" : "notifications.pending.delete", "jsNames" : [ "apple.notifications.cancelPending" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -3480,6 +3780,9 @@ "id" : "notifications.pending.read", "jsNames" : [ "apple.notifications.listPending" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -3513,6 +3816,9 @@ "id" : "notifications.permission.request", "jsNames" : [ "apple.notifications.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3545,6 +3851,9 @@ "id" : "notifications.remote.register", "jsNames" : [ "apple.notifications.registerRemote" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "types" @@ -3579,6 +3888,9 @@ "id" : "notifications.remote.token.read", "jsNames" : [ "apple.notifications.getRemoteToken" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3619,6 +3931,9 @@ "id" : "notifications.responses.read", "jsNames" : [ "apple.notifications.listResponses" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit", @@ -3679,6 +3994,9 @@ "id" : "notifications.schedule", "jsNames" : [ "apple.notifications.schedule" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -3722,6 +4040,9 @@ "id" : "notifications.settings.read", "jsNames" : [ "apple.notifications.getSettings" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3760,6 +4081,9 @@ "id" : "passkit.applePay.present", "jsNames" : [ "apple.wallet.presentPayment" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -3796,6 +4120,9 @@ "id" : "passkit.applePay.status", "jsNames" : [ "apple.wallet.canMakePayments" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "networks" @@ -3832,6 +4159,9 @@ "id" : "passkit.pass.add", "jsNames" : [ "apple.wallet.addPass" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -3869,6 +4199,9 @@ "id" : "passkit.pass.present", "jsNames" : [ "apple.wallet.presentPass" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -3907,6 +4240,9 @@ "id" : "passkit.passes.read", "jsNames" : [ "apple.wallet.listPasses" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "passTypeIdentifier", @@ -3943,6 +4279,9 @@ "id" : "passkit.wallet.status", "jsNames" : [ "apple.wallet.getStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3978,6 +4317,9 @@ "id" : "photos.export", "jsNames" : [ "apple.photos.export" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "outputPath" @@ -4018,6 +4360,9 @@ "id" : "photos.read", "jsNames" : [ "apple.photos.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mediaType", @@ -4063,6 +4408,9 @@ "id" : "photos.ui.pick", "jsNames" : [ "apple.photos.pick" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mediaType", @@ -4101,6 +4449,9 @@ "id" : "photos.ui.presentLimitedLibraryPicker", "jsNames" : [ "apple.photos.presentLimitedLibraryPicker" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -4150,6 +4501,9 @@ "id" : "print.ui.present", "jsNames" : [ "apple.print.present" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "path", @@ -4193,6 +4547,9 @@ "id" : "quicklook.ui.preview", "jsNames" : [ "apple.quicklook.preview" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "path", @@ -4229,6 +4586,9 @@ "id" : "reminders.delete", "jsNames" : [ "apple.reminders.deleteReminder" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4254,12 +4614,12 @@ }, "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.", + "calendarIdentifier" : "EventKit reminder calendarIdentifier to restrict results.", + "calendarIdentifiers" : "EventKit reminder calendarIdentifier strings to restrict results.", + "end" : "Due-date upper bound. ISO 8601 timestamp.", "includeCompleted" : "Whether completed reminders are included. Default: false.", "limit" : "Max number of reminder items. Default: 50.", - "start" : "Optional ISO8601 due-date lower bound." + "start" : "Due-date lower bound. ISO 8601 timestamp." }, "argumentTypes" : { "calendarIdentifier" : "string", @@ -4273,6 +4633,9 @@ "id" : "reminders.read", "jsNames" : [ "apple.reminders.listReminders" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "start", @@ -4299,43 +4662,71 @@ }, { "allowedStringValues" : { - "operation" : [ - "create", - "update", - "complete" - ] + + }, + "argumentHints" : { + "identifier" : "EventKit calendarItemIdentifier to complete.", + "isCompleted" : "Completion state. Default: true." + }, + "argumentTypes" : { + "identifier" : "string", + "isCompleted" : "bool" + }, + "example" : "await apple.reminders.completeReminder({ identifier: 'REMINDER_ID' })", + "id" : "reminders.write", + "jsNames" : [ + "apple.reminders.completeReminder" + ], + "nullableArguments" : [ + + ], + "optionalArguments" : [ + "isCompleted" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Object with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "summary" : "Set an existing reminder's completion state.", + "tags" : [ + "reminders", + "eventkit", + "task" + ], + "title" : "completeReminder" + }, + { + "allowedStringValues" : { + }, "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.", + "calendarIdentifier" : "Destination EventKit reminders calendarIdentifier.", + "dueDate" : "Reminder due date. ISO 8601 timestamp.", + "isCompleted" : "Completion state.", + "notes" : "Reminder notes.", "priority" : "EventKit reminder priority integer.", - "title" : "Reminder title string." + "title" : "Reminder title." }, "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" + "apple.reminders.createReminder" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ - "operation", - "identifier", - "title", "dueDate", "notes", "isCompleted", @@ -4343,13 +4734,13 @@ "calendarIdentifier" ], "requiredArguments" : [ - + "title" ], "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.", + "summary" : "Create a reminder.", "tags" : [ "reminders", "eventkit", @@ -4360,6 +4751,63 @@ { "allowedStringValues" : { + }, + "argumentHints" : { + "calendarIdentifier" : "Destination EventKit reminders calendarIdentifier.", + "dueDate" : "Reminder due date. ISO 8601 timestamp.", + "identifier" : "EventKit calendarItemIdentifier to update.", + "isCompleted" : "Completion state.", + "notes" : "Reminder notes.", + "priority" : "EventKit reminder priority integer.", + "title" : "Reminder title." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "dueDate" : "string", + "identifier" : "string", + "isCompleted" : "bool", + "notes" : "string", + "priority" : "number", + "title" : "string" + }, + "example" : "await apple.reminders.updateReminder({ identifier: 'REMINDER_ID', dueDate: null })", + "id" : "reminders.write", + "jsNames" : [ + "apple.reminders.updateReminder" + ], + "nullableArguments" : [ + "dueDate", + "notes", + "isCompleted", + "priority", + "calendarIdentifier" + ], + "optionalArguments" : [ + "title", + "dueDate", + "notes", + "isCompleted", + "priority", + "calendarIdentifier" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Object with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "summary" : "Patch an existing reminder by identifier.", + "tags" : [ + "reminders", + "eventkit", + "task" + ], + "title" : "updateReminder" + }, + { + "allowedStringValues" : { + }, "argumentHints" : { "timeoutMs" : "Optional timeout for waiting on UIApplication.open completion." @@ -4371,6 +4819,9 @@ "id" : "settings.ui.open", "jsNames" : [ "apple.settings.open" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -4416,6 +4867,9 @@ "id" : "share.ui.present", "jsNames" : [ "apple.share.present" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "text", @@ -4463,6 +4917,9 @@ "id" : "speech.file.transcribe", "jsNames" : [ "apple.speech.transcribeFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "locale", @@ -4507,6 +4964,9 @@ "id" : "speech.microphone.transcribe", "jsNames" : [ "apple.speech.transcribeMicrophone" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "locale", @@ -4546,6 +5006,9 @@ "id" : "speech.permission.request", "jsNames" : [ "apple.speech.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4579,6 +5042,9 @@ "id" : "speech.status", "jsNames" : [ "apple.speech.getStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4612,6 +5078,9 @@ "id" : "storekit.entitlements.read", "jsNames" : [ "apple.storekit.listEntitlements" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4645,6 +5114,9 @@ "id" : "storekit.products.read", "jsNames" : [ "apple.storekit.listProducts" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4682,6 +5154,9 @@ "id" : "storekit.purchase", "jsNames" : [ "apple.storekit.purchase" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "appAccountToken" @@ -4717,6 +5192,9 @@ "id" : "storekit.restore", "jsNames" : [ "apple.storekit.restore" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4755,6 +5233,9 @@ "id" : "storekit.transactions.read", "jsNames" : [ "apple.storekit.listTransactions" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit", @@ -4806,6 +5287,9 @@ "id" : "ui.alert.present", "jsNames" : [ "apple.ui.presentAlert" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "title", @@ -4852,6 +5336,9 @@ "id" : "ui.prompt.present", "jsNames" : [ "apple.ui.presentPrompt" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "title", @@ -4894,6 +5381,9 @@ "id" : "vision.image.analyze", "jsNames" : [ "apple.vision.analyzeImage" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "features", @@ -4930,6 +5420,9 @@ "id" : "weather.read", "jsNames" : [ "apple.weather.getCurrentWeather" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4968,6 +5461,9 @@ "id" : "web.ui.present", "jsNames" : [ "apple.web.present" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "entersReaderIfAvailable", From a240d0cd5c022e51df4bdaffaed68e64e29cfbcc Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 22 Oct 2026 01:23:45 -0700 Subject: [PATCH 9/9] Give built-in tools typed results, so declarations say what comes back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every helper was declared `Promise`, with a hand-written `resultSummary` sentence (120 of them, 111 distinct) that nothing checked against the JSON the bridge built. Models guessed field names from prose, and a host's `tsc` could not catch a wrong one. A result is now a `@ToolResult` struct. The macro generates its encoder and its schema from the one declaration, so they cannot disagree; an optional `@ToolField("hint")` becomes the field's doc comment. The schema (`CodeModeResultSchema`) is derived from Swift types at runtime rather than read by the macro, so shared and nested results compose: `CalendarEvent` serves list, create and update, and `[CalendarEvent]` describes itself. Its JSON form is hand-written and compact (`"string"`, `{"array": …}`, `{"object": [{name, schema, hint?}]}`), since it is public, persisted in the golden and eval fixtures, and shown to models in search results. `BuiltInCodeModeTool.call` returns `Result: CodeModeResultDescribable`. `JSONValue` conforms with schema `.any`, and `codeModeResultSummary` is a protocol-extension default derived from the schema, so the 14 unmigrated families compile unchanged with their prose overrides. The declarations render the schema as the promise's type argument and drop the `Returns:` line when it would only repeat the type. EventKit is migrated: `CalendarEvent`, `Reminder` and a delete result replace the hand-built serializers; the nine summary overrides are gone. The JSON a script receives is unchanged, including `""` where a missing string is coalesced today; whether those should become `null` is recorded in TODO.md as a deliberate decision still to take, along with element types for argument arrays and named interfaces in the whole-surface declarations. --- Sources/CodeMode/API/BridgeModels.swift | 13 +- Sources/CodeMode/API/CodeModeProvider.swift | 50 +++ .../Bridges/BuiltInCodeModeMacros.swift | 12 +- .../Bridges/BuiltInCodeModeTool.swift | 9 +- Sources/CodeMode/Bridges/EventKitBridge.swift | 112 ++++-- .../Bridges/EventKitCodeModeTools.swift | 55 +-- Sources/CodeMode/Catalog/BridgeCatalog.swift | 3 +- .../Catalog/TypeScriptDeclarations.swift | 76 +++- .../Host/CodeModeAgentToolDescriptions.swift | 5 +- .../Registry/CapabilityRegistry.swift | 97 ++++- .../CodeModeMacros/BuiltInCodeModeMacro.swift | 2 +- Sources/CodeModeMacros/CodeModeMacro.swift | 2 + Sources/CodeModeMacros/ToolResultMacro.swift | 124 ++++++ TODO.md | 23 +- .../BuiltInCodeModeMacroExpansionTests.swift | 198 ++++++++++ .../BuiltInCodeModeToolTests.swift | 75 ++++ .../CapabilityMetadataGoldenTests.swift | 2 + .../CodeModeResultSchemaTests.swift | 24 ++ Tests/CodeModeTests/EventKitBridgeTests.swift | 23 +- .../TypeScriptDeclarationsTests.swift | 85 ++++ .../capability-metadata-golden.json | 369 +++++++++++++++++- 21 files changed, 1246 insertions(+), 113 deletions(-) create mode 100644 Sources/CodeModeMacros/ToolResultMacro.swift create mode 100644 Tests/CodeModeTests/CodeModeResultSchemaTests.swift diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index 4f2c16c..ca36dc9 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -104,6 +104,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { public var argumentHints: [String: String] public var argumentConstraints: CapabilityArgumentConstraints public var resultSummary: String + public var resultSchema: CodeModeResultSchema? /// TypeScript declaration for this helper, generated from the fields above. /// Return it from `searchJavaScriptAPI` to give the model real types rather /// than a coarse `argumentTypes` map plus prose. @@ -124,6 +125,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { argumentHints: [String: String], argumentConstraints: CapabilityArgumentConstraints = .none, resultSummary: String, + resultSchema: CodeModeResultSchema? = nil, dts: String = "" ) { self.capability = capability @@ -140,19 +142,19 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { self.argumentHints = argumentHints self.argumentConstraints = argumentConstraints self.resultSummary = resultSummary + self.resultSchema = resultSchema self.dts = dts } private enum CodingKeys: String, CodingKey { case capability, capabilityKey, builtInCapability, jsNames, summary, tags, example case requiredArguments, optionalArguments, argumentTypes, argumentHints - case nullableArguments, argumentConstraints, resultSummary, dts + case nullableArguments, argumentConstraints, resultSummary, resultSchema, dts } - /// `dts` is optional on the way in. References encoded before it existed — - /// cached search results, eval fixtures — would otherwise fail to decode with - /// `keyNotFound`; when it is absent, the declaration is regenerated from the - /// decoded fields rather than left empty. + /// Older cached references may omit `resultSchema` and `dts`. A missing + /// schema preserves the JSON fallback; a missing declaration is regenerated + /// from the decoded fields rather than left empty. public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.capability = try container.decode(String.self, forKey: .capability) @@ -169,6 +171,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { self.argumentHints = try container.decode([String: String].self, forKey: .argumentHints) self.argumentConstraints = try container.decode(CapabilityArgumentConstraints.self, forKey: .argumentConstraints) self.resultSummary = try container.decode(String.self, forKey: .resultSummary) + self.resultSchema = try container.decodeIfPresent(CodeModeResultSchema.self, forKey: .resultSchema) self.dts = try container.decodeIfPresent(String.self, forKey: .dts) ?? "" if self.dts.isEmpty { self.dts = TypeScriptDeclarations.declaration(for: self) diff --git a/Sources/CodeMode/API/CodeModeProvider.swift b/Sources/CodeMode/API/CodeModeProvider.swift index ec1f494..a071ffb 100644 --- a/Sources/CodeMode/API/CodeModeProvider.swift +++ b/Sources/CodeMode/API/CodeModeProvider.swift @@ -258,6 +258,56 @@ public protocol CodeModeJSONEncodable { var codeModeJSONValue: JSONValue { get } } +/// A result whose JSON encoder and model-facing shape come from the same type. +public protocol CodeModeResultDescribable: CodeModeJSONEncodable { + static var codeModeResultSchema: CodeModeResultSchema { get } +} + +extension String: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .string } +} + +extension Int: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .number } +} + +extension Double: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .number } +} + +extension Float: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .number } +} + +extension Bool: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .bool } +} + +extension Date: CodeModeJSONEncodable, CodeModeResultDescribable { + public var codeModeJSONValue: JSONValue { .string(ISO8601Format()) } + public static var codeModeResultSchema: CodeModeResultSchema { .string } +} + +extension JSONValue: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .any } +} + +extension Optional: CodeModeJSONEncodable where Wrapped: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { map(\.codeModeJSONValue) ?? .null } +} + +extension Optional: CodeModeResultDescribable where Wrapped: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .nullable(Wrapped.codeModeResultSchema) } +} + +extension Array: CodeModeResultDescribable where Element: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .array(Element.codeModeResultSchema) } +} + +extension Dictionary: CodeModeResultDescribable where Key == String, Value: CodeModeResultDescribable { + public static var codeModeResultSchema: CodeModeResultSchema { .dictionary(Value.codeModeResultSchema) } +} + extension JSONValue: CodeModeJSONEncodable { public var codeModeJSONValue: JSONValue { self } } diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift index c06f5a0..df43cfe 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift @@ -3,7 +3,7 @@ import Foundation /// Generates the mechanical members of a `BuiltInCodeModeTool` — the identity /// statics, `codeModeArguments`, and `decode(arguments:)` — from the nested /// `Arguments` struct. Curated metadata (summary, tags, example, -/// permissions, result summary) and `call(arguments:context:)` stay +/// permissions, optional result summary override) and `call(arguments:context:)` stay /// hand-written. /// /// Annotate each argument property with `@ToolParam("hint")`. Property types @@ -50,3 +50,13 @@ enum CodeModeToolRawArguments { raw[name] = .string(value.rawValue) } } + +/// Derives a built-in result's JSON encoding and schema from its stored var/let +/// properties, in declaration order. Each field type must describe its result. +@attached(member, names: named(codeModeJSONValue), named(codeModeResultSchema)) +@attached(extension, conformances: CodeModeResultDescribable) +macro ToolResult() = #externalMacro(module: "CodeModeMacros", type: "ToolResultMacro") + +/// Optional documentation for one stored property of a `@ToolResult` struct. +@attached(peer) +macro ToolField(_ hint: String) = #externalMacro(module: "CodeModeMacros", type: "ToolFieldMacro") diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift index c8be660..ddce459 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -61,6 +61,7 @@ struct BuiltInToolArgument { /// migration is incremental. protocol BuiltInCodeModeTool: Sendable { associatedtype Arguments: Sendable + associatedtype Result: CodeModeResultDescribable static var codeModeCapability: CapabilityID { get } static var codeModePath: String { get } @@ -78,10 +79,11 @@ protocol BuiltInCodeModeTool: Sendable { /// Additional permissions selected from decoded arguments. Static /// requirements are enforced by the registry before the handler runs. func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> Result } extension BuiltInCodeModeTool { + static var codeModeResultSummary: String { Result.codeModeResultSchema.summary } static var codeModeAliasPaths: [String] { [] } static var codeModeRequiredPermissions: [PermissionKind] { [] } func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] { [] } @@ -116,14 +118,15 @@ extension CapabilityRegistration { argumentConstraints: allowedStringValues.isEmpty ? nil : CapabilityArgumentConstraints(allowedStringValues: allowedStringValues), - resultSummary: Tool.codeModeResultSummary + resultSummary: Tool.codeModeResultSummary, + resultSchema: Tool.Result.codeModeResultSchema == .any ? nil : Tool.Result.codeModeResultSchema ), handler: { rawArguments, context in let decoded = try tool.decode(arguments: rawArguments) for permission in tool.argumentDependentPermissions(for: decoded) { try context.requirePermission(permission) } - return try tool.call(arguments: decoded, context: context) + return try tool.call(arguments: decoded, context: context).codeModeJSONValue } ) } diff --git a/Sources/CodeMode/Bridges/EventKitBridge.swift b/Sources/CodeMode/Bridges/EventKitBridge.swift index 9c4948e..be64799 100644 --- a/Sources/CodeMode/Bridges/EventKitBridge.swift +++ b/Sources/CodeMode/Bridges/EventKitBridge.swift @@ -3,7 +3,7 @@ import Foundation import EventKit #endif -/// Shared EventKit lookup, patching, and serialization helpers. +/// Shared EventKit lookup and patching helpers. enum EventKitBridge { static let reminderFetchTimeoutSeconds: TimeInterval = 15 @@ -69,43 +69,81 @@ enum EventKitBridge { return calendar } - static func eventJSON(_ event: EKEvent) -> JSONValue { - .object([ - "identifier": .string(event.eventIdentifier ?? ""), - "title": .string(event.title ?? ""), - "startDate": .string(event.startDate.ISO8601Format()), - "endDate": .string(event.endDate.ISO8601Format()), - "notes": .string(event.notes ?? ""), - // `EKEvent.calendar` is `EKCalendar!` and is nil for an orphaned - // event — every neighboring field here is nil-coalesced, and this one - // crashed the execution thread. - "calendarIdentifier": .string(event.calendar?.calendarIdentifier ?? ""), - "calendarTitle": .string(event.calendar?.title ?? ""), - "location": .string(event.location ?? ""), - "url": .string(event.url?.absoluteString ?? ""), - "isAllDay": .bool(event.isAllDay), - ]) + #endif +} + +/// EventKit's existing JSON contract: absent strings remain empty strings. +@ToolResult +struct CalendarEvent: Sendable { + var identifier: String + var title: String + @ToolField("ISO 8601 timestamp") + var startDate: Date + @ToolField("ISO 8601 timestamp") + var endDate: Date + var notes: String + @ToolField(#"Empty ("") when the event has no calendar"#) + var calendarIdentifier: String + @ToolField(#"Empty ("") when the event has no calendar"#) + var calendarTitle: String + var location: String + @ToolField(#"Absolute URL, or "" when none"#) + var url: String + var isAllDay: Bool +} + +@ToolResult +struct Reminder: Sendable { + var identifier: String + var title: String + var isCompleted: Bool + // These stay strings because an absent date has historically encoded as "". + @ToolField(#"ISO 8601 timestamp, or "" when none"#) + var dueDate: String + @ToolField(#"ISO 8601 timestamp, or "" when none"#) + var completionDate: String + var notes: String + var priority: Int + @ToolField(#"Empty ("") when the reminder has no calendar"#) + var calendarIdentifier: String + @ToolField(#"Empty ("") when the reminder has no calendar"#) + var calendarTitle: String +} + +@ToolResult +struct EventKitDeletion: Sendable { + var identifier: String + var deleted: Bool +} + +#if canImport(EventKit) +extension CalendarEvent { + init(_ event: EKEvent) { + identifier = event.eventIdentifier ?? "" + title = event.title ?? "" + startDate = event.startDate + endDate = event.endDate + notes = event.notes ?? "" + // An orphaned event has no calendar (EKCalendar!). + calendarIdentifier = event.calendar?.calendarIdentifier ?? "" + calendarTitle = event.calendar?.title ?? "" + location = event.location ?? "" + url = event.url?.absoluteString ?? "" + isAllDay = event.isAllDay } +} - static func reminderJSON(_ reminder: EKReminder) -> JSONValue { - var object: [String: JSONValue] = [ - "identifier": .string(reminder.calendarItemIdentifier), - // `EKReminder.title` is `String!`. - "title": .string(reminder.title ?? ""), - "isCompleted": .bool(reminder.isCompleted), - "dueDate": .string(reminder.dueDateComponents?.date?.ISO8601Format() ?? ""), - "completionDate": .string(reminder.completionDate?.ISO8601Format() ?? ""), - "notes": .string(reminder.notes ?? ""), - "priority": .number(Double(reminder.priority)), - ] - if let calendar = reminder.calendar { - object["calendarIdentifier"] = .string(calendar.calendarIdentifier) - object["calendarTitle"] = .string(calendar.title) - } else { - object["calendarIdentifier"] = .string("") - object["calendarTitle"] = .string("") - } - return .object(object) +extension Reminder { + init(_ reminder: EKReminder) { + identifier = reminder.calendarItemIdentifier + title = reminder.title ?? "" + isCompleted = reminder.isCompleted + dueDate = reminder.dueDateComponents?.date?.ISO8601Format() ?? "" + completionDate = reminder.completionDate?.ISO8601Format() ?? "" + notes = reminder.notes ?? "" + priority = reminder.priority + calendarIdentifier = reminder.calendar?.calendarIdentifier ?? "" + calendarTitle = reminder.calendar?.title ?? "" } - #endif } +#endif diff --git a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift index 211f11a..35314b9 100644 --- a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift +++ b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift @@ -50,7 +50,6 @@ struct CalendarListEventsTool: BuiltInCodeModeTool { 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 codeModeResultSummary = "Array of events with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." struct Arguments: Sendable { @ToolParam("Range start; uses now when omitted.") @@ -65,7 +64,7 @@ struct CalendarListEventsTool: BuiltInCodeModeTool { var calendarIdentifiers: [String]? } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> [CalendarEvent] { #if canImport(EventKit) let store = EKEventStore() let start = arguments.start ?? Date() @@ -83,10 +82,10 @@ struct CalendarListEventsTool: BuiltInCodeModeTool { let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars) let events = store.events(matching: predicate).prefix(max(1, limit)).map { event in - EventKitBridge.eventJSON(event) + CalendarEvent(event) } - return .array(Array(events)) + return events #else _ = arguments throw BridgeError.unsupportedPlatform("EventKit") @@ -100,7 +99,6 @@ struct CalendarCreateEventTool: BuiltInCodeModeTool { 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 codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] - static let codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." struct Arguments: Sendable { @ToolParam("Event title.") @@ -121,7 +119,7 @@ struct CalendarCreateEventTool: BuiltInCodeModeTool { var calendarIdentifier: String? } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> CalendarEvent { #if canImport(EventKit) guard arguments.title.isEmpty == false else { throw BridgeError.invalidArguments("calendar.write title cannot be empty") @@ -154,7 +152,7 @@ struct CalendarCreateEventTool: BuiltInCodeModeTool { do { try store.save(event, span: .thisEvent) - return EventKitBridge.eventJSON(event) + return CalendarEvent(event) } catch { throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") } @@ -170,7 +168,6 @@ struct CalendarUpdateEventTool: BuiltInCodeModeTool { static let codeModeTags = ["calendar", "eventkit", "schedule"] static let codeModeExample = "await apple.calendar.updateEvent({ identifier: 'EVENT_ID', notes: null })" static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] - static let codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." struct Arguments: Sendable { @ToolParam("EventKit eventIdentifier to update.") @@ -193,7 +190,7 @@ struct CalendarUpdateEventTool: BuiltInCodeModeTool { var calendarIdentifier: String? } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> CalendarEvent { #if canImport(EventKit) let identifier = arguments.identifier guard identifier.isEmpty == false else { @@ -213,7 +210,7 @@ struct CalendarUpdateEventTool: BuiltInCodeModeTool { do { try store.save(event, span: .thisEvent) - return EventKitBridge.eventJSON(event) + return CalendarEvent(event) } catch { throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") } @@ -256,7 +253,6 @@ struct CalendarDeleteEventTool: BuiltInCodeModeTool { 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 codeModeResultSummary = "Object with identifier/deleted." struct Arguments: Sendable { @ToolParam("EventKit eventIdentifier from apple.calendar.listEvents.") @@ -265,7 +261,7 @@ struct CalendarDeleteEventTool: BuiltInCodeModeTool { var span: CalendarEventSpan = .thisEvent } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> EventKitDeletion { #if canImport(EventKit) let identifier = arguments.identifier guard identifier.isEmpty == false else { @@ -279,10 +275,7 @@ struct CalendarDeleteEventTool: BuiltInCodeModeTool { do { try store.remove(event, span: span) - return .object([ - "identifier": .string(identifier), - "deleted": .bool(true), - ]) + return EventKitDeletion(identifier: identifier, deleted: true) } catch { throw BridgeError.nativeFailure("calendar.delete failed: \(error.localizedDescription)") } @@ -384,7 +377,6 @@ struct RemindersListTool: BuiltInCodeModeTool { static let codeModeTags = ["reminders", "eventkit", "task"] static let codeModeExample = "await apple.reminders.listReminders({ limit: 20 })" static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] - static let codeModeResultSummary = "Array of reminders with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." struct Arguments: Sendable { @ToolParam("Due-date lower bound.") @@ -401,7 +393,7 @@ struct RemindersListTool: BuiltInCodeModeTool { var limit: Int = 50 } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> [Reminder] { #if canImport(EventKit) let store = EKEventStore() let includeCompleted = arguments.includeCompleted @@ -439,11 +431,11 @@ struct RemindersListTool: BuiltInCodeModeTool { return true } .prefix(limit) - complete(filtered.map { EventKitBridge.reminderJSON($0) }) + complete(filtered.map { Reminder($0) }) } } - return .array(result) + return result #else _ = arguments throw BridgeError.unsupportedPlatform("EventKit") @@ -457,7 +449,6 @@ struct RemindersCreateTool: BuiltInCodeModeTool { 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 codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." struct Arguments: Sendable { @ToolParam("Reminder title.") @@ -474,7 +465,7 @@ struct RemindersCreateTool: BuiltInCodeModeTool { var calendarIdentifier: String? } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> Reminder { #if canImport(EventKit) guard arguments.title.isEmpty == false else { throw BridgeError.invalidArguments("reminders.write title cannot be empty") @@ -496,7 +487,7 @@ struct RemindersCreateTool: BuiltInCodeModeTool { do { try store.save(reminder, commit: true) - return EventKitBridge.reminderJSON(reminder) + return Reminder(reminder) } catch { throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") } @@ -512,7 +503,6 @@ struct RemindersUpdateTool: BuiltInCodeModeTool { static let codeModeTags = ["reminders", "eventkit", "task"] static let codeModeExample = "await apple.reminders.updateReminder({ identifier: 'REMINDER_ID', dueDate: null })" static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] - static let codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." struct Arguments: Sendable { @ToolParam("EventKit calendarItemIdentifier to update.") @@ -531,7 +521,7 @@ struct RemindersUpdateTool: BuiltInCodeModeTool { var calendarIdentifier: Patch } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> Reminder { #if canImport(EventKit) let identifier = arguments.identifier guard identifier.isEmpty == false else { @@ -551,7 +541,7 @@ struct RemindersUpdateTool: BuiltInCodeModeTool { do { try store.save(reminder, commit: true) - return EventKitBridge.reminderJSON(reminder) + return Reminder(reminder) } catch { throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") } @@ -583,7 +573,6 @@ struct RemindersCompleteTool: BuiltInCodeModeTool { static let codeModeTags = ["reminders", "eventkit", "task"] static let codeModeExample = "await apple.reminders.completeReminder({ identifier: 'REMINDER_ID' })" static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] - static let codeModeResultSummary = "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." struct Arguments: Sendable { @ToolParam("EventKit calendarItemIdentifier to complete.") @@ -592,7 +581,7 @@ struct RemindersCompleteTool: BuiltInCodeModeTool { var isCompleted: Bool = true } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> Reminder { #if canImport(EventKit) let identifier = arguments.identifier guard identifier.isEmpty == false else { @@ -605,7 +594,7 @@ struct RemindersCompleteTool: BuiltInCodeModeTool { EventKitBridge.applyCompletion(.set(arguments.isCompleted), to: reminder) do { try store.save(reminder, commit: true) - return EventKitBridge.reminderJSON(reminder) + return Reminder(reminder) } catch { throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") } @@ -621,14 +610,13 @@ struct RemindersDeleteTool: BuiltInCodeModeTool { static let codeModeTags = ["reminders", "eventkit", "task", "delete"] static let codeModeExample = "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })" static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] - static let codeModeResultSummary = "Object with identifier/deleted." struct Arguments: Sendable { @ToolParam("EventKit calendarItemIdentifier from apple.reminders.listReminders.") var identifier: String } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> EventKitDeletion { #if canImport(EventKit) let identifier = arguments.identifier guard identifier.isEmpty == false else { @@ -641,10 +629,7 @@ struct RemindersDeleteTool: BuiltInCodeModeTool { do { try store.remove(reminder, commit: true) - return .object([ - "identifier": .string(identifier), - "deleted": .bool(true), - ]) + return EventKitDeletion(identifier: identifier, deleted: true) } catch { throw BridgeError.nativeFailure("reminders.delete failed: \(error.localizedDescription)") } diff --git a/Sources/CodeMode/Catalog/BridgeCatalog.swift b/Sources/CodeMode/Catalog/BridgeCatalog.swift index fe28309..a6ec466 100644 --- a/Sources/CodeMode/Catalog/BridgeCatalog.swift +++ b/Sources/CodeMode/Catalog/BridgeCatalog.swift @@ -160,7 +160,8 @@ final class BridgeCatalog: @unchecked Sendable { nullableArguments: function.nullableArguments, argumentHints: function.argumentHints, argumentConstraints: function.argumentConstraints, - resultSummary: function.resultSummary + resultSummary: function.resultSummary, + resultSchema: function.resultSchema ) var withTypes = reference withTypes.dts = TypeScriptDeclarations.declaration(for: reference) diff --git a/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift b/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift index ecf1849..60bc483 100644 --- a/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift +++ b/Sources/CodeMode/Catalog/TypeScriptDeclarations.swift @@ -9,10 +9,8 @@ import Foundation /// emitted as `.d.ts` — per capability for search results, and whole-surface for /// a system prompt. /// -/// Result types are `CodeModeValue` for now: built-in tools return untyped -/// `JSONValue` dictionaries, so a typed result would be a fiction. The prose -/// `resultSummary` is carried in the doc comment instead, and a per-capability -/// result schema can tighten this later without changing the shape here. +/// Typed built-in results carry a schema; transitional tools keep the JSON +/// fallback and their hand-written result summary. public enum TypeScriptDeclarations { /// Shared types and the runtime globals that are not catalog capabilities. /// @@ -169,7 +167,9 @@ public enum TypeScriptDeclarations { lines.append(" * \(reference.summary)") lines.append(" *") lines.append(" * Capability: \(reference.capability)") - lines.append(" * Returns: \(reference.resultSummary)") + if reference.resultSchema == nil || reference.resultSummary != reference.resultSchema?.summary { + lines.append(" * Returns: \(reference.resultSummary)") + } let aliases = reference.jsNames.filter { $0 != canonicalName } if aliases.isEmpty == false { @@ -188,13 +188,73 @@ public enum TypeScriptDeclarations { } private static func functionSignature(for reference: JavaScriptAPIReference) -> String { + let result = reference.resultSchema.map { resultType(for: $0) } ?? "CodeModeValue" let tree = ArgumentTree(reference: reference) guard tree.isEmpty == false else { - return "(): Promise" + return "(): Promise<\(result)>" } // `args` is optional only when nothing in it is required. let optionalMarker = reference.requiredArguments.isEmpty ? "?" : "" - return "(args\(optionalMarker): \(tree.render(indent: ""))): Promise" + return "(args\(optionalMarker): \(tree.render(indent: ""))): Promise<\(result)>" + } + + /// Both declarations and derived summaries use this renderer. Summaries + /// omit field documentation and line breaks, while preserving the type. + static func resultType(for schema: CodeModeResultSchema, indent: String = "", multiline: Bool = true) -> String { + switch schema { + case .string: return "string" + case .number: return "number" + case .bool: return "boolean" + case .any: return "CodeModeValue" + case let .nullable(value): + return "\(resultType(for: value, indent: indent, multiline: multiline)) | null" + case let .array(value): + return "Array<\(resultType(for: value, indent: indent, multiline: multiline))>" + case let .dictionary(value): + return "{ [key: string]: \(resultType(for: value, indent: indent, multiline: multiline)) }" + case let .object(fields): + guard !fields.isEmpty else { return "{}" } + let inner = indent + " " + let members = fields.map { field in + let optional: String + if case .nullable = field.schema { optional = "?" } else { optional = "" } + // Swift identifiers may be escaped or contain characters that + // require a quoted property name in TypeScript. + let name = field.name.range(of: #"^[A-Za-z_$][A-Za-z0-9_$]*$"#, options: .regularExpression) != nil + ? field.name + : stringLiteral(field.name) + let member = "\(name)\(optional): \(resultType(for: field.schema, indent: inner, multiline: multiline));" + guard multiline else { return member } + if let hint = field.hint, !hint.isEmpty { + let safeHint = hint.replacingOccurrences(of: "*/", with: "* /") + .components(separatedBy: .newlines).joined(separator: " ") + return "\(inner)/** \(safeHint) */\n\(inner)\(member)" + } + return inner + member + } + return multiline + ? "{\n\(members.joined(separator: "\n"))\n\(indent)}" + : "{ \(members.joined(separator: " ")) }" + } + } + + /// Quotes JSON/TypeScript strings without a throwing serialization step. + private static func stringLiteral(_ value: String) -> String { + let escaped = value.unicodeScalars.map { scalar -> String in + switch scalar { + case "\"": return "\\\"" + case "\\": return "\\\\" + case "\n": return "\\n" + case "\r": return "\\r" + case "\t": return "\\t" + default: + if scalar.value < 0x20 || scalar.value == 0x2028 || scalar.value == 0x2029 { + return String(format: "\\u%04x", scalar.value) + } + return String(scalar) + } + }.joined() + return "\"" + escaped + "\"" } // MARK: - Argument tree @@ -284,7 +344,7 @@ public enum TypeScriptDeclarations { if let allowedValues, allowedValues.isEmpty == false { // The constraint is the type: a union beats `string` plus prose the // model has to notice. - return allowedValues.map { "\"\($0)\"" }.joined(separator: " | ") + return allowedValues.map { stringLiteral($0) }.joined(separator: " | ") } switch type { diff --git a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift index 29d8c9f..868c5f5 100644 --- a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift +++ b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift @@ -61,6 +61,8 @@ public enum CodeModeAgentToolDescriptions { Search the bundled CodeMode JavaScript API catalog by executing an async JavaScript function against a preloaded api object. Use this before executeJavaScript to discover the correct JS helper names, capability IDs, required arguments, result shapes, and examples. The catalog is filtered to the current host platform, so unsupported helpers are hidden. Available in your search code: + type CodeModeResultSchema = "string" | "number" | "boolean" | "any" | { nullable: CodeModeResultSchema } | { array: CodeModeResultSchema } + | { dictionary: CodeModeResultSchema } | { object: Array<{ name: string; schema: CodeModeResultSchema; hint?: string }> }; interface JavaScriptAPIReference { capability: string; capabilityKey: string; @@ -76,6 +78,7 @@ public enum CodeModeAgentToolDescriptions { argumentHints: Record; argumentConstraints: { allowedStringValues: Record }; resultSummary: string; + resultSchema?: CodeModeResultSchema; // Recursive JSON shape for typed results. dts: string; } @@ -90,7 +93,7 @@ public enum CodeModeAgentToolDescriptions { Your code must evaluate to an async function and return JSON-serializable output. Search has a 2s budget, so slice broad result sets before returning them. - Prefer returning ref.dts: it is the TypeScript declaration for the helper, with argument names, types, string-literal unions for constrained values, optionality, and the result summary. It is more precise and usually shorter than assembling argumentTypes, argumentHints, and argumentConstraints yourself. + Prefer returning ref.dts: it is the TypeScript declaration for the helper, with argument names, types, string-literal unions for constrained values, optionality, typed results where available, and the result summary. It is more precise and usually shorter than assembling argumentTypes, argumentHints, and argumentConstraints yourself. Examples: async () => { diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 8b360dc..9da27e1 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -137,6 +137,95 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { } } +/// The JSON shape produced by a typed built-in result, composed from Swift types. +public indirect enum CodeModeResultSchema: Sendable, Codable, Equatable { + case string, number, bool, any + case nullable(CodeModeResultSchema) + case array(CodeModeResultSchema) + case dictionary(CodeModeResultSchema) + case object([Field]) + + private enum CodingKeys: String, CodingKey { + case nullable, array, dictionary, object + } + + public init(from decoder: Decoder) throws { + let scalar = try decoder.singleValueContainer() + if let name = try? scalar.decode(String.self) { + switch name { + case "string": self = .string + case "number": self = .number + case "boolean": self = .bool + case "any": self = .any + default: + throw DecodingError.dataCorruptedError(in: scalar, debugDescription: "Unknown result schema scalar: \(name)") + } + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + guard container.allKeys.count == 1, let key = container.allKeys.first else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Expected one result schema composite: nullable, array, dictionary, or object" + )) + } + switch key { + case .nullable: self = .nullable(try container.decode(Self.self, forKey: key)) + case .array: self = .array(try container.decode(Self.self, forKey: key)) + case .dictionary: self = .dictionary(try container.decode(Self.self, forKey: key)) + case .object: self = .object(try container.decode([Field].self, forKey: key)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .string: try "string".encode(to: encoder) + case .number: try "number".encode(to: encoder) + case .bool: try "boolean".encode(to: encoder) + case .any: try "any".encode(to: encoder) + case let .nullable(schema): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schema, forKey: .nullable) + case let .array(schema): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schema, forKey: .array) + case let .dictionary(schema): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schema, forKey: .dictionary) + case let .object(fields): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(fields, forKey: .object) + } + } + + public struct Field: Sendable, Codable, Equatable { + public var name: String + public var schema: CodeModeResultSchema + public var hint: String? + + public init(name: String, schema: CodeModeResultSchema, hint: String? = nil) { + self.name = name + self.schema = schema + self.hint = hint + } + + // The generated code uses this constrained initializer, so even a type + // with coincidentally matching members must explicitly conform. + init(name: String, describing type: Value.Type, hint: String? = nil) { + self.init(name: name, schema: Value.codeModeResultSchema, hint: hint) + } + } + + /// A single-line type for catalog search results. Unrestricted JSON keeps + /// the migration default rather than exposing the TypeScript alias. + /// This deliberately shares the catalog's type renderer so search summaries + /// and declarations use the same type spelling as the schema evolves. + var summary: String { + self == .any ? "JSON value" : TypeScriptDeclarations.resultType(for: self, multiline: false) + } +} + public struct CapabilityDescriptor: Sendable, Equatable { public var id: CapabilityID public var title: String @@ -151,6 +240,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { public var argumentHints: [String: String] public var argumentConstraints: CapabilityArgumentConstraints public var resultSummary: String + public var resultSchema: CodeModeResultSchema? public init( id: CapabilityID, @@ -165,7 +255,8 @@ public struct CapabilityDescriptor: Sendable, Equatable { nullableArguments: [String] = [], argumentHints: [String: String] = [:], argumentConstraints: CapabilityArgumentConstraints? = nil, - resultSummary: String = "JSON value" + resultSummary: String = "JSON value", + resultSchema: CodeModeResultSchema? = nil ) { self.id = id self.title = title @@ -180,6 +271,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { self.argumentHints = argumentHints self.argumentConstraints = argumentConstraints ?? CapabilityArgumentConstraints.defaults(for: id) self.resultSummary = resultSummary + self.resultSchema = resultSchema } private static func inferArgumentTypes(required: [String], optional: [String]) -> [String: CapabilityArgumentType] { @@ -388,6 +480,7 @@ struct RegisteredCodeModeFunction: Sendable { var argumentHints: [String: String] var argumentConstraints: CapabilityArgumentConstraints var resultSummary: String + var resultSchema: CodeModeResultSchema? var handler: CapabilityHandler var catalogCapability: String { @@ -415,6 +508,7 @@ struct RegisteredCodeModeFunction: Sendable { self.argumentHints = descriptor.argumentHints self.argumentConstraints = descriptor.argumentConstraints self.resultSummary = descriptor.resultSummary + self.resultSchema = descriptor.resultSchema self.handler = registration.handler } @@ -434,6 +528,7 @@ struct RegisteredCodeModeFunction: Sendable { self.argumentHints = registration.argumentHints self.argumentConstraints = registration.argumentConstraints self.resultSummary = registration.resultSummary + self.resultSchema = nil self.handler = registration.handler } } diff --git a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift index e215e69..6960ff1 100644 --- a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift +++ b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift @@ -21,7 +21,7 @@ public struct ToolParamMacro: PeerMacro { /// `codeModeAliasPaths`), the `codeModeArguments` metadata list, and /// `decode(arguments:)`. Tools with Patch fields also get `hasChanges(_:)` /// over their Patch and optional fields. Curated metadata (summary, tags, example, -/// permissions, result summary) and `call(arguments:context:)` stay +/// permissions, optional result summary override) and `call(arguments:context:)` stay /// hand-written. /// /// Nonoptional scalar properties may declare literal defaults; enum diff --git a/Sources/CodeModeMacros/CodeModeMacro.swift b/Sources/CodeModeMacros/CodeModeMacro.swift index 6d19b3f..575ba11 100644 --- a/Sources/CodeModeMacros/CodeModeMacro.swift +++ b/Sources/CodeModeMacros/CodeModeMacro.swift @@ -13,6 +13,8 @@ struct CodeModePlugin: CompilerPlugin { CodeModeResultMacro.self, BuiltInCodeModeMacro.self, ToolParamMacro.self, + ToolResultMacro.self, + ToolFieldMacro.self, ] } diff --git a/Sources/CodeModeMacros/ToolResultMacro.swift b/Sources/CodeModeMacros/ToolResultMacro.swift new file mode 100644 index 0000000..d0338fc --- /dev/null +++ b/Sources/CodeModeMacros/ToolResultMacro.swift @@ -0,0 +1,124 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// Marker read by `@ToolResult`; property types remain the compiler's concern. +public struct ToolFieldMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { [] } +} + +/// Generates encoding and schema from the same stored instance properties. +public struct ToolResultMacro: MemberMacro, ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + func reject(_ message: String, at syntax: some SyntaxProtocol) { + context.diagnose(Diagnostic(node: Syntax(syntax), message: MacroExpansionErrorMessage(message))) + } + guard let structure = declaration.as(StructDeclSyntax.self) else { + reject("@ToolResult can only be attached to a struct", at: node) + return [] + } + // Public/package results need equally visible protocol witnesses. As + // with @CodeMode, implicit member access handles private/fileprivate types. + let access = structure.modifiers.first { ["public", "package"].contains($0.name.text) } + .map { $0.name.text + " " } ?? "" + var encoded: [String] = [] + var fields: [String] = [] + for member in structure.memberBlock.members { + if member.decl.is(IfConfigDeclSyntax.self) { + reject("@ToolResult does not support conditional compilation blocks", at: member.decl) + return [] + } + guard let property = member.decl.as(VariableDeclSyntax.self) else { continue } + for binding in property.bindings { + if let identifier = binding.pattern.as(IdentifierPatternSyntax.self) { + let name = identifier.identifier.text.replacingOccurrences(of: "`", with: "") + if ["codeModeJSONValue", "codeModeResultSchema"].contains(name) { + reject("@ToolResult property name '\(name)' is reserved for generated members", at: property) + return [] + } + } + } + // Type metadata does not describe an instance's JSON payload. + if property.modifiers.contains(where: { ["static", "class"].contains($0.name.text) }) { continue } + if property.modifiers.contains(where: { $0.name.text == "lazy" }) { + reject("@ToolResult does not support lazy properties", at: property) + return [] + } + guard property.bindings.count == 1, + let binding = property.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self), + let type = binding.typeAnnotation?.type else { + reject("@ToolResult requires one named property with an explicit type per declaration", at: property) + return [] + } + if let accessors = binding.accessorBlock { + switch accessors.accessors { + case .getter: + reject("@ToolResult properties must be stored instance properties", at: property) + return [] + case let .accessors(list): + if list.contains(where: { !["willSet", "didSet"].contains($0.accessorSpecifier.text) }) { + reject("@ToolResult properties must be stored instance properties", at: property) + return [] + } + } + } + let markers = property.attributes.compactMap { $0.as(AttributeSyntax.self) } + .filter { $0.attributeName.trimmedDescription == "ToolField" } + var hint = "nil" + if let marker = markers.first { + guard markers.count == 1, + let arguments = marker.arguments?.as(LabeledExprListSyntax.self), + arguments.count == 1, + arguments.first?.label == nil, + let literal = arguments.first?.expression.as(StringLiteralExprSyntax.self), + literal.segments.allSatisfy({ $0.is(StringSegmentSyntax.self) }) else { + reject("@ToolField requires one string literal hint", at: marker) + return [] + } + hint = literal.trimmedDescription + } + let name = identifier.identifier.text.replacingOccurrences(of: "`", with: "") + let key = StringLiteralExprSyntax(content: name).description + encoded.append("\(key): self.\(identifier.trimmedDescription).codeModeJSONValue,") + // The constrained initializer enforces conformance for arbitrary + // nested and collection types without parsing them here. + fields.append("CodeModeResultSchema.Field(name: \(key), describing: (\(type.trimmedDescription)).self, hint: \(hint)),") + } + let object = encoded.isEmpty ? ".object([:])" : ".object([\n \(encoded.joined(separator: "\n "))\n ])" + let schema = fields.isEmpty ? ".object([])" : ".object([\n \(fields.joined(separator: "\n "))\n ])" + return [ + DeclSyntax(stringLiteral: """ + \(access)var codeModeJSONValue: JSONValue { + \(object) + } + """), + DeclSyntax(stringLiteral: """ + \(access)static var codeModeResultSchema: CodeModeResultSchema { + \(schema) + } + """), + ] + } + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { return [] } + return [try ExtensionDeclSyntax("extension \(type): CodeModeResultDescribable {}")] + } +} diff --git a/TODO.md b/TODO.md index c3e167f..7642bc4 100644 --- a/TODO.md +++ b/TODO.md @@ -128,8 +128,11 @@ tool model). `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.) + enum↔descriptor↔bridge agreement. For `@ToolResult` tools the summary, the + schema and the encoder come from one struct, so they cannot disagree; the + caveat below applies only to families still returning hand-built `JSONValue`. + (Golden-testing `resultSummary` against bridge JSON encoders for those — 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 @@ -140,6 +143,22 @@ tool model). generating the remaining families is still a follow-up. - [ ] Unify permission ownership across the remaining bridge families. EventKit requirements are now declared statically and enforced by the registry. +- [ ] **Migrate the remaining 14 families to the EventKit template** + (`PLAN-typed-results.md`; `EventKitCodeModeTools.swift` is the reference): + typed `Arguments` with macro defaults and `Date`, `Patch` on update tools, + `@ToolResult` structs, logic in `call` with the bridge reduced to shared + helpers, and that family's hand-written `globalThis.apple.*` bindings + deleted from `RuntimeJavaScript.swift`. Pair each with the async conversion + in `PLAN-async-bridges.md` phase 3 where the family's Apple API is async. + - [ ] Argument arrays carry no element type: `calendarIdentifiers: [String]` + renders as `CodeModeValue[]` while results render `Array`. Give + `CapabilityArgumentType.array` an element type. + - [ ] Whole-surface declarations repeat each result object literal per helper + (`CalendarEvent` ×3, `Reminder` ×4). Emit named interfaces once and + reference them. + - [ ] Decide `""` vs `null` for absent result strings (`notes`, `url`, + `completionDate`). The schema makes it a per-field change; the JSON scripts + receive changes, so it is a deliberate, documented decision. - [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 diff --git a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift index 4258d55..51816e8 100644 --- a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift +++ b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import Testing @@ -7,6 +8,8 @@ private func builtInTestMacros() -> [String: Macro.Type] { [ "BuiltInCodeMode": BuiltInCodeModeMacro.self, "ToolParam": ToolParamMacro.self, + "ToolResult": ToolResultMacro.self, + "ToolField": ToolFieldMacro.self, ] } @@ -396,3 +399,198 @@ func builtInCodeModeExpansionDiagnosesUnsupportedDefaults(type: String, initiali macros: builtInTestMacros() ) } + +@Test func toolResultExpansionComposesNestedOptionalArrayAndDateFields() { + assertMacroExpansion( + """ + struct Envelope { + @ToolResult + struct Result { + let child: Detail + @ToolField("May be absent.") + var label: String? + var dates: [Date] + var updated: Foundation.Date + } + } + """, + expandedSource: """ + struct Envelope { + struct Result { + let child: Detail + var label: String? + var dates: [Date] + var updated: Foundation.Date + + var codeModeJSONValue: JSONValue { + .object([ + "child": self.child.codeModeJSONValue, + "label": self.label.codeModeJSONValue, + "dates": self.dates.codeModeJSONValue, + "updated": self.updated.codeModeJSONValue, + ]) + } + + static var codeModeResultSchema: CodeModeResultSchema { + .object([ + CodeModeResultSchema.Field(name: "child", describing: (Detail).self, hint: nil), + CodeModeResultSchema.Field(name: "label", describing: (String?).self, hint: "May be absent."), + CodeModeResultSchema.Field(name: "dates", describing: ([Date]).self, hint: nil), + CodeModeResultSchema.Field(name: "updated", describing: (Foundation.Date).self, hint: nil), + ]) + } + } + } + + extension Envelope.Result: CodeModeResultDescribable { + } + """, + macros: builtInTestMacros() + ) +} + +@Test func toolResultExpansionSupportsEmptyResults() { + assertMacroExpansion( + """ + @ToolResult + struct Empty {} + """, + expandedSource: """ + struct Empty { + + var codeModeJSONValue: JSONValue { + .object([:]) + } + + static var codeModeResultSchema: CodeModeResultSchema { + .object([]) + } + } + + extension Empty: CodeModeResultDescribable { + } + """, + macros: builtInTestMacros() + ) +} + +@Test func toolResultExpansionRequiresAStruct() { + assertMacroExpansion( + """ + @ToolResult + class Broken {} + """, + expandedSource: "class Broken {}", + diagnostics: [DiagnosticSpec(message: "@ToolResult can only be attached to a struct", line: 1, column: 1)], + macros: builtInTestMacros() + ) +} + +@Test(arguments: [ + ("var value: String { \"computed\" }", "@ToolResult properties must be stored instance properties"), + ("lazy var value: String = \"lazy\"", "@ToolResult does not support lazy properties"), + ("var codeModeJSONValue: String", "@ToolResult property name 'codeModeJSONValue' is reserved for generated members"), + ("let codeModeResultSchema: String", "@ToolResult property name 'codeModeResultSchema' is reserved for generated members"), + ("static let codeModeResultSchema: String = \"reserved\"", "@ToolResult property name 'codeModeResultSchema' is reserved for generated members"), + ("#if DEBUG\nvar value: String\n#endif", "@ToolResult does not support conditional compilation blocks"), +]) +func toolResultExpansionDiagnosesUnsupportedProperties(property: String, message: String) { + assertMacroExpansion( + """ + @ToolResult + struct Broken { + \(property.replacingOccurrences(of: "\n", with: "\n ")) + } + """, + expandedSource: """ + struct Broken { + \(property.replacingOccurrences(of: "\n", with: "\n ")) + } + + extension Broken: CodeModeResultDescribable { + } + """, + diagnostics: [DiagnosticSpec(message: message, line: 3, column: 5)], + macros: builtInTestMacros() + ) +} + +@Test(arguments: ["var value = 1", "var first: Int, second: Int"]) +func toolResultExpansionRequiresExplicitIndividualTypes(property: String) { + assertMacroExpansion( + """ + @ToolResult + struct Broken { + \(property) + } + """, + expandedSource: """ + struct Broken { + \(property) + } + + extension Broken: CodeModeResultDescribable { + } + """, + diagnostics: [DiagnosticSpec(message: "@ToolResult requires one named property with an explicit type per declaration", line: 3, column: 5)], + macros: builtInTestMacros() + ) +} + +@Test func toolResultExpansionRequiresLiteralHints() { + assertMacroExpansion( + """ + @ToolResult + struct Broken { + @ToolField(makeHint()) + var value: String + } + """, + expandedSource: """ + struct Broken { + var value: String + } + + extension Broken: CodeModeResultDescribable { + } + """, + diagnostics: [DiagnosticSpec(message: "@ToolField requires one string literal hint", line: 3, column: 5)], + macros: builtInTestMacros() + ) +} + + +@Test(arguments: ["public", "package"]) +func toolResultExpansionMirrorsAccessAndSkipsStaticProperties(access: String) { + assertMacroExpansion( + """ + @ToolResult + \(access) struct Result { + static let shared = "metadata" + let count: Int + } + """, + expandedSource: """ + \(access) struct Result { + static let shared = "metadata" + let count: Int + + \(access) var codeModeJSONValue: JSONValue { + .object([ + "count": self.count.codeModeJSONValue, + ]) + } + + \(access) static var codeModeResultSchema: CodeModeResultSchema { + .object([ + CodeModeResultSchema.Field(name: "count", describing: (Int).self, hint: nil), + ]) + } + } + + extension Result: CodeModeResultDescribable { + } + """, + macros: builtInTestMacros() + ) +} diff --git a/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift index 0e9c9e2..14f6685 100644 --- a/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift +++ b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift @@ -257,3 +257,78 @@ private struct DynamicPermissionFixtureTool: BuiltInCodeModeTool { _ = try registration.handler(["mode": .string("alias")], context) #expect(context.allPermissionEvents().contains { $0.permission == .calendarWriteOnly && $0.status == .granted }) } + +@ToolResult +public struct TypedResultFixture { + @ToolResult + struct Child { + let name: String + } + + let child: Child + @ToolField("May be absent.") + var label: String? + var dates: [Date] + var counts: [String: Int?] + var ratio: Double + var fraction: Float + var enabled: Bool + var raw: JSONValue +} + +@BuiltInCodeMode(.calendarRead, path: "apple.calendar.typedResultFixture") +private struct TypedResultFixtureTool: BuiltInCodeModeTool { + static let codeModeSummary = "Exercise result composition." + static let codeModeTags: [String] = [] + static let codeModeExample = "" + struct Arguments: Sendable {} + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> [TypedResultFixture]? { + [TypedResultFixture( + child: .init(name: "nested"), label: nil, + dates: [Date(timeIntervalSince1970: 0)], counts: ["one": 1, "missing": nil], + ratio: 1.5, fraction: 0.25, enabled: true, raw: .array([.null]) + )] + } +} + +@Test func typedResultsEncodeThroughRegistrationAndDescribeTheirComposition() throws { + let registration = CapabilityRegistration(tool: TypedResultFixtureTool()) + let (context, sandbox) = try makeInvocationContext() + defer { cleanup(sandbox) } + #expect(try registration.handler([:], context) == .array([.object([ + "child": .object(["name": .string("nested")]), "label": .null, + "dates": .array([.string("1970-01-01T00:00:00Z")]), + "counts": .object(["one": .number(1), "missing": .null]), + "ratio": .number(1.5), "fraction": .number(0.25), "enabled": .bool(true), + "raw": .array([.null]), + ])])) + let expected: CodeModeResultSchema = .nullable(.array(.object([ + .init(name: "child", schema: .object([.init(name: "name", schema: .string)])), + .init(name: "label", schema: .nullable(.string), hint: "May be absent."), + .init(name: "dates", schema: .array(.string)), + .init(name: "counts", schema: .dictionary(.nullable(.number))), + .init(name: "ratio", schema: .number), + .init(name: "fraction", schema: .number), + .init(name: "enabled", schema: .bool), + .init(name: "raw", schema: .any), + ]))) + #expect(registration.descriptor.resultSchema == expected) + #expect(Optional<[TypedResultFixture]>.none.codeModeJSONValue == .null) + let reference = try #require(BridgeCatalog(registry: CapabilityRegistry(registrations: [registration])).allReferences().first) + #expect(reference.resultSchema == expected) + #expect(reference.dts.contains("/** May be absent. */")) + #expect(reference.dts.contains("label?: string | null;")) + #expect(reference.dts.contains("dates: Array;")) + #expect(reference.dts.contains("counts: { [key: string]: number | null };")) + #expect(reference.dts.contains("}> | null>")) +} + +@Test func untypedResultsRetainTheirSummaryOverrideAndOmitTheirSchema() throws { + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) + let reference = try #require(BridgeCatalog(registry: registry).reference(forJavaScriptName: "apple.fs.read")) + #expect(JSONValue.codeModeResultSchema.summary == "JSON value") + #expect(reference.resultSummary == "Object with path plus text or base64 field.") + #expect(reference.resultSchema == nil) + #expect(reference.dts.contains("Promise")) +} diff --git a/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift index 6b0c337..9c458fb 100644 --- a/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift +++ b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift @@ -19,6 +19,7 @@ private struct CapabilitySnapshot: Codable, Equatable { var argumentHints: [String: String] var allowedStringValues: [String: [String]] var resultSummary: String + var resultSchema: CodeModeResultSchema? init(_ registration: CapabilityRegistration) { let descriptor = registration.descriptor @@ -36,6 +37,7 @@ private struct CapabilitySnapshot: Codable, Equatable { argumentHints = descriptor.argumentHints allowedStringValues = descriptor.argumentConstraints.allowedStringValues resultSummary = descriptor.resultSummary + resultSchema = descriptor.resultSchema } } diff --git a/Tests/CodeModeTests/CodeModeResultSchemaTests.swift b/Tests/CodeModeTests/CodeModeResultSchemaTests.swift new file mode 100644 index 0000000..93fdf6e --- /dev/null +++ b/Tests/CodeModeTests/CodeModeResultSchemaTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func nestedResultSchemasRoundTripWithCompactJSON() throws { + let schema: CodeModeResultSchema = .nullable(.array(.object([ + .init(name: "stats", schema: .dictionary(.number), hint: "Counts."), + .init(name: "enabled", schema: .bool), + .init(name: "note", schema: .nullable(.string)), + .init(name: "raw", schema: .any), + ]))) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let encoded = try encoder.encode(schema) + #expect(String(decoding: encoded, as: UTF8.self) == #"{"nullable":{"array":{"object":[{"hint":"Counts.","name":"stats","schema":{"dictionary":"number"}},{"name":"enabled","schema":"boolean"},{"name":"note","schema":{"nullable":"string"}},{"name":"raw","schema":"any"}]}}}"#) + #expect(try JSONDecoder().decode(CodeModeResultSchema.self, from: encoded) == schema) +} + +@Test(arguments: [#""bool""#, "1", "{}", #"{"unknown":"string"}"#, #"{"array":"string","nullable":"number"}"#]) +func resultSchemaDecodingRejectsInvalidShapes(json: String) { + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CodeModeResultSchema.self, from: Data(json.utf8)) + } +} diff --git a/Tests/CodeModeTests/EventKitBridgeTests.swift b/Tests/CodeModeTests/EventKitBridgeTests.swift index 654559f..6006c58 100644 --- a/Tests/CodeModeTests/EventKitBridgeTests.swift +++ b/Tests/CodeModeTests/EventKitBridgeTests.swift @@ -159,11 +159,14 @@ import EventKit // every neighbouring field was nil-coalesced. #expect(event.calendar == nil) - let json = try #require(EventKitBridge.eventJSON(event).objectValue) - #expect(json.string("title") == "Orphaned") - #expect(json.string("calendarIdentifier") == "") - #expect(json.string("calendarTitle") == "") - #expect(json.string("notes") == "") + #expect(CalendarEvent(event).codeModeJSONValue == .object([ + "identifier": .string(""), "title": .string("Orphaned"), + "startDate": .string("2023-11-14T22:13:20Z"), + "endDate": .string("2023-11-14T23:13:20Z"), + "notes": .string(""), "calendarIdentifier": .string(""), + "calendarTitle": .string(""), "location": .string(""), + "url": .string(""), "isAllDay": .bool(false), + ])) } @Test func reminderSerializationSurvivesAMissingTitle() throws { @@ -171,10 +174,12 @@ import EventKit let reminder = EKReminder(eventStore: store) // `EKReminder.title` is `String!`. - let json = try #require(EventKitBridge.reminderJSON(reminder).objectValue) - #expect(json.string("title") == "") - #expect(json.string("calendarIdentifier") == "") - #expect(json.bool("isCompleted") == false) + #expect(Reminder(reminder).codeModeJSONValue == .object([ + "identifier": .string(reminder.calendarItemIdentifier), "title": .string(""), + "isCompleted": .bool(false), "dueDate": .string(""), "completionDate": .string(""), + "notes": .string(""), "priority": .number(0), + "calendarIdentifier": .string(""), "calendarTitle": .string(""), + ])) } #endif diff --git a/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift b/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift index e980779..818d3d5 100644 --- a/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift +++ b/Tests/CodeModeTests/TypeScriptDeclarationsTests.swift @@ -323,3 +323,88 @@ func writeTypeDeclarationsForCompilerCheck() async throws { } #expect(references.count > 100, "expected the full iOS catalog") } + +@Test(arguments: [ + ( + "apple.calendar.listEvents", + "Array<{ identifier: string; title: string; startDate: string; endDate: string; notes: string; calendarIdentifier: string; calendarTitle: string; location: string; url: string; isAllDay: boolean; }>", + """ + Array<{ + identifier: string; + title: string; + /** ISO 8601 timestamp */ + startDate: string; + /** ISO 8601 timestamp */ + endDate: string; + notes: string; + /** Empty ("") when the event has no calendar */ + calendarIdentifier: string; + /** Empty ("") when the event has no calendar */ + calendarTitle: string; + location: string; + /** Absolute URL, or "" when none */ + url: string; + isAllDay: boolean; + }> + """ + ), + ( + "apple.reminders.updateReminder", + "{ identifier: string; title: string; isCompleted: boolean; dueDate: string; completionDate: string; notes: string; priority: number; calendarIdentifier: string; calendarTitle: string; }", + """ + { + identifier: string; + title: string; + isCompleted: boolean; + /** ISO 8601 timestamp, or "" when none */ + dueDate: string; + /** ISO 8601 timestamp, or "" when none */ + completionDate: string; + notes: string; + priority: number; + /** Empty ("") when the reminder has no calendar */ + calendarIdentifier: string; + /** Empty ("") when the reminder has no calendar */ + calendarTitle: string; + } + """ + ), +]) +func eventKitCatalogDerivesSummariesAndPromiseTypes(path: String, summary: String, resultType: String) throws { + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) + let reference = try #require(BridgeCatalog(registry: registry).reference(forJavaScriptName: path)) + #expect(reference.resultSchema != nil) + #expect(reference.resultSummary == summary) + #expect(!reference.dts.contains(" * Returns:")) + // Namespace indentation does not affect the return type's contract. + let unindented = reference.dts.components(separatedBy: "\n").map { line in + line.hasPrefix(" ") ? String(line.dropFirst(4)) : line + }.joined(separator: "\n") + #expect(unindented.contains("Promise<\(resultType)>")) +} + +@Test func referencesEncodedWithoutResultSchemaStillDecode() throws { + let legacy = #"{"capability":"calendar.read","capabilityKey":"calendar.read","jsNames":["apple.calendar.listEvents"],"summary":"List events.","tags":[],"example":"","requiredArguments":[],"optionalArguments":[],"argumentTypes":{},"argumentHints":{},"argumentConstraints":{"allowedStringValues":{}},"resultSummary":"Events.","dts":"cached declaration"}"# + let decoded = try JSONDecoder().decode(JavaScriptAPIReference.self, from: Data(legacy.utf8)) + #expect(decoded.resultSchema == nil) + #expect(decoded.resultSummary == "Events.") + #expect(decoded.dts == "cached declaration") +} + + +@Test func typedResultDocumentationPreservesProseOverrides() { + var typed = reference() + typed.resultSchema = .object([.init(name: "text", schema: .string)]) + typed.resultSummary = "Decoded file contents." + let dts = TypeScriptDeclarations.declaration(for: typed) + #expect(dts.contains(" * Returns: Decoded file contents.")) + #expect(dts.contains("Promise<{\n text: string;\n }>")) +} + +@Test func resultFieldNamesAreQuotedAsTypeScriptStringLiterals() { + var typed = reference() + typed.resultSchema = .object([.init(name: "quote\"\\\n\t\r\u{0001}\u{2028}\u{2029}", schema: .string)]) + #expect(TypeScriptDeclarations.declaration(for: typed).contains( + #""quote\"\\\n\t\r\u0001\u2028\u2029": string;"# + )) +} diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json index 9140d4d..3ce5a0a 100644 --- a/Tests/CodeModeTests/capability-metadata-golden.json +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -650,7 +650,19 @@ "requiredPermissions" : [ "calendar" ], - "resultSummary" : "Object with identifier\/deleted.", + "resultSchema" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "deleted", + "schema" : "boolean" + } + ] + }, + "resultSummary" : "{ identifier: string; deleted: boolean; }", "summary" : "Delete an existing EventKit event by identifier.", "tags" : [ "calendar", @@ -699,7 +711,58 @@ "requiredPermissions" : [ "calendar" ], - "resultSummary" : "Array of events with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "resultSchema" : { + "array" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "title", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp", + "name" : "startDate", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp", + "name" : "endDate", + "schema" : "string" + }, + { + "name" : "notes", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the event has no calendar", + "name" : "calendarIdentifier", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the event has no calendar", + "name" : "calendarTitle", + "schema" : "string" + }, + { + "name" : "location", + "schema" : "string" + }, + { + "hint" : "Absolute URL, or \"\" when none", + "name" : "url", + "schema" : "string" + }, + { + "name" : "isAllDay", + "schema" : "boolean" + } + ] + } + }, + "resultSummary" : "Array<{ identifier: string; title: string; startDate: string; endDate: string; notes: string; calendarIdentifier: string; calendarTitle: string; location: string; url: string; isAllDay: boolean; }>", "summary" : "List events in a date range from EventKit.", "tags" : [ "calendar", @@ -902,7 +965,56 @@ "requiredPermissions" : [ "calendar.writeOnly" ], - "resultSummary" : "Object with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "resultSchema" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "title", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp", + "name" : "startDate", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp", + "name" : "endDate", + "schema" : "string" + }, + { + "name" : "notes", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the event has no calendar", + "name" : "calendarIdentifier", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the event has no calendar", + "name" : "calendarTitle", + "schema" : "string" + }, + { + "name" : "location", + "schema" : "string" + }, + { + "hint" : "Absolute URL, or \"\" when none", + "name" : "url", + "schema" : "string" + }, + { + "name" : "isAllDay", + "schema" : "boolean" + } + ] + }, + "resultSummary" : "{ identifier: string; title: string; startDate: string; endDate: string; notes: string; calendarIdentifier: string; calendarTitle: string; location: string; url: string; isAllDay: boolean; }", "summary" : "Create a calendar event.", "tags" : [ "calendar", @@ -964,7 +1076,56 @@ "requiredPermissions" : [ "calendar" ], - "resultSummary" : "Object with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "resultSchema" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "title", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp", + "name" : "startDate", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp", + "name" : "endDate", + "schema" : "string" + }, + { + "name" : "notes", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the event has no calendar", + "name" : "calendarIdentifier", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the event has no calendar", + "name" : "calendarTitle", + "schema" : "string" + }, + { + "name" : "location", + "schema" : "string" + }, + { + "hint" : "Absolute URL, or \"\" when none", + "name" : "url", + "schema" : "string" + }, + { + "name" : "isAllDay", + "schema" : "boolean" + } + ] + }, + "resultSummary" : "{ identifier: string; title: string; startDate: string; endDate: string; notes: string; calendarIdentifier: string; calendarTitle: string; location: string; url: string; isAllDay: boolean; }", "summary" : "Patch an existing event by identifier.", "tags" : [ "calendar", @@ -4599,7 +4760,19 @@ "requiredPermissions" : [ "reminders" ], - "resultSummary" : "Object with identifier\/deleted.", + "resultSchema" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "deleted", + "schema" : "boolean" + } + ] + }, + "resultSummary" : "{ identifier: string; deleted: boolean; }", "summary" : "Delete an existing EventKit reminder by identifier.", "tags" : [ "reminders", @@ -4651,7 +4824,53 @@ "requiredPermissions" : [ "reminders" ], - "resultSummary" : "Array of reminders with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "resultSchema" : { + "array" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "title", + "schema" : "string" + }, + { + "name" : "isCompleted", + "schema" : "boolean" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "dueDate", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "completionDate", + "schema" : "string" + }, + { + "name" : "notes", + "schema" : "string" + }, + { + "name" : "priority", + "schema" : "number" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarIdentifier", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarTitle", + "schema" : "string" + } + ] + } + }, + "resultSummary" : "Array<{ identifier: string; title: string; isCompleted: boolean; dueDate: string; completionDate: string; notes: string; priority: number; calendarIdentifier: string; calendarTitle: string; }>", "summary" : "Read incomplete reminders from EventKit.", "tags" : [ "reminders", @@ -4689,7 +4908,51 @@ "requiredPermissions" : [ "reminders" ], - "resultSummary" : "Object with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "resultSchema" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "title", + "schema" : "string" + }, + { + "name" : "isCompleted", + "schema" : "boolean" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "dueDate", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "completionDate", + "schema" : "string" + }, + { + "name" : "notes", + "schema" : "string" + }, + { + "name" : "priority", + "schema" : "number" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarIdentifier", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarTitle", + "schema" : "string" + } + ] + }, + "resultSummary" : "{ identifier: string; title: string; isCompleted: boolean; dueDate: string; completionDate: string; notes: string; priority: number; calendarIdentifier: string; calendarTitle: string; }", "summary" : "Set an existing reminder's completion state.", "tags" : [ "reminders", @@ -4739,7 +5002,51 @@ "requiredPermissions" : [ "reminders" ], - "resultSummary" : "Object with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "resultSchema" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "title", + "schema" : "string" + }, + { + "name" : "isCompleted", + "schema" : "boolean" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "dueDate", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "completionDate", + "schema" : "string" + }, + { + "name" : "notes", + "schema" : "string" + }, + { + "name" : "priority", + "schema" : "number" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarIdentifier", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarTitle", + "schema" : "string" + } + ] + }, + "resultSummary" : "{ identifier: string; title: string; isCompleted: boolean; dueDate: string; completionDate: string; notes: string; priority: number; calendarIdentifier: string; calendarTitle: string; }", "summary" : "Create a reminder.", "tags" : [ "reminders", @@ -4796,7 +5103,51 @@ "requiredPermissions" : [ "reminders" ], - "resultSummary" : "Object with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "resultSchema" : { + "object" : [ + { + "name" : "identifier", + "schema" : "string" + }, + { + "name" : "title", + "schema" : "string" + }, + { + "name" : "isCompleted", + "schema" : "boolean" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "dueDate", + "schema" : "string" + }, + { + "hint" : "ISO 8601 timestamp, or \"\" when none", + "name" : "completionDate", + "schema" : "string" + }, + { + "name" : "notes", + "schema" : "string" + }, + { + "name" : "priority", + "schema" : "number" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarIdentifier", + "schema" : "string" + }, + { + "hint" : "Empty (\"\") when the reminder has no calendar", + "name" : "calendarTitle", + "schema" : "string" + } + ] + }, + "resultSummary" : "{ identifier: string; title: string; isCompleted: boolean; dueDate: string; completionDate: string; notes: string; priority: number; calendarIdentifier: string; calendarTitle: string; }", "summary" : "Patch an existing reminder by identifier.", "tags" : [ "reminders",