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/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. 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. 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") diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index f3432ef..ca36dc9 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -100,9 +100,11 @@ 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 + 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. @@ -119,9 +121,11 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { requiredArguments: [String], optionalArguments: [String], argumentTypes: [String: CapabilityArgumentType], + nullableArguments: [String] = [], argumentHints: [String: String], argumentConstraints: CapabilityArgumentConstraints = .none, resultSummary: String, + resultSchema: CodeModeResultSchema? = nil, dts: String = "" ) { self.capability = capability @@ -134,22 +138,23 @@ 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 + 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 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) @@ -162,9 +167,11 @@ 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) + 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 a2f1311..df43cfe 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift @@ -2,19 +2,28 @@ import Foundation /// Generates the mechanical members of a `BuiltInCodeModeTool` — the identity /// statics, `codeModeArguments`, and `decode(arguments:)` — from the nested -/// `Arguments` struct. Curated metadata (title, summary, tags, example, -/// permissions, result summary) and `call(arguments:context:)` stay +/// `Arguments` struct. Curated metadata (summary, tags, example, +/// permissions, optional result summary override) and `call(arguments:context:)` stay /// hand-written. /// /// Annotate each argument property with `@ToolParam("hint")`. Property types /// may be JSON primitives (`String`, `Bool`, `Int`, `Double`, `[JSONValue]`, /// `[String: JSONValue]`, `JSONValue`, optionals thereof) or a /// `CodeModeStringEnum`, whose accepted values become the argument's -/// constraint metadata. A trailing un-annotated `var raw: [String: JSONValue]` +/// 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. +/// +/// `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, @@ -41,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 e54edca..ddce459 100644 --- a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -5,29 +5,49 @@ 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) { + 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.hint = hint + self.nullable = nullable + 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, nullable: Bool = false, hint: String, defaultValue: JSONValue? = nil) { self.name = name self.type = .string self.optional = optional - self.hint = hint + self.nullable = nullable + 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, @@ -41,13 +61,13 @@ struct BuiltInToolArgument { /// migration is incremental. protocol BuiltInCodeModeTool: Sendable { associatedtype Arguments: Sendable + associatedtype Result: CodeModeResultDescribable 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 } @@ -56,12 +76,17 @@ protocol BuiltInCodeModeTool: Sendable { static var codeModeResultSummary: String { get } func decode(arguments: [String: JSONValue]) throws -> Arguments - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue + /// 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 -> Result } extension BuiltInCodeModeTool { + static var codeModeResultSummary: String { Result.codeModeResultSchema.summary } static var codeModeAliasPaths: [String] { [] } static var codeModeRequiredPermissions: [PermissionKind] { [] } + func argumentDependentPermissions(for arguments: Arguments) -> [PermissionKind] { [] } } extension CapabilityRegistration { @@ -78,7 +103,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, @@ -86,17 +111,22 @@ 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. 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) - return try tool.call(arguments: decoded, context: context) + for permission in tool.argumentDependentPermissions(for: decoded) { + try context.requirePermission(permission) + } + return try tool.call(arguments: decoded, context: context).codeModeJSONValue } ) } 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/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/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 9750c4a..be64799 100644 --- a/Sources/CodeMode/Bridges/EventKitBridge.swift +++ b/Sources/CodeMode/Bridges/EventKitBridge.swift @@ -1,440 +1,28 @@ import Foundation - #if canImport(EventKit) import EventKit #endif -public final class EventKitBridge: @unchecked Sendable { +/// Shared EventKit lookup and patching helpers. +enum EventKitBridge { static let reminderFetchTimeoutSeconds: TimeInterval = 15 - 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) - } - - #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") - ?? Calendar.current.date(byAdding: .day, value: 14, to: start) - ?? start - let limit = arguments.int("limit") ?? 50 - let calendars = try resolveCalendars( - from: arguments, - 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 - } - - 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) - } - - #if canImport(EventKit) - switch operation { - case .create: - return try createEvent(arguments: arguments) - case .update: - return try updateEvent(arguments: arguments) - } - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #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) - } - - #if canImport(EventKit) - guard let identifier = arguments.string("identifier"), 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")) - - 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 - } - - public func readReminders(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.reminders, context: context) - guard status == .granted else { - throw BridgeError.permissionDenied(.reminders) - } - - #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 calendars = try resolveCalendars( - from: arguments, - 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 - } - - public func writeReminder(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.reminders, context: context) - guard status == .granted else { - throw BridgeError.permissionDenied(.reminders) - } - - #if canImport(EventKit) - switch try reminderOperation(arguments) { - case .create: - return try createReminder(arguments: arguments) - case .update, .complete: - return try updateReminder(arguments: arguments) - } - #else - _ = arguments - throw BridgeError.unsupportedPlatform("EventKit") - #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) - } - - #if canImport(EventKit) - guard let identifier = arguments.string("identifier"), 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 - } - - 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) - 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.string("notes") - event.location = arguments.string("location") - event.isAllDay = arguments.bool("isAllDay") ?? false - if let urlText = arguments.string("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") { - 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)") + 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 updateEvent(arguments: [String: JSONValue]) throws -> JSONValue { - guard let identifier = arguments.string("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 { - 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)") - } - - 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" - ) - } - - do { - try store.save(event, span: .thisEvent) - return Self.eventJSON(event) - } catch { - throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") - } - } - - private func createReminder(arguments: [String: JSONValue]) throws -> JSONValue { - guard let title = arguments.string("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.title = title - reminder.notes = arguments.string("notes") - reminder.priority = arguments.int("priority") ?? 0 - - if let dueDateText = arguments.string("dueDate"), dueDateText.isEmpty == false { - guard let dueDate = isoDate(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)") - } - } - - private func updateReminder(arguments: [String: JSONValue]) throws -> JSONValue { - guard let identifier = arguments.string("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 { - 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)") - } - - if arguments.keys.contains("title") { - guard let title = arguments.string("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 { - 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.keys.contains("notes") { - reminder.notes = arguments.string("notes") - } - if arguments.keys.contains("isCompleted") { - let isCompleted = arguments.bool("isCompleted") ?? false - reminder.isCompleted = isCompleted - reminder.completionDate = isCompleted ? (reminder.completionDate ?? Date()) : nil - } - if let priority = arguments.int("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)") - } - } - - private func resolveCalendars( - from arguments: [String: JSONValue], + static func resolveCalendars( + 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 @@ -451,7 +39,7 @@ public final class EventKitBridge: @unchecked Sendable { return matches } - private func calendar( + static func calendar( _ identifier: String, in store: EKEventStore, entityType: EKEntityType, @@ -466,8 +54,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") { + static func reminderCalendar(identifier: String?, in store: EKEventStore) throws -> EKCalendar { + if let calendarIdentifier = identifier { return try calendar( calendarIdentifier, in: store, @@ -481,64 +69,81 @@ 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 - } + #endif +} - /// 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) - } +/// 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 +} - static func reminderJSONForTesting(_ reminder: EKReminder) -> JSONValue { - reminderJSON(reminder) - } +@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 +} - private 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), - ]) +@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 } +} - private 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 788106e..35314b9 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,100 +46,248 @@ 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' })" 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("ISO8601 timestamp; defaults to now.") - var start: String? - @ToolParam("ISO8601 timestamp; defaults to start + 14 days.") - var end: String? - @ToolParam("Max number of items, default 50.") - var limit: Int? - @ToolParam("Optional EventKit calendarIdentifier to restrict results.") + @ToolParam("Range start; uses now when omitted.") + var start: Date? + @ToolParam("Range end; uses start + 14 days when omitted.") + var end: Date? + @ToolParam("Max number of items.") + var limit: Int = 50 + @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]? - var raw: [String: JSONValue] } - let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> [CalendarEvent] { + #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 + CalendarEvent(event) + } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.readEvents(arguments: arguments.raw, context: context) + return 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 codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." + static let codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] 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? - var raw: [String: JSONValue] } - let eventKit: EventKitBridge + 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") + } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.writeEvent(arguments: arguments.raw, context: context) + 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 CalendarEvent(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] + + 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 -> CalendarEvent { + #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 CalendarEvent(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' })" static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] - static let codeModeResultSummary = "Object with identifier/deleted." 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("Scope for recurring events.") + var span: CalendarEventSpan = .thisEvent } - let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> EventKitDeletion { + #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 - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.deleteEvent(arguments: arguments.raw, context: context) + do { + try store.remove(event, span: span) + return EventKitDeletion(identifier: identifier, deleted: 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' })" @@ -158,11 +295,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] } @@ -176,7 +313,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 })" @@ -186,11 +322,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] } @@ -204,7 +340,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' })" @@ -212,17 +347,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] } @@ -238,90 +373,269 @@ 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 })" 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("Optional ISO8601 due-date lower bound.") - var start: String? - @ToolParam("Optional ISO8601 due-date upper bound.") - var end: String? - @ToolParam("Whether completed reminders are included; default false.") - var includeCompleted: Bool? - @ToolParam("Optional EventKit reminder calendarIdentifier to restrict results.") + @ToolParam("Due-date lower bound.") + var start: Date? + @ToolParam("Due-date upper bound.") + var end: Date? + @ToolParam("Whether completed reminders are included.") + var includeCompleted: Bool = false + @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, 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 -> [Reminder] { + #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 { Reminder($0) }) + } + } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.readReminders(arguments: arguments.raw, context: context) + return 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? - var raw: [String: JSONValue] } - let eventKit: EventKitBridge + 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") + } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.writeReminder(arguments: arguments.raw, context: context) + 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 Reminder(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] + + 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 -> Reminder { + #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 Reminder(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] + + 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 -> Reminder { + #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 Reminder(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' })" 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 - var raw: [String: JSONValue] } - let eventKit: EventKitBridge + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> EventKitDeletion { + #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)") + } - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { - try eventKit.deleteReminder(arguments: arguments.raw, context: context) + do { + try store.remove(reminder, commit: true) + return EventKitDeletion(identifier: identifier, deleted: true) + } catch { + throw BridgeError.nativeFailure("reminders.delete failed: \(error.localizedDescription)") + } + #else + _ = arguments + throw BridgeError.unsupportedPlatform("EventKit") + #endif } } 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/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/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 0066ce5..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()" @@ -142,6 +138,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 { @@ -156,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()" @@ -182,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/Sources/CodeMode/Catalog/BridgeCatalog.swift b/Sources/CodeMode/Catalog/BridgeCatalog.swift index 8ec69f1..a6ec466 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,9 +157,11 @@ 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 + 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 29cfe77..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 @@ -228,7 +288,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, @@ -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 84e53b3..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; @@ -72,14 +74,18 @@ public enum CodeModeAgentToolDescriptions { requiredArguments: string[]; optionalArguments: string[]; argumentTypes: Record; + nullableArguments: string[]; argumentHints: Record; argumentConstraints: { allowedStringValues: Record }; resultSummary: string; + resultSchema?: CodeModeResultSchema; // Recursive JSON shape for typed results. dts: string; } 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; }; @@ -87,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 e5bb049..9da27e1 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))") } @@ -136,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 @@ -146,9 +236,11 @@ 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 + public var resultSchema: CodeModeResultSchema? public init( id: CapabilityID, @@ -160,9 +252,11 @@ public struct CapabilityDescriptor: Sendable, Equatable { requiredArguments: [String] = [], optionalArguments: [String] = [], argumentTypes: [String: CapabilityArgumentType] = [:], + 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 @@ -173,9 +267,11 @@ 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 + self.resultSchema = resultSchema } private static func inferArgumentTypes(required: [String], optional: [String]) -> [String: CapabilityArgumentType] { @@ -380,9 +476,11 @@ 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 + var resultSchema: CodeModeResultSchema? var handler: CapabilityHandler var catalogCapability: String { @@ -406,9 +504,11 @@ 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 + self.resultSchema = descriptor.resultSchema self.handler = registration.handler } @@ -424,16 +524,18 @@ 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 + self.resultSchema = nil self.handler = registration.handler } } 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 +545,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 +577,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 +585,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 +607,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 +640,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 +662,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 +685,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 +812,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 +820,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 { @@ -709,32 +834,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/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 f93dafd..6960ff1 100644 --- a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift +++ b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift @@ -19,11 +19,21 @@ 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, -/// permissions, result summary) and `call(arguments:context:)` stay +/// `decode(arguments:)`. Tools with Patch fields also get `hasChanges(_:)` +/// over their Patch and optional fields. Curated metadata (summary, tags, example, +/// permissions, optional result summary override) 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 @@ -70,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) } } @@ -79,6 +101,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { private struct ParsedArguments { var fields: [Field] var hasRawPassthrough: Bool + var initializerLabels: [String] } private struct Field { @@ -86,15 +109,19 @@ 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 /// CodeModeStringEnum-constrained overloads. case stringEnum + case date } private static func parseFields( @@ -102,6 +129,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { context: some MacroExpansionContext ) -> ParsedArguments? { var fields: [Field] = [] + var initializerLabels: [String] = [] var hasRawPassthrough = false var rawIsLast = true var invalid = false @@ -110,6 +138,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,6 +160,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { } let name = identifier.identifier.text + initializerLabels.append(name) let hint = toolParamHint(on: variable.attributes) if hint == nil, name == "raw" { @@ -148,13 +182,32 @@ public struct BuiltInCodeModeMacro: MemberMacro { } let info = BuiltInTypeInfo(typeSyntax: type) + var defaultExpression: String? + var defaultJSON: String? + if let initializer = binding.initializer?.value { + 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 + } + 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 + patch: info.patch, + hint: hint, + defaultExpression: defaultExpression, + defaultJSON: defaultJSON ) ) } @@ -166,7 +219,7 @@ public struct BuiltInCodeModeMacro: MemberMacro { return nil } - return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough) + return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough, initializerLabels: initializerLabels) } private static func toolParamHint(on attributes: AttributeListSyntax) -> String? { @@ -186,6 +239,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 +273,17 @@ 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.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)\(nullablePart), hint: \(stringLiteral(hint))\(defaultPart))," case let .primitive(argumentType): - return "BuiltInToolArgument(\(stringLiteral(field.name)), .\(argumentType)\(optionalPart), hint: \(stringLiteral(field.hint)))," + 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)))," + return "BuiltInToolArgument(\(stringLiteral(field.name)), oneOf: \(field.swiftType).self\(optionalPart)\(nullablePart), hint: \(stringLiteral(hint))\(defaultPart))," } }.joined(separator: "\n ") return """ @@ -216,21 +301,36 @@ 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.patch || field.defaultExpression != nil ? "optional" : "require" + let expression: String + if case .date = field.kind { + 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) = \(expression)\(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" } + return "\(name): \(name)" } body.append(initArguments.isEmpty ? "return Arguments()" : "return Arguments(\(initArguments.joined(separator: ", ")))") @@ -254,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(">") { @@ -269,6 +374,8 @@ private struct BuiltInTypeInfo { } switch unwrappedType { + case "Date", "Foundation.Date": + kind = .date case "String": kind = .primitive("string") case "Bool": 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 c5e8cf8..7642bc4 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. --- @@ -130,18 +128,37 @@ 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 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. +- [ ] **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 @@ -279,10 +296,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 fd0f850..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, ] } @@ -189,3 +192,405 @@ 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. ISO 8601 timestamp."), + BuiltInToolArgument("end", .string, optional: true, hint: "End timestamp. ISO 8601 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 builtInCodeModeExpansionDetectsPatchAndOptionalChanges() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarWrite, path: "apple.calendar.updateEvent") + struct PatchTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + @ToolParam("Identifier.") + var identifier: String + @ToolParam("Notes patch.") + var notes: Patch + @ToolParam("Time zone.") + var timeZone: String? + @ToolParam("Limit.") + var limit: Int = 50 + } + } + """, + expandedSource: """ + struct PatchTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var identifier: String + var notes: Patch + var timeZone: String? + var limit: Int = 50 + } + + static let codeModeCapability: CapabilityID = .calendarWrite + + static let codeModePath: String = "apple.calendar.updateEvent" + + static let codeModeArguments: [BuiltInToolArgument] = [ + 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 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 + } + } + """, + 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/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.. 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(registration.descriptor.requiredPermissions == [.reminders]) - #expect(registration.descriptor.requiredArguments.isEmpty) - #expect(registration.descriptor.optionalArguments.first == "operation") - #expect(registration.descriptor.argumentTypes["priority"] == .number) - #expect(registration.descriptor.argumentConstraints.allowedStringValues["operation"] == ["create", "update", "complete"]) - #expect(registration.descriptor.argumentHints["identifier"] == "EventKit calendarItemIdentifier to update or complete.") + #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 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]) +} + +@Test func migratedToolHintsOnlyExplainWhatTypesCannot() throws { + let bridges = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Sources/CodeMode/Bridges") + let tool = try Regex(#"(?s)@BuiltInCodeMode\(.*?(?=@BuiltInCodeMode|\z)"#) + let hint = try Regex(#"@ToolParam\("([^"]*)"\)"#) + var checked = 0 + for file in try FileManager.default.contentsOfDirectory(at: bridges, includingPropertiesForKeys: nil) where file.pathExtension == "swift" { + let source = try String(contentsOf: file, encoding: .utf8) + for match in source.matches(of: tool) { + let definition = String(match.output[0].substring!) + if definition.contains("var raw:") { continue } + for annotation in definition.matches(of: hint) { + let text = String(annotation.output[1].substring!) + #expect(!text.hasPrefix("Optional"), "\(file.lastPathComponent): \(text)") + #expect(!text.lowercased().contains("default"), "\(file.lastPathComponent): \(text)") + #expect(!text.contains("ISO8601"), "\(file.lastPathComponent): \(text)") + checked += 1 + } + } + } + #expect(checked > 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 }) +} + +@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 4176c9b..9c458fb 100644 --- a/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift +++ b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift @@ -15,9 +15,11 @@ 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 + var resultSchema: CodeModeResultSchema? init(_ registration: CapabilityRegistration) { let descriptor = registration.descriptor @@ -31,9 +33,11 @@ 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 + resultSchema = descriptor.resultSchema } } diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index d191fe1..5f89bce 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -3,11 +3,10 @@ import JavaScriptCore import Testing @testable import CodeMode - 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() { @@ -114,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]) @@ -161,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"]) @@ -210,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]) @@ -618,3 +620,82 @@ 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]) +} + +@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/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 da6c32a..6006c58 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,22 +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: ["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 bridge.writeEvent(arguments: [ - "operation": .string("update"), + _ = try registry.invoke("calendar.write", jsName: "apple.calendar.updateEvent", arguments: [ "identifier": .string("event-1"), "title": .string("Updated"), ], context: context) @@ -37,7 +36,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 +44,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: ["title": .string("T")], 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 +90,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) } @@ -103,7 +103,7 @@ import Testing 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] @@ -112,6 +112,7 @@ import Testing #if canImport(EventKit) #expect(observed.error?.code == "INVALID_ARGUMENTS") + #expect(observed.error?.message == "calendar.write title cannot be empty") #else #expect(observed.error?.code == "UNSUPPORTED_PLATFORM") #endif @@ -158,11 +159,14 @@ import EventKit // every neighbouring field was nil-coalesced. #expect(event.calendar == nil) - let json = try #require(EventKitBridge.eventJSONForTesting(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 { @@ -170,9 +174,258 @@ import EventKit let reminder = EKReminder(eventStore: store) // `EKReminder.title` is `String!`. - let json = try #require(EventKitBridge.reminderJSONForTesting(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 + +@Test func eventKitOmittedArgumentsUseDeclaredDefaults() throws { + let events = try CalendarListEventsTool().decode(arguments: [:]) + #expect(events.limit == 50) + #expect(events.start == nil) + #expect(events.end == nil) + let reminders = try RemindersListTool().decode(arguments: [:]) + #expect(reminders.limit == 50) + #expect(reminders.includeCompleted == false) + let deletion = try CalendarDeleteEventTool().decode(arguments: ["identifier": .string("E1")]) + #expect(deletion.span == .thisEvent) + + let overrides = try RemindersListTool().decode(arguments: ["limit": .number(3), "includeCompleted": .bool(true)]) + #expect(overrides.limit == 3) + #expect(overrides.includeCompleted) + let blank = try CalendarListEventsTool().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: [ + ("apple.calendar.createEvent", PermissionKind.calendarWriteOnly), + ("apple.calendar.updateEvent", .calendar), +]) +func eventKitWritesRequireTheirOwnStaticPermissions(helper: String, expected: PermissionKind) async throws { + let (tools, sandbox) = try makeTools(permissionBroker: FixedPermissionBroker(statuses: [ + .calendar: .denied, .calendarWriteOnly: .denied, + ])) + defer { cleanup(sandbox) } + 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 tool = CalendarUpdateEventTool() + 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 omitted.apply(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 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 tool = RemindersUpdateTool() + 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 omitted.apply(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 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 == 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 eventKitWriteDateErrorsUseStandardRepairableMessages(argument: String) throws { + let store = EKEventStore() + do { + if argument == "dueDate" { + 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 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) 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..818d3d5 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,8 +318,93 @@ 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") } + +@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 d28571b..3ce5a0a 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", @@ -38,7 +41,7 @@ "live-activities", "host-adapter" ], - "title" : "End Live Activity" + "title" : "end" }, { "allowedStringValues" : { @@ -56,6 +59,9 @@ "id" : "activity.list", "jsNames" : [ "apple.activity.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "activityType", @@ -74,7 +80,7 @@ "live-activities", "host-adapter" ], - "title" : "List Live Activities" + "title" : "list" }, { "allowedStringValues" : { @@ -90,6 +96,9 @@ "id" : "activity.pushToken.read", "jsNames" : [ "apple.activity.getPushToken" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -107,7 +116,7 @@ "live-activities", "push-token" ], - "title" : "Read Live Activity push token" + "title" : "getPushToken" }, { "allowedStringValues" : { @@ -131,6 +140,9 @@ "id" : "activity.start", "jsNames" : [ "apple.activity.start" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "contentState", @@ -151,7 +163,7 @@ "live-activities", "host-adapter" ], - "title" : "Start Live Activity" + "title" : "start" }, { "allowedStringValues" : { @@ -173,6 +185,9 @@ "id" : "activity.update", "jsNames" : [ "apple.activity.update" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "alert", @@ -192,7 +207,7 @@ "live-activities", "host-adapter" ], - "title" : "Update Live Activity" + "title" : "update" }, { "allowedStringValues" : { @@ -210,6 +225,9 @@ "id" : "alarm.cancel", "jsNames" : [ "ios.alarm.cancel" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -228,7 +246,7 @@ "alarms", "schedule" ], - "title" : "Cancel scheduled alarms" + "title" : "cancel" }, { "allowedStringValues" : { @@ -244,6 +262,9 @@ "id" : "alarm.permission.request", "jsNames" : [ "ios.alarm.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -261,7 +282,7 @@ "permission", "alarms" ], - "title" : "Request AlarmKit permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -277,6 +298,9 @@ "id" : "alarm.read", "jsNames" : [ "ios.alarm.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -294,7 +318,7 @@ "alarms", "schedule" ], - "title" : "List scheduled alarms" + "title" : "list" }, { "allowedStringValues" : { @@ -316,6 +340,9 @@ "id" : "alarm.schedule", "jsNames" : [ "ios.alarm.schedule" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -335,7 +362,7 @@ "alarms", "schedule" ], - "title" : "Schedule AlarmKit alarm" + "title" : "schedule" }, { "allowedStringValues" : { @@ -353,6 +380,9 @@ "id" : "appintents.donate", "jsNames" : [ "apple.appIntents.donate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "parameters" @@ -371,7 +401,7 @@ "donation", "host-adapter" ], - "title" : "Donate host App Intent action" + "title" : "donate" }, { "allowedStringValues" : { @@ -391,6 +421,9 @@ "id" : "appintents.handoffs.read", "jsNames" : [ "apple.appIntents.listHandoffs" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -412,7 +445,7 @@ "inbox", "events" ], - "title" : "Read App Intent handoff inbox" + "title" : "listHandoffs" }, { "allowedStringValues" : { @@ -430,6 +463,9 @@ "id" : "appintents.list", "jsNames" : [ "apple.appIntents.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "domain", @@ -449,7 +485,7 @@ "actions", "host-adapter" ], - "title" : "List host App Intent adapters" + "title" : "list" }, { "allowedStringValues" : { @@ -467,6 +503,9 @@ "id" : "appintents.open", "jsNames" : [ "apple.appIntents.open" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "parameters" @@ -485,7 +524,7 @@ "open", "host-adapter" ], - "title" : "Open host App Intent surface" + "title" : "open" }, { "allowedStringValues" : { @@ -505,6 +544,9 @@ "id" : "appintents.run", "jsNames" : [ "apple.appIntents.run" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "parameters", @@ -524,7 +566,7 @@ "actions", "host-adapter" ], - "title" : "Run host App Intent adapter" + "title" : "run" }, { "allowedStringValues" : { @@ -546,6 +588,9 @@ "id" : "auth.ui.webAuthenticate", "jsNames" : [ "apple.auth.webAuthenticate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "callbackURLScheme", @@ -567,7 +612,7 @@ "browser", "system-ui" ], - "title" : "Authenticate with system web UI" + "title" : "webAuthenticate" }, { "allowedStringValues" : { @@ -582,7 +627,7 @@ }, "argumentHints" : { "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", - "span" : "thisEvent (default) or futureEvents for recurring events." + "span" : "Scope for recurring events. Default: \"thisEvent\"." }, "argumentTypes" : { "identifier" : "string", @@ -592,6 +637,9 @@ "id" : "calendar.delete", "jsNames" : [ "apple.calendar.deleteEvent" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "span" @@ -602,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", @@ -610,18 +670,18 @@ "schedule", "delete" ], - "title" : "Delete calendar event" + "title" : "deleteEvent" }, { "allowedStringValues" : { }, "argumentHints" : { - "calendarIdentifier" : "Optional EventKit calendarIdentifier to restrict results.", - "calendarIdentifiers" : "Optional array of EventKit calendarIdentifier strings to restrict results.", - "end" : "ISO8601 timestamp; defaults to start + 14 days.", - "limit" : "Max number of items, default 50.", - "start" : "ISO8601 timestamp; defaults to now." + "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" : "Range start; uses now when omitted. ISO 8601 timestamp." }, "argumentTypes" : { "calendarIdentifier" : "string", @@ -634,6 +694,9 @@ "id" : "calendar.read", "jsNames" : [ "apple.calendar.listEvents" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "start", @@ -648,14 +711,65 @@ "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", "eventkit", "schedule" ], - "title" : "Read calendar events" + "title" : "listEvents" }, { "allowedStringValues" : { @@ -669,9 +783,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 +796,9 @@ "id" : "calendar.ui.pickCalendar", "jsNames" : [ "apple.calendar.pickCalendar" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "selectionStyle", @@ -702,17 +819,17 @@ "system-ui", "picker" ], - "title" : "Pick calendar with system UI" + "title" : "pickCalendar" }, { "allowedStringValues" : { }, "argumentHints" : { - "allowsCalendarPreview" : "Whether the UI may show calendar day previews; default true.", - "allowsEditing" : "Whether the user can edit from the detail UI; default false.", + "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 +841,9 @@ "id" : "calendar.ui.presentEvent", "jsNames" : [ "apple.calendar.presentEvent" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "allowsEditing", @@ -744,19 +864,19 @@ "system-ui", "details" ], - "title" : "Present calendar event details" + "title" : "presentEvent" }, { "allowedStringValues" : { }, "argumentHints" : { - "end" : "Optional ISO8601 end timestamp.", - "location" : "Optional location string.", - "notes" : "Optional event notes\/body text.", - "start" : "Optional ISO8601 start timestamp.", - "timeoutMs" : "Optional timeout for waiting on user save\/cancel.", - "title" : "Optional event title shown in the editor." + "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 +890,9 @@ "id" : "calendar.ui.presentNewEvent", "jsNames" : [ "apple.calendar.presentNewEvent" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "title", @@ -793,26 +916,127 @@ "system-ui", "picker" ], - "title" : "Present calendar event editor" + "title" : "presentNewEvent" }, { "allowedStringValues" : { - "operation" : [ - "create", - "update" + + }, + "argumentHints" : { + "calendarIdentifier" : "Destination EventKit calendarIdentifier.", + "end" : "Event end. ISO 8601 timestamp.", + "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", + "isAllDay" : "bool", + "location" : "string", + "notes" : "string", + "start" : "string", + "title" : "string", + "url" : "string" + }, + "example" : "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + "id" : "calendar.write", + "jsNames" : [ + "apple.calendar.createEvent" + ], + "nullableArguments" : [ + + ], + "optionalArguments" : [ + "notes", + "location", + "url", + "isAllDay", + "calendarIdentifier" + ], + "requiredArguments" : [ + "title", + "start", + "end" + ], + "requiredPermissions" : [ + "calendar.writeOnly" + ], + "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", + "eventkit", + "schedule" + ], + "title" : "createEvent" + }, + { + "allowedStringValues" : { + }, "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.", + "identifier" : "EventKit eventIdentifier to update.", "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", @@ -821,20 +1045,22 @@ "isAllDay" : "bool", "location" : "string", "notes" : "string", - "operation" : "string", "start" : "string", "title" : "string", "url" : "string" }, - "example" : "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + "example" : "await apple.calendar.updateEvent({ identifier: 'EVENT_ID', notes: null })", "id" : "calendar.write", "jsNames" : [ - "apple.calendar.createEvent", "apple.calendar.updateEvent" ], + "nullableArguments" : [ + "notes", + "location", + "url", + "isAllDay" + ], "optionalArguments" : [ - "operation", - "identifier", "title", "start", "end", @@ -845,19 +1071,68 @@ "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.", + "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", "eventkit", "schedule" ], - "title" : "Create or update calendar event" + "title" : "updateEvent" }, { "allowedStringValues" : { @@ -909,6 +1184,9 @@ "id" : "camera.ui.capture", "jsNames" : [ "apple.camera.capture" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mediaType", @@ -935,7 +1213,7 @@ "system-ui", "artifact" ], - "title" : "Capture photo or video with camera UI" + "title" : "capture" }, { "allowedStringValues" : { @@ -980,6 +1258,9 @@ "id" : "camera.ui.scanData", "jsNames" : [ "apple.camera.scanData" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mode", @@ -1010,7 +1291,7 @@ "visionkit", "system-ui" ], - "title" : "Scan text or barcodes with camera UI" + "title" : "scanData" }, { "allowedStringValues" : { @@ -1026,6 +1307,9 @@ "id" : "cloudkit.account.status", "jsNames" : [ "apple.cloudkit.getAccountStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "containerIdentifier" @@ -1044,7 +1328,7 @@ "sync", "account" ], - "title" : "Read CloudKit account status" + "title" : "getAccountStatus" }, { "allowedStringValues" : { @@ -1070,6 +1354,9 @@ "id" : "cloudkit.record.delete", "jsNames" : [ "apple.cloudkit.deleteRecord" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "database", @@ -1090,7 +1377,7 @@ "database", "delete" ], - "title" : "Delete CloudKit record" + "title" : "deleteRecord" }, { "allowedStringValues" : { @@ -1122,6 +1409,9 @@ "id" : "cloudkit.record.save", "jsNames" : [ "apple.cloudkit.saveRecord" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recordName", @@ -1145,7 +1435,7 @@ "database", "write" ], - "title" : "Save CloudKit record" + "title" : "saveRecord" }, { "allowedStringValues" : { @@ -1179,6 +1469,9 @@ "id" : "cloudkit.records.query", "jsNames" : [ "apple.cloudkit.queryRecords" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "database", @@ -1203,7 +1496,7 @@ "database", "query" ], - "title" : "Query CloudKit records" + "title" : "queryRecords" }, { "allowedStringValues" : { @@ -1239,6 +1532,9 @@ "id" : "cloudkit.subscription.save", "jsNames" : [ "apple.cloudkit.subscribe" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "database", @@ -1264,7 +1560,7 @@ "subscription", "inbox" ], - "title" : "Save CloudKit subscription" + "title" : "subscribe" }, { "allowedStringValues" : { @@ -1284,6 +1580,9 @@ "id" : "cloudkit.subscriptionEvents.read", "jsNames" : [ "apple.cloudkit.listEvents" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "subscriptionID", @@ -1305,7 +1604,7 @@ "inbox", "events" ], - "title" : "Read CloudKit subscription inbox" + "title" : "listEvents" }, { "allowedStringValues" : { @@ -1323,6 +1622,9 @@ "id" : "contacts.read", "jsNames" : [ "apple.contacts.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit", @@ -1341,7 +1643,7 @@ "address-book", "people" ], - "title" : "Read contacts" + "title" : "list" }, { "allowedStringValues" : { @@ -1359,6 +1661,9 @@ "id" : "contacts.search", "jsNames" : [ "apple.contacts.search" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -1376,7 +1681,7 @@ "search", "people" ], - "title" : "Search contacts" + "title" : "search" }, { "allowedStringValues" : { @@ -1399,6 +1704,9 @@ "id" : "contacts.ui.pick", "jsNames" : [ "apple.contacts.pick" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mode", @@ -1419,7 +1727,7 @@ "system-ui", "picker" ], - "title" : "Pick contacts with system UI" + "title" : "pick" }, { "allowedStringValues" : { @@ -1443,6 +1751,9 @@ "id" : "contacts.ui.presentContact", "jsNames" : [ "apple.contacts.presentContact" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "allowsEditing", @@ -1464,7 +1775,7 @@ "system-ui", "details" ], - "title" : "Present contact card" + "title" : "presentContact" }, { "allowedStringValues" : { @@ -1490,6 +1801,9 @@ "id" : "contacts.ui.presentNewContact", "jsNames" : [ "apple.contacts.presentNewContact" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "givenName", @@ -1513,7 +1827,7 @@ "system-ui", "create" ], - "title" : "Present new contact editor" + "title" : "presentNewContact" }, { "allowedStringValues" : { @@ -1536,6 +1850,9 @@ "jsNames" : [ "apple.documents.export", "apple.documents.save" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "path", @@ -1558,7 +1875,7 @@ "export", "save" ], - "title" : "Export documents with system UI" + "title" : "export" }, { "allowedStringValues" : { @@ -1580,6 +1897,9 @@ "id" : "documents.ui.openIn", "jsNames" : [ "apple.documents.openIn" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "name", @@ -1601,7 +1921,7 @@ "open-in", "handoff" ], - "title" : "Open document in another app" + "title" : "openIn" }, { "allowedStringValues" : { @@ -1623,6 +1943,9 @@ "id" : "documents.ui.pick", "jsNames" : [ "apple.documents.pick" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "contentTypes", @@ -1645,7 +1968,7 @@ "picker", "artifact" ], - "title" : "Pick documents with system UI" + "title" : "pick" }, { "allowedStringValues" : { @@ -1663,6 +1986,9 @@ "id" : "documents.ui.scan", "jsNames" : [ "apple.documents.scan" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "outputDirectory", @@ -1683,7 +2009,7 @@ "system-ui", "artifact" ], - "title" : "Scan documents with system UI" + "title" : "scan" }, { "allowedStringValues" : { @@ -1707,6 +2033,9 @@ "id" : "foundationModels.extract", "jsNames" : [ "apple.foundationModels.extract" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "schema", @@ -1728,7 +2057,7 @@ "llm", "structured-output" ], - "title" : "Extract structured data with Foundation Models" + "title" : "extract" }, { "allowedStringValues" : { @@ -1754,6 +2083,9 @@ "id" : "foundationModels.generate", "jsNames" : [ "apple.foundationModels.generate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "instructions", @@ -1776,7 +2108,7 @@ "llm", "generation" ], - "title" : "Generate text with Foundation Models" + "title" : "generate" }, { "allowedStringValues" : { @@ -1792,6 +2124,9 @@ "id" : "foundationModels.status", "jsNames" : [ "apple.foundationModels.getStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -1810,7 +2145,7 @@ "llm", "availability" ], - "title" : "Read Foundation Models availability" + "title" : "getStatus" }, { "allowedStringValues" : { @@ -1827,6 +2162,9 @@ "jsNames" : [ "apple.fs.access", "fs.promises.access" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -1844,7 +2182,7 @@ "io", "fs" ], - "title" : "Check path access" + "title" : "access" }, { "allowedStringValues" : { @@ -1867,6 +2205,9 @@ "jsNames" : [ "apple.fs.copy", "fs.promises.copyFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "overwrite", @@ -1886,7 +2227,7 @@ "io", "fs" ], - "title" : "Copy file" + "title" : "copy" }, { "allowedStringValues" : { @@ -1905,6 +2246,9 @@ "jsNames" : [ "apple.fs.delete", "fs.promises.rm" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recursive" @@ -1922,7 +2266,7 @@ "io", "fs" ], - "title" : "Delete file" + "title" : "delete" }, { "allowedStringValues" : { @@ -1938,6 +2282,9 @@ "id" : "fs.exists", "jsNames" : [ "apple.fs.exists" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -1955,7 +2302,7 @@ "io", "fs" ], - "title" : "Check path exists" + "title" : "exists" }, { "allowedStringValues" : { @@ -1972,6 +2319,9 @@ "jsNames" : [ "apple.fs.list", "fs.promises.readdir" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -1989,7 +2339,7 @@ "io", "fs" ], - "title" : "List directory" + "title" : "list" }, { "allowedStringValues" : { @@ -2008,6 +2358,9 @@ "jsNames" : [ "apple.fs.mkdir", "fs.promises.mkdir" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recursive" @@ -2025,7 +2378,7 @@ "io", "fs" ], - "title" : "Create directory" + "title" : "mkdir" }, { "allowedStringValues" : { @@ -2048,6 +2401,9 @@ "jsNames" : [ "apple.fs.move", "fs.promises.rename" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "overwrite", @@ -2067,7 +2423,7 @@ "io", "fs" ], - "title" : "Move file" + "title" : "move" }, { "allowedStringValues" : { @@ -2090,6 +2446,9 @@ "jsNames" : [ "apple.fs.read", "fs.promises.readFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "encoding" @@ -2107,7 +2466,7 @@ "io", "fs" ], - "title" : "Read file" + "title" : "read" }, { "allowedStringValues" : { @@ -2124,6 +2483,9 @@ "jsNames" : [ "apple.fs.stat", "fs.promises.stat" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2141,7 +2503,7 @@ "io", "fs" ], - "title" : "Stat path" + "title" : "stat" }, { "allowedStringValues" : { @@ -2166,6 +2528,9 @@ "jsNames" : [ "apple.fs.write", "fs.promises.writeFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "data", @@ -2184,7 +2549,7 @@ "io", "fs" ], - "title" : "Write file" + "title" : "write" }, { "allowedStringValues" : { @@ -2202,6 +2567,9 @@ "id" : "health.permission.request", "jsNames" : [ "apple.health.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "readTypes", @@ -2220,7 +2588,7 @@ "health", "permission" ], - "title" : "Request HealthKit permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -2244,6 +2612,9 @@ "id" : "health.read", "jsNames" : [ "apple.health.read" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "start", @@ -2264,7 +2635,7 @@ "health", "query" ], - "title" : "Read HealthKit samples" + "title" : "read" }, { "allowedStringValues" : { @@ -2288,6 +2659,9 @@ "id" : "health.write", "jsNames" : [ "apple.health.write" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "unit", @@ -2308,7 +2682,7 @@ "health", "write" ], - "title" : "Write HealthKit quantity sample" + "title" : "write" }, { "allowedStringValues" : { @@ -2326,6 +2700,9 @@ "id" : "home.read", "jsNames" : [ "apple.home.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "includeCharacteristics", @@ -2344,7 +2721,7 @@ "iot", "devices" ], - "title" : "Read HomeKit graph" + "title" : "list" }, { "allowedStringValues" : { @@ -2366,6 +2743,9 @@ "id" : "home.write", "jsNames" : [ "apple.home.writeCharacteristic" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "serviceType" @@ -2386,7 +2766,7 @@ "devices", "control" ], - "title" : "Write HomeKit characteristic" + "title" : "writeCharacteristic" }, { "allowedStringValues" : { @@ -2402,6 +2782,9 @@ "id" : "keychain.delete", "jsNames" : [ "apple.keychain.delete" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2419,7 +2802,7 @@ "token", "keychain" ], - "title" : "Delete Keychain value" + "title" : "delete" }, { "allowedStringValues" : { @@ -2435,6 +2818,9 @@ "id" : "keychain.read", "jsNames" : [ "apple.keychain.get" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2452,7 +2838,7 @@ "token", "keychain" ], - "title" : "Read Keychain value" + "title" : "get" }, { "allowedStringValues" : { @@ -2470,6 +2856,9 @@ "id" : "keychain.write", "jsNames" : [ "apple.keychain.set" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "value" @@ -2487,7 +2876,7 @@ "token", "keychain" ], - "title" : "Write Keychain value" + "title" : "set" }, { "allowedStringValues" : { @@ -2503,6 +2892,9 @@ "id" : "location.permission.request", "jsNames" : [ "apple.location.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2519,7 +2911,7 @@ "location", "permission" ], - "title" : "Request location permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -2536,6 +2928,9 @@ "jsNames" : [ "apple.location.getCurrentPosition", "apple.location.getPermissionStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mode" @@ -2553,7 +2948,7 @@ "permission", "geospatial" ], - "title" : "Read location state or coordinates" + "title" : "getCurrentPosition" }, { "allowedStringValues" : { @@ -2583,6 +2978,9 @@ "id" : "mail.ui.compose", "jsNames" : [ "apple.mail.compose" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "to", @@ -2608,7 +3006,7 @@ "system-ui", "share" ], - "title" : "Compose mail with system UI" + "title" : "compose" }, { "allowedStringValues" : { @@ -2628,6 +3026,9 @@ "id" : "maps.geocode", "jsNames" : [ "apple.maps.geocode" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "region", @@ -2647,7 +3048,7 @@ "geocode", "location" ], - "title" : "Geocode address" + "title" : "geocode" }, { "allowedStringValues" : { @@ -2678,6 +3079,9 @@ "id" : "maps.open", "jsNames" : [ "apple.maps.open" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "query", @@ -2701,7 +3105,7 @@ "open", "directions" ], - "title" : "Open Maps" + "title" : "open" }, { "allowedStringValues" : { @@ -2721,6 +3125,9 @@ "id" : "maps.reverseGeocode", "jsNames" : [ "apple.maps.reverseGeocode" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "locale" @@ -2740,7 +3147,7 @@ "reverse-geocode", "location" ], - "title" : "Reverse geocode coordinates" + "title" : "reverseGeocode" }, { "allowedStringValues" : { @@ -2769,6 +3176,9 @@ "id" : "maps.route.estimate", "jsNames" : [ "apple.maps.routeEstimate" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "transportType", @@ -2790,7 +3200,7 @@ "directions", "route" ], - "title" : "Estimate route" + "title" : "routeEstimate" }, { "allowedStringValues" : { @@ -2812,6 +3222,9 @@ "id" : "maps.search", "jsNames" : [ "apple.maps.search" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "region", @@ -2832,7 +3245,7 @@ "local-search", "places" ], - "title" : "Search local map items" + "title" : "search" }, { "allowedStringValues" : { @@ -2852,6 +3265,9 @@ "id" : "media.frame.extract", "jsNames" : [ "apple.media.extractFrame" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeMs", @@ -2870,7 +3286,7 @@ "avfoundation", "thumbnail" ], - "title" : "Extract video frame" + "title" : "extractFrame" }, { "allowedStringValues" : { @@ -2886,6 +3302,9 @@ "id" : "media.metadata.read", "jsNames" : [ "apple.media.metadata" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -2903,7 +3322,7 @@ "avfoundation", "metadata" ], - "title" : "Read media metadata" + "title" : "metadata" }, { "allowedStringValues" : { @@ -2923,6 +3342,9 @@ "id" : "media.transcode", "jsNames" : [ "apple.media.transcode" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "outputPath", @@ -2941,7 +3363,7 @@ "avfoundation", "transcode" ], - "title" : "Transcode media" + "title" : "transcode" }, { "allowedStringValues" : { @@ -2965,6 +3387,9 @@ "id" : "messages.ui.compose", "jsNames" : [ "apple.messages.compose" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "recipients", @@ -2988,7 +3413,7 @@ "system-ui", "share" ], - "title" : "Compose message with system UI" + "title" : "compose" }, { "allowedStringValues" : { @@ -3008,6 +3433,9 @@ "id" : "music.catalog.details", "jsNames" : [ "apple.music.getDetails" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "type", @@ -3027,7 +3455,7 @@ "catalog", "details" ], - "title" : "Read Music catalog details" + "title" : "getDetails" }, { "allowedStringValues" : { @@ -3049,6 +3477,9 @@ "id" : "music.catalog.search", "jsNames" : [ "apple.music.search" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "types", @@ -3069,7 +3500,7 @@ "catalog", "search" ], - "title" : "Search Music catalog" + "title" : "search" }, { "allowedStringValues" : { @@ -3087,6 +3518,9 @@ "id" : "music.library.read", "jsNames" : [ "apple.music.readLibrary" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "type", @@ -3105,7 +3539,7 @@ "musickit", "library" ], - "title" : "Read Music library" + "title" : "readLibrary" }, { "allowedStringValues" : { @@ -3121,6 +3555,9 @@ "id" : "music.permission.request", "jsNames" : [ "apple.music.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3138,7 +3575,7 @@ "musickit", "permission" ], - "title" : "Request Music permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -3170,6 +3607,9 @@ "id" : "music.playback.control", "jsNames" : [ "apple.music.play" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "catalogID", @@ -3191,7 +3631,7 @@ "playback", "queue" ], - "title" : "Control Music playback" + "title" : "play" }, { "allowedStringValues" : { @@ -3215,6 +3655,9 @@ "id" : "music.playlist.write", "jsNames" : [ "apple.music.writePlaylist" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "playlistID", @@ -3237,7 +3680,7 @@ "playlist", "write" ], - "title" : "Write Music playlist" + "title" : "writePlaylist" }, { "allowedStringValues" : { @@ -3253,6 +3696,9 @@ "id" : "music.subscription.status", "jsNames" : [ "apple.music.getSubscriptionStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3271,7 +3717,7 @@ "subscription", "catalog" ], - "title" : "Read Music subscription status" + "title" : "getSubscriptionStatus" }, { "allowedStringValues" : { @@ -3302,6 +3748,9 @@ "id" : "network.fetch", "jsNames" : [ "fetch" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "options.method", @@ -3340,6 +3789,9 @@ "id" : "notifications.categories.set", "jsNames" : [ "apple.notifications.setCategories" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3358,7 +3810,7 @@ "categories", "actions" ], - "title" : "Set notification categories" + "title" : "setCategories" }, { "allowedStringValues" : { @@ -3376,6 +3828,9 @@ "id" : "notifications.delivered.delete", "jsNames" : [ "apple.notifications.removeDelivered" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -3395,7 +3850,7 @@ "delivered", "delete" ], - "title" : "Delete delivered local notifications" + "title" : "removeDelivered" }, { "allowedStringValues" : { @@ -3411,6 +3866,9 @@ "id" : "notifications.delivered.read", "jsNames" : [ "apple.notifications.listDelivered" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -3428,7 +3886,7 @@ "local", "delivered" ], - "title" : "List delivered local notifications" + "title" : "listDelivered" }, { "allowedStringValues" : { @@ -3446,6 +3904,9 @@ "id" : "notifications.pending.delete", "jsNames" : [ "apple.notifications.cancelPending" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -3464,7 +3925,7 @@ "local", "schedule" ], - "title" : "Delete pending local notifications" + "title" : "cancelPending" }, { "allowedStringValues" : { @@ -3480,6 +3941,9 @@ "id" : "notifications.pending.read", "jsNames" : [ "apple.notifications.listPending" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit" @@ -3497,7 +3961,7 @@ "local", "schedule" ], - "title" : "List pending local notifications" + "title" : "listPending" }, { "allowedStringValues" : { @@ -3513,6 +3977,9 @@ "id" : "notifications.permission.request", "jsNames" : [ "apple.notifications.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3529,7 +3996,7 @@ "notifications", "permission" ], - "title" : "Request notification permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -3545,6 +4012,9 @@ "id" : "notifications.remote.register", "jsNames" : [ "apple.notifications.registerRemote" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "types" @@ -3563,7 +4033,7 @@ "remote", "registration" ], - "title" : "Register for remote notifications" + "title" : "registerRemote" }, { "allowedStringValues" : { @@ -3579,6 +4049,9 @@ "id" : "notifications.remote.token.read", "jsNames" : [ "apple.notifications.getRemoteToken" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3597,7 +4070,7 @@ "remote", "token" ], - "title" : "Read APNs device token" + "title" : "getRemoteToken" }, { "allowedStringValues" : { @@ -3619,6 +4092,9 @@ "id" : "notifications.responses.read", "jsNames" : [ "apple.notifications.listResponses" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit", @@ -3641,7 +4117,7 @@ "inbox", "events" ], - "title" : "Read notification response inbox" + "title" : "listResponses" }, { "allowedStringValues" : { @@ -3679,6 +4155,9 @@ "id" : "notifications.schedule", "jsNames" : [ "apple.notifications.schedule" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "identifier", @@ -3706,7 +4185,7 @@ "local", "schedule" ], - "title" : "Schedule local notification" + "title" : "schedule" }, { "allowedStringValues" : { @@ -3722,6 +4201,9 @@ "id" : "notifications.settings.read", "jsNames" : [ "apple.notifications.getSettings" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3740,7 +4222,7 @@ "settings", "permission" ], - "title" : "Read notification settings" + "title" : "getSettings" }, { "allowedStringValues" : { @@ -3760,6 +4242,9 @@ "id" : "passkit.applePay.present", "jsNames" : [ "apple.wallet.presentPayment" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -3780,7 +4265,7 @@ "system-ui", "safety" ], - "title" : "Present Apple Pay request" + "title" : "presentPayment" }, { "allowedStringValues" : { @@ -3796,6 +4281,9 @@ "id" : "passkit.applePay.status", "jsNames" : [ "apple.wallet.canMakePayments" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "networks" @@ -3814,7 +4302,7 @@ "payments", "safety" ], - "title" : "Read Apple Pay status" + "title" : "canMakePayments" }, { "allowedStringValues" : { @@ -3832,6 +4320,9 @@ "id" : "passkit.pass.add", "jsNames" : [ "apple.wallet.addPass" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -3851,7 +4342,7 @@ "add", "system-ui" ], - "title" : "Add Wallet pass" + "title" : "addPass" }, { "allowedStringValues" : { @@ -3869,6 +4360,9 @@ "id" : "passkit.pass.present", "jsNames" : [ "apple.wallet.presentPass" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -3887,7 +4381,7 @@ "passes", "system-ui" ], - "title" : "Present Wallet pass" + "title" : "presentPass" }, { "allowedStringValues" : { @@ -3907,6 +4401,9 @@ "id" : "passkit.passes.read", "jsNames" : [ "apple.wallet.listPasses" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "passTypeIdentifier", @@ -3927,7 +4424,7 @@ "passes", "read" ], - "title" : "List Wallet passes" + "title" : "listPasses" }, { "allowedStringValues" : { @@ -3943,6 +4440,9 @@ "id" : "passkit.wallet.status", "jsNames" : [ "apple.wallet.getStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -3960,7 +4460,7 @@ "wallet", "passes" ], - "title" : "Read Wallet status" + "title" : "getStatus" }, { "allowedStringValues" : { @@ -3978,6 +4478,9 @@ "id" : "photos.export", "jsNames" : [ "apple.photos.export" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "outputPath" @@ -3995,7 +4498,7 @@ "photo-library", "artifact" ], - "title" : "Export photo library asset" + "title" : "export" }, { "allowedStringValues" : { @@ -4018,6 +4521,9 @@ "id" : "photos.read", "jsNames" : [ "apple.photos.list" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mediaType", @@ -4036,7 +4542,7 @@ "photo-library", "media" ], - "title" : "List photo library assets" + "title" : "list" }, { "allowedStringValues" : { @@ -4063,6 +4569,9 @@ "id" : "photos.ui.pick", "jsNames" : [ "apple.photos.pick" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "mediaType", @@ -4085,7 +4594,7 @@ "picker", "artifact" ], - "title" : "Pick photos with system UI" + "title" : "pick" }, { "allowedStringValues" : { @@ -4101,6 +4610,9 @@ "id" : "photos.ui.presentLimitedLibraryPicker", "jsNames" : [ "apple.photos.presentLimitedLibraryPicker" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -4120,7 +4632,7 @@ "permission", "picker" ], - "title" : "Present limited Photos picker" + "title" : "presentLimitedLibraryPicker" }, { "allowedStringValues" : { @@ -4150,6 +4662,9 @@ "id" : "print.ui.present", "jsNames" : [ "apple.print.present" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "path", @@ -4173,7 +4688,7 @@ "system-ui", "export" ], - "title" : "Present print UI" + "title" : "present" }, { "allowedStringValues" : { @@ -4193,6 +4708,9 @@ "id" : "quicklook.ui.preview", "jsNames" : [ "apple.quicklook.preview" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "path", @@ -4213,7 +4731,7 @@ "documents", "system-ui" ], - "title" : "Preview files with Quick Look" + "title" : "preview" }, { "allowedStringValues" : { @@ -4229,6 +4747,9 @@ "id" : "reminders.delete", "jsNames" : [ "apple.reminders.deleteReminder" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4239,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", @@ -4247,19 +4780,19 @@ "task", "delete" ], - "title" : "Delete reminder" + "title" : "deleteReminder" }, { "allowedStringValues" : { }, "argumentHints" : { - "calendarIdentifier" : "Optional EventKit reminder calendarIdentifier to restrict results.", - "calendarIdentifiers" : "Optional array of EventKit reminder calendarIdentifier strings to restrict results.", - "end" : "Optional ISO8601 due-date upper bound.", - "includeCompleted" : "Whether completed reminders are included; default false.", - "limit" : "Max number of reminder items, default 50.", - "start" : "Optional ISO8601 due-date lower bound." + "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" : "Due-date lower bound. ISO 8601 timestamp." }, "argumentTypes" : { "calendarIdentifier" : "string", @@ -4273,6 +4806,9 @@ "id" : "reminders.read", "jsNames" : [ "apple.reminders.listReminders" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "start", @@ -4288,32 +4824,249 @@ "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", "eventkit", "task" ], - "title" : "Read reminders" + "title" : "listReminders" + }, + { + "allowedStringValues" : { + + }, + "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" + ], + "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", + "eventkit", + "task" + ], + "title" : "completeReminder" }, { "allowedStringValues" : { - "operation" : [ - "create", - "update", - "complete" + + }, + "argumentHints" : { + "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." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "dueDate" : "string", + "isCompleted" : "bool", + "notes" : "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" + ], + "nullableArguments" : [ + + ], + "optionalArguments" : [ + "dueDate", + "notes", + "isCompleted", + "priority", + "calendarIdentifier" + ], + "requiredArguments" : [ + "title" + ], + "requiredPermissions" : [ + "reminders" + ], + "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", + "eventkit", + "task" + ], + "title" : "createReminder" + }, + { + "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.", + "identifier" : "EventKit calendarItemIdentifier to update.", + "isCompleted" : "Completion state.", + "notes" : "Reminder notes.", "priority" : "EventKit reminder priority integer.", - "title" : "Reminder title string." + "title" : "Reminder title." }, "argumentTypes" : { "calendarIdentifier" : "string", @@ -4321,20 +5074,22 @@ "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' })", + "example" : "await apple.reminders.updateReminder({ identifier: 'REMINDER_ID', dueDate: null })", "id" : "reminders.write", "jsNames" : [ - "apple.reminders.createReminder", - "apple.reminders.updateReminder", - "apple.reminders.completeReminder" + "apple.reminders.updateReminder" + ], + "nullableArguments" : [ + "dueDate", + "notes", + "isCompleted", + "priority", + "calendarIdentifier" ], "optionalArguments" : [ - "operation", - "identifier", "title", "dueDate", "notes", @@ -4343,19 +5098,63 @@ "calendarIdentifier" ], "requiredArguments" : [ - + "identifier" ], "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.", + "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", "eventkit", "task" ], - "title" : "Create or update reminder" + "title" : "updateReminder" }, { "allowedStringValues" : { @@ -4371,6 +5170,9 @@ "id" : "settings.ui.open", "jsNames" : [ "apple.settings.open" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "timeoutMs" @@ -4388,7 +5190,7 @@ "permissions", "system-ui" ], - "title" : "Open app settings" + "title" : "open" }, { "allowedStringValues" : { @@ -4416,6 +5218,9 @@ "id" : "share.ui.present", "jsNames" : [ "apple.share.present" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "text", @@ -4439,7 +5244,7 @@ "export", "system-ui" ], - "title" : "Present share sheet" + "title" : "present" }, { "allowedStringValues" : { @@ -4463,6 +5268,9 @@ "id" : "speech.file.transcribe", "jsNames" : [ "apple.speech.transcribeFile" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "locale", @@ -4483,7 +5291,7 @@ "transcription", "audio" ], - "title" : "Transcribe audio file" + "title" : "transcribeFile" }, { "allowedStringValues" : { @@ -4507,6 +5315,9 @@ "id" : "speech.microphone.transcribe", "jsNames" : [ "apple.speech.transcribeMicrophone" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "locale", @@ -4530,7 +5341,7 @@ "audio", "microphone" ], - "title" : "Transcribe microphone audio" + "title" : "transcribeMicrophone" }, { "allowedStringValues" : { @@ -4546,6 +5357,9 @@ "id" : "speech.permission.request", "jsNames" : [ "apple.speech.requestPermission" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4563,7 +5377,7 @@ "permission", "transcription" ], - "title" : "Request speech recognition permission" + "title" : "requestPermission" }, { "allowedStringValues" : { @@ -4579,6 +5393,9 @@ "id" : "speech.status", "jsNames" : [ "apple.speech.getStatus" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4596,7 +5413,7 @@ "permission", "transcription" ], - "title" : "Read speech recognition permission status" + "title" : "getStatus" }, { "allowedStringValues" : { @@ -4612,6 +5429,9 @@ "id" : "storekit.entitlements.read", "jsNames" : [ "apple.storekit.listEntitlements" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4629,7 +5449,7 @@ "commerce", "entitlements" ], - "title" : "Read StoreKit entitlements" + "title" : "listEntitlements" }, { "allowedStringValues" : { @@ -4645,6 +5465,9 @@ "id" : "storekit.products.read", "jsNames" : [ "apple.storekit.listProducts" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4662,7 +5485,7 @@ "commerce", "products" ], - "title" : "Read StoreKit products" + "title" : "listProducts" }, { "allowedStringValues" : { @@ -4682,6 +5505,9 @@ "id" : "storekit.purchase", "jsNames" : [ "apple.storekit.purchase" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "appAccountToken" @@ -4701,7 +5527,7 @@ "purchase", "safety" ], - "title" : "Purchase StoreKit product" + "title" : "purchase" }, { "allowedStringValues" : { @@ -4717,6 +5543,9 @@ "id" : "storekit.restore", "jsNames" : [ "apple.storekit.restore" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4735,7 +5564,7 @@ "restore", "safety" ], - "title" : "Restore StoreKit purchases" + "title" : "restore" }, { "allowedStringValues" : { @@ -4755,6 +5584,9 @@ "id" : "storekit.transactions.read", "jsNames" : [ "apple.storekit.listTransactions" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "limit", @@ -4776,7 +5608,7 @@ "inbox", "events" ], - "title" : "Read StoreKit transaction inbox" + "title" : "listTransactions" }, { "allowedStringValues" : { @@ -4806,6 +5638,9 @@ "id" : "ui.alert.present", "jsNames" : [ "apple.ui.presentAlert" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "title", @@ -4828,7 +5663,7 @@ "dialog", "system-ui" ], - "title" : "Present alert with custom buttons" + "title" : "presentAlert" }, { "allowedStringValues" : { @@ -4852,6 +5687,9 @@ "id" : "ui.prompt.present", "jsNames" : [ "apple.ui.presentPrompt" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "title", @@ -4874,7 +5712,7 @@ "input", "system-ui" ], - "title" : "Present prompt with text fields" + "title" : "presentPrompt" }, { "allowedStringValues" : { @@ -4894,6 +5732,9 @@ "id" : "vision.image.analyze", "jsNames" : [ "apple.vision.analyzeImage" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "features", @@ -4912,7 +5753,7 @@ "image-analysis", "ml" ], - "title" : "Analyze image with Vision" + "title" : "analyzeImage" }, { "allowedStringValues" : { @@ -4930,6 +5771,9 @@ "id" : "weather.read", "jsNames" : [ "apple.weather.getCurrentWeather" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ @@ -4948,7 +5792,7 @@ "forecast", "weatherkit" ], - "title" : "Read WeatherKit weather" + "title" : "getCurrentWeather" }, { "allowedStringValues" : { @@ -4968,6 +5812,9 @@ "id" : "web.ui.present", "jsNames" : [ "apple.web.present" + ], + "nullableArguments" : [ + ], "optionalArguments" : [ "entersReaderIfAvailable", @@ -4987,6 +5834,6 @@ "browser", "system-ui" ], - "title" : "Present web page with system UI" + "title" : "present" } ] \ No newline at end of file