diff --git a/.github/workflows/codemode-evals.yml b/.github/workflows/codemode-evals.yml index 9d7679a..18c7eda 100644 --- a/.github/workflows/codemode-evals.yml +++ b/.github/workflows/codemode-evals.yml @@ -15,14 +15,15 @@ on: description: "Delay before each model request" required: false default: "1000" - schedule: - - cron: "17 10 * * *" concurrency: group: codemode-evals-${{ github.ref }} cancel-in-progress: false jobs: + # Deterministic evals + platform builds are continuous: they run on every PR + # and on pushes to main (merges). No schedule — a daily cron re-running + # unchanged main added no signal for these offline checks. deterministic: name: Deterministic evals runs-on: macos-latest @@ -31,7 +32,19 @@ jobs: uses: actions/checkout@v4 - name: Toolchain - run: swift --version + run: | + swift --version + xcodebuild -version + + - name: Cache SwiftPM build + uses: actions/cache@v4 + with: + path: | + .build + Tools/CodeModeEval/.build + key: spm-${{ runner.os }}-${{ hashFiles('Package.swift', 'Tools/CodeModeEval/Package.swift') }} + restore-keys: | + spm-${{ runner.os }}- - name: Test package run: swift test @@ -42,14 +55,66 @@ jobs: - name: Run deterministic evals run: swift run --package-path Tools/CodeModeEval codemode-eval run - llm-plan: - name: LLM eval planning + platform-build: + name: Build (${{ matrix.platform }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - platform: iOS + destination: generic/platform=iOS + - platform: visionOS + destination: generic/platform=visionOS + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Toolchain + run: | + swift --version + xcodebuild -version + + # The library-only CodeMode product must compile for every declared + # platform. `swift test` on macOS never builds the UIKit presenters or the + # CCodeModeJSC shim against the iOS/visionOS SDKs, so exercise them here. + # CODE_SIGNING_ALLOWED=NO keeps the library build from requiring a profile. + - name: List package schemes + run: xcodebuild -list -json | tee schemes.json + + # -skipMacroValidation: the package builds its own CodeModeMacros compiler + # plugin, and xcodebuild refuses unvalidated macros in noninteractive runs. + - name: Build CodeMode for ${{ matrix.platform }} + run: | + set -o pipefail + xcodebuild build \ + -scheme CodeMode \ + -destination '${{ matrix.destination }}' \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + # Live LLM regression gate. Manual only (workflow_dispatch) — it spends real + # provider calls, so it is never automatic. + # + # Requirements to actually execute the live run (all private, absent from this + # public repo by design): + # 1. Build the eval CLI with the private overlay so the real Wavelike-backed + # `codemode-eval llm` command is compiled in place of the LLMUnavailable + # stub (un-exclude LLM.swift + add the private Wavelike/CallableFunction + # package dependencies). + # 2. A WAVELIKE_API_KEY repository secret. + # Until both are present the job runs the budget preview and then skips the + # live+compare steps with a warning, instead of pretending or failing noisily. + llm-live: + name: LLM eval (live regression gate) runs-on: macos-latest needs: deterministic - if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + if: github.event_name == 'workflow_dispatch' env: INPUT_REPEAT_COUNT: ${{ github.event.inputs.repeat_count }} INPUT_REQUEST_DELAY_MS: ${{ github.event.inputs.request_delay_ms }} + WAVELIKE_API_KEY: ${{ secrets.WAVELIKE_API_KEY }} + WAVELIKE_MODEL_ID: ${{ vars.WAVELIKE_MODEL_ID }} steps: - name: Checkout uses: actions/checkout@v4 @@ -57,27 +122,59 @@ jobs: - name: Toolchain run: swift --version + - name: Determine whether live execution is configured + id: gate + run: | + if [ -z "${WAVELIKE_API_KEY}" ]; then + echo "::warning::WAVELIKE_API_KEY not set — live LLM evals need the private Wavelike runner and credentials. Running budget preview only." + echo "live=false" >> "$GITHUB_OUTPUT" + else + echo "live=true" >> "$GITHUB_OUTPUT" + fi + - name: Build eval CLI run: swift build --package-path Tools/CodeModeEval - - name: Preview LLM eval suites + - name: Preview LLM eval budget run: | repeat_count="${INPUT_REPEAT_COUNT:-5}" request_delay_ms="${INPUT_REQUEST_DELAY_MS:-1000}" + for suite in core failures catalog; do + swift run --package-path Tools/CodeModeEval codemode-eval plan \ + --suite "$suite" \ + --repeat "$repeat_count" \ + --request-delay-ms "$request_delay_ms" + done - swift run --package-path Tools/CodeModeEval codemode-eval plan \ - --suite core \ - --repeat "$repeat_count" \ - --request-delay-ms "$request_delay_ms" - - swift run --package-path Tools/CodeModeEval codemode-eval plan \ - --suite failures \ - --repeat "$repeat_count" \ - --request-delay-ms "$request_delay_ms" - - swift run --package-path Tools/CodeModeEval codemode-eval plan \ - --suite catalog \ - --repeat "$repeat_count" \ - --request-delay-ms "$request_delay_ms" + # Real regression gate: run the live suites that have committed baselines, + # then `compare` (default policy: no pass-rate or exact-capability + # regression) which fails the job on regression. `catalog` is previewed + # above but not compared until its baseline is generated and committed. + - name: Run live LLM evals and compare against baselines + if: steps.gate.outputs.live == 'true' + run: | + repeat_count="${INPUT_REPEAT_COUNT:-5}" + request_delay_ms="${INPUT_REQUEST_DELAY_MS:-1000}" + reports="Tools/CodeModeEval/.build/reports" + mkdir -p "$reports" + for suite in core failures; do + candidate="${reports}/${suite}-r${repeat_count}.json" + swift run --package-path Tools/CodeModeEval codemode-eval llm \ + --suite "$suite" \ + --repeat "$repeat_count" \ + --request-delay-ms "$request_delay_ms" \ + --output "$candidate" + swift run --package-path Tools/CodeModeEval codemode-eval compare \ + "Tools/CodeModeEval/Baselines/${suite}-r5-summary.json" \ + "$candidate" \ + --retry-tolerance 0.5 \ + --turn-tolerance 0.5 + done - echo "Live Wavelike-backed LLM execution is disabled in this workflow while the default eval package avoids private dependencies." + - name: Upload live eval reports + if: steps.gate.outputs.live == 'true' + uses: actions/upload-artifact@v4 + with: + name: llm-eval-reports + path: Tools/CodeModeEval/.build/reports/*.json + if-no-files-found: warn diff --git a/EVALS.md b/EVALS.md index 0d82476..cec052a 100644 --- a/EVALS.md +++ b/EVALS.md @@ -61,13 +61,24 @@ swift run --package-path Tools/CodeModeEval codemode-eval summarize \ ## CI Policy -The GitHub Actions workflow runs deterministic evals on PRs and pushes. Scheduled and manually dispatched runs build the same public eval CLI and preview `core`, `failures`, and `catalog` LLM suite budgets without making live provider calls or resolving private Wavelike dependencies. - -Scheduled/manual planning runs: - -- Preview `core`, `failures`, and `catalog` with repeat count 5 by default. -- Honor the manual `repeat_count` and `request_delay_ms` inputs for budget estimates. -- Leave baseline comparison to private live-report generation until a reviewed candidate report exists. +The GitHub Actions workflow runs the deterministic evals and the iOS/visionOS +platform builds continuously — on every pull request and on pushes to `main` +(merges). There is no schedule; a daily cron over unchanged `main` added no +signal for these offline checks. + +The live LLM regression gate (`llm-live` job) is **manual only** +(`workflow_dispatch`) because it spends real provider calls: + +- It always previews `core`, `failures`, and `catalog` budgets (honoring the + manual `repeat_count` / `request_delay_ms` inputs). +- If a `WAVELIKE_API_KEY` secret is configured **and** the CLI is built with the + private overlay (the real `codemode-eval llm` command rather than the + `LLMUnavailable` stub), it then runs the `core` and `failures` suites live and + `compare`s each against its committed baseline, failing the job on any + pass-rate or exact-capability regression. `catalog` is previewed but not + compared until its baseline is generated and committed. +- Without those, the live+compare steps skip with a warning, so the manual run + still succeeds and only reports the budget preview. ## Updating Baselines diff --git a/PHASE3-HANDOFF.md b/PHASE3-HANDOFF.md new file mode 100644 index 0000000..2e169d7 --- /dev/null +++ b/PHASE3-HANDOFF.md @@ -0,0 +1,143 @@ +# Phase 3 handoff: migrate remaining registrations to `@BuiltInCodeMode` + +Self-contained work spec for converting the remaining hand-written capability +registrations to the macro-authored tool idiom. Context: Phases 1–2 of +`PLAN-registration-macros.md` are done; EventKit is the finished reference. +This is mechanical work — the invariants below matter more than speed. + +## The one invariant + +**The advertised capability surface must not change except where this spec +says it may.** `Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift` pins +every registration's full metadata (jsNames, title, summary, tags, example, +permissions, argument lists/types/hints, constraints, resultSummary) against +`Tests/CodeModeTests/capability-metadata-golden.json`. + +Workflow per domain: +1. Convert the domain (recipe below). Run `swift test`. The golden test fails. +2. Regenerate: `CODEMODE_REGENERATE_GOLDEN=1 swift test --filter capabilityMetadata` +3. `git diff Tests/CodeModeTests/capability-metadata-golden.json` — **this diff + is the review artifact.** Every hunk must be one of the allowed diffs below; + anything else is a bug in your conversion. Fix the conversion, not the spec. +4. Full `swift test` green → one commit for the domain, golden diff included, + and the commit message lists which allowed-diff categories appear. + +Allowed golden diffs: +- `argumentHints` gaining entries for arguments that previously had no hint + (the tool idiom requires a hint per argument — write one consistent with the + bridge's actual behavior, and call it out in the commit message). +- `allowedStringValues` gaining alias spellings **only when the bridge already + accepts them** (cite the bridge line in the commit message). +- Nothing else. Not a reworded title, not a reordered jsNames array, not a + type change, not a constraint that moved keys. + +## Reference implementation (read these first) + +- `Sources/CodeMode/Bridges/EventKitCodeModeTools.swift` — 9 macro-authored + tools + 5 `CodeModeStringEnum`s. The pattern to replicate. +- `Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift` — what a + registration file looks like after conversion (a thin list). +- `Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift`, + `Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift`, + `Sources/CodeMode/API/CodeModeStringEnum.swift` — the infrastructure. +- Commits `6f5e7e8` (Phase 1) and `c45fb10` (Phase 2) show the full shape of a + domain conversion including bridge rewiring and tests. + +## Recipe per registration + +1. Create `Sources/CodeMode/Bridges/CodeModeTools.swift`. One + `@BuiltInCodeMode` struct per `CapabilityRegistration` in the old file. +2. Copy **verbatim**: title, summary, tags, example, requiredPermissions, + resultSummary. Do not improve the prose. +3. `path` = the registration's first jsName; `aliases:` = the rest, in order. +4. `Arguments` struct: one `@ToolParam("")` property per + declared argument, in the old required-then-optional order. Required args + are non-optional Swift types; optional args are optionals. +5. **Property types must reproduce the old effective type.** If the old + descriptor declared `argumentTypes`, match it. If it didn't, the effective + type came from `CapabilityDescriptor.inferArgumentTypes` + (`CapabilityRegistry.swift`) — look each name up in that table: + `.string`→`String`, `.number`→`Int` or `Double` (pick what the bridge + reads), `.bool`→`Bool`, `.array`→`[String]`/`[JSONValue]` (match bridge), + `.object`→`[String: JSONValue]`, and **names absent from the table were + `.any` — declare those as `JSONValue`**, never a tighter type. The golden + test catches mistakes here; trust it. +6. Constrained string arguments — any argument with a row in + `CapabilityArgumentConstraints.defaults(for:)` (`CapabilityRegistry.swift`): + - Define a `CodeModeStringEnum` whose **raw values are exactly the current + advertised list** (case names = raw values). Add `codeModeAliases` only + for spellings the bridge demonstrably accepts. + - Use it as the property type; the macro derives the constraint from it. + - Rewire the bridge's own parsing of that value to + `EnumType.codeModeValue(matching:)` (see `EventKitBridge.eventSpan`, + `SystemUIBridge.validateCalendarPickerArguments` for the pattern), so the + enum is the single source of truth. + - Delete the row from the `defaults(for:)` table in the same commit. + - Exception: **dotted-path constraints** (`networkFetch`'s + `options.responseEncoding`) stay in the central table — the tool argument + model is flat. Leave them and note it. +7. End every `Arguments` struct with `var raw: [String: JSONValue]` and call + the same bridge method the old handler called, passing `arguments.raw` and + the same `context`. Do not change bridge method signatures beyond the + constrained-value parsing rewiring in step 6. +8. Replace the old file's body with the thin builder-extension list (keep the + function name the builder calls, e.g. `systemUIRegistrations()` — see + `DefaultCapabilityLoader.loadAll()` for the roster). + +## Domain order and notes + +Work sequentially, one commit per domain, full suite green each time: + +1. **SystemUI** (`CapabilityRegistrations+SystemUI.swift`, 12 registrations) — + heaviest constraint user: `photosUIPick`/`contactsUIPick`/`cameraUICapture`/ + `cameraUIScanData` rows in the defaults table, and `SystemUIBridge` has + matching `lowercased()` validations to rewire (mediaType, cameraDevice, + flashMode, videoQuality, scan mode, preferredStyle…). Only enum-ify values + that have a defaults-table row today; leave other `lowercased()` checks + alone. +2. **Core** (`+Core.swift`, 11) — includes `networkFetch` (dotted-path + constraint stays in the table) and the filesystem/keychain area. Keychain is + already converted; don't touch `SimpleBuiltInCodeModeProviders.swift`. +3. **PeoplePhotosDocuments** (`+PeoplePhotosDocuments.swift`, 13) — + `photosRead` mediaType row; `PhotosBridge` lowercases mediaType, rewire it. +4. **SystemServices** (`+SystemServices.swift`, 19) — health/home/alarm/ + notifications. Do **not** add `.healthKit` to any `requiredPermissions` + (see `noBuiltInRegistrationGatesOnHealthKitPermission` test). Preserve the + existing permission declarations exactly. +5. **CloudPushSpeech** (`+CloudPushSpeech.swift`, 15) — the four CloudKit + capabilities share the `database` row; one enum, four tools. +6. **IntentsModelsActivityMaps** (`+IntentsModelsActivityMaps.swift`, 18) — + `activityEnd` dismissalPolicy and `mapsRouteEstimate`/`mapsOpen` + transportType rows. transportType is consumed in + `SystemAppleServiceClients.swift` (`SystemMapsMapping`) — rewire there. +7. **Commerce** (`+Commerce.swift`, 18) — mostly pass-through to host-supplied + clients (music/passKit/storeKit). Convert metadata + `musicPlaybackControl` + action enum (replicate the existing list; host clients stay authoritative + for semantics). Do not invent constraints for values the table doesn't + constrain today. + +## Hard rules + +- Do not touch `Sources/CodeMode/Runtime/RuntimeJavaScript.swift` (the JS + function table is a separate Phase-3 item, not this task). +- Do not reword any advertised string. Copy-paste, don't retype. +- Do not change permission ownership (some capabilities deliberately declare + `requiredPermissions: []` and check in the bridge — e.g. calendarWrite). +- Do not migrate `LocationWeather` or `EventKit` (done) and do not modify + `Tools/CodeModeEval` or CI. +- If a registration doesn't fit the recipe (unexpected handler shape, shared + state, anything surprising), **stop and leave that registration on the old + idiom in its file** with a `// PHASE3-SKIP: ` comment rather than + improvising. Mixed files are fine; wrong conversions are not. +- When all domains are done: delete any now-empty rows from `defaults(for:)`, + and update `TODO.md`'s structural-improvements section + the status block in + `PLAN-registration-macros.md`. + +## Definition of done (per domain) + +- `swift test` fully green (230+ tests). +- Golden diff contains only allowed categories, enumerated in the commit + message. +- The old registration file is a thin list; its metadata lives on tools. +- Constraint rows for the domain are deleted from the central table (except + dotted paths) and the owning bridge parses through the shared enum. diff --git a/PLAN-registration-macros.md b/PLAN-registration-macros.md new file mode 100644 index 0000000..522a774 --- /dev/null +++ b/PLAN-registration-macros.md @@ -0,0 +1,224 @@ +# Plan: macro-generated capability registrations + +Goal: reduce the ~115-capability / ~2,850-line hand-written registration surface +and eliminate the parallel-sources-of-truth drift (registration metadata vs +bridge parsing vs central constraint table vs argument-type inference), by +converging on one typed idiom that a macro can generate the boilerplate for. + +Written 2026-07-11 after auditing `Tools/CodeModeAuthoring` and the built-in +registration idioms. Verdict up front: **there is a real path forward, in three +phases, and the first phase pays for itself even if the macro phase is later +rejected.** + +**Status:** owner approved swift-syntax in the core graph (2026-07-11). +Phase 1 landed the same day: `BuiltInCodeModeTool` + `CodeModeStringEnum` +shipped, EventKit/Keychain/Location/Weather converged on the idiom, the four +EventKit-domain rows are gone from the central constraint table, and the +coherence test guards the remainder. + +**Phase 2 landed 2026-07-11 as well.** `CodeModeMacros` moved into the root +package (Tools/CodeModeAuthoring is now the `CodeModeAuthoring` product); +`@BuiltInCodeMode` + `@ToolParam` generate the identity statics, +`codeModeArguments`, and `decode(arguments:)` from the `Arguments` struct. The +EventKit domain is macro-authored. Design choices that differ slightly from +the sketch below: the macro treats unrecognized field types as +`CodeModeStringEnum` and emits them through constrained overloads +(`BuiltInToolArgument(_:oneOf:)`, `CodeModeToolRawArguments.canonicalize`), so +the *compiler* enforces the semantics on the expansion rather than the macro +guessing; and the transitional `raw: [String: JSONValue]` passthrough is a +convention the macro fills (canonicalized) when declared as the last stored +property — it disappears per-domain in Phase 3 when a bridge goes typed. + +**Phase 3 landed 2026-07-12 (per `PHASE3-HANDOFF.md`).** All seven remaining +domains migrated: SystemUI, Core/filesystem, People/Photos/Documents, +SystemServices, CloudKit/Push/Speech, Intents/Models/Activity/Maps, and +Commerce. Net result: **114 of 115 built-in capabilities are macro-authored**; +only `networkFetch` stays on the flat init because its nested dotted-path +arguments (`options.method`, …) can't be expressed by the flat `Arguments` +model (documented PHASE3-SKIP). The central `defaults(for:)` constraint table +now holds only networkFetch's `options.responseEncoding` row; every other +constrained value moved to a per-tool `CodeModeStringEnum`, and the owning +bridge/system-client parses through that same enum (SystemUIBridge, +PhotosBridge, SystemCloudKitMapping, SystemMapsMapping). Migration was guarded +throughout by `CapabilityMetadataGoldenTests` — the only golden changes across +all seven domains were `argumentHints` gaining entries for arguments that +previously advertised none (the flat-init idiom allowed missing hints; the tool +idiom requires one per argument). Two `.any` traps were caught by the golden and +preserved as `JSONValue` rather than tightened: `foundationModelsGenerate.prompt` +and `musicPlaylistWrite.name` (both absent from the inference table). + +Remaining follow-ups (all deferred, none blocking): the fifth metadata surface +(hand-written JS function table in `RuntimeJavaScript.swift`); deleting +`inferArgumentTypes` + the last constraint row once networkFetch is handled; and +enum-constraint support for the host-facing `@CodeMode`. + +--- + +## 1. What exists today + +### The macro package (`Tools/CodeModeAuthoring`) — built, tested, unwired +- Separate SPM package so core clients don't inherit swift-syntax (README is + explicit about this). +- `@CodeMode(path:description:)` — attached extension macro on a struct/class/ + actor with a nested `Arguments` struct (`@CodeModeParam` per field) and + optional nested `Result` (`@CodeModeResult`). Generates a `CodeModeProvider` + conformance: `codeModePath` + `codeModeRegistrations()` with + required/optional arguments, `argumentTypes`, `argumentHints`, + `resultSummary`, and a handler that decodes raw `[String: JSONValue]` into + the typed `Arguments` and calls `call(arguments:)` (async supported via + `CodeModeAsyncBridge.run`). +- 607-line implementation, 14 tests, **all passing on Swift 6.3.2 / Xcode 26.5** + (verified today). +- Scope: host-authored custom tools only (capability *keys*), not built-ins. + +### Three coexisting built-in idioms (the boilerplate) +1. **Flat convenience init** (`BuiltInCapabilityRegistration.swift`) — the bulk: + 115 `CapabilityRegistration(...)` callsites across nine + `CapabilityRegistrations+*.swift` files (~2,850 lines). All metadata is + hand-typed strings; the handler closes over a bridge and re-parses raw JSON. +2. **`BuiltInCodeModeTool` protocol** (`SimpleBuiltInCodeModeProviders.swift`, + private) — typed `Arguments` struct + `decode()` + `call(arguments:context:)` + with static metadata. Used for keychain/location/weather. This is a + *hand-rolled version of exactly what the macro generates* — proof the shape + works for built-ins. +3. **Raw `CodeModeRegistration`** inline — used where a capability has multiple + JS names (e.g. `locationRead` ⇒ `getPermissionStatus` + `getCurrentPosition`). + +### The drift surfaces this is meant to kill +| Source of truth | Where | Drift already bitten? | +|---|---|---| +| Registration metadata (args/types/hints) | `CapabilityRegistrations+*.swift` | — | +| Bridge argument parsing | each bridge (`arguments.string(...)`, `lowercased()`) | yes — calendar-span (fixed 2026-07-11) | +| Constraint defaults table | `CapabilityArgumentConstraints.defaults(for:)` central switch | same incident | +| Argument-type inference | `CapabilityDescriptor.inferArgumentTypes` known-name table | silent `.any` degradation | +| Platform support sets | `CapabilityPlatformSupport` | orthogonal, keep | +| Permission checks | split registry (`requiredPermissions`) vs bridge (`ensurePermission`) | healthKit confusion | + +### Gap analysis: macro v1 vs what built-ins need +- Handler signature discards `BridgeInvocationContext` — 73 of 115 built-in + handlers use `context` (permissions, audit, cancellation, presenters). +- No `requiredPermissions` (30 built-in declarations), no + `argumentConstraints`, no multi-jsName aliases, no `CapabilityID` (only + string keys), title/tags/example are *derived* while built-ins have curated + ones, handlers are async-wrapped while built-ins are sync. + +### The swift-syntax cost, measured today +- This toolchain defaults to `--enable-experimental-prebuilts` for macros. +- Clean build of the macro package **from scratch** (fresh `.build`, includes + swift-syntax 601.0.1): **24s wall** on this machine (~126s CPU, parallel). + Incremental: ~0. CI runners: expect ~1–2 min on a cold cache, amortized by + the SwiftPM cache we already added to CI. +- Conclusion: the original reason for exiling the macro package (swift-syntax + tax on every client) is far weaker than when that decision was made. It is + now a *policy* decision, not a showstopper. + +--- + +## 2. The plan + +### Phase 1 — converge on the typed-tool protocol (no macro, shippable alone) +Make idiom #2 the one idiom, extended to cover everything idiom #1 expresses: + +1. Promote `BuiltInCodeModeTool` out of `SimpleBuiltInCodeModeProviders.swift` + into its own file as the internal standard, renamed (working name: + `CodeModeToolDefinition`). Add the missing axes: + - `static var codeModeAliases: [String]` (multi-jsName), + - `static var requiredPermissions: [PermissionKind]`, + - curated `title`/`tags`/`example` (already present), + - `call(arguments:context:)` keeps the `BridgeInvocationContext`. +2. **Enum-typed constrained arguments** — the drift killer. Add a + `CodeModeEnumDecodable` refinement (String raw value + `CaseIterable` + + optional alias table). An argument declared as such an enum automatically + yields: + - `allowedStringValues` for the descriptor (including aliases), + - case-insensitive decode with aliases (same semantics the bridges + hand-roll with `lowercased()`), + - a single place where "what the bridge accepts" and "what the registry + advertises" are *the same declaration*. Calendar span becomes + `enum Span: String, CodeModeEnumDecodable { case thisEvent, futureEvents }` + with aliases `["this_event": .thisEvent, ...]`. +3. Migrate **one domain by hand** to validate the shape: EventKit (9 + registrations, includes the span history and permission checks). Delete its + rows from `CapabilityArgumentConstraints.defaults(for:)` — the constraint + comes from the tool now. +4. Add the drift test from TODO: for every registration, assert the descriptor + metadata matches what the tool's decode actually accepts (for enum params + this is true by construction; the test guards the unmigrated remainder). + +Exit criteria: one idiom documented, EventKit migrated, suite green, constraint +table shrinking. **Value even if Phase 2 never happens:** every migrated domain +is drift-proof; the boilerplate merely remains verbose. + +### Phase 2 — bring the macro into the main package (the boilerplate killer) +1. Move the `CodeModeMacros` macro target from `Tools/CodeModeAuthoring` into + the root `Package.swift`. The `CodeMode` target itself now uses it — this is + the point where every client inherits swift-syntax (measured cost above; + gate: owner sign-off). +2. Add an internal `@BuiltInCodeMode` attached macro targeting the Phase-1 + protocol. Macro generates the mechanical parts only: + - `decode(arguments:)` from the `Arguments` struct fields (incl. enum + params → constraints), + - `requiredArguments` / `optionalArguments` / `argumentTypes` / + `argumentHints` / `argumentConstraints` statics. + Hand-written (macro arguments or plain statics): `CapabilityID`, aliases, + permissions, curated title/summary/tags/example, `call(arguments:context:)`. +3. Because macro-authored and hand-written tools implement the same protocol, + migration is per-domain and incremental — no big bang, and a domain can be + reverted to hand-written without touching the registry. +4. `Tools/CodeModeAuthoring` keeps its public `@CodeMode` surface for hosts but + becomes a thin package re-exporting the same macro implementation (or a + second product of the main package — decide at implementation time; SPM only + builds targets a chosen product needs, but once `CodeMode` uses macros the + distinction stops mattering for build cost). +5. Extend the host-facing `@CodeMode` with the same enum-constraint support so + hosts get constraint metadata too (currently impossible for them). +6. CI gotcha to handle: `xcodebuild` validates macro plugins; the iOS/visionOS + `platform-build` job will need `-skipMacroValidation` (or trust plumbing). + +### Phase 3 — migrate the domains, delete the tables +Suggested order (drift risk × constraint-table usage first): +1. SystemUI (12 registrations, heaviest constraint user: pickers/camera/scan), +2. Core + Keychain + PeoplePhotosDocuments, +3. SystemServices (health/home/alarm — permission-sensitive, good test of the + `.healthKit` invariant), +4. CloudPushSpeech, IntentsModelsActivityMaps, +5. Commerce (music/passKit/storeKit) **last or never**: these bridges pass raw + arguments through to host-supplied clients, so a typed `Arguments` struct + adds little — keeping them on the hand-written protocol is a legitimate end + state. + +End state: delete `CapabilityArgumentConstraints.defaults(for:)` and +`CapabilityDescriptor.inferArgumentTypes` entirely; registry validation fails +loudly on unknown arguments (existing TODO item); `CapabilityPlatformSupport` +stays (orthogonal — could become a macro argument later, not required). + +--- + +## 3. What NOT to do +- **Don't generate registrations from bridge methods.** Bridges take + `[String: JSONValue]` — there is no type information to harvest. The typed + `Arguments` struct has to exist first, and once it exists the protocol+macro + design above is strictly simpler than source-scraping. +- **Don't do a 115-callsite big-bang migration.** The two-idiom coexistence + window is priced in; the drift test covers the unmigrated remainder. +- **Don't macro-generate curated prose** (titles, summaries, examples). The + derived versions in macro v1 (`title = last path component`) are fine for + host tools but would degrade the LLM-facing catalog for built-ins. + +## 4. Open decisions (owner) +1. Accept swift-syntax in the core package graph? (Measured: 24s clean local, + ~0 incremental, prebuilts default-on. If "no", stop after Phase 1 — still + worth it.) +2. Should the Phase-1 protocol be public (hosts could adopt it without macros) + or internal-only until it stabilizes? Recommendation: internal until Phase 3 + is done. +3. Keep `Tools/CodeModeAuthoring` as a separate package (re-export) or fold it + in as a product? Recommendation: fold in during Phase 2; one macro + implementation, two attribute surfaces. + +## 5. Effort estimate +- Phase 1: 1–2 focused sessions (protocol + enum decode + EventKit + drift test). +- Phase 2: 1–2 sessions (mostly extending the existing 607-line macro impl, + package plumbing, CI macro-validation flag). +- Phase 3: mechanical, ~1 session per 2–3 domains; parallelizable; each domain + lands independently green. diff --git a/Tools/CodeModeAuthoring/Package.resolved b/Package.resolved similarity index 78% rename from Tools/CodeModeAuthoring/Package.resolved rename to Package.resolved index b4dc611..12ea695 100644 --- a/Tools/CodeModeAuthoring/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b74d116a7a0a89a95f1429d15cd7d38a7d0658cca5bffcf3e4d4a90e195a6be1", + "originHash" : "64f55de85e09a0b764151796f67abf830d4994a2851694e5ec36adae193822d0", "pins" : [ { "identity" : "swift-syntax", diff --git a/Package.swift b/Package.swift index 44e13c0..f34b1f1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ // swift-tools-version: 6.1 import PackageDescription +import CompilerPluginSupport let package = Package( name: "CodeMode", @@ -13,14 +14,45 @@ let package = Package( name: "CodeMode", targets: ["CodeMode"] ), + .library( + name: "CodeModeAuthoring", + targets: ["CodeModeAuthoring"] + ), .library( name: "CodeModeEvaluation", targets: ["CodeModeEvaluation"] ), ], + dependencies: [ + // Used by the CodeModeMacros compiler plugin only; consumers get the + // prebuilt swift-syntax libraries on current toolchains (measured cost + // in PLAN-registration-macros.md). + .package(url: "https://github.com/swiftlang/swift-syntax.git", exact: "601.0.1"), + ], targets: [ .target( - name: "CodeMode" + name: "CCodeModeJSC", + linkerSettings: [ + .linkedFramework("JavaScriptCore") + ] + ), + .macro( + name: "CodeModeMacros", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .target( + name: "CodeMode", + dependencies: ["CCodeModeJSC", "CodeModeMacros"] + ), + .target( + name: "CodeModeAuthoring", + dependencies: ["CodeMode", "CodeModeMacros"] ), .target( name: "CodeModeEvaluation", @@ -30,6 +62,14 @@ let package = Package( name: "CodeModeTests", dependencies: ["CodeMode"] ), + .testTarget( + name: "CodeModeAuthoringTests", + dependencies: [ + "CodeModeAuthoring", + "CodeModeMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), .testTarget( name: "CodeModeEvalTests", dependencies: ["CodeModeEvaluation"] diff --git a/README.md b/README.md index 7211bb6..29bf64f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,13 @@ GitHub: [velos/CodeMode.swift](https://github.com/velos/CodeMode.swift) Add `CodeMode.swift` with Swift Package Manager: ```swift -.package(name: "CodeMode", url: "https://github.com/velos/CodeMode.swift", from: "0.1.0") +.package(url: "https://github.com/velos/CodeMode.swift", branch: "main") +``` + +No release has been tagged yet, so depend on `main` for now. Once `0.1.0` is tagged, prefer the versioned form: + +```swift +.package(url: "https://github.com/velos/CodeMode.swift", from: "0.1.0") ``` Then add the product to your target: @@ -125,6 +131,27 @@ let tools = CodeModeAgentTools( `CodeModeFileSystem` receives paths after `PathPolicy` resolution, so sandbox root enforcement stays in CodeMode while the host can route reads, writes, listings, moves, copies, deletes, and stats through another backing implementation. `LocalCodeModeFileSystem` preserves the default `FileManager` behavior. +## Network Access Policy + +`network.fetch` egress is governed by `CodeModeConfiguration.networkAccessPolicy`: + +- The default `NetworkAccessPolicy.standard` refuses loopback, RFC 1918, link-local (including cloud metadata addresses such as `169.254.169.254`), CGNAT, and unique-local destinations, plus `localhost` and `.local`/`.localhost`/`.internal` names, and caps buffered response bodies at 10 MB. +- Redirect targets are re-validated against the policy before they are followed. +- Refusals throw structured `NETWORK_POLICY_VIOLATION` errors and are written to the audit logger along with successful fetch destinations. +- `allowedHosts` restricts fetch to an explicit list (entries match the host and its subdomains, and deliberately allowlisted private hosts such as `localhost` are honored); `blockedHosts` refuses specific hosts; `NetworkAccessPolicy.permissive` restores unrestricted behavior. +- Matching is by URL host only; DNS resolution is not performed, so a public hostname that resolves to a private address is not detected. Hosts that need stricter guarantees should set `allowedHosts`. + +```swift +let tools = CodeModeAgentTools( + config: CodeModeConfiguration( + networkAccessPolicy: NetworkAccessPolicy( + allowedHosts: ["api.example.com"], + maxResponseBytes: 2_000_000 + ) + ) +) +``` + ## Search `searchJavaScriptAPI` accepts `JavaScriptAPISearchRequest`: @@ -213,7 +240,24 @@ System UI helpers are installed only on supported UI platforms. Shared iOS/visio - on success it returns `JavaScriptExecutionResult` - on failure it throws `CodeModeToolError` -- `call.cancel()` performs best-effort cancellation +- `call.cancel()` interrupts in-flight JavaScript + +### Timeout and cancellation + +`timeoutMs` and `cancel()` are enforced preemptively: a JavaScriptCore execution +time limit terminates CPU-bound scripts (for example `while (true) {}`) instead +of relying on a wall-clock check that cannot interrupt running JavaScript. + +- `timeoutMs` bounds total wall-clock for the execution, including the + synchronous portion of the script and any time already elapsed while a bridge + call blocked on native I/O (network, a UI picker). Hosts that present + long-running UI or issue slow requests should size `timeoutMs` accordingly; + the default is `10000`. +- Preemption uses `JSContextGroupSetExecutionTimeLimit`, which JavaScriptCore + exports but declares only in a private WebKit header. CodeMode reaches it + through a thin C shim (`CCodeModeJSC`). This is JavaScriptCore's only + mechanism for interrupting runaway scripts; hosts submitting to the App Store + should be aware they rely on this exported-but-private symbol. `CodeModeToolError` includes structured fields such as: diff --git a/Sources/CCodeModeJSC/include/CCodeModeJSC.h b/Sources/CCodeModeJSC/include/CCodeModeJSC.h new file mode 100644 index 0000000..bc61281 --- /dev/null +++ b/Sources/CCodeModeJSC/include/CCodeModeJSC.h @@ -0,0 +1,27 @@ +#ifndef CCODEMODE_JSC_H +#define CCODEMODE_JSC_H + +#include +#include + +/// Mirrors JavaScriptCore's JSShouldTerminateCallback (declared in the private +/// JSContextRefPrivate.h). Returning true terminates the executing script. +typedef bool (*CCodeModeShouldTerminateCallback)(JSContextRef ctx, void *userData); + +/// Installs a JavaScriptCore execution time limit on a context group. +/// +/// `JSContextGroupSetExecutionTimeLimit` / `JSContextGroupClearExecutionTimeLimit` +/// are exported by the JavaScriptCore dynamic library but declared only in +/// WebKit's private `JSContextRefPrivate.h`, which the public SDK module does +/// not vend. This shim re-declares the two symbols so Swift can call them +/// without the private header. JavaScriptCore invokes `callback` roughly every +/// `limitSeconds` of script CPU time; returning true terminates the script. +void ccodemode_set_execution_time_limit(JSContextGroupRef group, + double limitSeconds, + CCodeModeShouldTerminateCallback callback, + void *userData); + +/// Clears any execution time limit previously installed on the group. +void ccodemode_clear_execution_time_limit(JSContextGroupRef group); + +#endif /* CCODEMODE_JSC_H */ diff --git a/Sources/CCodeModeJSC/shim.c b/Sources/CCodeModeJSC/shim.c new file mode 100644 index 0000000..ef8fddc --- /dev/null +++ b/Sources/CCodeModeJSC/shim.c @@ -0,0 +1,21 @@ +#include "CCodeModeJSC.h" + +// Declared in JavaScriptCore's private JSContextRefPrivate.h but exported from +// the JavaScriptCore dynamic library on every Apple platform. Re-declared here +// so the package can bind to them without depending on the private SDK header. +extern void JSContextGroupSetExecutionTimeLimit(JSContextGroupRef group, + double limit, + CCodeModeShouldTerminateCallback callback, + void *userData); +extern void JSContextGroupClearExecutionTimeLimit(JSContextGroupRef group); + +void ccodemode_set_execution_time_limit(JSContextGroupRef group, + double limitSeconds, + CCodeModeShouldTerminateCallback callback, + void *userData) { + JSContextGroupSetExecutionTimeLimit(group, limitSeconds, callback, userData); +} + +void ccodemode_clear_execution_time_limit(JSContextGroupRef group) { + JSContextGroupClearExecutionTimeLimit(group); +} diff --git a/Sources/CodeMode/API/BridgeErrors.swift b/Sources/CodeMode/API/BridgeErrors.swift index 3fe881f..b9aa44b 100644 --- a/Sources/CodeMode/API/BridgeErrors.swift +++ b/Sources/CodeMode/API/BridgeErrors.swift @@ -13,6 +13,7 @@ enum BridgeError: Error, Sendable { case timeout(milliseconds: Int) case cancelled case pathViolation(String) + case networkPolicyViolation(String) case javascriptError(String) case nativeFailure(String) } @@ -44,6 +45,8 @@ extension BridgeError: LocalizedError { return "Execution cancelled" case let .pathViolation(message): return message + case let .networkPolicyViolation(message): + return message case let .javascriptError(message): return message case let .nativeFailure(message): @@ -73,6 +76,8 @@ extension BridgeError: LocalizedError { return "CANCELLED" case .pathViolation: return "PATH_POLICY_VIOLATION" + case .networkPolicyViolation: + return "NETWORK_POLICY_VIOLATION" case .javascriptError: return "JAVASCRIPT_ERROR" case .nativeFailure: diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index 0ad3d75..9127d1f 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -2,6 +2,7 @@ import Foundation public struct CodeModeConfiguration: Sendable { public var pathPolicy: any PathPolicy + public var networkAccessPolicy: NetworkAccessPolicy public var fileSystem: any CodeModeFileSystem public var artifactStore: any ArtifactStore public var permissionBroker: any PermissionBroker @@ -23,6 +24,7 @@ public struct CodeModeConfiguration: Sendable { public init( pathPolicy: any PathPolicy = DefaultPathPolicy(), + networkAccessPolicy: NetworkAccessPolicy = .standard, fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), artifactStore: any ArtifactStore = InMemoryArtifactStore(), permissionBroker: any PermissionBroker = SystemPermissionBroker(), @@ -43,6 +45,7 @@ public struct CodeModeConfiguration: Sendable { hostPlatform: HostPlatform = .current ) { self.pathPolicy = pathPolicy + self.networkAccessPolicy = networkAccessPolicy self.fileSystem = fileSystem self.artifactStore = artifactStore self.permissionBroker = permissionBroker diff --git a/Sources/CodeMode/API/CodeModeStringEnum.swift b/Sources/CodeMode/API/CodeModeStringEnum.swift new file mode 100644 index 0000000..f082bc4 --- /dev/null +++ b/Sources/CodeMode/API/CodeModeStringEnum.swift @@ -0,0 +1,49 @@ +import Foundation + +/// A string argument with a closed set of accepted values. +/// +/// Conforming enums are the single source of truth for a constrained string +/// argument: the advertised `allowedStringValues` in capability metadata, the +/// decode behavior (case-insensitive, alias-aware), and the typed value handed +/// to the implementation all come from one declaration, so they cannot drift. +/// +/// Raw values are the canonical spellings advertised to callers. Alternate +/// accepted spellings go in `codeModeAliases`; both are matched +/// case-insensitively, mirroring how bridges have historically parsed +/// constrained strings. +public protocol CodeModeStringEnum: CodeModeJSONDecodable, RawRepresentable, CaseIterable, Sendable +where RawValue == String { + /// Alternate accepted spellings mapped to their canonical case. + static var codeModeAliases: [String: Self] { get } +} + +extension CodeModeStringEnum { + public static var codeModeAliases: [String: Self] { [:] } + + /// Every accepted spelling, canonical raw values first, for capability + /// metadata (`CapabilityArgumentConstraints.allowedStringValues`). + public static var codeModeAllowedValues: [String] { + allCases.map(\.rawValue) + codeModeAliases.keys.sorted() + } + + /// Case-insensitive, alias-aware match; nil when the value is not accepted. + public static func codeModeValue(matching raw: String) -> Self? { + let normalized = raw.lowercased() + if let match = allCases.first(where: { $0.rawValue.lowercased() == normalized }) { + return match + } + return codeModeAliases.first { $0.key.lowercased() == normalized }?.value + } + + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> Self { + guard let string = value.stringValue else { + throw CodeModeFunctionError.invalidArguments("Argument \(argumentName) must be a string") + } + guard let match = codeModeValue(matching: string) else { + throw CodeModeFunctionError.invalidArguments( + "Argument \(argumentName) must be one of \(codeModeAllowedValues.joined(separator: ", "))" + ) + } + return match + } +} diff --git a/Sources/CodeMode/API/SystemAppleServiceClients.swift b/Sources/CodeMode/API/SystemAppleServiceClients.swift index 54f744a..1f3db40 100644 --- a/Sources/CodeMode/API/SystemAppleServiceClients.swift +++ b/Sources/CodeMode/API/SystemAppleServiceClients.swift @@ -207,13 +207,24 @@ public struct SystemMapsClient: MapsClient { let limit = arguments.int("limit") ?? 10 let geocoder = CLGeocoder() let placemarks: [CLPlacemark] = try waitForSystemClient(timeoutMs: timeoutMs, feature: "MapKit geocoding") { completion in - geocoder.geocodeAddressString(address, in: SystemMapsMapping.coreLocationRegion(arguments.object("region"))) { placemarks, error in + let handler: ([CLPlacemark]?, Error?) -> Void = { placemarks, error in if let error { completion.complete(.failure(error)) } else { completion.complete(.success(placemarks ?? [])) } } + // CLRegion (and the region-scoped geocode overload) are unavailable + // on visionOS; fall back to an unscoped geocode there. + #if os(visionOS) + geocoder.geocodeAddressString(address, completionHandler: handler) + #else + geocoder.geocodeAddressString( + address, + in: SystemMapsMapping.coreLocationRegion(arguments.object("region")), + completionHandler: handler + ) + #endif } return .array(placemarks.prefix(limit).map(SystemMapsMapping.placemarkJSON)) #else @@ -390,14 +401,12 @@ private extension String { } enum SystemCloudKitMapping { - static let allowedDatabases = ["private", "shared", "public"] - static func databaseName(arguments: [String: JSONValue]) throws -> String { - let database = arguments.string("database") ?? "private" - guard allowedDatabases.contains(database) else { - throw BridgeError.invalidArguments("CloudKit database must be one of \(allowedDatabases.joined(separator: ", "))") + let raw = arguments.string("database") ?? "private" + guard let database = CloudKitDatabase.codeModeValue(matching: raw) else { + throw BridgeError.invalidArguments("CloudKit database must be one of \(CloudKitDatabase.codeModeAllowedValues.joined(separator: ", "))") } - return database + return database.rawValue } static func recordFields(arguments: [String: JSONValue], capability: String) throws -> [String: JSONValue] { @@ -490,7 +499,7 @@ enum SystemCloudKitMapping { case "public": return container.publicCloudDatabase default: - throw BridgeError.invalidArguments("CloudKit database must be one of \(allowedDatabases.joined(separator: ", "))") + throw BridgeError.invalidArguments("CloudKit database must be one of \(CloudKitDatabase.codeModeAllowedValues.joined(separator: ", "))") } } @@ -627,7 +636,6 @@ private extension CKAccountStatus { #endif enum SystemMapsMapping { - static let allowedTransportTypes = ["automobile", "walking", "transit", "any"] static func coordinate(arguments: [String: JSONValue], name: String, capability: String) throws -> (latitude: Double, longitude: Double) { guard let latitude = arguments.double("latitude") else { @@ -643,10 +651,10 @@ enum SystemMapsMapping { guard let value = arguments.string("transportType") ?? defaultValue else { throw BridgeError.invalidArguments("Maps transportType is required") } - guard allowedTransportTypes.contains(value) else { - throw BridgeError.invalidArguments("Maps transportType must be one of \(allowedTransportTypes.joined(separator: ", "))") + guard let transportType = MapsTransportType.codeModeValue(matching: value) else { + throw BridgeError.invalidArguments("Maps transportType must be one of \(MapsTransportType.codeModeAllowedValues.joined(separator: ", "))") } - return value + return transportType.rawValue } static func appleMapsQueryURL(query: String) throws -> URL { @@ -659,6 +667,9 @@ enum SystemMapsMapping { } #if canImport(CoreLocation) + // CLRegion / CLCircularRegion are unavailable on visionOS; the region-scoped + // geocode overload is guarded out there, so this helper is too. + #if !os(visionOS) static func coreLocationRegion(_ arguments: [String: JSONValue]?) -> CLRegion? { guard let arguments else { return nil @@ -686,6 +697,7 @@ enum SystemMapsMapping { } return nil } + #endif static func placemarkJSON(_ placemark: CLPlacemark) -> JSONValue { var object: [String: JSONValue] = [:] diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift new file mode 100644 index 0000000..a2f1311 --- /dev/null +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Generates the mechanical members of a `BuiltInCodeModeTool` — the identity +/// statics, `codeModeArguments`, and `decode(arguments:)` — from the nested +/// `Arguments` struct. Curated metadata (title, summary, tags, example, +/// permissions, result summary) and `call(arguments:context:)` stay +/// hand-written. +/// +/// Annotate each argument property with `@ToolParam("hint")`. Property types +/// may be JSON primitives (`String`, `Bool`, `Int`, `Double`, `[JSONValue]`, +/// `[String: JSONValue]`, `JSONValue`, optionals thereof) or a +/// `CodeModeStringEnum`, whose accepted values become the argument's +/// constraint metadata. A trailing un-annotated `var raw: [String: JSONValue]` +/// receives the full argument dictionary with constrained values canonicalized +/// — the passthrough transitional tools use while bridges consume raw +/// dictionaries. +@attached(member, names: named(codeModeCapability), named(codeModePath), named(codeModeAliasPaths), named(codeModeArguments), named(decode)) +macro BuiltInCodeMode( + _ capability: CapabilityID, + path: String, + aliases: [String] = [] +) = #externalMacro(module: "CodeModeMacros", type: "BuiltInCodeModeMacro") + +/// Marks one argument property of a `@BuiltInCodeMode` tool and carries its +/// LLM-facing hint. +@attached(peer) +macro ToolParam(_ hint: String) = #externalMacro(module: "CodeModeMacros", type: "ToolParamMacro") + +/// Helpers referenced by `@BuiltInCodeMode`-generated `decode` implementations. +enum CodeModeToolRawArguments { + /// Replaces a constrained value in the raw passthrough dictionary with its + /// canonical spelling, so bridges parsing the raw dictionary see exactly + /// the advertised form. + static func canonicalize(_ raw: inout [String: JSONValue], _ name: String, _ value: Value?) { + if let value { + raw[name] = .string(value.rawValue) + } + } + + static func canonicalize(_ raw: inout [String: JSONValue], _ name: String, _ value: Value) { + raw[name] = .string(value.rawValue) + } +} diff --git a/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift new file mode 100644 index 0000000..e54edca --- /dev/null +++ b/Sources/CodeMode/Bridges/BuiltInCodeModeTool.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Declarative description of one argument of a built-in tool. +struct BuiltInToolArgument { + var name: String + var type: CapabilityArgumentType + var optional: Bool + var hint: String + /// Accepted spellings when the argument is constrained to a closed set; + /// nil for unconstrained arguments. + var allowedValues: [String]? + + init(_ name: String, _ type: CapabilityArgumentType, optional: Bool = false, hint: String) { + self.name = name + self.type = type + self.optional = optional + self.hint = hint + self.allowedValues = nil + } + + /// A string argument constrained to a `CodeModeStringEnum`'s accepted + /// values. The advertised constraint and the decode behavior both come + /// from the enum, so they cannot drift. + init(_ name: String, oneOf: Value.Type, optional: Bool = false, hint: String) { + self.name = name + self.type = .string + self.optional = optional + self.hint = hint + self.allowedValues = Value.codeModeAllowedValues + } +} + +/// The standard idiom for defining a built-in capability: typed arguments, +/// static metadata, and a handler that receives the invocation context. +/// +/// Registration metadata (required/optional argument lists, types, hints, and +/// constraints) is derived from `codeModeArguments`, so what the catalog +/// advertises and what `decode` accepts come from one declaration. Phase 2 of +/// PLAN-registration-macros.md generates `decode` and the argument list with a +/// macro; hand-written and macro-authored tools share this protocol, so +/// migration is incremental. +protocol BuiltInCodeModeTool: Sendable { + associatedtype Arguments: Sendable + + static var codeModeCapability: CapabilityID { get } + static var codeModePath: String { get } + /// Additional JavaScript names routed to this tool (same handler and + /// metadata), e.g. `apple.calendar.updateEvent` alongside `createEvent`. + static var codeModeAliasPaths: [String] { get } + static var codeModeTitle: String { get } + static var codeModeSummary: String { get } + static var codeModeTags: [String] { get } + static var codeModeExample: String { get } + static var codeModeRequiredPermissions: [PermissionKind] { get } + static var codeModeArguments: [BuiltInToolArgument] { get } + static var codeModeResultSummary: String { get } + + func decode(arguments: [String: JSONValue]) throws -> Arguments + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue +} + +extension BuiltInCodeModeTool { + static var codeModeAliasPaths: [String] { [] } + static var codeModeRequiredPermissions: [PermissionKind] { [] } +} + +extension CapabilityRegistration { + init(tool: Tool) { + let arguments = Tool.codeModeArguments + var allowedStringValues: [String: [String]] = [:] + for argument in arguments { + if let allowed = argument.allowedValues { + allowedStringValues[argument.name] = allowed + } + } + + self.init( + jsNames: [Tool.codeModePath] + Tool.codeModeAliasPaths, + descriptor: CapabilityDescriptor( + id: Tool.codeModeCapability, + title: Tool.codeModeTitle, + summary: Tool.codeModeSummary, + tags: Tool.codeModeTags, + example: Tool.codeModeExample, + requiredPermissions: Tool.codeModeRequiredPermissions, + requiredArguments: arguments.filter { $0.optional == false }.map(\.name), + optionalArguments: arguments.filter(\.optional).map(\.name), + argumentTypes: Dictionary(uniqueKeysWithValues: arguments.map { ($0.name, $0.type) }), + argumentHints: Dictionary(uniqueKeysWithValues: arguments.map { ($0.name, $0.hint) }), + // nil keeps the central defaults table in play for tools without + // enum-typed arguments while unmigrated capabilities still rely on it. + argumentConstraints: allowedStringValues.isEmpty + ? nil + : CapabilityArgumentConstraints(allowedStringValues: allowedStringValues), + resultSummary: Tool.codeModeResultSummary + ), + handler: { rawArguments, context in + let decoded = try tool.decode(arguments: rawArguments) + return try tool.call(arguments: decoded, context: context) + } + ) + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift index ef7661f..6d94967 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift @@ -19,269 +19,33 @@ extension DefaultCapabilityRegistrationBuilder { func cloudKitRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .cloudKitAccountStatus, - jsName: "apple.cloudkit.getAccountStatus", - title: "Read CloudKit account status", - summary: "Read iCloud account availability for CloudKit-backed agent state.", - tags: ["cloudkit", "icloud", "sync", "account"], - example: "await apple.cloudkit.getAccountStatus({ containerIdentifier: 'iCloud.com.example.app' })", - optionalArguments: ["containerIdentifier"], - argumentHints: [ - "containerIdentifier": "Optional iCloud container identifier; defaults to the host client's configured container.", - ], - resultSummary: "Object with status/accountAvailable and containerIdentifier when available." - ) { args, _ in - try cloudKit.accountStatus(arguments: args) - }, - CapabilityRegistration( - .cloudKitRecordsQuery, - jsName: "apple.cloudkit.queryRecords", - title: "Query CloudKit records", - summary: "Query records from a private/shared/public CloudKit database for synced agent state.", - tags: ["cloudkit", "icloud", "database", "query"], - example: "await apple.cloudkit.queryRecords({ database: 'private', recordType: 'Task', limit: 20 })", - requiredArguments: ["recordType"], - optionalArguments: ["database", "containerIdentifier", "zoneID", "predicate", "sortDescriptors", "desiredKeys", "limit"], - argumentHints: [ - "database": "private (default), shared, or public.", - "recordType": "CloudKit record type to query.", - "containerIdentifier": "Optional iCloud container identifier.", - "zoneID": "Optional custom zone identifier.", - "predicate": "Host-supported predicate object or string; keep predicates bounded and repairable.", - "sortDescriptors": "Optional array of sort descriptor objects.", - "desiredKeys": "Optional array of field keys to return.", - "limit": "Max records to return; default is host-defined.", - ], - resultSummary: "Array of records with recordName/recordType/fields/modifiedAt/database." - ) { args, _ in - try cloudKit.queryRecords(arguments: args) - }, - CapabilityRegistration( - .cloudKitRecordSave, - jsName: "apple.cloudkit.saveRecord", - title: "Save CloudKit record", - summary: "Create or update a CloudKit record in a private/shared/public database.", - tags: ["cloudkit", "icloud", "database", "write"], - example: "await apple.cloudkit.saveRecord({ database: 'private', recordType: 'Task', recordName: 'task-1', fields: { title: 'Review' } })", - requiredArguments: ["recordType", "fields"], - optionalArguments: ["recordName", "database", "containerIdentifier", "zoneID", "savePolicy"], - argumentHints: [ - "recordType": "CloudKit record type to create or update.", - "fields": "JSON object mapped by the host client into supported CloudKit field values.", - "recordName": "Optional CloudKit recordName; omitted means create a new record.", - "database": "private (default), shared, or public.", - "containerIdentifier": "Optional iCloud container identifier.", - "zoneID": "Optional custom zone identifier.", - "savePolicy": "Host-supported save policy such as changedKeys or allKeys.", - ], - resultSummary: "Saved record object with recordName/recordType/fields/changeTag/database." - ) { args, _ in - try cloudKit.saveRecord(arguments: args) - }, - CapabilityRegistration( - .cloudKitRecordDelete, - jsName: "apple.cloudkit.deleteRecord", - title: "Delete CloudKit record", - summary: "Delete a CloudKit record by recordName from a configured database.", - tags: ["cloudkit", "icloud", "database", "delete"], - example: "await apple.cloudkit.deleteRecord({ database: 'private', recordName: 'task-1' })", - requiredArguments: ["recordName"], - optionalArguments: ["database", "containerIdentifier", "zoneID"], - argumentHints: [ - "recordName": "CloudKit recordName to delete.", - "database": "private (default), shared, or public.", - "containerIdentifier": "Optional iCloud container identifier.", - "zoneID": "Optional custom zone identifier.", - ], - resultSummary: "Object with recordName/deleted/database." - ) { args, _ in - try cloudKit.deleteRecord(arguments: args) - }, - CapabilityRegistration( - .cloudKitSubscriptionSave, - jsName: "apple.cloudkit.subscribe", - title: "Save CloudKit subscription", - summary: "Register a bounded CloudKit query subscription so the host can enqueue subscription events later.", - tags: ["cloudkit", "icloud", "subscription", "inbox"], - example: "await apple.cloudkit.subscribe({ subscriptionID: 'tasks', database: 'private', recordType: 'Task' })", - requiredArguments: ["subscriptionID", "recordType"], - optionalArguments: ["database", "containerIdentifier", "zoneID", "predicate", "firesOnRecordCreation", "firesOnRecordUpdate", "firesOnRecordDeletion"], - argumentHints: [ - "subscriptionID": "Stable host-visible subscription identifier.", - "recordType": "CloudKit record type to observe.", - "database": "private (default), shared, or public.", - "predicate": "Host-supported predicate object or string.", - "firesOnRecordCreation": "Whether creation events are enqueued; default true.", - "firesOnRecordUpdate": "Whether update events are enqueued; default true.", - "firesOnRecordDeletion": "Whether delete events are enqueued; default true.", - ], - resultSummary: "Object with subscriptionID/saved/database." - ) { args, _ in - try cloudKit.saveSubscription(arguments: args) - }, - CapabilityRegistration( - .cloudKitSubscriptionEventsRead, - jsName: "apple.cloudkit.listEvents", - title: "Read CloudKit subscription inbox", - summary: "Read bounded CloudKit subscription events previously received by the host.", - tags: ["cloudkit", "icloud", "subscription", "inbox", "events"], - example: "await apple.cloudkit.listEvents({ subscriptionID: 'tasks', limit: 20 })", - optionalArguments: ["subscriptionID", "limit", "afterCursor"], - argumentHints: [ - "subscriptionID": "Optional subscription filter.", - "limit": "Maximum number of inbox events to read.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of subscription event objects with cursor/subscriptionID/recordName/reason/database." - ) { args, _ in - try cloudKit.readSubscriptionEvents(arguments: args) - }, + CapabilityRegistration(tool: CloudKitAccountStatusTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitRecordsQueryTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitRecordSaveTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitRecordDeleteTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitSubscriptionSaveTool(cloudKit: cloudKit)), + CapabilityRegistration(tool: CloudKitSubscriptionEventsReadTool(cloudKit: cloudKit)), ] } func remoteNotificationRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .notificationsRemoteRegister, - jsName: "apple.notifications.registerRemote", - title: "Register for remote notifications", - summary: "Ask the host app to register with APNs and record the client device-token lifecycle.", - tags: ["notifications", "apns", "remote", "registration"], - example: "await apple.notifications.registerRemote()", - optionalArguments: ["types"], - argumentHints: [ - "types": "Optional host-supported notification types; APNs provider sending is intentionally out of scope.", - ], - resultSummary: "Object with registered/status and deviceToken when already available." - ) { args, _ in - try remoteNotifications.register(arguments: args) - }, - CapabilityRegistration( - .notificationsRemoteTokenRead, - jsName: "apple.notifications.getRemoteToken", - title: "Read APNs device token", - summary: "Read the latest APNs device token captured by the host app.", - tags: ["notifications", "apns", "remote", "token"], - example: "await apple.notifications.getRemoteToken()", - resultSummary: "Object with token/environment/updatedAt or null token when registration has not completed." - ) { args, _ in - try remoteNotifications.readToken(arguments: args) - }, - CapabilityRegistration( - .notificationsSettingsRead, - jsName: "apple.notifications.getSettings", - title: "Read notification settings", - summary: "Read UserNotifications/APNs client settings exposed by the host.", - tags: ["notifications", "apns", "settings", "permission"], - example: "await apple.notifications.getSettings()", - resultSummary: "Object with authorizationStatus/alert/badge/sound/criticalAlert/providesAppNotificationSettings." - ) { args, _ in - try remoteNotifications.readSettings(arguments: args) - }, - CapabilityRegistration( - .notificationsCategoriesSet, - jsName: "apple.notifications.setCategories", - title: "Set notification categories", - summary: "Register host-approved notification categories and actions for push/local response handling.", - tags: ["notifications", "apns", "categories", "actions"], - example: "await apple.notifications.setCategories({ categories: [{ identifier: 'task', actions: [{ identifier: 'done', title: 'Done' }] }] })", - requiredArguments: ["categories"], - argumentHints: [ - "categories": "Array of category definitions with identifier/actions/options approved by the host.", - ], - resultSummary: "Object with registered category identifiers." - ) { args, _ in - try remoteNotifications.setCategories(arguments: args) - }, - CapabilityRegistration( - .notificationsResponsesRead, - jsName: "apple.notifications.listResponses", - title: "Read notification response inbox", - summary: "Read bounded notification action/open responses captured by the host for later agent handling.", - tags: ["notifications", "apns", "response", "inbox", "events"], - example: "await apple.notifications.listResponses({ limit: 20 })", - optionalArguments: ["limit", "categoryIdentifier", "actionIdentifier", "afterCursor"], - argumentHints: [ - "limit": "Maximum number of response events to read.", - "categoryIdentifier": "Optional category filter.", - "actionIdentifier": "Optional action filter.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of response events with cursor/identifier/actionIdentifier/categoryIdentifier/userText/userInfo/date." - ) { args, _ in - try remoteNotifications.readResponses(arguments: args) - }, + CapabilityRegistration(tool: NotificationsRemoteRegisterTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsRemoteTokenReadTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsSettingsReadTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsCategoriesSetTool(remoteNotifications: remoteNotifications)), + CapabilityRegistration(tool: NotificationsResponsesReadTool(remoteNotifications: remoteNotifications)), ] } func speechRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .speechPermissionRequest, - jsName: "apple.speech.requestPermission", - title: "Request speech recognition permission", - summary: "Trigger the Speech recognition permission prompt.", - tags: ["speech", "permission", "transcription"], - example: "await apple.speech.requestPermission()", - resultSummary: "Object with status/granted." - ) { _, context in - try speech.requestPermission(context: context) - }, - CapabilityRegistration( - .speechStatus, - jsName: "apple.speech.getStatus", - title: "Read speech recognition permission status", - summary: "Read Speech recognition permission status without starting capture.", - tags: ["speech", "permission", "transcription"], - example: "await apple.speech.getStatus()", - resultSummary: "Object with status/granted." - ) { _, context in - try speech.status(context: context) - }, - CapabilityRegistration( - .speechFileTranscribe, - jsName: "apple.speech.transcribeFile", - title: "Transcribe audio file", - summary: "Transcribe a sandbox audio file through the host Speech adapter.", - tags: ["speech", "transcription", "audio"], - example: "await apple.speech.transcribeFile({ path: 'tmp:meeting.m4a', locale: 'en-US', timeoutMs: 60000 })", - requiredPermissions: [.speechRecognition], - requiredArguments: ["path"], - optionalArguments: ["locale", "timeoutMs", "requiresOnDeviceRecognition", "taskHint"], - argumentHints: [ - "path": "Sandbox audio file path.", - "locale": "BCP-47 locale identifier such as en-US.", - "timeoutMs": "Maximum transcription wait time in milliseconds.", - "requiresOnDeviceRecognition": "Whether to require on-device recognition when the host supports it.", - "taskHint": "Speech task hint such as dictation, search, or confirmation.", - ], - resultSummary: "Object with transcript/segments/locale/isFinal/durationSeconds." - ) { args, context in - try speech.transcribeFile(arguments: args, context: context) - }, - CapabilityRegistration( - .speechMicrophoneTranscribe, - jsName: "apple.speech.transcribeMicrophone", - title: "Transcribe microphone audio", - summary: "Run live microphone transcription through a host-mediated session with an explicit timeout.", - tags: ["speech", "transcription", "audio", "microphone"], - example: "await apple.speech.transcribeMicrophone({ locale: 'en-US', timeoutMs: 15000 })", - requiredPermissions: [.speechRecognition, .microphone], - optionalArguments: ["locale", "timeoutMs", "requiresOnDeviceRecognition", "taskHint", "partialResults"], - argumentHints: [ - "locale": "BCP-47 locale identifier such as en-US.", - "timeoutMs": "Maximum microphone capture/transcription time in milliseconds.", - "requiresOnDeviceRecognition": "Whether to require on-device recognition when the host supports it.", - "taskHint": "Speech task hint such as dictation, search, or confirmation.", - "partialResults": "Whether partial transcripts may be returned.", - ], - resultSummary: "Object with transcript/segments/locale/isFinal/timedOut." - ) { args, context in - try speech.transcribeMicrophone(arguments: args, context: context) - }, + CapabilityRegistration(tool: SpeechPermissionRequestTool(speech: speech)), + CapabilityRegistration(tool: SpeechStatusTool(speech: speech)), + CapabilityRegistration(tool: SpeechFileTranscribeTool(speech: speech)), + CapabilityRegistration(tool: SpeechMicrophoneTranscribeTool(speech: speech)), ] } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift index 31255d4..dae5b08 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift @@ -3,299 +3,36 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func musicRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .musicPermissionRequest, - jsName: "apple.music.requestPermission", - title: "Request Music permission", - summary: "Request Apple Music/media-library permission before library or playback actions.", - tags: ["music", "musickit", "permission"], - example: "await apple.music.requestPermission()", - resultSummary: "Object with status/granted." - ) { _, context in - try music.requestPermission(context: context) - }, - CapabilityRegistration( - .musicSubscriptionStatus, - jsName: "apple.music.getSubscriptionStatus", - title: "Read Music subscription status", - summary: "Read MusicKit subscription/capability status exposed by the host.", - tags: ["music", "musickit", "subscription", "catalog"], - example: "await apple.music.getSubscriptionStatus()", - resultSummary: "Object with canPlayCatalogContent/hasCloudLibraryEnabled/status." - ) { args, _ in - try music.subscriptionStatus(arguments: args) - }, - CapabilityRegistration( - .musicCatalogSearch, - jsName: "apple.music.search", - title: "Search Music catalog", - summary: "Search the Apple Music catalog for songs, albums, artists, playlists, or stations.", - tags: ["music", "musickit", "catalog", "search"], - example: "await apple.music.search({ term: 'Miles Davis', types: ['artists', 'albums'], limit: 5 })", - requiredArguments: ["term"], - optionalArguments: ["types", "limit", "countryCode"], - argumentHints: [ - "term": "Catalog search term.", - "types": "Optional array such as songs, albums, artists, playlists, stations.", - "limit": "Maximum results.", - "countryCode": "Optional storefront country code.", - ], - resultSummary: "Object grouped by result type with catalog identifiers and metadata." - ) { args, _ in - try music.searchCatalog(arguments: args) - }, - CapabilityRegistration( - .musicCatalogDetails, - jsName: "apple.music.getDetails", - title: "Read Music catalog details", - summary: "Read details for a catalog item identifier through MusicKit.", - tags: ["music", "musickit", "catalog", "details"], - example: "await apple.music.getDetails({ identifier: '123', type: 'albums' })", - requiredArguments: ["identifier"], - optionalArguments: ["type", "countryCode"], - argumentHints: [ - "identifier": "Music catalog item identifier.", - "type": "Catalog type such as songs, albums, artists, playlists, or stations.", - ], - resultSummary: "Catalog item details object." - ) { args, _ in - try music.catalogDetails(arguments: args) - }, - CapabilityRegistration( - .musicLibraryRead, - jsName: "apple.music.readLibrary", - title: "Read Music library", - summary: "Read the user's Music library playlists or items after Music permission is granted.", - tags: ["music", "musickit", "library"], - example: "await apple.music.readLibrary({ type: 'playlists', limit: 20 })", - requiredPermissions: [.music], - optionalArguments: ["type", "limit"], - argumentHints: [ - "type": "Library type such as playlists, songs, albums, or artists.", - "limit": "Maximum library items.", - ], - resultSummary: "Array of library items with identifiers and metadata." - ) { args, context in - try music.readLibrary(arguments: args, context: context) - }, - CapabilityRegistration( - .musicPlaylistWrite, - jsName: "apple.music.writePlaylist", - title: "Write Music playlist", - summary: "Create or update a user-library playlist through a host MusicKit adapter.", - tags: ["music", "musickit", "library", "playlist", "write"], - example: "await apple.music.writePlaylist({ name: 'Focus', catalogIDs: ['song-id'] })", - requiredPermissions: [.music], - requiredArguments: ["name"], - optionalArguments: ["playlistID", "catalogIDs", "libraryIDs", "description"], - argumentHints: [ - "name": "Playlist name.", - "playlistID": "Optional existing playlist identifier to update.", - "catalogIDs": "Optional catalog song identifiers to add.", - "libraryIDs": "Optional library song identifiers to add.", - ], - resultSummary: "Object with playlistID/name/written." - ) { args, context in - try music.writePlaylist(arguments: args, context: context) - }, - CapabilityRegistration( - .musicPlaybackControl, - jsName: "apple.music.play", - title: "Control Music playback", - summary: "Control Music playback queue through a user-authorized host adapter.", - tags: ["music", "musickit", "playback", "queue"], - example: "await apple.music.play({ action: 'playCatalog', catalogID: 'song-id' })", - requiredPermissions: [.music], - requiredArguments: ["action"], - optionalArguments: ["catalogID", "libraryID", "queue", "startPlaying"], - argumentHints: [ - "action": "Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.", - "catalogID": "Optional catalog item identifier.", - "libraryID": "Optional library item identifier.", - "queue": "Optional queue definition approved by the host adapter.", - ], - resultSummary: "Object with action/status/currentItem when available." - ) { args, context in - try music.controlPlayback(arguments: args, context: context) - }, + CapabilityRegistration(tool: MusicPermissionRequestTool(music: music)), + CapabilityRegistration(tool: MusicSubscriptionStatusTool(music: music)), + CapabilityRegistration(tool: MusicCatalogSearchTool(music: music)), + CapabilityRegistration(tool: MusicCatalogDetailsTool(music: music)), + CapabilityRegistration(tool: MusicLibraryReadTool(music: music)), + CapabilityRegistration(tool: MusicPlaylistWriteTool(music: music)), + CapabilityRegistration(tool: MusicPlaybackControlTool(music: music)), ] } func passKitRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .passKitWalletStatus, - jsName: "apple.wallet.getStatus", - title: "Read Wallet status", - summary: "Read PassKit Wallet availability and pass-library access status.", - tags: ["passkit", "wallet", "passes"], - example: "await apple.wallet.getStatus()", - resultSummary: "Object with available/canAddPasses/canPresentPasses." - ) { args, _ in - try passKit.walletStatus(arguments: args) - }, - CapabilityRegistration( - .passKitPassesRead, - jsName: "apple.wallet.listPasses", - title: "List Wallet passes", - summary: "List Wallet passes accessible to the host app through PassKit.", - tags: ["passkit", "wallet", "passes", "read"], - example: "await apple.wallet.listPasses({ limit: 20 })", - optionalArguments: ["passTypeIdentifier", "serialNumber", "limit"], - argumentHints: [ - "passTypeIdentifier": "Optional pass type filter.", - "serialNumber": "Optional serial number filter.", - "limit": "Maximum accessible passes.", - ], - resultSummary: "Array of passes with passTypeIdentifier/serialNumber/organizationName/description." - ) { args, _ in - try passKit.listPasses(arguments: args) - }, - CapabilityRegistration( - .passKitPassAdd, - jsName: "apple.wallet.addPass", - title: "Add Wallet pass", - summary: "Present user-mediated UI to add a sandbox .pkpass file to Wallet.", - tags: ["passkit", "wallet", "passes", "add", "system-ui"], - example: "await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' })", - requiredArguments: ["path"], - optionalArguments: ["timeoutMs"], - argumentHints: [ - "path": "Sandbox path to a .pkpass file.", - "timeoutMs": "Optional timeout for user-mediated add-pass UI.", - ], - resultSummary: "Object with action/added/passTypeIdentifier/serialNumber." - ) { args, context in - try passKit.addPass(arguments: args, context: context) - }, - CapabilityRegistration( - .passKitPassPresent, - jsName: "apple.wallet.presentPass", - title: "Present Wallet pass", - summary: "Present user-mediated details for an accessible Wallet pass.", - tags: ["passkit", "wallet", "passes", "system-ui"], - example: "await apple.wallet.presentPass({ identifier: 'pass-id' })", - requiredArguments: ["identifier"], - optionalArguments: ["timeoutMs"], - resultSummary: "Object with action/presented/identifier." - ) { args, _ in - try passKit.presentPass(arguments: args) - }, - CapabilityRegistration( - .passKitApplePayStatus, - jsName: "apple.wallet.canMakePayments", - title: "Read Apple Pay status", - summary: "Read Apple Pay capability using host merchant configuration; no merchant setup is accepted from JavaScript.", - tags: ["passkit", "apple-pay", "payments", "safety"], - example: "await apple.wallet.canMakePayments()", - optionalArguments: ["networks"], - argumentHints: [ - "networks": "Optional supported payment networks to test against host merchant configuration.", - ], - resultSummary: "Object with canMakePayments/canMakePaymentsUsingNetworks." - ) { args, _ in - try passKit.applePayStatus(arguments: args) - }, - CapabilityRegistration( - .passKitApplePayPresent, - jsName: "apple.wallet.presentPayment", - title: "Present Apple Pay request", - summary: "Present a host-defined Apple Pay payment request using host merchant configuration after explicit user-visible confirmation.", - tags: ["passkit", "apple-pay", "payments", "system-ui", "safety"], - example: "await apple.wallet.presentPayment({ paymentRequestID: 'checkout-123', confirmed: true })", - requiredArguments: ["paymentRequestID", "confirmed"], - optionalArguments: ["timeoutMs"], - argumentHints: [ - "paymentRequestID": "Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript.", - "confirmed": "Must be true after explicit user-visible confirmation before presenting Apple Pay.", - ], - resultSummary: "Object with action/authorized/paymentRequestID/status." - ) { args, _ in - try passKit.presentApplePay(arguments: args) - }, + CapabilityRegistration(tool: PassKitWalletStatusTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitPassesReadTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitPassAddTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitPassPresentTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitApplePayStatusTool(passKit: passKit)), + CapabilityRegistration(tool: PassKitApplePayPresentTool(passKit: passKit)), ] } func storeKitRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .storeKitProductsRead, - jsName: "apple.storekit.listProducts", - title: "Read StoreKit products", - summary: "Read StoreKit product metadata for host-configured product identifiers.", - tags: ["storekit", "commerce", "products"], - example: "await apple.storekit.listProducts({ productIDs: ['pro.monthly'] })", - requiredArguments: ["productIDs"], - argumentHints: [ - "productIDs": "Array of host-configured StoreKit product identifiers.", - ], - resultSummary: "Array of products with id/displayName/description/price/type." - ) { args, _ in - try storeKit.products(arguments: args) - }, - CapabilityRegistration( - .storeKitEntitlementsRead, - jsName: "apple.storekit.listEntitlements", - title: "Read StoreKit entitlements", - summary: "Read current StoreKit entitlements and verified transaction state.", - tags: ["storekit", "commerce", "entitlements"], - example: "await apple.storekit.listEntitlements()", - resultSummary: "Array of current entitlements with productID/transactionID/expirationDate." - ) { args, _ in - try storeKit.currentEntitlements(arguments: args) - }, - CapabilityRegistration( - .storeKitPurchase, - jsName: "apple.storekit.purchase", - title: "Purchase StoreKit product", - summary: "Present StoreKit purchase UI for a host-configured product after explicit user-visible confirmation.", - tags: ["storekit", "commerce", "purchase", "safety"], - example: "await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true })", - requiredArguments: ["productID", "confirmed"], - optionalArguments: ["appAccountToken"], - argumentHints: [ - "productID": "Host-configured StoreKit product identifier.", - "confirmed": "Must be true after explicit user-visible confirmation before purchase UI is presented.", - "appAccountToken": "Optional UUID string for StoreKit appAccountToken.", - ], - resultSummary: "Object with status/productID/transactionID when completed." - ) { args, _ in - try storeKit.purchase(arguments: args) - }, - CapabilityRegistration( - .storeKitRestore, - jsName: "apple.storekit.restore", - title: "Restore StoreKit purchases", - summary: "Trigger StoreKit restore/sync after explicit user-visible confirmation.", - tags: ["storekit", "commerce", "restore", "safety"], - example: "await apple.storekit.restore({ confirmed: true })", - requiredArguments: ["confirmed"], - argumentHints: [ - "confirmed": "Must be true after explicit user-visible confirmation before restore starts.", - ], - resultSummary: "Object with restored/status." - ) { args, _ in - try storeKit.restore(arguments: args) - }, - CapabilityRegistration( - .storeKitTransactionsRead, - jsName: "apple.storekit.listTransactions", - title: "Read StoreKit transaction inbox", - summary: "Read bounded transaction updates captured by the host StoreKit adapter.", - tags: ["storekit", "commerce", "transactions", "inbox", "events"], - example: "await apple.storekit.listTransactions({ limit: 20 })", - optionalArguments: ["limit", "productID", "afterCursor"], - argumentHints: [ - "limit": "Maximum transaction updates to return.", - "productID": "Optional product filter.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of transaction events with cursor/productID/transactionID/status/date." - ) { args, _ in - try storeKit.transactionUpdates(arguments: args) - }, + CapabilityRegistration(tool: StoreKitProductsReadTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitEntitlementsReadTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitPurchaseTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitRestoreTool(storeKit: storeKit)), + CapabilityRegistration(tool: StoreKitTransactionsReadTool(storeKit: storeKit)), ] } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift index e151a66..706b4af 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift @@ -1,6 +1,12 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { + // PHASE3-SKIP: networkFetch declares nested dotted-path arguments + // (options.method, options.headers, …). The @BuiltInCodeMode Arguments + // model is flat — property names map to top-level JSON keys — so it cannot + // reproduce the advertised optionalArguments list without changing it. + // Its options.responseEncoding constraint stays in the central defaults + // table. Left on the flat-init idiom. func networkRegistrations() -> [CapabilityRegistration] { [ CapabilityRegistration( @@ -46,197 +52,16 @@ extension DefaultCapabilityRegistrationBuilder { func filesystemRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.fs.list", "fs.promises.readdir"], - descriptor: .init( - id: .fsList, - title: "List directory", - summary: "List files/directories within allowed sandbox roots as entry objects.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.list({ path: 'tmp:' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "Directory path using allowed root prefix (tmp:, caches:, documents:).", - ], - resultSummary: "Array of entry objects with name/path/isDirectory/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings." - ), - handler: { args, context in - try fs.list(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.read", "fs.promises.readFile"], - descriptor: .init( - id: .fsRead, - title: "Read file", - summary: "Read text/base64 file data within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })", - requiredArguments: ["path"], - optionalArguments: ["encoding"], - argumentHints: [ - "path": "File path using allowed root prefix.", - "encoding": "utf8 (default) or base64.", - ], - resultSummary: "Object with path plus text or base64 field." - ), - handler: { args, context in - try fs.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.write", "fs.promises.writeFile"], - descriptor: .init( - id: .fsWrite, - title: "Write file", - summary: "Write text/base64 file data within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })", - requiredArguments: ["path"], - optionalArguments: ["data", "encoding"], - argumentHints: [ - "path": "File path using allowed root prefix.", - "data": "UTF-8 text or base64 string depending on encoding.", - "encoding": "utf8 (default) or base64.", - ], - resultSummary: "Object with path and bytesWritten." - ), - handler: { args, context in - try fs.write(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.move", "fs.promises.rename"], - descriptor: .init( - id: .fsMove, - title: "Move file", - summary: "Move file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", - requiredArguments: ["from", "to"], - argumentHints: [ - "from": "Source sandbox path.", - "to": "Destination sandbox path.", - ], - resultSummary: "Object with from/to resolved paths." - ), - handler: { args, context in - try fs.move(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.copy", "fs.promises.copyFile"], - descriptor: .init( - id: .fsCopy, - title: "Copy file", - summary: "Copy file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", - requiredArguments: ["from", "to"], - argumentHints: [ - "from": "Source sandbox path.", - "to": "Destination sandbox path.", - ], - resultSummary: "Object with from/to resolved paths." - ), - handler: { args, context in - try fs.copy(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.delete", "fs.promises.rm"], - descriptor: .init( - id: .fsDelete, - title: "Delete file", - summary: "Delete file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.delete({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - optionalArguments: ["recursive"], - argumentHints: [ - "path": "Path to file or directory.", - "recursive": "Required as true when deleting a directory.", - ], - resultSummary: "Object with deleted flag and path." - ), - handler: { args, context in - try fs.delete(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.stat", "fs.promises.stat"], - descriptor: .init( - id: .fsStat, - title: "Stat path", - summary: "Read file metadata within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.stat({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path.", - ], - resultSummary: "Object with path/isDirectory/size/createdAt/modifiedAt." - ), - handler: { args, context in - try fs.stat(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.mkdir", "fs.promises.mkdir"], - descriptor: .init( - id: .fsMkdir, - title: "Create directory", - summary: "Create directories within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })", - requiredArguments: ["path"], - optionalArguments: ["recursive"], - argumentHints: [ - "path": "Directory path to create.", - "recursive": "Boolean; default true.", - ], - resultSummary: "Object with created flag and path." - ), - handler: { args, context in - try fs.mkdir(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.exists"], - descriptor: .init( - id: .fsExists, - title: "Check path exists", - summary: "Check if file/directory exists within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.exists({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path to check.", - ], - resultSummary: "Boolean." - ), - handler: { args, context in - try fs.exists(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.fs.access", "fs.promises.access"], - descriptor: .init( - id: .fsAccess, - title: "Check path access", - summary: "Check read/write access for path within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.access({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path to inspect.", - ], - resultSummary: "Object with readable/writable/path." - ), - handler: { args, context in - try fs.access(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: FSListTool(fs: fs)), + CapabilityRegistration(tool: FSReadTool(fs: fs)), + CapabilityRegistration(tool: FSWriteTool(fs: fs)), + CapabilityRegistration(tool: FSMoveTool(fs: fs)), + CapabilityRegistration(tool: FSCopyTool(fs: fs)), + CapabilityRegistration(tool: FSDeleteTool(fs: fs)), + CapabilityRegistration(tool: FSStatTool(fs: fs)), + CapabilityRegistration(tool: FSMkdirTool(fs: fs)), + CapabilityRegistration(tool: FSExistsTool(fs: fs)), + CapabilityRegistration(tool: FSAccessTool(fs: fs)), ] } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift index ad5b4e9..07f1f6b 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift @@ -3,245 +3,15 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func calendarAndReminderRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.calendar.listEvents"], - descriptor: .init( - id: .calendarRead, - title: "Read calendar events", - summary: "List events in a date range from EventKit.", - tags: ["calendar", "eventkit", "schedule"], - example: "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })", - requiredPermissions: [.calendar], - optionalArguments: ["start", "end", "limit", "calendarIdentifier", "calendarIdentifiers"], - argumentHints: [ - "start": "ISO8601 timestamp; defaults to now.", - "end": "ISO8601 timestamp; defaults to start + 14 days.", - "limit": "Max number of items, default 50.", - "calendarIdentifier": "Optional EventKit calendarIdentifier to restrict results.", - "calendarIdentifiers": "Optional array of EventKit calendarIdentifier strings to restrict results.", - ], - resultSummary: "Array of events with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." - ), - handler: { args, context in - try eventKit.readEvents(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.createEvent", "apple.calendar.updateEvent"], - descriptor: .init( - id: .calendarWrite, - title: "Create or update calendar event", - summary: "Create a calendar event or patch an existing event by identifier.", - tags: ["calendar", "eventkit", "schedule"], - example: "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", - requiredPermissions: [], - optionalArguments: [ - "operation", - "identifier", - "title", - "start", - "end", - "notes", - "location", - "url", - "isAllDay", - "calendarIdentifier", - ], - argumentHints: [ - "operation": "create (default without identifier) or update (default with identifier).", - "identifier": "EventKit eventIdentifier to update. Updates require full calendar access at runtime.", - "title": "Event title string.", - "start": "ISO8601 start timestamp.", - "end": "ISO8601 end timestamp.", - "notes": "Optional notes/body string.", - "location": "Optional location string.", - "url": "Optional absolute URL string attached to the event.", - "isAllDay": "Whether the event is all-day.", - "calendarIdentifier": "Optional destination EventKit calendarIdentifier.", - ], - resultSummary: "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." - ), - handler: { args, context in - try eventKit.writeEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.deleteEvent"], - descriptor: .init( - id: .calendarDelete, - title: "Delete calendar event", - summary: "Delete an existing EventKit event by identifier.", - tags: ["calendar", "eventkit", "schedule", "delete"], - example: "await apple.calendar.deleteEvent({ identifier: 'EVENT_ID', span: 'thisEvent' })", - requiredPermissions: [.calendar], - requiredArguments: ["identifier"], - optionalArguments: ["span"], - argumentHints: [ - "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", - "span": "thisEvent (default) or futureEvents for recurring events.", - ], - resultSummary: "Object with identifier/deleted." - ), - handler: { args, context in - try eventKit.deleteEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.pickCalendar"], - descriptor: .init( - id: .calendarUIPickCalendar, - title: "Pick calendar with system UI", - summary: "Present EventKit calendar chooser UI and return the user-selected writable calendars.", - tags: ["calendar", "eventkit", "system-ui", "picker"], - example: "await apple.calendar.pickCalendar({ selectionStyle: 'single' })", - requiredPermissions: [.calendarWriteOnly], - optionalArguments: ["selectionStyle", "displayStyle", "timeoutMs"], - argumentTypes: [ - "selectionStyle": .string, - "displayStyle": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "selectionStyle": "single (default) or multiple.", - "displayStyle": "writable (default) or all.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Array of selected calendars with identifier/title/type/allowsContentModifications." - ), - handler: { args, context in - try systemUI.pickCalendar(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.presentEvent"], - descriptor: .init( - id: .calendarUIPresentEvent, - title: "Present calendar event details", - summary: "Present system UI for an existing calendar event identifier.", - tags: ["calendar", "eventkit", "system-ui", "details"], - example: "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })", - requiredPermissions: [.calendar], - requiredArguments: ["identifier"], - optionalArguments: ["allowsEditing", "allowsCalendarPreview", "timeoutMs"], - argumentTypes: [ - "identifier": .string, - "allowsEditing": .bool, - "allowsCalendarPreview": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", - "allowsEditing": "Whether the user can edit from the detail UI; default false.", - "allowsCalendarPreview": "Whether the UI may show calendar day previews; default true.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed." - ), - handler: { args, context in - try systemUI.presentCalendarEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.calendar.presentNewEvent"], - descriptor: .init( - id: .calendarUIPresentNewEvent, - title: "Present calendar event editor", - summary: "Present system UI to let the user create or edit a new calendar event draft.", - tags: ["calendar", "eventkit", "system-ui", "picker"], - example: "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", - requiredPermissions: [.calendarWriteOnly], - optionalArguments: ["title", "start", "end", "notes", "location", "timeoutMs"], - argumentTypes: [ - "title": .string, - "start": .string, - "end": .string, - "notes": .string, - "location": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "title": "Optional event title shown in the editor.", - "start": "Optional ISO8601 start timestamp.", - "end": "Optional ISO8601 end timestamp.", - "notes": "Optional event notes/body text.", - "location": "Optional location string.", - "timeoutMs": "Optional timeout for waiting on user save/cancel.", - ], - resultSummary: "Object with action plus identifier/title when the user saves." - ), - handler: { args, context in - try systemUI.presentNewCalendarEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.reminders.listReminders"], - descriptor: .init( - id: .remindersRead, - title: "Read reminders", - summary: "Read incomplete reminders from EventKit.", - tags: ["reminders", "eventkit", "task"], - example: "await apple.reminders.listReminders({ limit: 20 })", - requiredPermissions: [.reminders], - optionalArguments: ["start", "end", "includeCompleted", "calendarIdentifier", "calendarIdentifiers", "limit"], - argumentHints: [ - "start": "Optional ISO8601 due-date lower bound.", - "end": "Optional ISO8601 due-date upper bound.", - "includeCompleted": "Whether completed reminders are included; default false.", - "calendarIdentifier": "Optional EventKit reminder calendarIdentifier to restrict results.", - "calendarIdentifiers": "Optional array of EventKit reminder calendarIdentifier strings to restrict results.", - "limit": "Max number of reminder items, default 50.", - ], - resultSummary: "Array of reminders with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." - ), - handler: { args, context in - try eventKit.readReminders(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.reminders.createReminder", "apple.reminders.updateReminder", "apple.reminders.completeReminder"], - descriptor: .init( - id: .remindersWrite, - title: "Create or update reminder", - summary: "Create a reminder or patch an existing reminder by identifier.", - tags: ["reminders", "eventkit", "task"], - example: "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })", - requiredPermissions: [.reminders], - optionalArguments: ["operation", "identifier", "title", "dueDate", "notes", "isCompleted", "priority", "calendarIdentifier"], - argumentHints: [ - "operation": "create (default without identifier), update, or complete.", - "identifier": "EventKit calendarItemIdentifier to update or complete.", - "title": "Reminder title string.", - "dueDate": "Optional ISO8601 due date timestamp.", - "notes": "Optional reminder notes.", - "isCompleted": "Completion state for update or completeReminder; default true for completeReminder.", - "priority": "EventKit reminder priority integer.", - "calendarIdentifier": "Optional destination EventKit reminders calendarIdentifier.", - ], - resultSummary: "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." - ), - handler: { args, context in - try eventKit.writeReminder(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.reminders.deleteReminder"], - descriptor: .init( - id: .remindersDelete, - title: "Delete reminder", - summary: "Delete an existing EventKit reminder by identifier.", - tags: ["reminders", "eventkit", "task", "delete"], - example: "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })", - requiredPermissions: [.reminders], - requiredArguments: ["identifier"], - argumentHints: [ - "identifier": "EventKit calendarItemIdentifier from apple.reminders.listReminders.", - ], - resultSummary: "Object with identifier/deleted." - ), - handler: { args, context in - try eventKit.deleteReminder(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: CalendarListEventsTool(eventKit: eventKit)), + CapabilityRegistration(tool: CalendarWriteEventTool(eventKit: eventKit)), + CapabilityRegistration(tool: CalendarDeleteEventTool(eventKit: eventKit)), + CapabilityRegistration(tool: CalendarPickCalendarTool(systemUI: systemUI)), + CapabilityRegistration(tool: CalendarPresentEventTool(systemUI: systemUI)), + CapabilityRegistration(tool: CalendarPresentNewEventTool(systemUI: systemUI)), + CapabilityRegistration(tool: RemindersListTool(eventKit: eventKit)), + CapabilityRegistration(tool: RemindersWriteTool(eventKit: eventKit)), + CapabilityRegistration(tool: RemindersDeleteTool(eventKit: eventKit)), ] } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift index 6e261f2..3f2d505 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift @@ -3,318 +3,42 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func appIntentsRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .appIntentsList, - jsName: "apple.appIntents.list", - title: "List host App Intent adapters", - summary: "List host-registered App Intents or Shortcuts actions exposed to CodeMode.", - tags: ["appintents", "shortcuts", "actions", "host-adapter"], - example: "await apple.appIntents.list()", - optionalArguments: ["domain", "limit"], - argumentHints: [ - "domain": "Optional host-defined action domain filter.", - "limit": "Maximum actions to return.", - ], - resultSummary: "Array of actions with identifier/title/summary/requiredParameters." - ) { args, _ in - try appIntents.listActions(arguments: args) - }, - CapabilityRegistration( - .appIntentsRun, - jsName: "apple.appIntents.run", - title: "Run host App Intent adapter", - summary: "Run a host-registered App Intent adapter with structured parameters.", - tags: ["appintents", "shortcuts", "actions", "host-adapter"], - example: "await apple.appIntents.run({ identifier: 'createNote', parameters: { title: 'Draft' } })", - requiredArguments: ["identifier"], - optionalArguments: ["parameters", "timeoutMs"], - argumentHints: [ - "identifier": "Host-registered action identifier from apple.appIntents.list.", - "parameters": "Structured parameters validated by the host adapter.", - "timeoutMs": "Maximum wait time in milliseconds.", - ], - resultSummary: "Adapter-defined structured result." - ) { args, _ in - try appIntents.runAction(arguments: args) - }, - CapabilityRegistration( - .appIntentsDonate, - jsName: "apple.appIntents.donate", - title: "Donate host App Intent action", - summary: "Ask the host to donate a supported action to Shortcuts/Siri suggestions where feasible.", - tags: ["appintents", "shortcuts", "donation", "host-adapter"], - example: "await apple.appIntents.donate({ identifier: 'createNote', parameters: { title: 'Draft' } })", - requiredArguments: ["identifier"], - optionalArguments: ["parameters"], - argumentHints: [ - "identifier": "Host-registered action identifier.", - "parameters": "Structured action parameters used for the donation.", - ], - resultSummary: "Object with donated/status." - ) { args, _ in - try appIntents.donateAction(arguments: args) - }, - CapabilityRegistration( - .appIntentsOpen, - jsName: "apple.appIntents.open", - title: "Open host App Intent surface", - summary: "Open a host-provided App Intent, Shortcuts, or app surface for user-mediated continuation.", - tags: ["appintents", "shortcuts", "open", "host-adapter"], - example: "await apple.appIntents.open({ identifier: 'showTask', parameters: { id: 'task-1' } })", - requiredArguments: ["identifier"], - optionalArguments: ["parameters"], - argumentHints: [ - "identifier": "Host-registered open action identifier.", - "parameters": "Structured parameters for the host open action.", - ], - resultSummary: "Object with opened/status." - ) { args, _ in - try appIntents.openAction(arguments: args) - }, - CapabilityRegistration( - .appIntentsHandoffsRead, - jsName: "apple.appIntents.listHandoffs", - title: "Read App Intent handoff inbox", - summary: "Read bounded App Intent handoff events captured by the host app.", - tags: ["appintents", "shortcuts", "handoff", "inbox", "events"], - example: "await apple.appIntents.listHandoffs({ limit: 20 })", - optionalArguments: ["identifier", "limit", "afterCursor"], - argumentHints: [ - "identifier": "Optional action identifier filter.", - "limit": "Maximum handoff events to return.", - "afterCursor": "Optional host-provided cursor for incremental reads.", - ], - resultSummary: "Array of handoff events with cursor/identifier/parameters/date/source." - ) { args, _ in - try appIntents.readHandoffs(arguments: args) - }, + CapabilityRegistration(tool: AppIntentsListTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsRunTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsDonateTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsOpenTool(appIntents: appIntents)), + CapabilityRegistration(tool: AppIntentsHandoffsReadTool(appIntents: appIntents)), ] } func foundationModelsRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .foundationModelsStatus, - jsName: "apple.foundationModels.getStatus", - title: "Read Foundation Models availability", - summary: "Read host Foundation Models availability/status before attempting local generation.", - tags: ["foundationmodels", "apple-intelligence", "llm", "availability"], - example: "await apple.foundationModels.getStatus()", - resultSummary: "Object with available/status/reason/modelIdentifier when available." - ) { args, _ in - try foundationModels.status(arguments: args) - }, - CapabilityRegistration( - .foundationModelsGenerate, - jsName: "apple.foundationModels.generate", - title: "Generate text with Foundation Models", - summary: "Generate text locally through the host Foundation Models adapter when available.", - tags: ["foundationmodels", "apple-intelligence", "llm", "generation"], - example: "await apple.foundationModels.generate({ prompt: 'Summarize this note', maxTokens: 200 })", - requiredArguments: ["prompt"], - optionalArguments: ["instructions", "temperature", "maxTokens", "schemaIdentifier", "timeoutMs"], - argumentHints: [ - "prompt": "Prompt text sent to the host Foundation Models session.", - "instructions": "Optional host-approved system instructions.", - "temperature": "Optional sampling temperature when supported.", - "maxTokens": "Optional maximum output tokens.", - "schemaIdentifier": "Optional host-defined output schema identifier.", - "timeoutMs": "Maximum generation wait time in milliseconds.", - ], - resultSummary: "Object with text/finishReason/usage when available." - ) { args, _ in - try foundationModels.generateText(arguments: args) - }, - CapabilityRegistration( - .foundationModelsExtract, - jsName: "apple.foundationModels.extract", - title: "Extract structured data with Foundation Models", - summary: "Run host-defined structured extraction or classification with Foundation Models.", - tags: ["foundationmodels", "apple-intelligence", "llm", "structured-output"], - example: "await apple.foundationModels.extract({ input: text, schemaIdentifier: 'todo' })", - requiredArguments: ["input"], - optionalArguments: ["schema", "schemaIdentifier", "instructions", "timeoutMs"], - argumentHints: [ - "input": "Input text to classify or extract from.", - "schema": "Optional JSON schema object accepted by the host adapter.", - "schemaIdentifier": "Preferred host-defined schema identifier.", - "instructions": "Optional task-specific instructions.", - "timeoutMs": "Maximum extraction wait time in milliseconds.", - ], - resultSummary: "Object with values/classification/confidence/schemaIdentifier." - ) { args, _ in - try foundationModels.extract(arguments: args) - }, + CapabilityRegistration(tool: FoundationModelsStatusTool(foundationModels: foundationModels)), + CapabilityRegistration(tool: FoundationModelsGenerateTool(foundationModels: foundationModels)), + CapabilityRegistration(tool: FoundationModelsExtractTool(foundationModels: foundationModels)), ] } func activityRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .activityList, - jsName: "apple.activity.list", - title: "List Live Activities", - summary: "List host-registered ActivityKit Live Activities visible to CodeMode.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.list()", - optionalArguments: ["activityType", "limit"], - resultSummary: "Array of activities with identifier/activityType/state/contentState/attributes." - ) { args, _ in - try activity.listActivities(arguments: args) - }, - CapabilityRegistration( - .activityStart, - jsName: "apple.activity.start", - title: "Start Live Activity", - summary: "Start a host-registered ActivityKit Live Activity adapter.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.start({ activityType: 'delivery', attributes: {}, contentState: {} })", - requiredArguments: ["activityType", "attributes"], - optionalArguments: ["contentState", "pushType", "staleDate"], - argumentHints: [ - "activityType": "Host-registered activity adapter type.", - "attributes": "Adapter-defined immutable attributes.", - "contentState": "Adapter-defined mutable content state.", - "pushType": "Optional push token request mode when the adapter supports remote updates.", - "staleDate": "Optional ISO8601 stale date.", - ], - resultSummary: "Object with identifier/activityType/state/pushToken when available." - ) { args, _ in - try activity.startActivity(arguments: args) - }, - CapabilityRegistration( - .activityUpdate, - jsName: "apple.activity.update", - title: "Update Live Activity", - summary: "Update an existing host-registered Live Activity content state.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.update({ identifier: 'activity-1', contentState: { progress: 0.7 } })", - requiredArguments: ["identifier", "contentState"], - optionalArguments: ["alert", "staleDate"], - resultSummary: "Object with identifier/updated/state." - ) { args, _ in - try activity.updateActivity(arguments: args) - }, - CapabilityRegistration( - .activityEnd, - jsName: "apple.activity.end", - title: "End Live Activity", - summary: "End a host-registered Live Activity, optionally with final content state.", - tags: ["activitykit", "live-activities", "host-adapter"], - example: "await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'immediate' })", - requiredArguments: ["identifier"], - optionalArguments: ["contentState", "dismissalPolicy"], - resultSummary: "Object with identifier/ended/state." - ) { args, _ in - try activity.endActivity(arguments: args) - }, - CapabilityRegistration( - .activityPushTokenRead, - jsName: "apple.activity.getPushToken", - title: "Read Live Activity push token", - summary: "Read a Live Activity push token when the host adapter supports push updates.", - tags: ["activitykit", "live-activities", "push-token"], - example: "await apple.activity.getPushToken({ identifier: 'activity-1' })", - requiredArguments: ["identifier"], - resultSummary: "Object with identifier/pushToken/environment." - ) { args, _ in - try activity.readPushToken(arguments: args) - }, + CapabilityRegistration(tool: ActivityListTool(activity: activity)), + CapabilityRegistration(tool: ActivityStartTool(activity: activity)), + CapabilityRegistration(tool: ActivityUpdateTool(activity: activity)), + CapabilityRegistration(tool: ActivityEndTool(activity: activity)), + CapabilityRegistration(tool: ActivityPushTokenReadTool(activity: activity)), ] } func mapsRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .mapsGeocode, - jsName: "apple.maps.geocode", - title: "Geocode address", - summary: "Resolve an address or place text to coordinate candidates through MapKit.", - tags: ["maps", "mapkit", "geocode", "location"], - example: "await apple.maps.geocode({ address: '1 Infinite Loop, Cupertino', limit: 3 })", - requiredArguments: ["address"], - optionalArguments: ["region", "limit"], - argumentHints: [ - "address": "Address or place text to geocode.", - "region": "Optional search bias region object.", - "limit": "Maximum coordinate candidates.", - ], - resultSummary: "Array of placemarks with name/address/latitude/longitude." - ) { args, _ in - try maps.geocode(arguments: args) - }, - CapabilityRegistration( - .mapsReverseGeocode, - jsName: "apple.maps.reverseGeocode", - title: "Reverse geocode coordinates", - summary: "Resolve latitude/longitude into address candidates through MapKit.", - tags: ["maps", "mapkit", "reverse-geocode", "location"], - example: "await apple.maps.reverseGeocode({ latitude: 37.3318, longitude: -122.0312 })", - requiredArguments: ["latitude", "longitude"], - optionalArguments: ["locale"], - resultSummary: "Array of placemarks with name/address/latitude/longitude." - ) { args, _ in - try maps.reverseGeocode(arguments: args) - }, - CapabilityRegistration( - .mapsSearch, - jsName: "apple.maps.search", - title: "Search local map items", - summary: "Search for local businesses, addresses, or points of interest through MapKit.", - tags: ["maps", "mapkit", "local-search", "places"], - example: "await apple.maps.search({ query: 'coffee near me', limit: 5 })", - requiredArguments: ["query"], - optionalArguments: ["region", "resultTypes", "limit"], - argumentHints: [ - "query": "Search query string.", - "region": "Optional coordinate region object for local bias.", - "resultTypes": "Optional host-supported result type filters.", - "limit": "Maximum map items.", - ], - resultSummary: "Array of map items with name/address/category/latitude/longitude/url." - ) { args, _ in - try maps.search(arguments: args) - }, - CapabilityRegistration( - .mapsRouteEstimate, - jsName: "apple.maps.routeEstimate", - title: "Estimate route", - summary: "Estimate route distance and travel time between two coordinates or map items.", - tags: ["maps", "mapkit", "directions", "route"], - example: "await apple.maps.routeEstimate({ origin: { latitude: 37.33, longitude: -122.03 }, destination: { latitude: 37.77, longitude: -122.42 }, transportType: 'automobile' })", - requiredArguments: ["origin", "destination"], - optionalArguments: ["transportType", "departureDate", "arrivalDate"], - argumentHints: [ - "origin": "Coordinate or map item object.", - "destination": "Coordinate or map item object.", - "transportType": "automobile (default), walking, or transit when supported.", - ], - resultSummary: "Object with distanceMeters/expectedTravelTimeSeconds/transportType." - ) { args, _ in - try maps.routeEstimate(arguments: args) - }, - CapabilityRegistration( - .mapsOpen, - jsName: "apple.maps.open", - title: "Open Maps", - summary: "Open Apple Maps with coordinates, a query, URL, or directions in a user-mediated handoff.", - tags: ["maps", "mapkit", "open", "directions"], - example: "await apple.maps.open({ query: 'Apple Park' })", - optionalArguments: ["query", "url", "latitude", "longitude", "destination", "transportType"], - argumentHints: [ - "query": "Place/search query to open.", - "url": "Optional maps URL to open.", - "latitude": "Latitude when opening coordinates.", - "longitude": "Longitude when opening coordinates.", - "destination": "Optional destination object for directions.", - ], - resultSummary: "Object with opened/target." - ) { args, _ in - try maps.open(arguments: args) - }, + CapabilityRegistration(tool: MapsGeocodeTool(maps: maps)), + CapabilityRegistration(tool: MapsReverseGeocodeTool(maps: maps)), + CapabilityRegistration(tool: MapsSearchTool(maps: maps)), + CapabilityRegistration(tool: MapsRouteEstimateTool(maps: maps)), + CapabilityRegistration(tool: MapsOpenTool(maps: maps)), ] } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift index d38daed..aa47b5f 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift @@ -3,335 +3,25 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func contactRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.contacts.list"], - descriptor: .init( - id: .contactsRead, - title: "Read contacts", - summary: "Read contacts with bounded fields.", - tags: ["contacts", "address-book", "people"], - example: "await apple.contacts.list({ limit: 25 })", - requiredPermissions: [.contacts], - optionalArguments: ["limit", "identifiers"], - argumentHints: [ - "limit": "Max number of contacts, default 50.", - "identifiers": "Optional array of contact identifiers for targeted read.", - ], - resultSummary: "Array of contacts with identifier/name/organization/phones/emails." - ), - handler: { args, context in - try contacts.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.search"], - descriptor: .init( - id: .contactsSearch, - title: "Search contacts", - summary: "Search contacts by name.", - tags: ["contacts", "search", "people"], - example: "await apple.contacts.search({ query: 'Alex', limit: 10 })", - requiredPermissions: [.contacts], - requiredArguments: ["query"], - optionalArguments: ["limit"], - argumentHints: [ - "query": "Name text to match.", - "limit": "Max number of contacts, default 20.", - ], - resultSummary: "Array of contact objects." - ), - handler: { args, context in - try contacts.search(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.pick"], - descriptor: .init( - id: .contactsUIPick, - title: "Pick contacts with system UI", - summary: "Present system contact picker UI and return selected contacts without requiring full Contacts permission.", - tags: ["contacts", "people", "system-ui", "picker"], - example: "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })", - optionalArguments: ["mode", "displayedPropertyKeys", "timeoutMs"], - argumentTypes: [ - "mode": .string, - "displayedPropertyKeys": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "mode": "single (default) or multiple.", - "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Array of selected contacts with identifier/name/organization/phones/emails." - ), - handler: { args, context in - try systemUI.pickContacts(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.presentContact"], - descriptor: .init( - id: .contactsUIPresentContact, - title: "Present contact card", - summary: "Present system contact card UI for a contact identifier.", - tags: ["contacts", "people", "system-ui", "details"], - example: "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })", - requiredPermissions: [.contacts], - requiredArguments: ["identifier"], - optionalArguments: ["allowsEditing", "allowsActions", "displayedPropertyKeys", "timeoutMs"], - argumentTypes: [ - "identifier": .string, - "allowsEditing": .bool, - "allowsActions": .bool, - "displayedPropertyKeys": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "identifier": "Contact identifier from apple.contacts.list/search/pick.", - "allowsEditing": "Whether the user can edit the contact; default false.", - "allowsActions": "Whether built-in actions like call/message are shown; default true.", - "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action and contact when available." - ), - handler: { args, context in - try systemUI.presentContact(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.contacts.presentNewContact"], - descriptor: .init( - id: .contactsUIPresentNewContact, - title: "Present new contact editor", - summary: "Present system UI for creating a new contact draft.", - tags: ["contacts", "people", "system-ui", "create"], - example: "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })", - requiredPermissions: [.contacts], - optionalArguments: ["givenName", "familyName", "organization", "phoneNumbers", "emailAddresses", "timeoutMs"], - argumentTypes: [ - "givenName": .string, - "familyName": .string, - "organization": .string, - "phoneNumbers": .array, - "emailAddresses": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "givenName": "Optional given name to prefill.", - "familyName": "Optional family name to prefill.", - "organization": "Optional organization to prefill.", - "phoneNumbers": "Optional array of phone number strings.", - "emailAddresses": "Optional array of email address strings.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action and contact when the user saves." - ), - handler: { args, context in - try systemUI.presentNewContact(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: ContactsReadTool(contacts: contacts)), + CapabilityRegistration(tool: ContactsSearchTool(contacts: contacts)), + CapabilityRegistration(tool: ContactsUIPickTool(systemUI: systemUI)), + CapabilityRegistration(tool: ContactsUIPresentContactTool(systemUI: systemUI)), + CapabilityRegistration(tool: ContactsUIPresentNewContactTool(systemUI: systemUI)), ] } func photoAndDocumentRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.photos.list"], - descriptor: .init( - id: .photosRead, - title: "List photo library assets", - summary: "List photos/videos from the user photo library.", - tags: ["photos", "photo-library", "media"], - example: "await apple.photos.list({ mediaType: 'image', limit: 20 })", - requiredPermissions: [.photoLibrary], - optionalArguments: ["mediaType", "limit"], - argumentHints: [ - "mediaType": "any (default), image, or video.", - "limit": "Max number of results, default 50.", - ], - resultSummary: "Array of assets with localIdentifier/mediaType/dimensions/date metadata." - ), - handler: { args, context in - try photos.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.photos.export"], - descriptor: .init( - id: .photosExport, - title: "Export photo library asset", - summary: "Export a photo/video asset to sandbox file path and register artifact handle.", - tags: ["photos", "photo-library", "artifact"], - example: "await apple.photos.export({ localIdentifier: 'ABC/L0/001', outputPath: 'tmp:exported.jpg' })", - requiredPermissions: [.photoLibrary], - requiredArguments: ["localIdentifier"], - optionalArguments: ["outputPath"], - argumentHints: [ - "localIdentifier": "PHAsset localIdentifier from photos.read result.", - "outputPath": "Optional sandbox output path; defaults to tmp-generated file.", - ], - resultSummary: "Object with path/artifactID/localIdentifier/mediaType/bytes." - ), - handler: { args, context in - try photos.export(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.photos.pick"], - descriptor: .init( - id: .photosUIPick, - title: "Pick photos with system UI", - summary: "Present system photo picker UI and export selected assets into the sandbox artifact store.", - tags: ["photos", "photo-library", "system-ui", "picker", "artifact"], - example: "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })", - optionalArguments: ["mediaType", "limit", "outputDirectory", "timeoutMs"], - argumentTypes: [ - "mediaType": .string, - "limit": .number, - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "mediaType": "any (default), image/photo, or video.", - "limit": "Maximum number of selectable items; default 1.", - "outputDirectory": "Optional sandbox directory for exported picker files; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user selection/export.", - ], - resultSummary: "Array of selected assets with path/artifactID/mediaType/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.pickPhotos(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.photos.presentLimitedLibraryPicker"], - descriptor: .init( - id: .photosUIPresentLimitedLibraryPicker, - title: "Present limited Photos picker", - summary: "Present the Photos limited-library management UI so the user can update the app's selected photo set.", - tags: ["photos", "photo-library", "system-ui", "permission", "picker"], - example: "await apple.photos.presentLimitedLibraryPicker()", - optionalArguments: ["timeoutMs"], - argumentTypes: [ - "timeoutMs": .number, - ], - argumentHints: [ - "timeoutMs": "Optional timeout for waiting on the limited-library picker completion.", - ], - resultSummary: "Object with action/status and selectedIdentifiers when the limited selection changes." - ), - handler: { args, context in - try systemUI.presentLimitedPhotoLibraryPicker(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.pick"], - descriptor: .init( - id: .documentsUIPick, - title: "Pick documents with system UI", - summary: "Present Files document picker UI and copy selected documents into the sandbox artifact store.", - tags: ["documents", "files", "system-ui", "picker", "artifact"], - example: "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })", - optionalArguments: ["contentTypes", "allowMultiple", "outputDirectory", "timeoutMs"], - argumentTypes: [ - "contentTypes": .array, - "allowMultiple": .bool, - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "contentTypes": "Optional array of UTType identifiers; defaults to public.item.", - "allowMultiple": "Whether multiple files may be selected; default false.", - "outputDirectory": "Optional sandbox directory for copied files; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user selection/copy.", - ], - resultSummary: "Array of selected documents with path/artifactID/filename/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.pickDocuments(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.export", "apple.documents.save"], - descriptor: .init( - id: .documentsUIExport, - title: "Export documents with system UI", - summary: "Present Files export UI to save one or more sandbox files to a user-selected destination.", - tags: ["documents", "files", "system-ui", "export", "save"], - example: "await apple.documents.export({ path: 'tmp:report.pdf' })", - optionalArguments: ["path", "paths", "asCopy", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "asCopy": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to export.", - "paths": "Optional array of sandbox file paths to export.", - "asCopy": "Whether to export as a copy; default true.", - "timeoutMs": "Optional timeout for waiting on export completion.", - ], - resultSummary: "Object with action/count and destination URLs when the provider returns them." - ), - handler: { args, context in - try systemUI.exportDocuments(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.openIn"], - descriptor: .init( - id: .documentsUIOpenIn, - title: "Open document in another app", - summary: "Present the system Open In menu for a sandbox file.", - tags: ["documents", "files", "system-ui", "open-in", "handoff"], - example: "await apple.documents.openIn({ path: 'tmp:report.pdf' })", - requiredArguments: ["path"], - optionalArguments: ["name", "uti", "timeoutMs"], - argumentTypes: [ - "path": .string, - "name": .string, - "uti": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Sandbox file path to hand off.", - "name": "Optional display name for the document interaction controller.", - "uti": "Optional uniform type identifier override.", - "timeoutMs": "Optional timeout for waiting on the Open In menu dismissal.", - ], - resultSummary: "Object with action and application bundle identifier when the user hands off the file." - ), - handler: { args, context in - try systemUI.openDocument(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.documents.scan"], - descriptor: .init( - id: .documentsUIScan, - title: "Scan documents with system UI", - summary: "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store.", - tags: ["documents", "scan", "camera", "system-ui", "artifact"], - example: "await apple.documents.scan({ outputDirectory: 'tmp:scans' })", - optionalArguments: ["outputDirectory", "timeoutMs"], - argumentTypes: [ - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "outputDirectory": "Optional sandbox directory for scanned page images; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user scanning/export.", - ], - resultSummary: "Array of scanned page artifacts with path/artifactID/pageIndex/mediaType/bytes." - ), - handler: { args, context in - try systemUI.scanDocuments(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: PhotosReadTool(photos: photos)), + CapabilityRegistration(tool: PhotosExportTool(photos: photos)), + CapabilityRegistration(tool: PhotosUIPickTool(systemUI: systemUI)), + CapabilityRegistration(tool: PhotosUIPresentLimitedLibraryPickerTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIPickTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIExportTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIOpenInTool(systemUI: systemUI)), + CapabilityRegistration(tool: DocumentsUIScanTool(systemUI: systemUI)), ] } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift index 4a7fe72..810a4bb 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift @@ -3,403 +3,55 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func visionRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .visionImageAnalyze, - jsName: "apple.vision.analyzeImage", - title: "Analyze image with Vision", - summary: "Run on-device image analysis for labels/text/barcodes on sandbox image paths.", - tags: ["vision", "image-analysis", "ml"], - example: "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })", - requiredArguments: ["path"], - optionalArguments: ["features", "maxResults"], - argumentHints: [ - "path": "Sandbox image path to analyze.", - "features": "Optional array including labels/text/barcodes.", - "maxResults": "Max observations returned per feature, default 5.", - ], - resultSummary: "Object containing requested analysis sections such as labels/text/barcodes." - ) { args, context in - try vision.analyzeImage(arguments: args, context: context) - }, + CapabilityRegistration(tool: VisionImageAnalyzeTool(vision: vision)), ] } func notificationRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .notificationsPermissionRequest, - jsName: "apple.notifications.requestPermission", - title: "Request notification permission", - summary: "Request local notification authorization from the user.", - tags: ["notifications", "permission"], - example: "await apple.notifications.requestPermission()", - resultSummary: "Object with status/granted fields." - ) { _, context in - try notifications.requestPermission(context: context) - }, - CapabilityRegistration( - .notificationsSchedule, - jsName: "apple.notifications.schedule", - title: "Schedule local notification", - summary: "Schedule a local notification using time interval or fireDate trigger.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })", - requiredPermissions: [.notifications], - requiredArguments: ["title"], - optionalArguments: [ - "identifier", - "subtitle", - "body", - "secondsFromNow", - "fireDate", - "repeats", - "sound", - "badge", - "userInfo", - "threadIdentifier", - "categoryIdentifier", - ], - argumentHints: [ - "title": "Notification title text.", - "identifier": "Optional request identifier; defaults to codemode UUID.", - "subtitle": "Optional subtitle text.", - "body": "Optional body text.", - "secondsFromNow": "Delay in seconds for time interval trigger (default 5).", - "fireDate": "Optional ISO8601 timestamp for calendar trigger.", - "repeats": "Boolean repeat flag (time interval requires >= 60 seconds).", - "sound": "default (default), none, or a bundled custom sound name.", - "badge": "Optional app icon badge number.", - "userInfo": "Optional property-list-safe userInfo object.", - "threadIdentifier": "Optional thread identifier for notification grouping.", - "categoryIdentifier": "Optional category identifier for notification actions.", - ], - resultSummary: "Object with identifier/scheduled/repeats." - ) { args, context in - try notifications.schedule(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsPendingRead, - jsName: "apple.notifications.listPending", - title: "List pending local notifications", - summary: "List pending local notification requests.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.listPending({ limit: 20 })", - requiredPermissions: [.notifications], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of pending requests to return, default 50.", - ], - resultSummary: "Array of pending requests with identifiers/content/trigger metadata." - ) { args, context in - try notifications.readPending(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsPendingDelete, - jsName: "apple.notifications.cancelPending", - title: "Delete pending local notifications", - summary: "Delete pending local notifications by identifier list or clear all.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })", - requiredPermissions: [.notifications], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single pending request identifier to remove.", - "identifiers": "Array of pending request identifiers to remove. Omit both to clear all pending requests.", - ], - resultSummary: "Object with deleted/count fields." - ) { args, context in - try notifications.deletePending(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsDeliveredRead, - jsName: "apple.notifications.listDelivered", - title: "List delivered local notifications", - summary: "List notifications currently delivered in Notification Center.", - tags: ["notifications", "local", "delivered"], - example: "await apple.notifications.listDelivered({ limit: 20 })", - requiredPermissions: [.notifications], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of delivered notifications to return, default 50.", - ], - resultSummary: "Array of delivered notifications with identifiers/content/date metadata." - ) { args, context in - try notifications.readDelivered(arguments: args, context: context) - }, - CapabilityRegistration( - .notificationsDeliveredDelete, - jsName: "apple.notifications.removeDelivered", - title: "Delete delivered local notifications", - summary: "Delete delivered notifications by identifier list or clear all.", - tags: ["notifications", "local", "delivered", "delete"], - example: "await apple.notifications.removeDelivered({ identifiers: ['codemode.1', 'codemode.2'] })", - requiredPermissions: [.notifications], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single delivered notification identifier to remove.", - "identifiers": "Array of delivered notification identifiers to remove. Omit both to clear all delivered notifications.", - ], - resultSummary: "Object with deleted/count fields." - ) { args, context in - try notifications.deleteDelivered(arguments: args, context: context) - }, + CapabilityRegistration(tool: NotificationsPermissionRequestTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsScheduleTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsPendingReadTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsPendingDeleteTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsDeliveredReadTool(notifications: notifications)), + CapabilityRegistration(tool: NotificationsDeliveredDeleteTool(notifications: notifications)), ] } func alarmRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .alarmPermissionRequest, - jsName: "ios.alarm.requestPermission", - title: "Request AlarmKit permission", - summary: "Request AlarmKit authorization from the user.", - tags: ["alarmkit", "permission", "alarms"], - example: "await ios.alarm.requestPermission()", - resultSummary: "Object with status/granted fields." - ) { _, context in - try alarm.requestPermission(context: context) - }, - CapabilityRegistration( - .alarmRead, - jsName: "ios.alarm.list", - title: "List scheduled alarms", - summary: "List scheduled alarms known to the bridge runtime.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.list({ limit: 20 })", - requiredPermissions: [.alarmKit], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of scheduled alarms returned, default 50.", - ], - resultSummary: "Array of scheduled alarms with identifier/title/timing fields." - ) { args, context in - try alarm.read(arguments: args, context: context) - }, - CapabilityRegistration( - .alarmSchedule, - jsName: "ios.alarm.schedule", - title: "Schedule AlarmKit alarm", - summary: "Schedule an AlarmKit alarm using secondsFromNow or fireDate.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })", - requiredPermissions: [.alarmKit], - requiredArguments: ["title"], - optionalArguments: ["identifier", "secondsFromNow", "fireDate"], - argumentHints: [ - "title": "Alarm title shown in presentation.", - "identifier": "Optional UUID string; generated when omitted.", - "secondsFromNow": "Fallback relative delay in seconds, default 60.", - "fireDate": "Optional absolute ISO8601 date; used when provided.", - ], - resultSummary: "Object with identifier/scheduled/title." - ) { args, context in - try alarm.schedule(arguments: args, context: context) - }, - CapabilityRegistration( - .alarmCancel, - jsName: "ios.alarm.cancel", - title: "Cancel scheduled alarms", - summary: "Cancel one or more scheduled alarms by identifier, or clear all known alarms.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })", - requiredPermissions: [.alarmKit], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single alarm identifier UUID string.", - "identifiers": "Array of alarm identifier UUID strings. Omit both to cancel all known alarms.", - ], - resultSummary: "Object with deleted/count fields." - ) { args, context in - try alarm.cancel(arguments: args, context: context) - }, + CapabilityRegistration(tool: AlarmPermissionRequestTool(alarm: alarm)), + CapabilityRegistration(tool: AlarmReadTool(alarm: alarm)), + CapabilityRegistration(tool: AlarmScheduleTool(alarm: alarm)), + CapabilityRegistration(tool: AlarmCancelTool(alarm: alarm)), ] } func healthRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .healthPermissionRequest, - jsName: "apple.health.requestPermission", - title: "Request HealthKit permission", - summary: "Request HealthKit authorization for requested read/write types.", - tags: ["healthkit", "health", "permission"], - example: "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })", - optionalArguments: ["readTypes", "writeTypes"], - argumentTypes: [ - "readTypes": .array, - "writeTypes": .array, - ], - argumentHints: [ - "readTypes": "Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.", - "writeTypes": "Optional array of type names to write (quantity types only).", - ], - resultSummary: "Object with status/granted fields and requested type arrays." - ) { args, context in - try health.requestPermission(arguments: args, context: context) - }, - CapabilityRegistration( - .healthRead, - jsName: "apple.health.read", - title: "Read HealthKit samples", - summary: "Read HealthKit samples for a supported type and date range.", - tags: ["healthkit", "health", "query"], - example: "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })", - requiredArguments: ["type"], - optionalArguments: ["start", "end", "limit", "unit"], - argumentTypes: [ - "type": .string, - "start": .string, - "end": .string, - "limit": .number, - "unit": .string, - ], - argumentHints: [ - "type": "Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.", - "start": "Optional ISO8601 start timestamp; defaults to last 24h.", - "end": "Optional ISO8601 end timestamp; defaults to now.", - "limit": "Max number of samples, default 50.", - "unit": "Optional unit override for quantity types (count, bpm, kcal, kg, m).", - ], - resultSummary: "Array of samples with identifier/type/value and timing metadata." - ) { args, context in - try health.read(arguments: args, context: context) - }, - CapabilityRegistration( - .healthWrite, - jsName: "apple.health.write", - title: "Write HealthKit quantity sample", - summary: "Write a HealthKit quantity sample for supported writable quantity types.", - tags: ["healthkit", "health", "write"], - example: "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })", - requiredArguments: ["type", "value"], - optionalArguments: ["unit", "start", "end"], - argumentTypes: [ - "type": .string, - "value": .number, - "unit": .string, - "start": .string, - "end": .string, - ], - argumentHints: [ - "type": "Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.", - "value": "Numeric sample value.", - "unit": "Optional unit (count, bpm, kcal, kg, m).", - "start": "Optional ISO8601 start timestamp; defaults to now.", - "end": "Optional ISO8601 end timestamp; defaults to start.", - ], - resultSummary: "Object with identifier/type/value/unit and written=true." - ) { args, context in - try health.write(arguments: args, context: context) - }, + CapabilityRegistration(tool: HealthPermissionRequestTool(health: health)), + CapabilityRegistration(tool: HealthReadTool(health: health)), + CapabilityRegistration(tool: HealthWriteTool(health: health)), ] } func homeRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .homeRead, - jsName: "apple.home.list", - title: "Read HomeKit graph", - summary: "Read homes/accessories/services (and optional characteristics) from HomeKit.", - tags: ["homekit", "iot", "devices"], - example: "await apple.home.list({ includeCharacteristics: true, limit: 5 })", - requiredPermissions: [.homeKit], - optionalArguments: ["includeCharacteristics", "limit"], - argumentHints: [ - "includeCharacteristics": "Boolean; include characteristic details when true.", - "limit": "Max number of homes to return, default 10.", - ], - resultSummary: "Array of homes with accessories/services snapshot." - ) { args, context in - try home.read(arguments: args, context: context) - }, - CapabilityRegistration( - .homeWrite, - jsName: "apple.home.writeCharacteristic", - title: "Write HomeKit characteristic", - summary: "Write a value to a writable HomeKit characteristic for a target accessory.", - tags: ["homekit", "iot", "devices", "control"], - example: "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })", - requiredPermissions: [.homeKit], - requiredArguments: ["accessoryIdentifier", "characteristicType", "value"], - optionalArguments: ["serviceType"], - argumentTypes: [ - "accessoryIdentifier": .string, - "characteristicType": .string, - "value": .any, - "serviceType": .string, - ], - argumentHints: [ - "accessoryIdentifier": "Accessory UUID string from home.read output.", - "characteristicType": "Characteristic type identifier (e.g. HMCharacteristicTypePowerState).", - "value": "Target value; string/number/bool/null.", - "serviceType": "Optional service type filter for characteristic lookup.", - ], - resultSummary: "Object with accessoryIdentifier/characteristicType/written." - ) { args, context in - try home.write(arguments: args, context: context) - }, + CapabilityRegistration(tool: HomeReadTool(home: home)), + CapabilityRegistration(tool: HomeWriteTool(home: home)), ] } func mediaRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - .mediaMetadataRead, - jsName: "apple.media.metadata", - title: "Read media metadata", - summary: "Read duration and track metadata from media files.", - tags: ["media", "avfoundation", "metadata"], - example: "await apple.media.metadata({ path: 'tmp:video.mov' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "Sandbox path like tmp:clip.mov.", - ], - resultSummary: "Object with path/durationSeconds/tracks." - ) { args, context in - try media.metadata(arguments: args, context: context) - }, - CapabilityRegistration( - .mediaFrameExtract, - jsName: "apple.media.extractFrame", - title: "Extract video frame", - summary: "Extract frame at time offset and persist JPEG output.", - tags: ["media", "avfoundation", "thumbnail"], - example: "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })", - requiredArguments: ["path"], - optionalArguments: ["timeMs", "outputPath"], - argumentHints: [ - "path": "Input video sandbox path.", - "timeMs": "Frame timestamp in milliseconds; default 0.", - "outputPath": "Optional output sandbox path; defaults to tmp-generated JPEG.", - ], - resultSummary: "Object with output path and artifactID." - ) { args, context in - try media.extractFrame(arguments: args, context: context) - }, - CapabilityRegistration( - .mediaTranscode, - jsName: "apple.media.transcode", - title: "Transcode media", - summary: "Transcode media into MP4 with preset quality.", - tags: ["media", "avfoundation", "transcode"], - example: "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })", - requiredArguments: ["path"], - optionalArguments: ["outputPath", "preset"], - argumentHints: [ - "path": "Input media sandbox path.", - "outputPath": "Optional output sandbox path; defaults to tmp-generated mp4.", - "preset": "AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality.", - ], - resultSummary: "Object with output path/artifactID/preset." - ) { args, context in - try media.transcode(arguments: args, context: context) - }, + CapabilityRegistration(tool: MediaMetadataReadTool(media: media)), + CapabilityRegistration(tool: MediaFrameExtractTool(media: media)), + CapabilityRegistration(tool: MediaTranscodeTool(media: media)), ] } } diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift index d8bfdc1..3b862fb 100644 --- a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift @@ -3,391 +3,18 @@ import Foundation extension DefaultCapabilityRegistrationBuilder { func interactionUIRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - jsNames: ["apple.share.present"], - descriptor: .init( - id: .shareUIPresent, - title: "Present share sheet", - summary: "Present system share sheet for text, URLs, and sandbox file artifacts.", - tags: ["share", "export", "system-ui"], - example: "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })", - optionalArguments: ["text", "url", "path", "paths", "subject", "excludedActivityTypes", "timeoutMs"], - argumentTypes: [ - "text": .string, - "url": .string, - "path": .string, - "paths": .array, - "subject": .string, - "excludedActivityTypes": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "text": "Optional text item to share.", - "url": "Optional absolute HTTP(S) URL to share.", - "path": "Optional single sandbox file path to share.", - "paths": "Optional array of sandbox file paths to share.", - "subject": "Optional subject for services that support it.", - "excludedActivityTypes": "Optional array of UIActivity.ActivityType raw value strings to hide.", - "timeoutMs": "Optional timeout for waiting on share completion.", - ], - resultSummary: "Object with completed/activityType/action." - ), - handler: { args, context in - try systemUI.presentShareSheet(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.quicklook.preview"], - descriptor: .init( - id: .quickLookUIPreview, - title: "Preview files with Quick Look", - summary: "Present Quick Look preview UI for one or more sandbox file artifacts.", - tags: ["quicklook", "preview", "documents", "system-ui"], - example: "await apple.quicklook.preview({ path: 'tmp:report.pdf' })", - optionalArguments: ["path", "paths", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to preview.", - "paths": "Optional array of sandbox file paths to preview.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed and count." - ), - handler: { args, context in - try systemUI.previewQuickLook(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.camera.capture"], - descriptor: .init( - id: .cameraUICapture, - title: "Capture photo or video with camera UI", - summary: "Present system camera UI and export captured media into the sandbox artifact store.", - tags: ["camera", "capture", "photos", "system-ui", "artifact"], - example: "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })", - optionalArguments: [ - "mediaType", - "outputDirectory", - "timeoutMs", - "allowsEditing", - "cameraDevice", - "flashMode", - "videoQuality", - "maximumDurationSeconds", - ], - argumentTypes: [ - "mediaType": .string, - "outputDirectory": .string, - "timeoutMs": .number, - "allowsEditing": .bool, - "cameraDevice": .string, - "flashMode": .string, - "videoQuality": .string, - "maximumDurationSeconds": .number, - ], - argumentHints: [ - "mediaType": "any (default), image/photo, or video.", - "outputDirectory": "Optional sandbox directory for captured media; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on capture/export.", - "allowsEditing": "Whether the system editor is shown before returning media; default false.", - "cameraDevice": "rear (default) or front.", - "flashMode": "auto (default), on, or off.", - "videoQuality": "UIImagePickerController quality name such as high, medium, low, 640x480, iFrame1280x720, or iFrame960x540.", - "maximumDurationSeconds": "Optional maximum duration for video capture.", - ], - resultSummary: "Object with path/artifactID/mediaType/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.captureCamera(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.camera.scanData"], - descriptor: .init( - id: .cameraUIScanData, - title: "Scan text or barcodes with camera UI", - summary: "Present VisionKit live data scanner UI and return recognized text or barcode payloads.", - tags: ["camera", "scan", "barcode", "text", "visionkit", "system-ui"], - example: "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })", - optionalArguments: [ - "mode", - "recognizedDataTypes", - "languages", - "qualityLevel", - "recognizesMultipleItems", - "returnsOnFirstResult", - "isGuidanceEnabled", - "isHighlightingEnabled", - "isPinchToZoomEnabled", - "isHighFrameRateTrackingEnabled", - "timeoutMs", - ], - argumentTypes: [ - "mode": .string, - "recognizedDataTypes": .array, - "languages": .array, - "qualityLevel": .string, - "recognizesMultipleItems": .bool, - "returnsOnFirstResult": .bool, - "isGuidanceEnabled": .bool, - "isHighlightingEnabled": .bool, - "isPinchToZoomEnabled": .bool, - "isHighFrameRateTrackingEnabled": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "mode": "any (default), text, or barcode.", - "recognizedDataTypes": "Optional array containing text and/or barcode; overrides mode.", - "languages": "Optional text recognition language identifiers.", - "qualityLevel": "balanced (default), fast, or accurate.", - "recognizesMultipleItems": "Whether the scanner tracks multiple items at once; default false.", - "returnsOnFirstResult": "Whether to dismiss as soon as data is recognized; default true.", - "isGuidanceEnabled": "Whether VisionKit guidance UI is shown; default true.", - "isHighlightingEnabled": "Whether recognized items are highlighted; default true.", - "isPinchToZoomEnabled": "Whether pinch-to-zoom is enabled; default true.", - "isHighFrameRateTrackingEnabled": "Whether high-frame-rate tracking is enabled; default true.", - "timeoutMs": "Optional timeout for waiting on a scan result or cancellation.", - ], - resultSummary: "Object with action and items containing text transcripts or barcode payloads." - ), - handler: { args, context in - try systemUI.scanData(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.mail.compose"], - descriptor: .init( - id: .mailUICompose, - title: "Compose mail with system UI", - summary: "Present system mail compose UI with optional recipients, body, and sandbox file attachments.", - tags: ["mail", "compose", "system-ui", "share"], - example: "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })", - optionalArguments: ["to", "cc", "bcc", "subject", "body", "isHTML", "attachments", "timeoutMs"], - argumentTypes: [ - "to": .array, - "cc": .array, - "bcc": .array, - "subject": .string, - "body": .string, - "isHTML": .bool, - "attachments": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "to": "Optional array of recipient email strings.", - "cc": "Optional array of CC email strings.", - "bcc": "Optional array of BCC email strings.", - "subject": "Optional subject.", - "body": "Optional message body.", - "isHTML": "Whether body should be treated as HTML; default false.", - "attachments": "Optional array of { path, mimeType?, filename? } sandbox file attachments.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action sent/saved/cancelled/failed." - ), - handler: { args, context in - try systemUI.composeMail(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.messages.compose"], - descriptor: .init( - id: .messagesUICompose, - title: "Compose message with system UI", - summary: "Present system Messages compose UI with optional recipients, body, and sandbox file attachments.", - tags: ["messages", "sms", "compose", "system-ui", "share"], - example: "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })", - optionalArguments: ["recipients", "subject", "body", "attachments", "timeoutMs"], - argumentTypes: [ - "recipients": .array, - "subject": .string, - "body": .string, - "attachments": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "recipients": "Optional array of phone number or address strings.", - "subject": "Optional subject on devices/accounts that support it.", - "body": "Optional message body.", - "attachments": "Optional array of { path, filename? } sandbox file attachments.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action sent/cancelled/failed." - ), - handler: { args, context in - try systemUI.composeMessage(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.print.present"], - descriptor: .init( - id: .printUIPresent, - title: "Present print UI", - summary: "Present the system print sheet for one or more sandbox files.", - tags: ["print", "documents", "system-ui", "export"], - example: "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })", - optionalArguments: ["path", "paths", "jobName", "outputType", "showsNumberOfCopies", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "jobName": .string, - "outputType": .string, - "showsNumberOfCopies": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to print.", - "paths": "Optional array of sandbox file paths to print.", - "jobName": "Optional print job name.", - "outputType": "general (default), photo, or grayscale.", - "showsNumberOfCopies": "Whether copy count controls are shown; default true.", - "timeoutMs": "Optional timeout for waiting on print completion/cancellation.", - ], - resultSummary: "Object with action/completed for the print interaction." - ), - handler: { args, context in - try systemUI.presentPrint(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.web.present"], - descriptor: .init( - id: .webUIPresent, - title: "Present web page with system UI", - summary: "Present an HTTP(S) URL with the system Safari view controller.", - tags: ["web", "safari", "browser", "system-ui"], - example: "await apple.web.present({ url: 'https://example.com' })", - requiredArguments: ["url"], - optionalArguments: ["entersReaderIfAvailable", "timeoutMs"], - argumentTypes: [ - "url": .string, - "entersReaderIfAvailable": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "url": "Absolute HTTP(S) URL to present.", - "entersReaderIfAvailable": "Whether Safari may enter Reader automatically; default false.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed." - ), - handler: { args, context in - try systemUI.presentWeb(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.auth.webAuthenticate"], - descriptor: .init( - id: .authUIWebAuthenticate, - title: "Authenticate with system web UI", - summary: "Start an ASWebAuthenticationSession for OAuth-style browser authentication.", - tags: ["auth", "oauth", "web", "browser", "system-ui"], - example: "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })", - requiredArguments: ["url"], - optionalArguments: ["callbackURLScheme", "prefersEphemeralSession", "timeoutMs"], - argumentTypes: [ - "url": .string, - "callbackURLScheme": .string, - "prefersEphemeralSession": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "url": "Absolute HTTP(S) authentication URL.", - "callbackURLScheme": "Optional custom URL scheme that completes the session.", - "prefersEphemeralSession": "Whether to prefer a private browser session; default false.", - "timeoutMs": "Optional timeout for waiting on callback/cancellation.", - ], - resultSummary: "Object with action callback/cancelled and callbackURL when available." - ), - handler: { args, context in - try systemUI.authenticateWeb(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.ui.presentAlert"], - descriptor: .init( - id: .uiAlertPresent, - title: "Present alert with custom buttons", - summary: "Present a system alert or action sheet and return the button the user selects.", - tags: ["ui", "alert", "dialog", "system-ui"], - example: "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })", - requiredArguments: ["buttons"], - optionalArguments: ["title", "message", "preferredStyle", "sourceRect", "timeoutMs"], - argumentTypes: [ - "title": .string, - "message": .string, - "preferredStyle": .string, - "buttons": .array, - "sourceRect": .object, - "timeoutMs": .number, - ], - argumentHints: [ - "title": "Optional alert title.", - "message": "Optional alert message.", - "preferredStyle": "alert (default) or actionSheet.", - "sourceRect": "Optional { x, y, width, height } anchor for action sheets.", - "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Object with action/buttonID/buttonTitle/buttonIndex/style for the selected button." - ), - handler: { args, context in - try systemUI.presentAlert(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.ui.presentPrompt"], - descriptor: .init( - id: .uiPromptPresent, - title: "Present prompt with text fields", - summary: "Present a system alert with one or more text fields and custom buttons.", - tags: ["ui", "alert", "prompt", "input", "system-ui"], - example: "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })", - requiredArguments: ["fields", "buttons"], - optionalArguments: ["title", "message", "timeoutMs"], - argumentTypes: [ - "title": .string, - "message": .string, - "fields": .array, - "buttons": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "fields": "Array of { id?, placeholder?, text?/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", - "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Object with selected button metadata and values keyed by field id." - ), - handler: { args, context in - try systemUI.presentPrompt(arguments: args, context: context) - } - ), - CapabilityRegistration( - jsNames: ["apple.settings.open"], - descriptor: .init( - id: .settingsUIOpen, - title: "Open app settings", - summary: "Open the host app's Settings page so the user can recover denied permissions.", - tags: ["settings", "permissions", "system-ui"], - example: "await apple.settings.open()", - optionalArguments: ["timeoutMs"], - argumentTypes: [ - "timeoutMs": .number, - ], - argumentHints: [ - "timeoutMs": "Optional timeout for waiting on UIApplication.open completion.", - ], - resultSummary: "Object with action opened/failed and opened boolean." - ), - handler: { args, context in - try systemUI.openSettings(arguments: args, context: context) - } - ), + CapabilityRegistration(tool: ShareUIPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: QuickLookUIPreviewTool(systemUI: systemUI)), + CapabilityRegistration(tool: CameraUICaptureTool(systemUI: systemUI)), + CapabilityRegistration(tool: CameraUIScanDataTool(systemUI: systemUI)), + CapabilityRegistration(tool: MailUIComposeTool(systemUI: systemUI)), + CapabilityRegistration(tool: MessagesUIComposeTool(systemUI: systemUI)), + CapabilityRegistration(tool: PrintUIPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: WebUIPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: AuthUIWebAuthenticateTool(systemUI: systemUI)), + CapabilityRegistration(tool: UIAlertPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: UIPromptPresentTool(systemUI: systemUI)), + CapabilityRegistration(tool: SettingsUIOpenTool(systemUI: systemUI)), ] } } diff --git a/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift b/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift new file mode 100644 index 0000000..c670cb6 --- /dev/null +++ b/Sources/CodeMode/Bridges/CloudPushSpeechCodeModeTools.swift @@ -0,0 +1,393 @@ +import Foundation + +/// Constrained CloudKit database selector; shared by the four database ops. +enum CloudKitDatabase: String, CodeModeStringEnum { + case `private` + case shared + case `public` +} + +// MARK: - CloudKit + +@BuiltInCodeMode(.cloudKitAccountStatus, path: "apple.cloudkit.getAccountStatus") +struct CloudKitAccountStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read CloudKit account status" + static let codeModeSummary = "Read iCloud account availability for CloudKit-backed agent state." + static let codeModeTags = ["cloudkit", "icloud", "sync", "account"] + static let codeModeExample = "await apple.cloudkit.getAccountStatus({ containerIdentifier: 'iCloud.com.example.app' })" + static let codeModeResultSummary = "Object with status/accountAvailable and containerIdentifier when available." + + struct Arguments: Sendable { + @ToolParam("Optional iCloud container identifier; defaults to the host client's configured container.") + var containerIdentifier: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.accountStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitRecordsQuery, path: "apple.cloudkit.queryRecords") +struct CloudKitRecordsQueryTool: BuiltInCodeModeTool { + static let codeModeTitle = "Query CloudKit records" + static let codeModeSummary = "Query records from a private/shared/public CloudKit database for synced agent state." + static let codeModeTags = ["cloudkit", "icloud", "database", "query"] + static let codeModeExample = "await apple.cloudkit.queryRecords({ database: 'private', recordType: 'Task', limit: 20 })" + static let codeModeResultSummary = "Array of records with recordName/recordType/fields/modifiedAt/database." + + struct Arguments: Sendable { + @ToolParam("CloudKit record type to query.") + var recordType: String + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + @ToolParam("Host-supported predicate object or string; keep predicates bounded and repairable.") + var predicate: JSONValue? + @ToolParam("Optional array of sort descriptor objects.") + var sortDescriptors: [JSONValue]? + @ToolParam("Optional array of field keys to return.") + var desiredKeys: [String]? + @ToolParam("Max records to return; default is host-defined.") + var limit: Int? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.queryRecords(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitRecordSave, path: "apple.cloudkit.saveRecord") +struct CloudKitRecordSaveTool: BuiltInCodeModeTool { + static let codeModeTitle = "Save CloudKit record" + static let codeModeSummary = "Create or update a CloudKit record in a private/shared/public database." + static let codeModeTags = ["cloudkit", "icloud", "database", "write"] + static let codeModeExample = "await apple.cloudkit.saveRecord({ database: 'private', recordType: 'Task', recordName: 'task-1', fields: { title: 'Review' } })" + static let codeModeResultSummary = "Saved record object with recordName/recordType/fields/changeTag/database." + + struct Arguments: Sendable { + @ToolParam("CloudKit record type to create or update.") + var recordType: String + @ToolParam("JSON object mapped by the host client into supported CloudKit field values.") + var fields: [String: JSONValue] + @ToolParam("Optional CloudKit recordName; omitted means create a new record.") + var recordName: String? + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + @ToolParam("Host-supported save policy such as changedKeys or allKeys.") + var savePolicy: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.saveRecord(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitRecordDelete, path: "apple.cloudkit.deleteRecord") +struct CloudKitRecordDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete CloudKit record" + static let codeModeSummary = "Delete a CloudKit record by recordName from a configured database." + static let codeModeTags = ["cloudkit", "icloud", "database", "delete"] + static let codeModeExample = "await apple.cloudkit.deleteRecord({ database: 'private', recordName: 'task-1' })" + static let codeModeResultSummary = "Object with recordName/deleted/database." + + struct Arguments: Sendable { + @ToolParam("CloudKit recordName to delete.") + var recordName: String + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.deleteRecord(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitSubscriptionSave, path: "apple.cloudkit.subscribe") +struct CloudKitSubscriptionSaveTool: BuiltInCodeModeTool { + static let codeModeTitle = "Save CloudKit subscription" + static let codeModeSummary = "Register a bounded CloudKit query subscription so the host can enqueue subscription events later." + static let codeModeTags = ["cloudkit", "icloud", "subscription", "inbox"] + static let codeModeExample = "await apple.cloudkit.subscribe({ subscriptionID: 'tasks', database: 'private', recordType: 'Task' })" + static let codeModeResultSummary = "Object with subscriptionID/saved/database." + + struct Arguments: Sendable { + @ToolParam("Stable host-visible subscription identifier.") + var subscriptionID: String + @ToolParam("CloudKit record type to observe.") + var recordType: String + @ToolParam("private (default), shared, or public.") + var database: CloudKitDatabase? + @ToolParam("Optional iCloud container identifier.") + var containerIdentifier: String? + @ToolParam("Optional custom zone identifier.") + var zoneID: String? + @ToolParam("Host-supported predicate object or string.") + var predicate: JSONValue? + @ToolParam("Whether creation events are enqueued; default true.") + var firesOnRecordCreation: Bool? + @ToolParam("Whether update events are enqueued; default true.") + var firesOnRecordUpdate: Bool? + @ToolParam("Whether delete events are enqueued; default true.") + var firesOnRecordDeletion: Bool? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.saveSubscription(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.cloudKitSubscriptionEventsRead, path: "apple.cloudkit.listEvents") +struct CloudKitSubscriptionEventsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read CloudKit subscription inbox" + static let codeModeSummary = "Read bounded CloudKit subscription events previously received by the host." + static let codeModeTags = ["cloudkit", "icloud", "subscription", "inbox", "events"] + static let codeModeExample = "await apple.cloudkit.listEvents({ subscriptionID: 'tasks', limit: 20 })" + static let codeModeResultSummary = "Array of subscription event objects with cursor/subscriptionID/recordName/reason/database." + + struct Arguments: Sendable { + @ToolParam("Optional subscription filter.") + var subscriptionID: String? + @ToolParam("Maximum number of inbox events to read.") + var limit: Int? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let cloudKit: CloudKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try cloudKit.readSubscriptionEvents(arguments: arguments.raw) + } +} + +// MARK: - Remote notifications + +@BuiltInCodeMode(.notificationsRemoteRegister, path: "apple.notifications.registerRemote") +struct NotificationsRemoteRegisterTool: BuiltInCodeModeTool { + static let codeModeTitle = "Register for remote notifications" + static let codeModeSummary = "Ask the host app to register with APNs and record the client device-token lifecycle." + static let codeModeTags = ["notifications", "apns", "remote", "registration"] + static let codeModeExample = "await apple.notifications.registerRemote()" + static let codeModeResultSummary = "Object with registered/status and deviceToken when already available." + + struct Arguments: Sendable { + @ToolParam("Optional host-supported notification types; APNs provider sending is intentionally out of scope.") + var types: [String]? + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.register(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsRemoteTokenRead, path: "apple.notifications.getRemoteToken") +struct NotificationsRemoteTokenReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read APNs device token" + static let codeModeSummary = "Read the latest APNs device token captured by the host app." + static let codeModeTags = ["notifications", "apns", "remote", "token"] + static let codeModeExample = "await apple.notifications.getRemoteToken()" + static let codeModeResultSummary = "Object with token/environment/updatedAt or null token when registration has not completed." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.readToken(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsSettingsRead, path: "apple.notifications.getSettings") +struct NotificationsSettingsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read notification settings" + static let codeModeSummary = "Read UserNotifications/APNs client settings exposed by the host." + static let codeModeTags = ["notifications", "apns", "settings", "permission"] + static let codeModeExample = "await apple.notifications.getSettings()" + static let codeModeResultSummary = "Object with authorizationStatus/alert/badge/sound/criticalAlert/providesAppNotificationSettings." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.readSettings(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsCategoriesSet, path: "apple.notifications.setCategories") +struct NotificationsCategoriesSetTool: BuiltInCodeModeTool { + static let codeModeTitle = "Set notification categories" + static let codeModeSummary = "Register host-approved notification categories and actions for push/local response handling." + static let codeModeTags = ["notifications", "apns", "categories", "actions"] + static let codeModeExample = "await apple.notifications.setCategories({ categories: [{ identifier: 'task', actions: [{ identifier: 'done', title: 'Done' }] }] })" + static let codeModeResultSummary = "Object with registered category identifiers." + + struct Arguments: Sendable { + @ToolParam("Array of category definitions with identifier/actions/options approved by the host.") + var categories: [JSONValue] + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.setCategories(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.notificationsResponsesRead, path: "apple.notifications.listResponses") +struct NotificationsResponsesReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read notification response inbox" + static let codeModeSummary = "Read bounded notification action/open responses captured by the host for later agent handling." + static let codeModeTags = ["notifications", "apns", "response", "inbox", "events"] + static let codeModeExample = "await apple.notifications.listResponses({ limit: 20 })" + static let codeModeResultSummary = "Array of response events with cursor/identifier/actionIdentifier/categoryIdentifier/userText/userInfo/date." + + struct Arguments: Sendable { + @ToolParam("Maximum number of response events to read.") + var limit: Int? + @ToolParam("Optional category filter.") + var categoryIdentifier: String? + @ToolParam("Optional action filter.") + var actionIdentifier: String? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let remoteNotifications: RemoteNotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try remoteNotifications.readResponses(arguments: arguments.raw) + } +} + +// MARK: - Speech + +@BuiltInCodeMode(.speechPermissionRequest, path: "apple.speech.requestPermission") +struct SpeechPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request speech recognition permission" + static let codeModeSummary = "Trigger the Speech recognition permission prompt." + static let codeModeTags = ["speech", "permission", "transcription"] + static let codeModeExample = "await apple.speech.requestPermission()" + static let codeModeResultSummary = "Object with status/granted." + + struct Arguments: Sendable {} + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.speechStatus, path: "apple.speech.getStatus") +struct SpeechStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read speech recognition permission status" + static let codeModeSummary = "Read Speech recognition permission status without starting capture." + static let codeModeTags = ["speech", "permission", "transcription"] + static let codeModeExample = "await apple.speech.getStatus()" + static let codeModeResultSummary = "Object with status/granted." + + struct Arguments: Sendable {} + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.status(context: context) + } +} + +@BuiltInCodeMode(.speechFileTranscribe, path: "apple.speech.transcribeFile") +struct SpeechFileTranscribeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Transcribe audio file" + static let codeModeSummary = "Transcribe a sandbox audio file through the host Speech adapter." + static let codeModeTags = ["speech", "transcription", "audio"] + static let codeModeExample = "await apple.speech.transcribeFile({ path: 'tmp:meeting.m4a', locale: 'en-US', timeoutMs: 60000 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.speechRecognition] + static let codeModeResultSummary = "Object with transcript/segments/locale/isFinal/durationSeconds." + + struct Arguments: Sendable { + @ToolParam("Sandbox audio file path.") + var path: String + @ToolParam("BCP-47 locale identifier such as en-US.") + var locale: String? + @ToolParam("Maximum transcription wait time in milliseconds.") + var timeoutMs: Double? + @ToolParam("Whether to require on-device recognition when the host supports it.") + var requiresOnDeviceRecognition: Bool? + @ToolParam("Speech task hint such as dictation, search, or confirmation.") + var taskHint: String? + var raw: [String: JSONValue] + } + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.transcribeFile(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.speechMicrophoneTranscribe, path: "apple.speech.transcribeMicrophone") +struct SpeechMicrophoneTranscribeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Transcribe microphone audio" + static let codeModeSummary = "Run live microphone transcription through a host-mediated session with an explicit timeout." + static let codeModeTags = ["speech", "transcription", "audio", "microphone"] + static let codeModeExample = "await apple.speech.transcribeMicrophone({ locale: 'en-US', timeoutMs: 15000 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.speechRecognition, .microphone] + static let codeModeResultSummary = "Object with transcript/segments/locale/isFinal/timedOut." + + struct Arguments: Sendable { + @ToolParam("BCP-47 locale identifier such as en-US.") + var locale: String? + @ToolParam("Maximum microphone capture/transcription time in milliseconds.") + var timeoutMs: Double? + @ToolParam("Whether to require on-device recognition when the host supports it.") + var requiresOnDeviceRecognition: Bool? + @ToolParam("Speech task hint such as dictation, search, or confirmation.") + var taskHint: String? + @ToolParam("Whether partial transcripts may be returned.") + var partialResults: Bool? + var raw: [String: JSONValue] + } + + let speech: SpeechBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try speech.transcribeMicrophone(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift b/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift new file mode 100644 index 0000000..deb7c4a --- /dev/null +++ b/Sources/CodeMode/Bridges/CommerceCodeModeTools.swift @@ -0,0 +1,440 @@ +import Foundation + +/// Constrained playback action; host MusicClient stays authoritative for +/// semantics, so only the advertised value set is enforced here. +enum MusicPlaybackAction: String, CodeModeStringEnum { + case play + case pause + case stop + case skipToNext + case skipToPrevious + case playCatalog + case playLibrary +} + +// MARK: - Music + +@BuiltInCodeMode(.musicPermissionRequest, path: "apple.music.requestPermission") +struct MusicPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request Music permission" + static let codeModeSummary = "Request Apple Music/media-library permission before library or playback actions." + static let codeModeTags = ["music", "musickit", "permission"] + static let codeModeExample = "await apple.music.requestPermission()" + static let codeModeResultSummary = "Object with status/granted." + + struct Arguments: Sendable {} + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.musicSubscriptionStatus, path: "apple.music.getSubscriptionStatus") +struct MusicSubscriptionStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Music subscription status" + static let codeModeSummary = "Read MusicKit subscription/capability status exposed by the host." + static let codeModeTags = ["music", "musickit", "subscription", "catalog"] + static let codeModeExample = "await apple.music.getSubscriptionStatus()" + static let codeModeResultSummary = "Object with canPlayCatalogContent/hasCloudLibraryEnabled/status." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.subscriptionStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.musicCatalogSearch, path: "apple.music.search") +struct MusicCatalogSearchTool: BuiltInCodeModeTool { + static let codeModeTitle = "Search Music catalog" + static let codeModeSummary = "Search the Apple Music catalog for songs, albums, artists, playlists, or stations." + static let codeModeTags = ["music", "musickit", "catalog", "search"] + static let codeModeExample = "await apple.music.search({ term: 'Miles Davis', types: ['artists', 'albums'], limit: 5 })" + static let codeModeResultSummary = "Object grouped by result type with catalog identifiers and metadata." + + struct Arguments: Sendable { + @ToolParam("Catalog search term.") + var term: String + @ToolParam("Optional array such as songs, albums, artists, playlists, stations.") + var types: [String]? + @ToolParam("Maximum results.") + var limit: Int? + @ToolParam("Optional storefront country code.") + var countryCode: String? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.searchCatalog(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.musicCatalogDetails, path: "apple.music.getDetails") +struct MusicCatalogDetailsTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Music catalog details" + static let codeModeSummary = "Read details for a catalog item identifier through MusicKit." + static let codeModeTags = ["music", "musickit", "catalog", "details"] + static let codeModeExample = "await apple.music.getDetails({ identifier: '123', type: 'albums' })" + static let codeModeResultSummary = "Catalog item details object." + + struct Arguments: Sendable { + @ToolParam("Music catalog item identifier.") + var identifier: String + @ToolParam("Catalog type such as songs, albums, artists, playlists, or stations.") + var type: String? + @ToolParam("Optional storefront country code.") + var countryCode: String? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.catalogDetails(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.musicLibraryRead, path: "apple.music.readLibrary") +struct MusicLibraryReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Music library" + static let codeModeSummary = "Read the user's Music library playlists or items after Music permission is granted." + static let codeModeTags = ["music", "musickit", "library"] + static let codeModeExample = "await apple.music.readLibrary({ type: 'playlists', limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.music] + static let codeModeResultSummary = "Array of library items with identifiers and metadata." + + struct Arguments: Sendable { + @ToolParam("Library type such as playlists, songs, albums, or artists.") + var type: String? + @ToolParam("Maximum library items.") + var limit: Int? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.readLibrary(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.musicPlaylistWrite, path: "apple.music.writePlaylist") +struct MusicPlaylistWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write Music playlist" + static let codeModeSummary = "Create or update a user-library playlist through a host MusicKit adapter." + static let codeModeTags = ["music", "musickit", "library", "playlist", "write"] + static let codeModeExample = "await apple.music.writePlaylist({ name: 'Focus', catalogIDs: ['song-id'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.music] + static let codeModeResultSummary = "Object with playlistID/name/written." + + struct Arguments: Sendable { + // `name` is absent from the argument-type inference table, so its + // historical advertised type is `any` — declared as JSONValue to match. + @ToolParam("Playlist name.") + var name: JSONValue + @ToolParam("Optional existing playlist identifier to update.") + var playlistID: String? + @ToolParam("Optional catalog song identifiers to add.") + var catalogIDs: [String]? + @ToolParam("Optional library song identifiers to add.") + var libraryIDs: [String]? + @ToolParam("Optional playlist description.") + var description: String? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.writePlaylist(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.musicPlaybackControl, path: "apple.music.play") +struct MusicPlaybackControlTool: BuiltInCodeModeTool { + static let codeModeTitle = "Control Music playback" + static let codeModeSummary = "Control Music playback queue through a user-authorized host adapter." + static let codeModeTags = ["music", "musickit", "playback", "queue"] + static let codeModeExample = "await apple.music.play({ action: 'playCatalog', catalogID: 'song-id' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.music] + static let codeModeResultSummary = "Object with action/status/currentItem when available." + + struct Arguments: Sendable { + @ToolParam("Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.") + var action: MusicPlaybackAction + @ToolParam("Optional catalog item identifier.") + var catalogID: String? + @ToolParam("Optional library item identifier.") + var libraryID: String? + @ToolParam("Optional queue definition approved by the host adapter.") + var queue: [String: JSONValue]? + @ToolParam("Whether playback should begin immediately; host-adapter defined.") + var startPlaying: Bool? + var raw: [String: JSONValue] + } + + let music: MusicBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try music.controlPlayback(arguments: arguments.raw, context: context) + } +} + +// MARK: - PassKit / Wallet + +@BuiltInCodeMode(.passKitWalletStatus, path: "apple.wallet.getStatus") +struct PassKitWalletStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Wallet status" + static let codeModeSummary = "Read PassKit Wallet availability and pass-library access status." + static let codeModeTags = ["passkit", "wallet", "passes"] + static let codeModeExample = "await apple.wallet.getStatus()" + static let codeModeResultSummary = "Object with available/canAddPasses/canPresentPasses." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.walletStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitPassesRead, path: "apple.wallet.listPasses") +struct PassKitPassesReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List Wallet passes" + static let codeModeSummary = "List Wallet passes accessible to the host app through PassKit." + static let codeModeTags = ["passkit", "wallet", "passes", "read"] + static let codeModeExample = "await apple.wallet.listPasses({ limit: 20 })" + static let codeModeResultSummary = "Array of passes with passTypeIdentifier/serialNumber/organizationName/description." + + struct Arguments: Sendable { + @ToolParam("Optional pass type filter.") + var passTypeIdentifier: String? + @ToolParam("Optional serial number filter.") + var serialNumber: String? + @ToolParam("Maximum accessible passes.") + var limit: Int? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.listPasses(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitPassAdd, path: "apple.wallet.addPass") +struct PassKitPassAddTool: BuiltInCodeModeTool { + static let codeModeTitle = "Add Wallet pass" + static let codeModeSummary = "Present user-mediated UI to add a sandbox .pkpass file to Wallet." + static let codeModeTags = ["passkit", "wallet", "passes", "add", "system-ui"] + static let codeModeExample = "await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' })" + static let codeModeResultSummary = "Object with action/added/passTypeIdentifier/serialNumber." + + struct Arguments: Sendable { + @ToolParam("Sandbox path to a .pkpass file.") + var path: String + @ToolParam("Optional timeout for user-mediated add-pass UI.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.addPass(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.passKitPassPresent, path: "apple.wallet.presentPass") +struct PassKitPassPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present Wallet pass" + static let codeModeSummary = "Present user-mediated details for an accessible Wallet pass." + static let codeModeTags = ["passkit", "wallet", "passes", "system-ui"] + static let codeModeExample = "await apple.wallet.presentPass({ identifier: 'pass-id' })" + static let codeModeResultSummary = "Object with action/presented/identifier." + + struct Arguments: Sendable { + @ToolParam("Accessible Wallet pass identifier from apple.wallet.listPasses.") + var identifier: String + @ToolParam("Optional timeout for user-mediated pass presentation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.presentPass(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitApplePayStatus, path: "apple.wallet.canMakePayments") +struct PassKitApplePayStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Apple Pay status" + static let codeModeSummary = "Read Apple Pay capability using host merchant configuration; no merchant setup is accepted from JavaScript." + static let codeModeTags = ["passkit", "apple-pay", "payments", "safety"] + static let codeModeExample = "await apple.wallet.canMakePayments()" + static let codeModeResultSummary = "Object with canMakePayments/canMakePaymentsUsingNetworks." + + struct Arguments: Sendable { + @ToolParam("Optional supported payment networks to test against host merchant configuration.") + var networks: [String]? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.applePayStatus(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.passKitApplePayPresent, path: "apple.wallet.presentPayment") +struct PassKitApplePayPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present Apple Pay request" + static let codeModeSummary = "Present a host-defined Apple Pay payment request using host merchant configuration after explicit user-visible confirmation." + static let codeModeTags = ["passkit", "apple-pay", "payments", "system-ui", "safety"] + static let codeModeExample = "await apple.wallet.presentPayment({ paymentRequestID: 'checkout-123', confirmed: true })" + static let codeModeResultSummary = "Object with action/authorized/paymentRequestID/status." + + struct Arguments: Sendable { + @ToolParam("Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript.") + var paymentRequestID: String + @ToolParam("Must be true after explicit user-visible confirmation before presenting Apple Pay.") + var confirmed: Bool + @ToolParam("Optional timeout for user-mediated Apple Pay UI.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let passKit: PassKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try passKit.presentApplePay(arguments: arguments.raw) + } +} + +// MARK: - StoreKit + +@BuiltInCodeMode(.storeKitProductsRead, path: "apple.storekit.listProducts") +struct StoreKitProductsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read StoreKit products" + static let codeModeSummary = "Read StoreKit product metadata for host-configured product identifiers." + static let codeModeTags = ["storekit", "commerce", "products"] + static let codeModeExample = "await apple.storekit.listProducts({ productIDs: ['pro.monthly'] })" + static let codeModeResultSummary = "Array of products with id/displayName/description/price/type." + + struct Arguments: Sendable { + @ToolParam("Array of host-configured StoreKit product identifiers.") + var productIDs: [String] + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.products(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitEntitlementsRead, path: "apple.storekit.listEntitlements") +struct StoreKitEntitlementsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read StoreKit entitlements" + static let codeModeSummary = "Read current StoreKit entitlements and verified transaction state." + static let codeModeTags = ["storekit", "commerce", "entitlements"] + static let codeModeExample = "await apple.storekit.listEntitlements()" + static let codeModeResultSummary = "Array of current entitlements with productID/transactionID/expirationDate." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.currentEntitlements(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitPurchase, path: "apple.storekit.purchase") +struct StoreKitPurchaseTool: BuiltInCodeModeTool { + static let codeModeTitle = "Purchase StoreKit product" + static let codeModeSummary = "Present StoreKit purchase UI for a host-configured product after explicit user-visible confirmation." + static let codeModeTags = ["storekit", "commerce", "purchase", "safety"] + static let codeModeExample = "await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true })" + static let codeModeResultSummary = "Object with status/productID/transactionID when completed." + + struct Arguments: Sendable { + @ToolParam("Host-configured StoreKit product identifier.") + var productID: String + @ToolParam("Must be true after explicit user-visible confirmation before purchase UI is presented.") + var confirmed: Bool + @ToolParam("Optional UUID string for StoreKit appAccountToken.") + var appAccountToken: String? + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.purchase(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitRestore, path: "apple.storekit.restore") +struct StoreKitRestoreTool: BuiltInCodeModeTool { + static let codeModeTitle = "Restore StoreKit purchases" + static let codeModeSummary = "Trigger StoreKit restore/sync after explicit user-visible confirmation." + static let codeModeTags = ["storekit", "commerce", "restore", "safety"] + static let codeModeExample = "await apple.storekit.restore({ confirmed: true })" + static let codeModeResultSummary = "Object with restored/status." + + struct Arguments: Sendable { + @ToolParam("Must be true after explicit user-visible confirmation before restore starts.") + var confirmed: Bool + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.restore(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.storeKitTransactionsRead, path: "apple.storekit.listTransactions") +struct StoreKitTransactionsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read StoreKit transaction inbox" + static let codeModeSummary = "Read bounded transaction updates captured by the host StoreKit adapter." + static let codeModeTags = ["storekit", "commerce", "transactions", "inbox", "events"] + static let codeModeExample = "await apple.storekit.listTransactions({ limit: 20 })" + static let codeModeResultSummary = "Array of transaction events with cursor/productID/transactionID/status/date." + + struct Arguments: Sendable { + @ToolParam("Maximum transaction updates to return.") + var limit: Int? + @ToolParam("Optional product filter.") + var productID: String? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let storeKit: StoreKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try storeKit.transactionUpdates(arguments: arguments.raw) + } +} diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index 2ff5130..96eb81e 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -3,6 +3,7 @@ import Foundation public enum DefaultCapabilityLoader { public static func loadAllRegistrations( fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), + networkAccessPolicy: NetworkAccessPolicy = .standard, eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox(), cloudKitClient: any CloudKitClient = UnavailableCloudKitClient(), remoteNotificationsClient: any RemoteNotificationsClient = UnavailableRemoteNotificationsClient(), @@ -17,6 +18,7 @@ public enum DefaultCapabilityLoader { ) -> [CapabilityRegistration] { DefaultCapabilityRegistrationBuilder( fileSystem: fileSystem, + networkAccessPolicy: networkAccessPolicy, eventInbox: eventInbox, cloudKitClient: cloudKitClient, remoteNotificationsClient: remoteNotificationsClient, @@ -62,6 +64,7 @@ struct DefaultCapabilityRegistrationBuilder { init( fileSystem: any CodeModeFileSystem, + networkAccessPolicy: NetworkAccessPolicy, eventInbox: any CodeModeEventInbox, cloudKitClient: any CloudKitClient, remoteNotificationsClient: any RemoteNotificationsClient, @@ -75,7 +78,7 @@ struct DefaultCapabilityRegistrationBuilder { storeKitClient: any StoreKitClient ) { self.fs = FileSystemBridge(fileSystem: fileSystem) - self.network = NetworkBridge() + self.network = NetworkBridge(policy: networkAccessPolicy) self.keychain = KeychainBridge() self.location = LocationBridge() self.weather = WeatherBridge() diff --git a/Sources/CodeMode/Bridges/EventKitBridge.swift b/Sources/CodeMode/Bridges/EventKitBridge.swift index b393005..279c31b 100644 --- a/Sources/CodeMode/Bridges/EventKitBridge.swift +++ b/Sources/CodeMode/Bridges/EventKitBridge.swift @@ -38,8 +38,8 @@ public final class EventKitBridge: @unchecked Sendable { } public func writeEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let operation = eventOperation(arguments) - let permission: PermissionKind = operation == "update" ? .calendar : .calendarWriteOnly + let operation = try eventOperation(arguments) + let permission: PermissionKind = operation == .update ? .calendar : .calendarWriteOnly let status = resolvePermission(permission, context: context) guard status == .granted else { throw BridgeError.permissionDenied(permission) @@ -47,12 +47,10 @@ public final class EventKitBridge: @unchecked Sendable { #if canImport(EventKit) switch operation { - case "create": + case .create: return try createEvent(arguments: arguments) - case "update": + case .update: return try updateEvent(arguments: arguments) - default: - throw BridgeError.invalidArguments("calendar.write operation must be create or update") } #else _ = arguments @@ -146,13 +144,11 @@ public final class EventKitBridge: @unchecked Sendable { } #if canImport(EventKit) - switch reminderOperation(arguments) { - case "create": + switch try reminderOperation(arguments) { + case .create: return try createReminder(arguments: arguments) - case "update", "complete": + case .update, .complete: return try updateReminder(arguments: arguments) - default: - throw BridgeError.invalidArguments("reminders.write operation must be create, update, or complete") } #else _ = arguments @@ -199,18 +195,24 @@ public final class EventKitBridge: @unchecked Sendable { return ISO8601DateFormatter().date(from: text) } - private func eventOperation(_ arguments: [String: JSONValue]) -> String { - if let operation = arguments.string("operation")?.lowercased() { + private func eventOperation(_ arguments: [String: JSONValue]) throws -> CalendarWriteOperation { + if let text = arguments.string("operation") { + guard let operation = CalendarWriteOperation.codeModeValue(matching: text) else { + throw BridgeError.invalidArguments("calendar.write operation must be one of \(CalendarWriteOperation.codeModeAllowedValues.joined(separator: ", "))") + } return operation } - return arguments.string("identifier") == nil ? "create" : "update" + return arguments.string("identifier") == nil ? .create : .update } - private func reminderOperation(_ arguments: [String: JSONValue]) -> String { - if let operation = arguments.string("operation")?.lowercased() { + private func reminderOperation(_ arguments: [String: JSONValue]) throws -> ReminderWriteOperation { + if let text = arguments.string("operation") { + guard let operation = ReminderWriteOperation.codeModeValue(matching: text) else { + throw BridgeError.invalidArguments("reminders.write operation must be one of \(ReminderWriteOperation.codeModeAllowedValues.joined(separator: ", "))") + } return operation } - return arguments.string("identifier") == nil ? "create" : "update" + return arguments.string("identifier") == nil ? .create : .update } #if canImport(EventKit) @@ -469,14 +471,13 @@ public final class EventKitBridge: @unchecked Sendable { } private func eventSpan(_ text: String?) throws -> EKSpan { - switch text?.lowercased() ?? "thisevent" { - case "thisevent", "this_event", "this": + guard let text, text.isEmpty == false else { return .thisEvent - case "futureevents", "future_events", "future": - return .futureEvents - default: - throw BridgeError.invalidArguments("calendar.delete span must be thisEvent or futureEvents") } + guard let span = CalendarEventSpan.codeModeValue(matching: text) else { + throw BridgeError.invalidArguments("calendar.delete span must be one of \(CalendarEventSpan.codeModeAllowedValues.joined(separator: ", "))") + } + return span.ekSpan } private static func eventJSON(_ event: EKEvent) -> JSONValue { diff --git a/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift new file mode 100644 index 0000000..788106e --- /dev/null +++ b/Sources/CodeMode/Bridges/EventKitCodeModeTools.swift @@ -0,0 +1,327 @@ +import Foundation +#if canImport(EventKit) +import EventKit +#endif + +// MARK: - Constrained argument values +// +// These enums are the single source of truth for their arguments: the catalog +// advertises `codeModeAllowedValues`, tool decode and the bridges parse through +// `codeModeValue(matching:)`, so advertised and accepted spellings cannot drift. + +enum CalendarEventSpan: String, CodeModeStringEnum { + case thisEvent + case futureEvents + + static let codeModeAliases: [String: Self] = [ + "this_event": .thisEvent, + "this": .thisEvent, + "future_events": .futureEvents, + "future": .futureEvents, + ] + + #if canImport(EventKit) + var ekSpan: EKSpan { + switch self { + case .thisEvent: + return .thisEvent + case .futureEvents: + return .futureEvents + } + } + #endif +} + +enum CalendarWriteOperation: String, CodeModeStringEnum { + case create + case update +} + +enum ReminderWriteOperation: String, CodeModeStringEnum { + case create + case update + case complete +} + +enum CalendarPickerSelectionStyle: String, CodeModeStringEnum { + case single + case multiple +} + +enum CalendarPickerDisplayStyle: String, CodeModeStringEnum { + case writable + case all +} + +// MARK: - Calendar tools + +@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.") + var calendarIdentifier: String? + @ToolParam("Optional array of EventKit calendarIdentifier strings to restrict results.") + var calendarIdentifiers: [String]? + var raw: [String: JSONValue] + } + + let eventKit: EventKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.readEvents(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.calendarWrite, path: "apple.calendar.createEvent", aliases: ["apple.calendar.updateEvent"]) +struct CalendarWriteEventTool: BuiltInCodeModeTool { + static let codeModeTitle = "Create or update calendar event" + static let codeModeSummary = "Create a calendar event or patch an existing event by identifier." + static let codeModeTags = ["calendar", "eventkit", "schedule"] + static let codeModeExample = "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })" + static let codeModeResultSummary = "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." + + struct Arguments: Sendable { + @ToolParam("create (default without identifier) or update (default with identifier).") + var operation: CalendarWriteOperation? + @ToolParam("EventKit eventIdentifier to update. Updates require full calendar access at runtime.") + var identifier: String? + @ToolParam("Event title string.") + var title: String? + @ToolParam("ISO8601 start timestamp.") + var start: String? + @ToolParam("ISO8601 end timestamp.") + var end: String? + @ToolParam("Optional notes/body string.") + var notes: String? + @ToolParam("Optional location string.") + var location: String? + @ToolParam("Optional absolute URL string attached to the event.") + var url: String? + @ToolParam("Whether the event is all-day.") + var isAllDay: Bool? + @ToolParam("Optional destination EventKit calendarIdentifier.") + var calendarIdentifier: String? + var raw: [String: JSONValue] + } + + let eventKit: EventKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.writeEvent(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.calendarDelete, path: "apple.calendar.deleteEvent") +struct CalendarDeleteEventTool: BuiltInCodeModeTool { + 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] + } + + let eventKit: EventKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.deleteEvent(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.calendarUIPickCalendar, path: "apple.calendar.pickCalendar") +struct CalendarPickCalendarTool: BuiltInCodeModeTool { + static let codeModeTitle = "Pick calendar with system UI" + static let codeModeSummary = "Present EventKit calendar chooser UI and return the user-selected writable calendars." + static let codeModeTags = ["calendar", "eventkit", "system-ui", "picker"] + static let codeModeExample = "await apple.calendar.pickCalendar({ selectionStyle: 'single' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] + static let codeModeResultSummary = "Array of selected calendars with identifier/title/type/allowsContentModifications." + + struct Arguments: Sendable { + @ToolParam("single (default) or multiple.") + var selectionStyle: CalendarPickerSelectionStyle? + @ToolParam("writable (default) or all.") + var displayStyle: CalendarPickerDisplayStyle? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickCalendar(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.calendarUIPresentEvent, path: "apple.calendar.presentEvent") +struct CalendarPresentEventTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present calendar event details" + static let codeModeSummary = "Present system UI for an existing calendar event identifier." + static let codeModeTags = ["calendar", "eventkit", "system-ui", "details"] + static let codeModeExample = "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendar] + static let codeModeResultSummary = "Object with action dismissed." + + struct Arguments: Sendable { + @ToolParam("EventKit eventIdentifier from apple.calendar.listEvents.") + var identifier: String + @ToolParam("Whether the user can edit from the detail UI; default false.") + var allowsEditing: Bool? + @ToolParam("Whether the UI may show calendar day previews; default true.") + var allowsCalendarPreview: Bool? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentCalendarEvent(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.calendarUIPresentNewEvent, path: "apple.calendar.presentNewEvent") +struct CalendarPresentNewEventTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present calendar event editor" + static let codeModeSummary = "Present system UI to let the user create or edit a new calendar event draft." + static let codeModeTags = ["calendar", "eventkit", "system-ui", "picker"] + static let codeModeExample = "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.calendarWriteOnly] + static let codeModeResultSummary = "Object with action plus identifier/title when the user saves." + + struct Arguments: Sendable { + @ToolParam("Optional event title shown in the editor.") + var title: String? + @ToolParam("Optional ISO8601 start timestamp.") + var start: String? + @ToolParam("Optional ISO8601 end timestamp.") + var end: String? + @ToolParam("Optional event notes/body text.") + var notes: String? + @ToolParam("Optional location string.") + var location: String? + @ToolParam("Optional timeout for waiting on user save/cancel.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentNewCalendarEvent(arguments: arguments.raw, context: context) + } +} + +// MARK: - Reminder tools + +@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.") + var calendarIdentifier: String? + @ToolParam("Optional array of EventKit reminder calendarIdentifier strings to restrict results.") + var calendarIdentifiers: [String]? + @ToolParam("Max number of reminder items, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let eventKit: EventKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.readReminders(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.remindersWrite, path: "apple.reminders.createReminder", aliases: ["apple.reminders.updateReminder", "apple.reminders.completeReminder"]) +struct RemindersWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Create or update reminder" + static let codeModeSummary = "Create a reminder or patch an existing reminder by identifier." + static let codeModeTags = ["reminders", "eventkit", "task"] + static let codeModeExample = "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.reminders] + static let 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.") + var notes: String? + @ToolParam("Completion state for update or completeReminder; default true for completeReminder.") + var isCompleted: Bool? + @ToolParam("EventKit reminder priority integer.") + var priority: Int? + @ToolParam("Optional destination EventKit reminders calendarIdentifier.") + var calendarIdentifier: String? + var raw: [String: JSONValue] + } + + let eventKit: EventKitBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try eventKit.writeReminder(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.remindersDelete, path: "apple.reminders.deleteReminder") +struct RemindersDeleteTool: BuiltInCodeModeTool { + 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 -> JSONValue { + try eventKit.deleteReminder(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift b/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift new file mode 100644 index 0000000..650190b --- /dev/null +++ b/Sources/CodeMode/Bridges/FileSystemCodeModeTools.swift @@ -0,0 +1,228 @@ +import Foundation + +// Filesystem capabilities. Flat arguments, no constrained values, so no +// CodeModeStringEnum is needed here. + +@BuiltInCodeMode(.fsList, path: "apple.fs.list", aliases: ["fs.promises.readdir"]) +struct FSListTool: BuiltInCodeModeTool { + static let codeModeTitle = "List directory" + static let codeModeSummary = "List files/directories within allowed sandbox roots as entry objects." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.list({ path: 'tmp:' })" + static let codeModeResultSummary = "Array of entry objects with name/path/isDirectory/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings." + + struct Arguments: Sendable { + @ToolParam("Directory path using allowed root prefix (tmp:, caches:, documents:).") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.list(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsRead, path: "apple.fs.read", aliases: ["fs.promises.readFile"]) +struct FSReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read file" + static let codeModeSummary = "Read text/base64 file data within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })" + static let codeModeResultSummary = "Object with path plus text or base64 field." + + struct Arguments: Sendable { + @ToolParam("File path using allowed root prefix.") + var path: String + @ToolParam("utf8 (default) or base64.") + var encoding: String? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsWrite, path: "apple.fs.write", aliases: ["fs.promises.writeFile"]) +struct FSWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write file" + static let codeModeSummary = "Write text/base64 file data within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })" + static let codeModeResultSummary = "Object with path and bytesWritten." + + struct Arguments: Sendable { + @ToolParam("File path using allowed root prefix.") + var path: String + @ToolParam("UTF-8 text or base64 string depending on encoding.") + var data: String? + @ToolParam("utf8 (default) or base64.") + var encoding: String? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.write(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsMove, path: "apple.fs.move", aliases: ["fs.promises.rename"]) +struct FSMoveTool: BuiltInCodeModeTool { + static let codeModeTitle = "Move file" + static let codeModeSummary = "Move file/directory within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })" + static let codeModeResultSummary = "Object with from/to resolved paths." + + struct Arguments: Sendable { + @ToolParam("Source sandbox path.") + var from: String + @ToolParam("Destination sandbox path.") + var to: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.move(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsCopy, path: "apple.fs.copy", aliases: ["fs.promises.copyFile"]) +struct FSCopyTool: BuiltInCodeModeTool { + static let codeModeTitle = "Copy file" + static let codeModeSummary = "Copy file/directory within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })" + static let codeModeResultSummary = "Object with from/to resolved paths." + + struct Arguments: Sendable { + @ToolParam("Source sandbox path.") + var from: String + @ToolParam("Destination sandbox path.") + var to: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.copy(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsDelete, path: "apple.fs.delete", aliases: ["fs.promises.rm"]) +struct FSDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete file" + static let codeModeSummary = "Delete file/directory within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.delete({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Object with deleted flag and path." + + struct Arguments: Sendable { + @ToolParam("Path to file or directory.") + var path: String + @ToolParam("Required as true when deleting a directory.") + var recursive: Bool? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.delete(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsStat, path: "apple.fs.stat", aliases: ["fs.promises.stat"]) +struct FSStatTool: BuiltInCodeModeTool { + static let codeModeTitle = "Stat path" + static let codeModeSummary = "Read file metadata within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.stat({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Object with path/isDirectory/size/createdAt/modifiedAt." + + struct Arguments: Sendable { + @ToolParam("File or directory path.") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.stat(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsMkdir, path: "apple.fs.mkdir", aliases: ["fs.promises.mkdir"]) +struct FSMkdirTool: BuiltInCodeModeTool { + static let codeModeTitle = "Create directory" + static let codeModeSummary = "Create directories within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })" + static let codeModeResultSummary = "Object with created flag and path." + + struct Arguments: Sendable { + @ToolParam("Directory path to create.") + var path: String + @ToolParam("Boolean; default true.") + var recursive: Bool? + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.mkdir(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsExists, path: "apple.fs.exists") +struct FSExistsTool: BuiltInCodeModeTool { + static let codeModeTitle = "Check path exists" + static let codeModeSummary = "Check if file/directory exists within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.exists({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Boolean." + + struct Arguments: Sendable { + @ToolParam("File or directory path to check.") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.exists(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.fsAccess, path: "apple.fs.access", aliases: ["fs.promises.access"]) +struct FSAccessTool: BuiltInCodeModeTool { + static let codeModeTitle = "Check path access" + static let codeModeSummary = "Check read/write access for path within allowed sandbox roots." + static let codeModeTags = ["filesystem", "io", "fs"] + static let codeModeExample = "await apple.fs.access({ path: 'tmp:data.json' })" + static let codeModeResultSummary = "Object with readable/writable/path." + + struct Arguments: Sendable { + @ToolParam("File or directory path to inspect.") + var path: String + var raw: [String: JSONValue] + } + + let fs: FileSystemBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try fs.access(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift b/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift new file mode 100644 index 0000000..e125b3b --- /dev/null +++ b/Sources/CodeMode/Bridges/IntentsModelsActivityMapsCodeModeTools.swift @@ -0,0 +1,485 @@ +import Foundation + +// MARK: - Constrained argument values + +enum ActivityDismissalPolicy: String, CodeModeStringEnum { + case `default` + case immediate +} + +enum MapsTransportType: String, CodeModeStringEnum { + case automobile + case walking + case transit + case any +} + +// MARK: - App Intents + +@BuiltInCodeMode(.appIntentsList, path: "apple.appIntents.list") +struct AppIntentsListTool: BuiltInCodeModeTool { + static let codeModeTitle = "List host App Intent adapters" + static let codeModeSummary = "List host-registered App Intents or Shortcuts actions exposed to CodeMode." + static let codeModeTags = ["appintents", "shortcuts", "actions", "host-adapter"] + static let codeModeExample = "await apple.appIntents.list()" + static let codeModeResultSummary = "Array of actions with identifier/title/summary/requiredParameters." + + struct Arguments: Sendable { + @ToolParam("Optional host-defined action domain filter.") + var domain: String? + @ToolParam("Maximum actions to return.") + var limit: Int? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.listActions(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsRun, path: "apple.appIntents.run") +struct AppIntentsRunTool: BuiltInCodeModeTool { + static let codeModeTitle = "Run host App Intent adapter" + static let codeModeSummary = "Run a host-registered App Intent adapter with structured parameters." + static let codeModeTags = ["appintents", "shortcuts", "actions", "host-adapter"] + static let codeModeExample = "await apple.appIntents.run({ identifier: 'createNote', parameters: { title: 'Draft' } })" + static let codeModeResultSummary = "Adapter-defined structured result." + + struct Arguments: Sendable { + @ToolParam("Host-registered action identifier from apple.appIntents.list.") + var identifier: String + @ToolParam("Structured parameters validated by the host adapter.") + var parameters: [String: JSONValue]? + @ToolParam("Maximum wait time in milliseconds.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.runAction(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsDonate, path: "apple.appIntents.donate") +struct AppIntentsDonateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Donate host App Intent action" + static let codeModeSummary = "Ask the host to donate a supported action to Shortcuts/Siri suggestions where feasible." + static let codeModeTags = ["appintents", "shortcuts", "donation", "host-adapter"] + static let codeModeExample = "await apple.appIntents.donate({ identifier: 'createNote', parameters: { title: 'Draft' } })" + static let codeModeResultSummary = "Object with donated/status." + + struct Arguments: Sendable { + @ToolParam("Host-registered action identifier.") + var identifier: String + @ToolParam("Structured action parameters used for the donation.") + var parameters: [String: JSONValue]? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.donateAction(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsOpen, path: "apple.appIntents.open") +struct AppIntentsOpenTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open host App Intent surface" + static let codeModeSummary = "Open a host-provided App Intent, Shortcuts, or app surface for user-mediated continuation." + static let codeModeTags = ["appintents", "shortcuts", "open", "host-adapter"] + static let codeModeExample = "await apple.appIntents.open({ identifier: 'showTask', parameters: { id: 'task-1' } })" + static let codeModeResultSummary = "Object with opened/status." + + struct Arguments: Sendable { + @ToolParam("Host-registered open action identifier.") + var identifier: String + @ToolParam("Structured parameters for the host open action.") + var parameters: [String: JSONValue]? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.openAction(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.appIntentsHandoffsRead, path: "apple.appIntents.listHandoffs") +struct AppIntentsHandoffsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read App Intent handoff inbox" + static let codeModeSummary = "Read bounded App Intent handoff events captured by the host app." + static let codeModeTags = ["appintents", "shortcuts", "handoff", "inbox", "events"] + static let codeModeExample = "await apple.appIntents.listHandoffs({ limit: 20 })" + static let codeModeResultSummary = "Array of handoff events with cursor/identifier/parameters/date/source." + + struct Arguments: Sendable { + @ToolParam("Optional action identifier filter.") + var identifier: String? + @ToolParam("Maximum handoff events to return.") + var limit: Int? + @ToolParam("Optional host-provided cursor for incremental reads.") + var afterCursor: String? + var raw: [String: JSONValue] + } + + let appIntents: AppIntentsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try appIntents.readHandoffs(arguments: arguments.raw) + } +} + +// MARK: - Foundation Models + +@BuiltInCodeMode(.foundationModelsStatus, path: "apple.foundationModels.getStatus") +struct FoundationModelsStatusTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Foundation Models availability" + static let codeModeSummary = "Read host Foundation Models availability/status before attempting local generation." + static let codeModeTags = ["foundationmodels", "apple-intelligence", "llm", "availability"] + static let codeModeExample = "await apple.foundationModels.getStatus()" + static let codeModeResultSummary = "Object with available/status/reason/modelIdentifier when available." + + struct Arguments: Sendable { + var raw: [String: JSONValue] + } + + let foundationModels: FoundationModelsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try foundationModels.status(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.foundationModelsGenerate, path: "apple.foundationModels.generate") +struct FoundationModelsGenerateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Generate text with Foundation Models" + static let codeModeSummary = "Generate text locally through the host Foundation Models adapter when available." + static let codeModeTags = ["foundationmodels", "apple-intelligence", "llm", "generation"] + static let codeModeExample = "await apple.foundationModels.generate({ prompt: 'Summarize this note', maxTokens: 200 })" + static let codeModeResultSummary = "Object with text/finishReason/usage when available." + + struct Arguments: Sendable { + // `prompt` is absent from the argument-type inference table, so its + // historical advertised type is `any` — declared as JSONValue to match. + @ToolParam("Prompt text sent to the host Foundation Models session.") + var prompt: JSONValue + @ToolParam("Optional host-approved system instructions.") + var instructions: String? + @ToolParam("Optional sampling temperature when supported.") + var temperature: Double? + @ToolParam("Optional maximum output tokens.") + var maxTokens: Int? + @ToolParam("Optional host-defined output schema identifier.") + var schemaIdentifier: String? + @ToolParam("Maximum generation wait time in milliseconds.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let foundationModels: FoundationModelsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try foundationModels.generateText(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.foundationModelsExtract, path: "apple.foundationModels.extract") +struct FoundationModelsExtractTool: BuiltInCodeModeTool { + static let codeModeTitle = "Extract structured data with Foundation Models" + static let codeModeSummary = "Run host-defined structured extraction or classification with Foundation Models." + static let codeModeTags = ["foundationmodels", "apple-intelligence", "llm", "structured-output"] + static let codeModeExample = "await apple.foundationModels.extract({ input: text, schemaIdentifier: 'todo' })" + static let codeModeResultSummary = "Object with values/classification/confidence/schemaIdentifier." + + struct Arguments: Sendable { + @ToolParam("Input text to classify or extract from.") + var input: String + @ToolParam("Optional JSON schema object accepted by the host adapter.") + var schema: [String: JSONValue]? + @ToolParam("Preferred host-defined schema identifier.") + var schemaIdentifier: String? + @ToolParam("Optional task-specific instructions.") + var instructions: String? + @ToolParam("Maximum extraction wait time in milliseconds.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let foundationModels: FoundationModelsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try foundationModels.extract(arguments: arguments.raw) + } +} + +// MARK: - Live Activities + +@BuiltInCodeMode(.activityList, path: "apple.activity.list") +struct ActivityListTool: BuiltInCodeModeTool { + static let codeModeTitle = "List Live Activities" + static let codeModeSummary = "List host-registered ActivityKit Live Activities visible to CodeMode." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.list()" + static let codeModeResultSummary = "Array of activities with identifier/activityType/state/contentState/attributes." + + struct Arguments: Sendable { + @ToolParam("Optional host-registered activity adapter type filter.") + var activityType: String? + @ToolParam("Maximum activities to return.") + var limit: Int? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.listActivities(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityStart, path: "apple.activity.start") +struct ActivityStartTool: BuiltInCodeModeTool { + static let codeModeTitle = "Start Live Activity" + static let codeModeSummary = "Start a host-registered ActivityKit Live Activity adapter." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.start({ activityType: 'delivery', attributes: {}, contentState: {} })" + static let codeModeResultSummary = "Object with identifier/activityType/state/pushToken when available." + + struct Arguments: Sendable { + @ToolParam("Host-registered activity adapter type.") + var activityType: String + @ToolParam("Adapter-defined immutable attributes.") + var attributes: [String: JSONValue] + @ToolParam("Adapter-defined mutable content state.") + var contentState: [String: JSONValue]? + @ToolParam("Optional push token request mode when the adapter supports remote updates.") + var pushType: String? + @ToolParam("Optional ISO8601 stale date.") + var staleDate: String? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.startActivity(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityUpdate, path: "apple.activity.update") +struct ActivityUpdateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Update Live Activity" + static let codeModeSummary = "Update an existing host-registered Live Activity content state." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.update({ identifier: 'activity-1', contentState: { progress: 0.7 } })" + static let codeModeResultSummary = "Object with identifier/updated/state." + + struct Arguments: Sendable { + @ToolParam("Live Activity identifier from apple.activity.list/start.") + var identifier: String + @ToolParam("Adapter-defined mutable content state.") + var contentState: [String: JSONValue] + @ToolParam("Optional alert configuration object for the update.") + var alert: [String: JSONValue]? + @ToolParam("Optional ISO8601 stale date.") + var staleDate: String? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.updateActivity(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityEnd, path: "apple.activity.end") +struct ActivityEndTool: BuiltInCodeModeTool { + static let codeModeTitle = "End Live Activity" + static let codeModeSummary = "End a host-registered Live Activity, optionally with final content state." + static let codeModeTags = ["activitykit", "live-activities", "host-adapter"] + static let codeModeExample = "await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'immediate' })" + static let codeModeResultSummary = "Object with identifier/ended/state." + + struct Arguments: Sendable { + @ToolParam("Live Activity identifier from apple.activity.list/start.") + var identifier: String + @ToolParam("Optional final adapter-defined content state.") + var contentState: [String: JSONValue]? + @ToolParam("default (default) or immediate.") + var dismissalPolicy: ActivityDismissalPolicy? + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.endActivity(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.activityPushTokenRead, path: "apple.activity.getPushToken") +struct ActivityPushTokenReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read Live Activity push token" + static let codeModeSummary = "Read a Live Activity push token when the host adapter supports push updates." + static let codeModeTags = ["activitykit", "live-activities", "push-token"] + static let codeModeExample = "await apple.activity.getPushToken({ identifier: 'activity-1' })" + static let codeModeResultSummary = "Object with identifier/pushToken/environment." + + struct Arguments: Sendable { + @ToolParam("Live Activity identifier from apple.activity.list/start.") + var identifier: String + var raw: [String: JSONValue] + } + + let activity: ActivityBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try activity.readPushToken(arguments: arguments.raw) + } +} + +// MARK: - Maps + +@BuiltInCodeMode(.mapsGeocode, path: "apple.maps.geocode") +struct MapsGeocodeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Geocode address" + static let codeModeSummary = "Resolve an address or place text to coordinate candidates through MapKit." + static let codeModeTags = ["maps", "mapkit", "geocode", "location"] + static let codeModeExample = "await apple.maps.geocode({ address: '1 Infinite Loop, Cupertino', limit: 3 })" + static let codeModeResultSummary = "Array of placemarks with name/address/latitude/longitude." + + struct Arguments: Sendable { + @ToolParam("Address or place text to geocode.") + var address: String + @ToolParam("Optional search bias region object.") + var region: [String: JSONValue]? + @ToolParam("Maximum coordinate candidates.") + var limit: Int? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.geocode(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsReverseGeocode, path: "apple.maps.reverseGeocode") +struct MapsReverseGeocodeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Reverse geocode coordinates" + static let codeModeSummary = "Resolve latitude/longitude into address candidates through MapKit." + static let codeModeTags = ["maps", "mapkit", "reverse-geocode", "location"] + static let codeModeExample = "await apple.maps.reverseGeocode({ latitude: 37.3318, longitude: -122.0312 })" + static let codeModeResultSummary = "Array of placemarks with name/address/latitude/longitude." + + struct Arguments: Sendable { + @ToolParam("Latitude in decimal degrees.") + var latitude: Double + @ToolParam("Longitude in decimal degrees.") + var longitude: Double + @ToolParam("Optional BCP-47 locale identifier for result localization.") + var locale: String? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.reverseGeocode(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsSearch, path: "apple.maps.search") +struct MapsSearchTool: BuiltInCodeModeTool { + static let codeModeTitle = "Search local map items" + static let codeModeSummary = "Search for local businesses, addresses, or points of interest through MapKit." + static let codeModeTags = ["maps", "mapkit", "local-search", "places"] + static let codeModeExample = "await apple.maps.search({ query: 'coffee near me', limit: 5 })" + static let codeModeResultSummary = "Array of map items with name/address/category/latitude/longitude/url." + + struct Arguments: Sendable { + @ToolParam("Search query string.") + var query: String + @ToolParam("Optional coordinate region object for local bias.") + var region: [String: JSONValue]? + @ToolParam("Optional host-supported result type filters.") + var resultTypes: [String]? + @ToolParam("Maximum map items.") + var limit: Int? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.search(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsRouteEstimate, path: "apple.maps.routeEstimate") +struct MapsRouteEstimateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Estimate route" + static let codeModeSummary = "Estimate route distance and travel time between two coordinates or map items." + static let codeModeTags = ["maps", "mapkit", "directions", "route"] + static let codeModeExample = "await apple.maps.routeEstimate({ origin: { latitude: 37.33, longitude: -122.03 }, destination: { latitude: 37.77, longitude: -122.42 }, transportType: 'automobile' })" + static let codeModeResultSummary = "Object with distanceMeters/expectedTravelTimeSeconds/transportType." + + struct Arguments: Sendable { + @ToolParam("Coordinate or map item object.") + var origin: [String: JSONValue] + @ToolParam("Coordinate or map item object.") + var destination: [String: JSONValue] + @ToolParam("automobile (default), walking, or transit when supported.") + var transportType: MapsTransportType? + @ToolParam("Optional ISO8601 departure date.") + var departureDate: String? + @ToolParam("Optional ISO8601 arrival date.") + var arrivalDate: String? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.routeEstimate(arguments: arguments.raw) + } +} + +@BuiltInCodeMode(.mapsOpen, path: "apple.maps.open") +struct MapsOpenTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open Maps" + static let codeModeSummary = "Open Apple Maps with coordinates, a query, URL, or directions in a user-mediated handoff." + static let codeModeTags = ["maps", "mapkit", "open", "directions"] + static let codeModeExample = "await apple.maps.open({ query: 'Apple Park' })" + static let codeModeResultSummary = "Object with opened/target." + + struct Arguments: Sendable { + @ToolParam("Place/search query to open.") + var query: String? + @ToolParam("Optional maps URL to open.") + var url: String? + @ToolParam("Latitude when opening coordinates.") + var latitude: Double? + @ToolParam("Longitude when opening coordinates.") + var longitude: Double? + @ToolParam("Optional destination object for directions.") + var destination: [String: JSONValue]? + @ToolParam("automobile (default), walking, transit, or any.") + var transportType: MapsTransportType? + var raw: [String: JSONValue] + } + + let maps: MapsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try maps.open(arguments: arguments.raw) + } +} diff --git a/Sources/CodeMode/Bridges/NetworkBridge.swift b/Sources/CodeMode/Bridges/NetworkBridge.swift index 8f9ae00..ef33703 100644 --- a/Sources/CodeMode/Bridges/NetworkBridge.swift +++ b/Sources/CodeMode/Bridges/NetworkBridge.swift @@ -4,9 +4,11 @@ public final class NetworkBridge: @unchecked Sendable { private static let defaultTimeoutMs = 30_000 private let session: URLSession + private let policy: NetworkAccessPolicy - public init(session: URLSession = .shared) { + public init(session: URLSession = .shared, policy: NetworkAccessPolicy = .standard) { self.session = session + self.policy = policy } public func fetch(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { @@ -19,6 +21,14 @@ public final class NetworkBridge: @unchecked Sendable { throw BridgeError.invalidArguments("network.fetch requires an absolute HTTP(S) 'url'") } + if let reason = policy.violationReason(for: url) { + context.auditLogger.log(AuditEvent( + capability: CapabilityID.networkFetch.rawValue, + message: "denied \(urlString): \(reason)" + )) + throw BridgeError.networkPolicyViolation(reason) + } + let options = arguments.object("options") ?? [:] let timeoutMs = options.int("timeoutMs") ?? Self.defaultTimeoutMs guard timeoutMs > 0 else { @@ -57,57 +67,163 @@ public final class NetworkBridge: @unchecked Sendable { request.httpBody = data } - let semaphore = DispatchSemaphore(value: 0) - let resultBox = SynchronizedBox?>(nil) - - let task = session.dataTask(with: request) { data, urlResponse, error in - if let error { - resultBox.set(.failure(error)) - } else { - resultBox.set(.success((data, urlResponse))) - } - semaphore.signal() - } + let handler = FetchTaskHandler(policy: policy) + let task = session.dataTask(with: request) + task.delegate = handler task.resume() - guard semaphore.wait(timeout: .now() + TimeInterval(timeoutMs) / 1_000) == .success else { + guard handler.semaphore.wait(timeout: .now() + TimeInterval(timeoutMs) / 1_000) == .success else { task.cancel() throw BridgeError.timeout(milliseconds: timeoutMs) } - guard let result = resultBox.get() else { - throw BridgeError.nativeFailure("network.fetch finished without result") + let outcome = handler.outcome() + + if let reason = outcome.violationReason { + context.auditLogger.log(AuditEvent( + capability: CapabilityID.networkFetch.rawValue, + message: "denied \(urlString): \(reason)" + )) + throw BridgeError.networkPolicyViolation(reason) } - switch result { - case let .failure(error): + if let error = outcome.error { throw BridgeError.nativeFailure("network.fetch failed: \(error.localizedDescription)") - case let .success((responseData, response)): - guard let httpResponse = response as? HTTPURLResponse else { - throw BridgeError.nativeFailure("network.fetch received non-HTTP response") - } + } - let responseData = responseData ?? Data() - let headers = httpResponse.allHeaderFields.reduce(into: [String: JSONValue]()) { partial, pair in - partial[String(describing: pair.key)] = .string(String(describing: pair.value)) - } + guard let httpResponse = outcome.response else { + throw BridgeError.nativeFailure("network.fetch received non-HTTP response") + } - context.log(.info, message: "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)") - - var object: [String: JSONValue] = [ - "ok": .bool((200...299).contains(httpResponse.statusCode)), - "status": .number(Double(httpResponse.statusCode)), - "statusText": .string(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)), - "headers": .object(headers), - ] - if responseEncoding == "base64" { - object["bodyBase64"] = .string(responseData.base64EncodedString()) - object["bodyText"] = .string("") - } else { - object["bodyText"] = .string(String(data: responseData, encoding: .utf8) ?? "") - } + let responseData = outcome.data + let headers = httpResponse.allHeaderFields.reduce(into: [String: JSONValue]()) { partial, pair in + partial[String(describing: pair.key)] = .string(String(describing: pair.value)) + } + + let summary = "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)" + context.log(.info, message: summary) + context.auditLogger.log(AuditEvent( + capability: CapabilityID.networkFetch.rawValue, + message: summary + )) + + var object: [String: JSONValue] = [ + "ok": .bool((200...299).contains(httpResponse.statusCode)), + "status": .number(Double(httpResponse.statusCode)), + "statusText": .string(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)), + "headers": .object(headers), + ] + if responseEncoding == "base64" { + object["bodyBase64"] = .string(responseData.base64EncodedString()) + object["bodyText"] = .string("") + } else { + object["bodyText"] = .string(String(data: responseData, encoding: .utf8) ?? "") + } + + return .object(object) + } +} + +/// Per-task delegate that enforces the network access policy while the +/// transfer is in flight: redirect targets are re-validated before they are +/// followed, and the response body is cancelled as soon as it exceeds the +/// policy's size cap instead of being buffered whole. +private final class FetchTaskHandler: NSObject, URLSessionDataDelegate, @unchecked Sendable { + struct Outcome { + var response: HTTPURLResponse? + var data: Data + var error: Error? + var violationReason: String? + } + + let semaphore = DispatchSemaphore(value: 0) + + private let policy: NetworkAccessPolicy + private let lock = NSLock() + private var response: HTTPURLResponse? + private var data = Data() + private var completionError: Error? + private var violationReason: String? + + init(policy: NetworkAccessPolicy) { + self.policy = policy + } + + func outcome() -> Outcome { + lock.lock() + defer { lock.unlock() } + return Outcome( + response: response, + data: data, + error: completionError, + violationReason: violationReason + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + if let url = request.url, let reason = policy.violationReason(for: url) { + setViolation("redirect to \(url.absoluteString) refused: \(reason)") + completionHandler(nil) + return + } + completionHandler(request) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + if response.expectedContentLength > 0, response.expectedContentLength > Int64(policy.maxResponseBytes) { + setViolation("response of \(response.expectedContentLength) bytes exceeds the \(policy.maxResponseBytes)-byte network access policy limit") + completionHandler(.cancel) + return + } + + lock.lock() + self.response = response as? HTTPURLResponse + // Content-Length is known and within the cap here: reserve once instead + // of growing the buffer through repeated reallocations as chunks arrive. + if response.expectedContentLength > 0 { + data.reserveCapacity(Int(response.expectedContentLength)) + } + lock.unlock() + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive chunk: Data) { + lock.lock() + data.append(chunk) + let overLimit = data.count > policy.maxResponseBytes + lock.unlock() + + if overLimit { + setViolation("response exceeded the \(policy.maxResponseBytes)-byte network access policy limit") + dataTask.cancel() + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + lock.lock() + if completionError == nil { + completionError = error + } + lock.unlock() + semaphore.signal() + } - return .object(object) + private func setViolation(_ reason: String) { + lock.lock() + if violationReason == nil { + violationReason = reason } + lock.unlock() } } diff --git a/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift b/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift new file mode 100644 index 0000000..e51e78b --- /dev/null +++ b/Sources/CodeMode/Bridges/PeoplePhotosDocumentsCodeModeTools.swift @@ -0,0 +1,349 @@ +import Foundation + +// Constrained argument value for the contacts picker; MediaTypeFilter (shared +// with camera/photos) lives in SystemUICodeModeTools.swift. +enum ContactPickerMode: String, CodeModeStringEnum { + case single + case multiple +} + +// MARK: - Contacts + +@BuiltInCodeMode(.contactsRead, path: "apple.contacts.list") +struct ContactsReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read contacts" + static let codeModeSummary = "Read contacts with bounded fields." + static let codeModeTags = ["contacts", "address-book", "people"] + static let codeModeExample = "await apple.contacts.list({ limit: 25 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Array of contacts with identifier/name/organization/phones/emails." + + struct Arguments: Sendable { + @ToolParam("Max number of contacts, default 50.") + var limit: Int? + @ToolParam("Optional array of contact identifiers for targeted read.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let contacts: ContactsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try contacts.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsSearch, path: "apple.contacts.search") +struct ContactsSearchTool: BuiltInCodeModeTool { + static let codeModeTitle = "Search contacts" + static let codeModeSummary = "Search contacts by name." + static let codeModeTags = ["contacts", "search", "people"] + static let codeModeExample = "await apple.contacts.search({ query: 'Alex', limit: 10 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Array of contact objects." + + struct Arguments: Sendable { + @ToolParam("Name text to match.") + var query: String + @ToolParam("Max number of contacts, default 20.") + var limit: Int? + var raw: [String: JSONValue] + } + + let contacts: ContactsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try contacts.search(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsUIPick, path: "apple.contacts.pick") +struct ContactsUIPickTool: BuiltInCodeModeTool { + static let codeModeTitle = "Pick contacts with system UI" + static let codeModeSummary = "Present system contact picker UI and return selected contacts without requiring full Contacts permission." + static let codeModeTags = ["contacts", "people", "system-ui", "picker"] + static let codeModeExample = "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })" + static let codeModeResultSummary = "Array of selected contacts with identifier/name/organization/phones/emails." + + struct Arguments: Sendable { + @ToolParam("single (default) or multiple.") + var mode: ContactPickerMode? + @ToolParam("Optional array of CNContact property key strings to display.") + var displayedPropertyKeys: [String]? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickContacts(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsUIPresentContact, path: "apple.contacts.presentContact") +struct ContactsUIPresentContactTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present contact card" + static let codeModeSummary = "Present system contact card UI for a contact identifier." + static let codeModeTags = ["contacts", "people", "system-ui", "details"] + static let codeModeExample = "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Object with action and contact when available." + + struct Arguments: Sendable { + @ToolParam("Contact identifier from apple.contacts.list/search/pick.") + var identifier: String + @ToolParam("Whether the user can edit the contact; default false.") + var allowsEditing: Bool? + @ToolParam("Whether built-in actions like call/message are shown; default true.") + var allowsActions: Bool? + @ToolParam("Optional array of CNContact property key strings to display.") + var displayedPropertyKeys: [String]? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentContact(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.contactsUIPresentNewContact, path: "apple.contacts.presentNewContact") +struct ContactsUIPresentNewContactTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present new contact editor" + static let codeModeSummary = "Present system UI for creating a new contact draft." + static let codeModeTags = ["contacts", "people", "system-ui", "create"] + static let codeModeExample = "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.contacts] + static let codeModeResultSummary = "Object with action and contact when the user saves." + + struct Arguments: Sendable { + @ToolParam("Optional given name to prefill.") + var givenName: String? + @ToolParam("Optional family name to prefill.") + var familyName: String? + @ToolParam("Optional organization to prefill.") + var organization: String? + @ToolParam("Optional array of phone number strings.") + var phoneNumbers: [String]? + @ToolParam("Optional array of email address strings.") + var emailAddresses: [String]? + @ToolParam("Optional timeout for waiting on user completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentNewContact(arguments: arguments.raw, context: context) + } +} + +// MARK: - Photos + +@BuiltInCodeMode(.photosRead, path: "apple.photos.list") +struct PhotosReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List photo library assets" + static let codeModeSummary = "List photos/videos from the user photo library." + static let codeModeTags = ["photos", "photo-library", "media"] + static let codeModeExample = "await apple.photos.list({ mediaType: 'image', limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.photoLibrary] + static let codeModeResultSummary = "Array of assets with localIdentifier/mediaType/dimensions/date metadata." + + struct Arguments: Sendable { + @ToolParam("any (default), image, or video.") + var mediaType: MediaTypeFilter? + @ToolParam("Max number of results, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let photos: PhotosBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try photos.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.photosExport, path: "apple.photos.export") +struct PhotosExportTool: BuiltInCodeModeTool { + static let codeModeTitle = "Export photo library asset" + static let codeModeSummary = "Export a photo/video asset to sandbox file path and register artifact handle." + static let codeModeTags = ["photos", "photo-library", "artifact"] + static let codeModeExample = "await apple.photos.export({ localIdentifier: 'ABC/L0/001', outputPath: 'tmp:exported.jpg' })" + static let codeModeRequiredPermissions: [PermissionKind] = [.photoLibrary] + static let codeModeResultSummary = "Object with path/artifactID/localIdentifier/mediaType/bytes." + + struct Arguments: Sendable { + @ToolParam("PHAsset localIdentifier from photos.read result.") + var localIdentifier: String + @ToolParam("Optional sandbox output path; defaults to tmp-generated file.") + var outputPath: String? + var raw: [String: JSONValue] + } + + let photos: PhotosBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try photos.export(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.photosUIPick, path: "apple.photos.pick") +struct PhotosUIPickTool: BuiltInCodeModeTool { + static let codeModeTitle = "Pick photos with system UI" + static let codeModeSummary = "Present system photo picker UI and export selected assets into the sandbox artifact store." + static let codeModeTags = ["photos", "photo-library", "system-ui", "picker", "artifact"] + static let codeModeExample = "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })" + static let codeModeResultSummary = "Array of selected assets with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + + struct Arguments: Sendable { + @ToolParam("any (default), image/photo, or video.") + var mediaType: MediaTypeFilter? + @ToolParam("Maximum number of selectable items; default 1.") + var limit: Int? + @ToolParam("Optional sandbox directory for exported picker files; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on user selection/export.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickPhotos(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.photosUIPresentLimitedLibraryPicker, path: "apple.photos.presentLimitedLibraryPicker") +struct PhotosUIPresentLimitedLibraryPickerTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present limited Photos picker" + static let codeModeSummary = "Present the Photos limited-library management UI so the user can update the app's selected photo set." + static let codeModeTags = ["photos", "photo-library", "system-ui", "permission", "picker"] + static let codeModeExample = "await apple.photos.presentLimitedLibraryPicker()" + static let codeModeResultSummary = "Object with action/status and selectedIdentifiers when the limited selection changes." + + struct Arguments: Sendable { + @ToolParam("Optional timeout for waiting on the limited-library picker completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentLimitedPhotoLibraryPicker(arguments: arguments.raw, context: context) + } +} + +// MARK: - Documents + +@BuiltInCodeMode(.documentsUIPick, path: "apple.documents.pick") +struct DocumentsUIPickTool: BuiltInCodeModeTool { + static let codeModeTitle = "Pick documents with system UI" + static let codeModeSummary = "Present Files document picker UI and copy selected documents into the sandbox artifact store." + static let codeModeTags = ["documents", "files", "system-ui", "picker", "artifact"] + static let codeModeExample = "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })" + static let codeModeResultSummary = "Array of selected documents with path/artifactID/filename/uniformTypeIdentifier/bytes." + + struct Arguments: Sendable { + @ToolParam("Optional array of UTType identifiers; defaults to public.item.") + var contentTypes: [String]? + @ToolParam("Whether multiple files may be selected; default false.") + var allowMultiple: Bool? + @ToolParam("Optional sandbox directory for copied files; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on user selection/copy.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.pickDocuments(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.documentsUIExport, path: "apple.documents.export", aliases: ["apple.documents.save"]) +struct DocumentsUIExportTool: BuiltInCodeModeTool { + static let codeModeTitle = "Export documents with system UI" + static let codeModeSummary = "Present Files export UI to save one or more sandbox files to a user-selected destination." + static let codeModeTags = ["documents", "files", "system-ui", "export", "save"] + static let codeModeExample = "await apple.documents.export({ path: 'tmp:report.pdf' })" + static let codeModeResultSummary = "Object with action/count and destination URLs when the provider returns them." + + struct Arguments: Sendable { + @ToolParam("Single sandbox file path to export.") + var path: String? + @ToolParam("Optional array of sandbox file paths to export.") + var paths: [String]? + @ToolParam("Whether to export as a copy; default true.") + var asCopy: Bool? + @ToolParam("Optional timeout for waiting on export completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.exportDocuments(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.documentsUIOpenIn, path: "apple.documents.openIn") +struct DocumentsUIOpenInTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open document in another app" + static let codeModeSummary = "Present the system Open In menu for a sandbox file." + static let codeModeTags = ["documents", "files", "system-ui", "open-in", "handoff"] + static let codeModeExample = "await apple.documents.openIn({ path: 'tmp:report.pdf' })" + static let codeModeResultSummary = "Object with action and application bundle identifier when the user hands off the file." + + struct Arguments: Sendable { + @ToolParam("Sandbox file path to hand off.") + var path: String + @ToolParam("Optional display name for the document interaction controller.") + var name: String? + @ToolParam("Optional uniform type identifier override.") + var uti: String? + @ToolParam("Optional timeout for waiting on the Open In menu dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.openDocument(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.documentsUIScan, path: "apple.documents.scan") +struct DocumentsUIScanTool: BuiltInCodeModeTool { + static let codeModeTitle = "Scan documents with system UI" + static let codeModeSummary = "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store." + static let codeModeTags = ["documents", "scan", "camera", "system-ui", "artifact"] + static let codeModeExample = "await apple.documents.scan({ outputDirectory: 'tmp:scans' })" + static let codeModeResultSummary = "Array of scanned page artifacts with path/artifactID/pageIndex/mediaType/bytes." + + struct Arguments: Sendable { + @ToolParam("Optional sandbox directory for scanned page images; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on user scanning/export.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.scanDocuments(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Bridges/PhotosBridge.swift b/Sources/CodeMode/Bridges/PhotosBridge.swift index 926e83f..1c25ebe 100644 --- a/Sources/CodeMode/Bridges/PhotosBridge.swift +++ b/Sources/CodeMode/Bridges/PhotosBridge.swift @@ -19,18 +19,18 @@ public final class PhotosBridge: @unchecked Sendable { #if canImport(Photos) let limit = max(1, arguments.int("limit") ?? 50) - let mediaTypeFilter = (arguments.string("mediaType") ?? "any").lowercased() + let mediaTypeFilter = MediaTypeFilter.codeModeValue(matching: arguments.string("mediaType") ?? "any") let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] let assets: PHFetchResult switch mediaTypeFilter { - case "image", "photo": + case .image, .photo: assets = PHAsset.fetchAssets(with: .image, options: options) - case "video": + case .video: assets = PHAsset.fetchAssets(with: .video, options: options) - default: + case .any, nil: assets = PHAsset.fetchAssets(with: options) } diff --git a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift index b9f2592..194d853 100644 --- a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift +++ b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift @@ -4,131 +4,14 @@ protocol BuiltInCodeModeProvider: Sendable { func capabilityRegistrations() -> [CapabilityRegistration] } -private struct BuiltInToolArgument { - var name: String - var type: CapabilityArgumentType - var optional: Bool - var hint: String - - init(_ name: String, _ type: CapabilityArgumentType, optional: Bool = false, hint: String) { - self.name = name - self.type = type - self.optional = optional - self.hint = hint - } -} - -private protocol BuiltInCodeModeTool: Sendable { - associatedtype Arguments: Sendable - - static var codeModePath: String { get } - static var codeModeTitle: String { get } - static var codeModeSummary: String { get } - static var codeModeTags: [String] { get } - static var codeModeExample: String { get } - static var codeModeArguments: [BuiltInToolArgument] { get } - static var codeModeResultSummary: String { get } - - func decode(arguments: [String: JSONValue]) throws -> Arguments - func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue -} - -private extension BuiltInCodeModeTool { - static var requiredArguments: [String] { - codeModeArguments.filter { $0.optional == false }.map(\.name) - } - - static var optionalArguments: [String] { - codeModeArguments.filter(\.optional).map(\.name) - } - - static var argumentTypes: [String: CapabilityArgumentType] { - Dictionary(uniqueKeysWithValues: codeModeArguments.map { ($0.name, $0.type) }) - } - - static var argumentHints: [String: String] { - Dictionary(uniqueKeysWithValues: codeModeArguments.map { ($0.name, $0.hint) }) - } - - func codeModeRegistration(capabilityKey: CodeModeCapabilityKey) -> CodeModeRegistration { - CodeModeRegistration( - capabilityKey: capabilityKey, - jsPath: Self.codeModePath, - title: Self.codeModeTitle, - summary: Self.codeModeSummary, - tags: Self.codeModeTags, - example: Self.codeModeExample, - requiredArguments: Self.requiredArguments, - optionalArguments: Self.optionalArguments, - argumentTypes: Self.argumentTypes, - argumentHints: Self.argumentHints, - resultSummary: Self.codeModeResultSummary - ) { rawArguments, context in - let decodedArguments = try decode(arguments: rawArguments) - return try call(arguments: decodedArguments, context: context) - } - } -} - -extension CapabilityRegistration { - init( - builtInCapability: CapabilityID, - jsNames: [String]? = nil, - registration: CodeModeRegistration, - requiredPermissions: [PermissionKind] = [] - ) { - self.init( - jsNames: jsNames ?? [registration.jsPath], - descriptor: CapabilityDescriptor( - id: builtInCapability, - title: registration.title, - summary: registration.summary, - tags: registration.tags, - example: registration.example, - requiredPermissions: requiredPermissions, - requiredArguments: registration.requiredArguments, - optionalArguments: registration.optionalArguments, - argumentTypes: registration.argumentTypes, - argumentHints: registration.argumentHints, - argumentConstraints: registration.argumentConstraints == .none ? nil : registration.argumentConstraints, - resultSummary: registration.resultSummary - ), - handler: registration.handler - ) - } - - fileprivate init( - builtInCapability: CapabilityID, - jsNames: [String]? = nil, - tool: Tool, - requiredPermissions: [PermissionKind] = [] - ) { - self.init( - builtInCapability: builtInCapability, - jsNames: jsNames, - registration: tool.codeModeRegistration(capabilityKey: builtInCapability.codeModeKey), - requiredPermissions: requiredPermissions - ) - } -} - struct KeychainCodeModeBuiltIns: BuiltInCodeModeProvider { let keychain: KeychainBridge func capabilityRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - builtInCapability: .keychainRead, - tool: KeychainReadTool(keychain: keychain) - ), - CapabilityRegistration( - builtInCapability: .keychainWrite, - tool: KeychainWriteTool(keychain: keychain) - ), - CapabilityRegistration( - builtInCapability: .keychainDelete, - tool: KeychainDeleteTool(keychain: keychain) - ), + CapabilityRegistration(tool: KeychainReadTool(keychain: keychain)), + CapabilityRegistration(tool: KeychainWriteTool(keychain: keychain)), + CapabilityRegistration(tool: KeychainDeleteTool(keychain: keychain)), ] } } @@ -139,34 +22,9 @@ struct LocationWeatherCodeModeBuiltIns: BuiltInCodeModeProvider { func capabilityRegistrations() -> [CapabilityRegistration] { [ - CapabilityRegistration( - builtInCapability: .locationRead, - jsNames: ["apple.location.getPermissionStatus", "apple.location.getCurrentPosition"], - registration: CodeModeRegistration( - capabilityKey: CapabilityID.locationRead.codeModeKey, - jsPath: "apple.location.getCurrentPosition", - title: "Read location state or coordinates", - summary: "Read location permission status or current coordinates.", - tags: ["location", "permission", "geospatial"], - example: "await apple.location.getCurrentPosition()", - optionalArguments: ["mode"], - argumentTypes: ["mode": .string], - argumentHints: [ - "mode": "permissionStatus or current (default current).", - ], - resultSummary: "Permission status string or coordinates object." - ) { args, context in - try location.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - builtInCapability: .locationPermissionRequest, - tool: LocationPermissionRequestTool(location: location) - ), - CapabilityRegistration( - builtInCapability: .weatherRead, - tool: WeatherReadTool(weather: weather) - ), + CapabilityRegistration(tool: LocationReadTool(location: location)), + CapabilityRegistration(tool: LocationPermissionRequestTool(location: location)), + CapabilityRegistration(tool: WeatherReadTool(weather: weather)), ] } } @@ -176,6 +34,7 @@ private struct KeychainReadTool: BuiltInCodeModeTool { var key: String } + static let codeModeCapability: CapabilityID = .keychainRead static let codeModePath = "apple.keychain.get" static let codeModeTitle = "Read Keychain value" static let codeModeSummary = "Read a string value from app-scoped Keychain storage." @@ -203,6 +62,7 @@ private struct KeychainWriteTool: BuiltInCodeModeTool { var value: String? } + static let codeModeCapability: CapabilityID = .keychainWrite static let codeModePath = "apple.keychain.set" static let codeModeTitle = "Write Keychain value" static let codeModeSummary = "Store or update a string value in app-scoped Keychain storage." @@ -237,6 +97,7 @@ private struct KeychainDeleteTool: BuiltInCodeModeTool { var key: String } + static let codeModeCapability: CapabilityID = .keychainDelete static let codeModePath = "apple.keychain.delete" static let codeModeTitle = "Delete Keychain value" static let codeModeSummary = "Delete an app-scoped Keychain value." @@ -258,9 +119,42 @@ private struct KeychainDeleteTool: BuiltInCodeModeTool { } } +private struct LocationReadTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var mode: String? + } + + static let codeModeCapability: CapabilityID = .locationRead + static let codeModePath = "apple.location.getCurrentPosition" + static let codeModeAliasPaths = ["apple.location.getPermissionStatus"] + static let codeModeTitle = "Read location state or coordinates" + static let codeModeSummary = "Read location permission status or current coordinates." + static let codeModeTags = ["location", "permission", "geospatial"] + static let codeModeExample = "await apple.location.getCurrentPosition()" + static let codeModeArguments = [ + BuiltInToolArgument("mode", .string, optional: true, hint: "permissionStatus or current (default current)."), + ] + static let codeModeResultSummary = "Permission status string or coordinates object." + + let location: LocationBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments(mode: try CodeModeArgumentDecoder.optional("mode", as: String.self, in: arguments)) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + var payload: [String: JSONValue] = [:] + if let mode = arguments.mode { + payload["mode"] = .string(mode) + } + return try location.read(arguments: payload, context: context) + } +} + private struct LocationPermissionRequestTool: BuiltInCodeModeTool { struct Arguments: Sendable {} + static let codeModeCapability: CapabilityID = .locationPermissionRequest static let codeModePath = "apple.location.requestPermission" static let codeModeTitle = "Request location permission" static let codeModeSummary = "Trigger location when-in-use permission request flow." @@ -286,6 +180,7 @@ private struct WeatherReadTool: BuiltInCodeModeTool { var longitude: Double } + static let codeModeCapability: CapabilityID = .weatherRead static let codeModePath = "apple.weather.getCurrentWeather" static let codeModeTitle = "Read WeatherKit weather" static let codeModeSummary = "Fetch current weather for a latitude/longitude pair." diff --git a/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift b/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift new file mode 100644 index 0000000..e5c97d4 --- /dev/null +++ b/Sources/CodeMode/Bridges/SystemServicesCodeModeTools.swift @@ -0,0 +1,492 @@ +import Foundation + +// Vision / notifications / alarms / health / home / media capabilities. +// No constrained argument values in this domain, so no CodeModeStringEnums and +// no bridge rewiring. Health tools intentionally declare no requiredPermissions: +// HealthKit read authorization is opaque, so HealthBridge performs its own +// per-type authorization (see noBuiltInRegistrationGatesOnHealthKitPermission). + +// MARK: - Vision + +@BuiltInCodeMode(.visionImageAnalyze, path: "apple.vision.analyzeImage") +struct VisionImageAnalyzeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Analyze image with Vision" + static let codeModeSummary = "Run on-device image analysis for labels/text/barcodes on sandbox image paths." + static let codeModeTags = ["vision", "image-analysis", "ml"] + static let codeModeExample = "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })" + static let codeModeResultSummary = "Object containing requested analysis sections such as labels/text/barcodes." + + struct Arguments: Sendable { + @ToolParam("Sandbox image path to analyze.") + var path: String + @ToolParam("Optional array including labels/text/barcodes.") + var features: [String]? + @ToolParam("Max observations returned per feature, default 5.") + var maxResults: Int? + var raw: [String: JSONValue] + } + + let vision: VisionBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try vision.analyzeImage(arguments: arguments.raw, context: context) + } +} + +// MARK: - Notifications + +@BuiltInCodeMode(.notificationsPermissionRequest, path: "apple.notifications.requestPermission") +struct NotificationsPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request notification permission" + static let codeModeSummary = "Request local notification authorization from the user." + static let codeModeTags = ["notifications", "permission"] + static let codeModeExample = "await apple.notifications.requestPermission()" + static let codeModeResultSummary = "Object with status/granted fields." + + struct Arguments: Sendable {} + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.notificationsSchedule, path: "apple.notifications.schedule") +struct NotificationsScheduleTool: BuiltInCodeModeTool { + static let codeModeTitle = "Schedule local notification" + static let codeModeSummary = "Schedule a local notification using time interval or fireDate trigger." + static let codeModeTags = ["notifications", "local", "schedule"] + static let codeModeExample = "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Object with identifier/scheduled/repeats." + + struct Arguments: Sendable { + @ToolParam("Notification title text.") + var title: String + @ToolParam("Optional request identifier; defaults to codemode UUID.") + var identifier: String? + @ToolParam("Optional subtitle text.") + var subtitle: String? + @ToolParam("Optional body text.") + var body: String? + @ToolParam("Delay in seconds for time interval trigger (default 5).") + var secondsFromNow: Double? + @ToolParam("Optional ISO8601 timestamp for calendar trigger.") + var fireDate: String? + @ToolParam("Boolean repeat flag (time interval requires >= 60 seconds).") + var repeats: Bool? + @ToolParam("default (default), none, or a bundled custom sound name.") + var sound: String? + @ToolParam("Optional app icon badge number.") + var badge: Int? + @ToolParam("Optional property-list-safe userInfo object.") + var userInfo: [String: JSONValue]? + @ToolParam("Optional thread identifier for notification grouping.") + var threadIdentifier: String? + @ToolParam("Optional category identifier for notification actions.") + var categoryIdentifier: String? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.schedule(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsPendingRead, path: "apple.notifications.listPending") +struct NotificationsPendingReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List pending local notifications" + static let codeModeSummary = "List pending local notification requests." + static let codeModeTags = ["notifications", "local", "schedule"] + static let codeModeExample = "await apple.notifications.listPending({ limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Array of pending requests with identifiers/content/trigger metadata." + + struct Arguments: Sendable { + @ToolParam("Max number of pending requests to return, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.readPending(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsPendingDelete, path: "apple.notifications.cancelPending") +struct NotificationsPendingDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete pending local notifications" + static let codeModeSummary = "Delete pending local notifications by identifier list or clear all." + static let codeModeTags = ["notifications", "local", "schedule"] + static let codeModeExample = "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Object with deleted/count fields." + + struct Arguments: Sendable { + @ToolParam("Single pending request identifier to remove.") + var identifier: String? + @ToolParam("Array of pending request identifiers to remove. Omit both to clear all pending requests.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.deletePending(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsDeliveredRead, path: "apple.notifications.listDelivered") +struct NotificationsDeliveredReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List delivered local notifications" + static let codeModeSummary = "List notifications currently delivered in Notification Center." + static let codeModeTags = ["notifications", "local", "delivered"] + static let codeModeExample = "await apple.notifications.listDelivered({ limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Array of delivered notifications with identifiers/content/date metadata." + + struct Arguments: Sendable { + @ToolParam("Max number of delivered notifications to return, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.readDelivered(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.notificationsDeliveredDelete, path: "apple.notifications.removeDelivered") +struct NotificationsDeliveredDeleteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Delete delivered local notifications" + static let codeModeSummary = "Delete delivered notifications by identifier list or clear all." + static let codeModeTags = ["notifications", "local", "delivered", "delete"] + static let codeModeExample = "await apple.notifications.removeDelivered({ identifiers: ['codemode.1', 'codemode.2'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.notifications] + static let codeModeResultSummary = "Object with deleted/count fields." + + struct Arguments: Sendable { + @ToolParam("Single delivered notification identifier to remove.") + var identifier: String? + @ToolParam("Array of delivered notification identifiers to remove. Omit both to clear all delivered notifications.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let notifications: NotificationsBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try notifications.deleteDelivered(arguments: arguments.raw, context: context) + } +} + +// MARK: - Alarms + +@BuiltInCodeMode(.alarmPermissionRequest, path: "ios.alarm.requestPermission") +struct AlarmPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request AlarmKit permission" + static let codeModeSummary = "Request AlarmKit authorization from the user." + static let codeModeTags = ["alarmkit", "permission", "alarms"] + static let codeModeExample = "await ios.alarm.requestPermission()" + static let codeModeResultSummary = "Object with status/granted fields." + + struct Arguments: Sendable {} + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.requestPermission(context: context) + } +} + +@BuiltInCodeMode(.alarmRead, path: "ios.alarm.list") +struct AlarmReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "List scheduled alarms" + static let codeModeSummary = "List scheduled alarms known to the bridge runtime." + static let codeModeTags = ["alarmkit", "alarms", "schedule"] + static let codeModeExample = "await ios.alarm.list({ limit: 20 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.alarmKit] + static let codeModeResultSummary = "Array of scheduled alarms with identifier/title/timing fields." + + struct Arguments: Sendable { + @ToolParam("Max number of scheduled alarms returned, default 50.") + var limit: Int? + var raw: [String: JSONValue] + } + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.alarmSchedule, path: "ios.alarm.schedule") +struct AlarmScheduleTool: BuiltInCodeModeTool { + static let codeModeTitle = "Schedule AlarmKit alarm" + static let codeModeSummary = "Schedule an AlarmKit alarm using secondsFromNow or fireDate." + static let codeModeTags = ["alarmkit", "alarms", "schedule"] + static let codeModeExample = "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.alarmKit] + static let codeModeResultSummary = "Object with identifier/scheduled/title." + + struct Arguments: Sendable { + @ToolParam("Alarm title shown in presentation.") + var title: String + @ToolParam("Optional UUID string; generated when omitted.") + var identifier: String? + @ToolParam("Fallback relative delay in seconds, default 60.") + var secondsFromNow: Double? + @ToolParam("Optional absolute ISO8601 date; used when provided.") + var fireDate: String? + var raw: [String: JSONValue] + } + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.schedule(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.alarmCancel, path: "ios.alarm.cancel") +struct AlarmCancelTool: BuiltInCodeModeTool { + static let codeModeTitle = "Cancel scheduled alarms" + static let codeModeSummary = "Cancel one or more scheduled alarms by identifier, or clear all known alarms." + static let codeModeTags = ["alarmkit", "alarms", "schedule"] + static let codeModeExample = "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })" + static let codeModeRequiredPermissions: [PermissionKind] = [.alarmKit] + static let codeModeResultSummary = "Object with deleted/count fields." + + struct Arguments: Sendable { + @ToolParam("Single alarm identifier UUID string.") + var identifier: String? + @ToolParam("Array of alarm identifier UUID strings. Omit both to cancel all known alarms.") + var identifiers: [String]? + var raw: [String: JSONValue] + } + + let alarm: AlarmBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try alarm.cancel(arguments: arguments.raw, context: context) + } +} + +// MARK: - Health + +@BuiltInCodeMode(.healthPermissionRequest, path: "apple.health.requestPermission") +struct HealthPermissionRequestTool: BuiltInCodeModeTool { + static let codeModeTitle = "Request HealthKit permission" + static let codeModeSummary = "Request HealthKit authorization for requested read/write types." + static let codeModeTags = ["healthkit", "health", "permission"] + static let codeModeExample = "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })" + static let codeModeResultSummary = "Object with status/granted fields and requested type arrays." + + struct Arguments: Sendable { + @ToolParam("Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.") + var readTypes: [String]? + @ToolParam("Optional array of type names to write (quantity types only).") + var writeTypes: [String]? + var raw: [String: JSONValue] + } + + let health: HealthBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try health.requestPermission(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.healthRead, path: "apple.health.read") +struct HealthReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read HealthKit samples" + static let codeModeSummary = "Read HealthKit samples for a supported type and date range." + static let codeModeTags = ["healthkit", "health", "query"] + static let codeModeExample = "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })" + static let codeModeResultSummary = "Array of samples with identifier/type/value and timing metadata." + + struct Arguments: Sendable { + @ToolParam("Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.") + var type: String + @ToolParam("Optional ISO8601 start timestamp; defaults to last 24h.") + var start: String? + @ToolParam("Optional ISO8601 end timestamp; defaults to now.") + var end: String? + @ToolParam("Max number of samples, default 50.") + var limit: Int? + @ToolParam("Optional unit override for quantity types (count, bpm, kcal, kg, m).") + var unit: String? + var raw: [String: JSONValue] + } + + let health: HealthBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try health.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.healthWrite, path: "apple.health.write") +struct HealthWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write HealthKit quantity sample" + static let codeModeSummary = "Write a HealthKit quantity sample for supported writable quantity types." + static let codeModeTags = ["healthkit", "health", "write"] + static let codeModeExample = "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })" + static let codeModeResultSummary = "Object with identifier/type/value/unit and written=true." + + struct Arguments: Sendable { + @ToolParam("Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.") + var type: String + @ToolParam("Numeric sample value.") + var value: Double + @ToolParam("Optional unit (count, bpm, kcal, kg, m).") + var unit: String? + @ToolParam("Optional ISO8601 start timestamp; defaults to now.") + var start: String? + @ToolParam("Optional ISO8601 end timestamp; defaults to start.") + var end: String? + var raw: [String: JSONValue] + } + + let health: HealthBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try health.write(arguments: arguments.raw, context: context) + } +} + +// MARK: - Home + +@BuiltInCodeMode(.homeRead, path: "apple.home.list") +struct HomeReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read HomeKit graph" + static let codeModeSummary = "Read homes/accessories/services (and optional characteristics) from HomeKit." + static let codeModeTags = ["homekit", "iot", "devices"] + static let codeModeExample = "await apple.home.list({ includeCharacteristics: true, limit: 5 })" + static let codeModeRequiredPermissions: [PermissionKind] = [.homeKit] + static let codeModeResultSummary = "Array of homes with accessories/services snapshot." + + struct Arguments: Sendable { + @ToolParam("Boolean; include characteristic details when true.") + var includeCharacteristics: Bool? + @ToolParam("Max number of homes to return, default 10.") + var limit: Int? + var raw: [String: JSONValue] + } + + let home: HomeBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try home.read(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.homeWrite, path: "apple.home.writeCharacteristic") +struct HomeWriteTool: BuiltInCodeModeTool { + static let codeModeTitle = "Write HomeKit characteristic" + static let codeModeSummary = "Write a value to a writable HomeKit characteristic for a target accessory." + static let codeModeTags = ["homekit", "iot", "devices", "control"] + static let codeModeExample = "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })" + static let codeModeRequiredPermissions: [PermissionKind] = [.homeKit] + static let codeModeResultSummary = "Object with accessoryIdentifier/characteristicType/written." + + struct Arguments: Sendable { + @ToolParam("Accessory UUID string from home.read output.") + var accessoryIdentifier: String + @ToolParam("Characteristic type identifier (e.g. HMCharacteristicTypePowerState).") + var characteristicType: String + @ToolParam("Target value; string/number/bool/null.") + var value: JSONValue + @ToolParam("Optional service type filter for characteristic lookup.") + var serviceType: String? + var raw: [String: JSONValue] + } + + let home: HomeBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try home.write(arguments: arguments.raw, context: context) + } +} + +// MARK: - Media + +@BuiltInCodeMode(.mediaMetadataRead, path: "apple.media.metadata") +struct MediaMetadataReadTool: BuiltInCodeModeTool { + static let codeModeTitle = "Read media metadata" + static let codeModeSummary = "Read duration and track metadata from media files." + static let codeModeTags = ["media", "avfoundation", "metadata"] + static let codeModeExample = "await apple.media.metadata({ path: 'tmp:video.mov' })" + static let codeModeResultSummary = "Object with path/durationSeconds/tracks." + + struct Arguments: Sendable { + @ToolParam("Sandbox path like tmp:clip.mov.") + var path: String + var raw: [String: JSONValue] + } + + let media: MediaBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try media.metadata(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.mediaFrameExtract, path: "apple.media.extractFrame") +struct MediaFrameExtractTool: BuiltInCodeModeTool { + static let codeModeTitle = "Extract video frame" + static let codeModeSummary = "Extract frame at time offset and persist JPEG output." + static let codeModeTags = ["media", "avfoundation", "thumbnail"] + static let codeModeExample = "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })" + static let codeModeResultSummary = "Object with output path and artifactID." + + struct Arguments: Sendable { + @ToolParam("Input video sandbox path.") + var path: String + @ToolParam("Frame timestamp in milliseconds; default 0.") + var timeMs: Double? + @ToolParam("Optional output sandbox path; defaults to tmp-generated JPEG.") + var outputPath: String? + var raw: [String: JSONValue] + } + + let media: MediaBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try media.extractFrame(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.mediaTranscode, path: "apple.media.transcode") +struct MediaTranscodeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Transcode media" + static let codeModeSummary = "Transcode media into MP4 with preset quality." + static let codeModeTags = ["media", "avfoundation", "transcode"] + static let codeModeExample = "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })" + static let codeModeResultSummary = "Object with output path/artifactID/preset." + + struct Arguments: Sendable { + @ToolParam("Input media sandbox path.") + var path: String + @ToolParam("Optional output sandbox path; defaults to tmp-generated mp4.") + var outputPath: String? + @ToolParam("AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality.") + var preset: String? + var raw: [String: JSONValue] + } + + let media: MediaBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try media.transcode(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift index 77207e9..a5b59bb 100644 --- a/Sources/CodeMode/Bridges/SystemUIBridge.swift +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -128,8 +128,8 @@ public final class SystemUIBridge: @unchecked Sendable { public func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try validatePathOrPaths(arguments, capability: "print.ui.present") - if let outputType = arguments.string("outputType")?.lowercased(), - ["general", "photo", "grayscale"].contains(outputType) == false + if let outputType = arguments.string("outputType"), + PrintOutputType.codeModeValue(matching: outputType) == nil { throw BridgeError.invalidArguments("print.ui.present outputType must be general, photo, or grayscale") } @@ -172,16 +172,16 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateCalendarPickerArguments(_ arguments: [String: JSONValue]) throws { - if let selectionStyle = arguments.string("selectionStyle")?.lowercased(), - ["single", "multiple"].contains(selectionStyle) == false + if let selectionStyle = arguments.string("selectionStyle"), + CalendarPickerSelectionStyle.codeModeValue(matching: selectionStyle) == nil { - throw BridgeError.invalidArguments("calendar.ui.pickCalendar selectionStyle must be single or multiple") + throw BridgeError.invalidArguments("calendar.ui.pickCalendar selectionStyle must be one of \(CalendarPickerSelectionStyle.codeModeAllowedValues.joined(separator: ", "))") } - if let displayStyle = arguments.string("displayStyle")?.lowercased(), - ["writable", "all"].contains(displayStyle) == false + if let displayStyle = arguments.string("displayStyle"), + CalendarPickerDisplayStyle.codeModeValue(matching: displayStyle) == nil { - throw BridgeError.invalidArguments("calendar.ui.pickCalendar displayStyle must be writable or all") + throw BridgeError.invalidArguments("calendar.ui.pickCalendar displayStyle must be one of \(CalendarPickerDisplayStyle.codeModeAllowedValues.joined(separator: ", "))") } try validateTimeoutMs(arguments, capability: "calendar.ui.pickCalendar") @@ -201,8 +201,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validatePhotoPickerArguments(_ arguments: [String: JSONValue], capability: String = "photos.ui.pick") throws { - if let mediaType = arguments.string("mediaType")?.lowercased(), - ["any", "image", "photo", "video"].contains(mediaType) == false + if let mediaType = arguments.string("mediaType"), + MediaTypeFilter.codeModeValue(matching: mediaType) == nil { throw BridgeError.invalidArguments("\(capability) mediaType must be any, image, photo, or video") } @@ -219,20 +219,20 @@ public final class SystemUIBridge: @unchecked Sendable { try validatePhotoPickerArguments(arguments, capability: "camera.ui.capture") try validateOptionalBool(arguments, key: "allowsEditing", capability: "camera.ui.capture") - if let cameraDevice = arguments.string("cameraDevice")?.lowercased(), - ["rear", "front"].contains(cameraDevice) == false + if let cameraDevice = arguments.string("cameraDevice"), + CameraDevice.codeModeValue(matching: cameraDevice) == nil { throw BridgeError.invalidArguments("camera.ui.capture cameraDevice must be rear or front") } - if let flashMode = arguments.string("flashMode")?.lowercased(), - ["auto", "on", "off"].contains(flashMode) == false + if let flashMode = arguments.string("flashMode"), + CameraFlashMode.codeModeValue(matching: flashMode) == nil { throw BridgeError.invalidArguments("camera.ui.capture flashMode must be auto, on, or off") } - if let videoQuality = arguments.string("videoQuality")?.lowercased(), - ["high", "medium", "low", "640x480", "iframe1280x720", "iframe960x540"].contains(videoQuality) == false + if let videoQuality = arguments.string("videoQuality"), + CameraVideoQuality.codeModeValue(matching: videoQuality) == nil { throw BridgeError.invalidArguments("camera.ui.capture videoQuality must be high, medium, low, 640x480, iFrame1280x720, or iFrame960x540") } @@ -243,8 +243,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateContactPickerArguments(_ arguments: [String: JSONValue]) throws { - if let mode = arguments.string("mode")?.lowercased(), - ["single", "multiple"].contains(mode) == false + if let mode = arguments.string("mode"), + ContactPickerMode.codeModeValue(matching: mode) == nil { throw BridgeError.invalidArguments("contacts.ui.pick mode must be single or multiple") } @@ -254,8 +254,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateAlertArguments(_ arguments: [String: JSONValue]) throws { - if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), - ["alert", "actionSheet", "actionsheet"].contains(preferredStyle) == false + if let preferredStyle = arguments.string("preferredStyle"), + AlertPreferredStyle.codeModeValue(matching: preferredStyle) == nil { throw BridgeError.invalidArguments("ui.alert.present preferredStyle must be alert or actionSheet") } @@ -291,9 +291,7 @@ public final class SystemUIBridge: @unchecked Sendable { private func validatePromptArguments(_ arguments: [String: JSONValue]) throws { try validateAlertArguments(arguments, capability: "ui.prompt.present") - if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), - ["actionSheet", "actionsheet"].contains(preferredStyle) - { + if AlertPreferredStyle.codeModeValue(matching: arguments.string("preferredStyle") ?? "") == .actionSheet { throw BridgeError.invalidArguments("ui.prompt.present preferredStyle must be alert") } @@ -318,8 +316,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateAlertArguments(_ arguments: [String: JSONValue], capability: String) throws { - if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), - ["alert", "actionSheet", "actionsheet"].contains(preferredStyle) == false + if let preferredStyle = arguments.string("preferredStyle"), + AlertPreferredStyle.codeModeValue(matching: preferredStyle) == nil { throw BridgeError.invalidArguments("\(capability) preferredStyle must be alert or actionSheet") } @@ -353,8 +351,8 @@ public final class SystemUIBridge: @unchecked Sendable { } private func validateDataScannerArguments(_ arguments: [String: JSONValue]) throws { - if let mode = arguments.string("mode")?.lowercased(), - ["any", "text", "barcode"].contains(mode) == false + if let mode = arguments.string("mode"), + DataScannerMode.codeModeValue(matching: mode) == nil { throw BridgeError.invalidArguments("camera.ui.scanData mode must be any, text, or barcode") } @@ -369,8 +367,8 @@ public final class SystemUIBridge: @unchecked Sendable { } try validateStringArray(arguments, key: "languages", capability: "camera.ui.scanData") - if let qualityLevel = arguments.string("qualityLevel")?.lowercased(), - ["balanced", "fast", "accurate"].contains(qualityLevel) == false + if let qualityLevel = arguments.string("qualityLevel"), + DataScannerQualityLevel.codeModeValue(matching: qualityLevel) == nil { throw BridgeError.invalidArguments("camera.ui.scanData qualityLevel must be balanced, fast, or accurate") } diff --git a/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift b/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift new file mode 100644 index 0000000..b4d47d7 --- /dev/null +++ b/Sources/CodeMode/Bridges/SystemUICodeModeTools.swift @@ -0,0 +1,435 @@ +import Foundation + +// MARK: - Constrained argument values +// +// Single source of truth for these arguments: the catalog advertises +// `codeModeAllowedValues`, tool decode and SystemUIBridge parse through +// `codeModeValue(matching:)`, so advertised and accepted spellings cannot drift. + +/// Shared by camera capture and (later) the photos pickers. +enum MediaTypeFilter: String, CodeModeStringEnum { + case any + case image + case photo + case video +} + +enum CameraDevice: String, CodeModeStringEnum { + case rear + case front +} + +enum CameraFlashMode: String, CodeModeStringEnum { + case auto + case on + case off +} + +enum CameraVideoQuality: String, CodeModeStringEnum { + case high + case medium + case low + case res640x480 = "640x480" + case iFrame1280x720 + case iFrame960x540 +} + +enum DataScannerMode: String, CodeModeStringEnum { + case any + case text + case barcode +} + +enum DataScannerQualityLevel: String, CodeModeStringEnum { + case balanced + case fast + case accurate +} + +enum PrintOutputType: String, CodeModeStringEnum { + case general + case photo + case grayscale +} + +enum AlertPreferredStyle: String, CodeModeStringEnum { + case alert + case actionSheet + + // The bridge accepts the all-lowercase spelling; keep it advertised. + static let codeModeAliases: [String: Self] = [ + "actionsheet": .actionSheet, + ] +} + +// MARK: - Share / Quick Look + +@BuiltInCodeMode(.shareUIPresent, path: "apple.share.present") +struct ShareUIPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present share sheet" + static let codeModeSummary = "Present system share sheet for text, URLs, and sandbox file artifacts." + static let codeModeTags = ["share", "export", "system-ui"] + static let codeModeExample = "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })" + static let codeModeResultSummary = "Object with completed/activityType/action." + + struct Arguments: Sendable { + @ToolParam("Optional text item to share.") + var text: String? + @ToolParam("Optional absolute HTTP(S) URL to share.") + var url: String? + @ToolParam("Optional single sandbox file path to share.") + var path: String? + @ToolParam("Optional array of sandbox file paths to share.") + var paths: [String]? + @ToolParam("Optional subject for services that support it.") + var subject: String? + @ToolParam("Optional array of UIActivity.ActivityType raw value strings to hide.") + var excludedActivityTypes: [String]? + @ToolParam("Optional timeout for waiting on share completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentShareSheet(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.quickLookUIPreview, path: "apple.quicklook.preview") +struct QuickLookUIPreviewTool: BuiltInCodeModeTool { + static let codeModeTitle = "Preview files with Quick Look" + static let codeModeSummary = "Present Quick Look preview UI for one or more sandbox file artifacts." + static let codeModeTags = ["quicklook", "preview", "documents", "system-ui"] + static let codeModeExample = "await apple.quicklook.preview({ path: 'tmp:report.pdf' })" + static let codeModeResultSummary = "Object with action dismissed and count." + + struct Arguments: Sendable { + @ToolParam("Single sandbox file path to preview.") + var path: String? + @ToolParam("Optional array of sandbox file paths to preview.") + var paths: [String]? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.previewQuickLook(arguments: arguments.raw, context: context) + } +} + +// MARK: - Camera + +@BuiltInCodeMode(.cameraUICapture, path: "apple.camera.capture") +struct CameraUICaptureTool: BuiltInCodeModeTool { + static let codeModeTitle = "Capture photo or video with camera UI" + static let codeModeSummary = "Present system camera UI and export captured media into the sandbox artifact store." + static let codeModeTags = ["camera", "capture", "photos", "system-ui", "artifact"] + static let codeModeExample = "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })" + static let codeModeResultSummary = "Object with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + + struct Arguments: Sendable { + @ToolParam("any (default), image/photo, or video.") + var mediaType: MediaTypeFilter? + @ToolParam("Optional sandbox directory for captured media; defaults to tmp:.") + var outputDirectory: String? + @ToolParam("Optional timeout for waiting on capture/export.") + var timeoutMs: Double? + @ToolParam("Whether the system editor is shown before returning media; default false.") + var allowsEditing: Bool? + @ToolParam("rear (default) or front.") + var cameraDevice: CameraDevice? + @ToolParam("auto (default), on, or off.") + var flashMode: CameraFlashMode? + @ToolParam("UIImagePickerController quality name such as high, medium, low, 640x480, iFrame1280x720, or iFrame960x540.") + var videoQuality: CameraVideoQuality? + @ToolParam("Optional maximum duration for video capture.") + var maximumDurationSeconds: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.captureCamera(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.cameraUIScanData, path: "apple.camera.scanData") +struct CameraUIScanDataTool: BuiltInCodeModeTool { + static let codeModeTitle = "Scan text or barcodes with camera UI" + static let codeModeSummary = "Present VisionKit live data scanner UI and return recognized text or barcode payloads." + static let codeModeTags = ["camera", "scan", "barcode", "text", "visionkit", "system-ui"] + static let codeModeExample = "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })" + static let codeModeResultSummary = "Object with action and items containing text transcripts or barcode payloads." + + struct Arguments: Sendable { + @ToolParam("any (default), text, or barcode.") + var mode: DataScannerMode? + @ToolParam("Optional array containing text and/or barcode; overrides mode.") + var recognizedDataTypes: [String]? + @ToolParam("Optional text recognition language identifiers.") + var languages: [String]? + @ToolParam("balanced (default), fast, or accurate.") + var qualityLevel: DataScannerQualityLevel? + @ToolParam("Whether the scanner tracks multiple items at once; default false.") + var recognizesMultipleItems: Bool? + @ToolParam("Whether to dismiss as soon as data is recognized; default true.") + var returnsOnFirstResult: Bool? + @ToolParam("Whether VisionKit guidance UI is shown; default true.") + var isGuidanceEnabled: Bool? + @ToolParam("Whether recognized items are highlighted; default true.") + var isHighlightingEnabled: Bool? + @ToolParam("Whether pinch-to-zoom is enabled; default true.") + var isPinchToZoomEnabled: Bool? + @ToolParam("Whether high-frame-rate tracking is enabled; default true.") + var isHighFrameRateTrackingEnabled: Bool? + @ToolParam("Optional timeout for waiting on a scan result or cancellation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.scanData(arguments: arguments.raw, context: context) + } +} + +// MARK: - Mail / Messages / Print + +@BuiltInCodeMode(.mailUICompose, path: "apple.mail.compose") +struct MailUIComposeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Compose mail with system UI" + static let codeModeSummary = "Present system mail compose UI with optional recipients, body, and sandbox file attachments." + static let codeModeTags = ["mail", "compose", "system-ui", "share"] + static let codeModeExample = "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })" + static let codeModeResultSummary = "Object with action sent/saved/cancelled/failed." + + struct Arguments: Sendable { + @ToolParam("Optional array of recipient email strings.") + var to: [String]? + @ToolParam("Optional array of CC email strings.") + var cc: [String]? + @ToolParam("Optional array of BCC email strings.") + var bcc: [String]? + @ToolParam("Optional subject.") + var subject: String? + @ToolParam("Optional message body.") + var body: String? + @ToolParam("Whether body should be treated as HTML; default false.") + var isHTML: Bool? + @ToolParam("Optional array of { path, mimeType?, filename? } sandbox file attachments.") + var attachments: [JSONValue]? + @ToolParam("Optional timeout for waiting on user completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.composeMail(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.messagesUICompose, path: "apple.messages.compose") +struct MessagesUIComposeTool: BuiltInCodeModeTool { + static let codeModeTitle = "Compose message with system UI" + static let codeModeSummary = "Present system Messages compose UI with optional recipients, body, and sandbox file attachments." + static let codeModeTags = ["messages", "sms", "compose", "system-ui", "share"] + static let codeModeExample = "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })" + static let codeModeResultSummary = "Object with action sent/cancelled/failed." + + struct Arguments: Sendable { + @ToolParam("Optional array of phone number or address strings.") + var recipients: [String]? + @ToolParam("Optional subject on devices/accounts that support it.") + var subject: String? + @ToolParam("Optional message body.") + var body: String? + @ToolParam("Optional array of { path, filename? } sandbox file attachments.") + var attachments: [JSONValue]? + @ToolParam("Optional timeout for waiting on user completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.composeMessage(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.printUIPresent, path: "apple.print.present") +struct PrintUIPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present print UI" + static let codeModeSummary = "Present the system print sheet for one or more sandbox files." + static let codeModeTags = ["print", "documents", "system-ui", "export"] + static let codeModeExample = "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })" + static let codeModeResultSummary = "Object with action/completed for the print interaction." + + struct Arguments: Sendable { + @ToolParam("Single sandbox file path to print.") + var path: String? + @ToolParam("Optional array of sandbox file paths to print.") + var paths: [String]? + @ToolParam("Optional print job name.") + var jobName: String? + @ToolParam("general (default), photo, or grayscale.") + var outputType: PrintOutputType? + @ToolParam("Whether copy count controls are shown; default true.") + var showsNumberOfCopies: Bool? + @ToolParam("Optional timeout for waiting on print completion/cancellation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentPrint(arguments: arguments.raw, context: context) + } +} + +// MARK: - Web / Auth + +@BuiltInCodeMode(.webUIPresent, path: "apple.web.present") +struct WebUIPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present web page with system UI" + static let codeModeSummary = "Present an HTTP(S) URL with the system Safari view controller." + static let codeModeTags = ["web", "safari", "browser", "system-ui"] + static let codeModeExample = "await apple.web.present({ url: 'https://example.com' })" + static let codeModeResultSummary = "Object with action dismissed." + + struct Arguments: Sendable { + @ToolParam("Absolute HTTP(S) URL to present.") + var url: String + @ToolParam("Whether Safari may enter Reader automatically; default false.") + var entersReaderIfAvailable: Bool? + @ToolParam("Optional timeout for waiting on dismissal.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentWeb(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.authUIWebAuthenticate, path: "apple.auth.webAuthenticate") +struct AuthUIWebAuthenticateTool: BuiltInCodeModeTool { + static let codeModeTitle = "Authenticate with system web UI" + static let codeModeSummary = "Start an ASWebAuthenticationSession for OAuth-style browser authentication." + static let codeModeTags = ["auth", "oauth", "web", "browser", "system-ui"] + static let codeModeExample = "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })" + static let codeModeResultSummary = "Object with action callback/cancelled and callbackURL when available." + + struct Arguments: Sendable { + @ToolParam("Absolute HTTP(S) authentication URL.") + var url: String + @ToolParam("Optional custom URL scheme that completes the session.") + var callbackURLScheme: String? + @ToolParam("Whether to prefer a private browser session; default false.") + var prefersEphemeralSession: Bool? + @ToolParam("Optional timeout for waiting on callback/cancellation.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.authenticateWeb(arguments: arguments.raw, context: context) + } +} + +// MARK: - Alerts / Prompts / Settings + +@BuiltInCodeMode(.uiAlertPresent, path: "apple.ui.presentAlert") +struct UIAlertPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present alert with custom buttons" + static let codeModeSummary = "Present a system alert or action sheet and return the button the user selects." + static let codeModeTags = ["ui", "alert", "dialog", "system-ui"] + static let codeModeExample = "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })" + static let codeModeResultSummary = "Object with action/buttonID/buttonTitle/buttonIndex/style for the selected button." + + struct Arguments: Sendable { + @ToolParam("Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.") + var buttons: [JSONValue] + @ToolParam("Optional alert title.") + var title: String? + @ToolParam("Optional alert message.") + var message: String? + @ToolParam("alert (default) or actionSheet.") + var preferredStyle: AlertPreferredStyle? + @ToolParam("Optional { x, y, width, height } anchor for action sheets.") + var sourceRect: [String: JSONValue]? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentAlert(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.uiPromptPresent, path: "apple.ui.presentPrompt") +struct UIPromptPresentTool: BuiltInCodeModeTool { + static let codeModeTitle = "Present prompt with text fields" + static let codeModeSummary = "Present a system alert with one or more text fields and custom buttons." + static let codeModeTags = ["ui", "alert", "prompt", "input", "system-ui"] + static let codeModeExample = "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })" + static let codeModeResultSummary = "Object with selected button metadata and values keyed by field id." + + struct Arguments: Sendable { + @ToolParam("Array of { id?, placeholder?, text?/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.") + var fields: [JSONValue] + @ToolParam("Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.") + var buttons: [JSONValue] + @ToolParam("Optional alert title.") + var title: String? + @ToolParam("Optional alert message.") + var message: String? + @ToolParam("Optional timeout for waiting on user selection.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.presentPrompt(arguments: arguments.raw, context: context) + } +} + +@BuiltInCodeMode(.settingsUIOpen, path: "apple.settings.open") +struct SettingsUIOpenTool: BuiltInCodeModeTool { + static let codeModeTitle = "Open app settings" + static let codeModeSummary = "Open the host app's Settings page so the user can recover denied permissions." + static let codeModeTags = ["settings", "permissions", "system-ui"] + static let codeModeExample = "await apple.settings.open()" + static let codeModeResultSummary = "Object with action opened/failed and opened boolean." + + struct Arguments: Sendable { + @ToolParam("Optional timeout for waiting on UIApplication.open completion.") + var timeoutMs: Double? + var raw: [String: JSONValue] + } + + let systemUI: SystemUIBridge + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try systemUI.openSettings(arguments: arguments.raw, context: context) + } +} diff --git a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift index d8a0a3d..62cace0 100644 --- a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift +++ b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift @@ -128,6 +128,7 @@ public enum CodeModeAgentToolDescriptions { PERMISSION_DENIED: the capability is allowlisted but the OS, host, or custom provider denied permission; request permission if a helper exists, otherwise tell the user or host. UI_PRESENTER_UNAVAILABLE: host configuration issue; do not retry the same call. INVALID_ARGUMENTS: use the catalog requiredArguments, optionalArguments, argumentHints, and example. + NETWORK_POLICY_VIOLATION: the host's network access policy refused the destination; do not retry the same URL and do not add capabilities to work around it. """ ) diff --git a/Sources/CodeMode/Host/CodeModeAgentTools.swift b/Sources/CodeMode/Host/CodeModeAgentTools.swift index 2163504..1959dfb 100644 --- a/Sources/CodeMode/Host/CodeModeAgentTools.swift +++ b/Sources/CodeMode/Host/CodeModeAgentTools.swift @@ -8,6 +8,7 @@ public final class CodeModeAgentTools: @unchecked Sendable { public init(config: CodeModeConfiguration = .init()) { let allDefaultRegistrations = DefaultCapabilityLoader.loadAllRegistrations( fileSystem: config.fileSystem, + networkAccessPolicy: config.networkAccessPolicy, eventInbox: config.eventInbox, cloudKitClient: config.cloudKitClient, remoteNotificationsClient: config.remoteNotificationsClient, diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 1914b78..7e7d489 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -48,71 +48,23 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { return .init(allowedStringValues: [ "options.responseEncoding": ["text", "base64"], ]) - case .calendarWrite: - return .init(allowedStringValues: [ - "operation": ["create", "update"], - ]) - case .calendarDelete: - return .init(allowedStringValues: [ - "span": ["thisEvent", "futureEvents"], - ]) - case .calendarUIPickCalendar: - return .init(allowedStringValues: [ - "selectionStyle": ["single", "multiple"], - "displayStyle": ["writable", "all"], - ]) - case .remindersWrite: - return .init(allowedStringValues: [ - "operation": ["create", "update", "complete"], - ]) - case .photosRead: - return .init(allowedStringValues: [ - "mediaType": ["any", "image", "photo", "video"], - ]) - case .photosUIPick: - return .init(allowedStringValues: [ - "mediaType": ["any", "image", "photo", "video"], - ]) - case .contactsUIPick: - return .init(allowedStringValues: [ - "mode": ["single", "multiple"], - ]) - case .cameraUICapture: - return .init(allowedStringValues: [ - "mediaType": ["any", "image", "photo", "video"], - "cameraDevice": ["rear", "front"], - "flashMode": ["auto", "on", "off"], - "videoQuality": ["high", "medium", "low", "640x480", "iFrame1280x720", "iFrame960x540", "iframe1280x720", "iframe960x540"], - ]) - case .cameraUIScanData: - return .init(allowedStringValues: [ - "mode": ["any", "text", "barcode"], - "qualityLevel": ["balanced", "fast", "accurate"], - ]) - case .printUIPresent: - return .init(allowedStringValues: [ - "outputType": ["general", "photo", "grayscale"], - ]) - case .uiAlertPresent: - return .init(allowedStringValues: [ - "preferredStyle": ["alert", "actionSheet", "actionsheet"], - ]) - case .cloudKitRecordsQuery, .cloudKitRecordSave, .cloudKitRecordDelete, .cloudKitSubscriptionSave: - return .init(allowedStringValues: [ - "database": ["private", "shared", "public"], - ]) - case .activityEnd: - return .init(allowedStringValues: [ - "dismissalPolicy": ["default", "immediate"], - ]) - case .mapsRouteEstimate, .mapsOpen: - return .init(allowedStringValues: [ - "transportType": ["automobile", "walking", "transit", "any"], - ]) - case .musicPlaybackControl: - return .init(allowedStringValues: [ - "action": ["play", "pause", "stop", "skipToNext", "skipToPrevious", "playCatalog", "playLibrary"], - ]) + // calendarWrite / calendarDelete / calendarUIPickCalendar / remindersWrite + // 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' + // CodeModeStringEnum arguments (SystemUICodeModeTools.swift / + // PeoplePhotosDocumentsCodeModeTools.swift), not this table. + // cameraUICapture / cameraUIScanData / printUIPresent / uiAlertPresent + // constraints now come from their tools' CodeModeStringEnum arguments + // (SystemUICodeModeTools.swift), not this table. + // cloudKit database constraint now comes from the tools' CloudKitDatabase + // argument (CloudPushSpeechCodeModeTools.swift), not this table. + // activityEnd (ActivityDismissalPolicy) and mapsRouteEstimate/mapsOpen + // (MapsTransportType) constraints now come from their tools' + // CodeModeStringEnum arguments (IntentsModelsActivityMapsCodeModeTools.swift). + // musicPlaybackControl action constraint now comes from the tool's + // MusicPlaybackAction argument (CommerceCodeModeTools.swift). default: return .none } @@ -130,7 +82,10 @@ public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { guard let string = value.stringValue else { throw BridgeError.invalidArguments("\(capabilityName) expected '\(path)' as string, received \(Self.jsonTypeName(for: value))") } - guard allowed.contains(string) else { + // Bridges parse these values case-insensitively; match that here so the + // registry never rejects an argument the bridge would accept. + let normalized = string.lowercased() + guard allowed.contains(where: { $0.lowercased() == normalized }) else { throw BridgeError.invalidArguments("\(capabilityName) \(path) must be one of \(allowed.joined(separator: ", "))") } } diff --git a/Sources/CodeMode/Runtime/BridgeRuntime.swift b/Sources/CodeMode/Runtime/BridgeRuntime.swift index fb467c4..4eec3c2 100644 --- a/Sources/CodeMode/Runtime/BridgeRuntime.swift +++ b/Sources/CodeMode/Runtime/BridgeRuntime.swift @@ -436,46 +436,110 @@ final class BridgeRuntime: @unchecked Sendable { }); """ + let watchdog = ExecutionWatchdog(timeoutMs: timeoutMs, cancellationController: cancellationController) + watchdog.install(on: context) + defer { watchdog.uninstall(from: context) } + lastException.set(nil) let evaluation = context.evaluateScript(script) + + switch watchdog.termination { + case .timedOut: + throw toolError( + code: "EXECUTION_TIMEOUT", + message: "Execution timed out after \(timeoutMs)ms", + transcript: invocationContext + ) + case .cancelled: + throw toolError( + code: "CANCELLED", + message: "Execution cancelled", + transcript: invocationContext + ) + case nil: + break + } + let snapshot = lastException.get() ?? Self.snapshot(from: context.exception) if snapshot != nil || evaluation == nil { throw syntaxError(from: snapshot, lineOffset: 4) } - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) + let settlement = try waitForSettlement( + context: context, + watchdog: watchdog, + cancellationController: cancellationController, + invocationContext: invocationContext, + timeoutCode: "EXECUTION_TIMEOUT", + timeoutMessage: "Execution timed out after \(timeoutMs)ms", + cancelMessage: "Execution cancelled" + ) + + // Give result serialization its own bounded budget so a script that + // settled near the deadline still serializes, while a runaway getter or + // toJSON is terminated instead of hanging the execution thread. + watchdog.rearm(timeoutMs: max(timeoutMs, 1_000)) + + switch settlement { + case .fulfilled: + let output = try decodeOutput(from: context) + if output == nil { + invocationContext.recordDiagnostic(Self.noReturnValueDiagnostic(for: code)) + } + return output + case .rejected: + let payload = rejectionPayload(from: context) + throw classifyRejectedError(payload, invocationContext: invocationContext) + } + } + + private enum SettlementState { + case fulfilled + case rejected + } - while Date() < deadline { + /// Polls the wrapped promise's settled state until it resolves, is + /// cancelled, or the watchdog deadline passes. The settled state is checked + /// before the deadline on every iteration, so a script that has already + /// fulfilled — the common case under the synchronous bridge model — returns + /// its result even if evaluation finished a hair past the deadline, instead + /// of being discarded with a spurious timeout. + private func waitForSettlement( + context: JSContext, + watchdog: ExecutionWatchdog, + cancellationController: ExecutionCancellationController, + invocationContext: BridgeInvocationContext, + timeoutCode: String, + timeoutMessage: String, + cancelMessage: String + ) throws -> SettlementState { + while true { if cancellationController.isCancelled || Task.isCancelled { cancellationController.cancel() - throw toolError( - code: "CANCELLED", - message: "Execution cancelled", - transcript: invocationContext - ) + throw toolError(code: "CANCELLED", message: cancelMessage, transcript: invocationContext) } let state = context.evaluateScript("globalThis.__codemode.state")?.toString() ?? "unknown" switch state { case "fulfilled": - let output = try decodeOutput(from: context) - if output == nil { - invocationContext.recordDiagnostic(Self.noReturnValueDiagnostic(for: code)) - } - return output + return .fulfilled case "rejected": - let payload = rejectionPayload(from: context) - throw classifyRejectedError(payload, invocationContext: invocationContext) + return .rejected default: + if let termination = watchdog.termination { + switch termination { + case .cancelled: + throw toolError(code: "CANCELLED", message: cancelMessage, transcript: invocationContext) + case .timedOut: + throw toolError(code: timeoutCode, message: timeoutMessage, transcript: invocationContext) + } + } + if Date() >= watchdog.deadline { + throw toolError(code: timeoutCode, message: timeoutMessage, transcript: invocationContext) + } Thread.sleep(forTimeInterval: 0.01) } } - - throw toolError( - code: "EXECUTION_TIMEOUT", - message: "Execution timed out after \(timeoutMs)ms", - transcript: invocationContext - ) } private static func noReturnValueDiagnostic(for code: String) -> ToolDiagnostic { @@ -547,46 +611,58 @@ final class BridgeRuntime: @unchecked Sendable { }); """ + let watchdog = ExecutionWatchdog(timeoutMs: timeoutMs, cancellationController: cancellationController) + watchdog.install(on: context) + defer { watchdog.uninstall(from: context) } + lastException.set(nil) let evaluation = context.evaluateScript(script) + + switch watchdog.termination { + case .timedOut: + throw toolError( + code: "SEARCH_TIMEOUT", + message: "Search timed out after \(timeoutMs)ms", + transcript: invocationContext + ) + case .cancelled: + throw toolError( + code: "CANCELLED", + message: "Search cancelled", + transcript: invocationContext + ) + case nil: + break + } + let snapshot = lastException.get() ?? Self.snapshot(from: context.exception) if snapshot != nil || evaluation == nil { throw syntaxError(from: snapshot, lineOffset: 5) } - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) + let settlement = try waitForSettlement( + context: context, + watchdog: watchdog, + cancellationController: cancellationController, + invocationContext: invocationContext, + timeoutCode: "SEARCH_TIMEOUT", + timeoutMessage: "Search timed out after \(timeoutMs)ms", + cancelMessage: "Search cancelled" + ) - while Date() < deadline { - if cancellationController.isCancelled || Task.isCancelled { - cancellationController.cancel() - throw toolError( - code: "CANCELLED", - message: "Search cancelled", - transcript: invocationContext - ) - } + watchdog.rearm(timeoutMs: max(timeoutMs, 1_000)) - let state = context.evaluateScript("globalThis.__codemode.state")?.toString() ?? "unknown" - switch state { - case "fulfilled": - return try decodeOutput( - from: context, - errorCode: "INVALID_SEARCH_RESULT", - errorMessagePrefix: "Search result must be JSON-serializable" - ) - case "rejected": - let payload = rejectionPayload(from: context) - throw classifySearchRejectedError(payload, invocationContext: invocationContext) - default: - Thread.sleep(forTimeInterval: 0.01) - } + switch settlement { + case .fulfilled: + return try decodeOutput( + from: context, + errorCode: "INVALID_SEARCH_RESULT", + errorMessagePrefix: "Search result must be JSON-serializable" + ) + case .rejected: + let payload = rejectionPayload(from: context) + throw classifySearchRejectedError(payload, invocationContext: invocationContext) } - - throw toolError( - code: "SEARCH_TIMEOUT", - message: "Search timed out after \(timeoutMs)ms", - transcript: invocationContext - ) } private func decodeOutput( @@ -858,6 +934,12 @@ final class BridgeRuntime: @unchecked Sendable { "Configure CodeModeConfiguration.systemUIPresenter before using UI-presenting helpers.", "Do not retry this helper until the host provides a SystemUIPresenter.", ] + case .networkPolicyViolation: + suggestions = [ + "The host app's network access policy refused this destination.", + "This is not repaired by adding more allowedCapabilities; do not retry the same URL.", + "Only public HTTP(S) destinations permitted by CodeModeConfiguration.networkAccessPolicy are reachable.", + ] default: suggestions = bridgeSuggestions(for: capability, capabilityKey: capabilityKey) } @@ -920,6 +1002,7 @@ final class BridgeRuntime: @unchecked Sendable { "UI_PRESENTER_UNAVAILABLE", "EXECUTION_TIMEOUT", "PATH_POLICY_VIOLATION", + "NETWORK_POLICY_VIOLATION", "JAVASCRIPT_ERROR", "NATIVE_FAILURE", "CANCELLED", diff --git a/Sources/CodeMode/Runtime/ExecutionWatchdog.swift b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift new file mode 100644 index 0000000..a9b8ea4 --- /dev/null +++ b/Sources/CodeMode/Runtime/ExecutionWatchdog.swift @@ -0,0 +1,140 @@ +import Foundation +import JavaScriptCore +import CCodeModeJSC + +/// Preemptively terminates JavaScript that runs past its deadline or is cancelled. +/// +/// Bridge calls are synchronous, so the entire user script — including its full +/// promise graph — settles inside a single `evaluateScript` call. A wall-clock +/// check after evaluation can therefore never interrupt CPU-bound code such as +/// `while (true) {}`. This installs a JavaScriptCore execution time limit whose +/// callback re-checks the deadline and the cancellation flag on a short interval +/// and terminates the script when either trips. +/// +/// The underlying `JSContextGroupSetExecutionTimeLimit` API is reached through +/// the `CCodeModeJSC` shim because it is exported by JavaScriptCore but declared +/// only in a private WebKit header. It is the only mechanism JavaScriptCore +/// offers for interrupting runaway scripts; hosts shipping to the App Store +/// should be aware they are relying on this exported-but-private symbol. +/// +/// Because the time limit is measured against script CPU time, a synchronous +/// bridge call that blocks on native I/O (network, a UI picker) does not itself +/// count against the deadline while it is blocked, but the time already elapsed +/// does: `timeoutMs` bounds total wall-clock across an execution, so hosts that +/// present long-running UI or issue slow requests should size `timeoutMs` +/// accordingly. +final class ExecutionWatchdog: @unchecked Sendable { + enum Termination: Sendable { + case timedOut + case cancelled + } + + /// How often the termination callback runs while a script is executing. + /// Bounds both timeout overshoot and cancellation latency. + /// + /// JavaScriptCore is documented to re-invoke the callback on this interval + /// when it returns false, but on current OS releases (observed on macOS 26) + /// returning false permanently disarms the watchdog instead. The callback + /// therefore re-installs the time limit itself before returning false; see + /// `codeModeWatchdogShouldTerminate`. + fileprivate static let checkInterval: TimeInterval = 0.05 + + private let lock = NSLock() + private var deadlineValue: Date + private var terminationValue: Termination? + private let cancellationController: ExecutionCancellationController + + init(timeoutMs: Int, cancellationController: ExecutionCancellationController) { + self.deadlineValue = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + self.cancellationController = cancellationController + } + + var deadline: Date { + lock.lock() + defer { lock.unlock() } + return deadlineValue + } + + var termination: Termination? { + lock.lock() + defer { lock.unlock() } + return terminationValue + } + + /// Starts a fresh time budget for a follow-on phase such as serializing the + /// result. A script that settled close to the original deadline still gets a + /// bounded-but-usable window to run `JSON.stringify` (which may invoke + /// user-defined getters/`toJSON`), while a runaway getter is still terminated. + func rearm(timeoutMs: Int) { + lock.lock() + deadlineValue = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + lock.unlock() + } + + func install(on context: JSContext) { + guard let contextRef = context.jsGlobalContextRef else { + return + } + let group = JSContextGetGroup(contextRef) + let info = Unmanaged.passUnretained(self).toOpaque() + ccodemode_set_execution_time_limit(group, Self.checkInterval, codeModeWatchdogShouldTerminate, info) + } + + /// Callers must keep the watchdog installed only while `self` is alive; the + /// callback holds an unretained reference. + func uninstall(from context: JSContext) { + guard let contextRef = context.jsGlobalContextRef else { + return + } + ccodemode_clear_execution_time_limit(JSContextGetGroup(contextRef)) + } + + fileprivate func shouldTerminate() -> Bool { + lock.lock() + defer { lock.unlock() } + + if terminationValue != nil { + return true + } + if cancellationController.isCancelled { + terminationValue = .cancelled + return true + } + if Date() >= deadlineValue { + terminationValue = .timedOut + return true + } + return false + } +} + +/// C-convention termination callback passed to `JSContextGroupSetExecutionTimeLimit`. +/// +/// A free function rather than a closure so it can reference itself: on current +/// OS releases (observed on macOS 26) JavaScriptCore does not re-arm the time +/// limit when the callback returns false — the callback fires exactly once and +/// the watchdog is dead for the rest of the execution. Re-installing the limit +/// here before returning false restores the documented periodic-check behavior. +/// Verified against a minimal JSC reproduction; without the re-arm a +/// `while (true) {}` script runs forever after the first 50ms check. +private func codeModeWatchdogShouldTerminate( + _ ctx: JSContextRef?, + _ info: UnsafeMutableRawPointer? +) -> Bool { + guard let info else { + return false + } + let watchdog = Unmanaged.fromOpaque(info).takeUnretainedValue() + if watchdog.shouldTerminate() { + return true + } + if let ctx { + ccodemode_set_execution_time_limit( + JSContextGetGroup(ctx), + ExecutionWatchdog.checkInterval, + codeModeWatchdogShouldTerminate, + info + ) + } + return false +} diff --git a/Sources/CodeMode/Security/NetworkAccessPolicy.swift b/Sources/CodeMode/Security/NetworkAccessPolicy.swift new file mode 100644 index 0000000..3dd10f8 --- /dev/null +++ b/Sources/CodeMode/Security/NetworkAccessPolicy.swift @@ -0,0 +1,173 @@ +import Foundation + +/// Controls which destinations `network.fetch` may reach and how much response +/// data it may buffer. +/// +/// The default (`.standard`) blocks loopback, private-range, link-local, and +/// other non-public destinations so agent-authored JavaScript cannot reach +/// `127.0.0.1`, cloud metadata endpoints such as `169.254.169.254`, or hosts on +/// the local network, and caps buffered response bodies at 10 MB. +/// +/// Host matching is by URL host only; DNS resolution is not performed, so a +/// public hostname that resolves to a private address is not detected. Hosts +/// that need stricter guarantees should set `allowedHosts`. +public struct NetworkAccessPolicy: Sendable, Equatable { + /// When non-nil, only these hosts are reachable. Entries match the host + /// exactly or as a parent domain (`"example.com"` also matches + /// `"api.example.com"`). Entries bypass the private-network check, so a + /// host app can deliberately allowlist `"localhost"`. + public var allowedHosts: [String]? + + /// Hosts that are always refused, matched like `allowedHosts` entries and + /// checked before it. + public var blockedHosts: [String] + + /// Refuses loopback, RFC 1918, link-local (including cloud metadata), + /// CGNAT, unique-local and IPv4-mapped IPv6 literals, `localhost`, and + /// `.local`/`.localhost`/`.internal` names. + public var blocksPrivateNetworks: Bool + + /// Maximum response body size in bytes that fetch will buffer. Responses + /// that exceed this are cancelled mid-transfer. + public var maxResponseBytes: Int + + public init( + allowedHosts: [String]? = nil, + blockedHosts: [String] = [], + blocksPrivateNetworks: Bool = true, + maxResponseBytes: Int = 10_485_760 + ) { + self.allowedHosts = allowedHosts + self.blockedHosts = blockedHosts + self.blocksPrivateNetworks = blocksPrivateNetworks + self.maxResponseBytes = maxResponseBytes + } + + /// Secure default: private networks blocked, 10 MB response cap. + public static let standard = NetworkAccessPolicy() + + /// No destination restrictions and no response size cap. Matches the + /// pre-policy behavior of `network.fetch`; opt in deliberately. + public static let permissive = NetworkAccessPolicy( + blocksPrivateNetworks: false, + maxResponseBytes: .max + ) + + /// Returns a human-readable reason the URL is refused, or nil when allowed. + func violationReason(for url: URL) -> String? { + guard let rawHost = url.host, rawHost.isEmpty == false else { + return "URL has no host" + } + let host = Self.normalized(host: rawHost) + + if blockedHosts.contains(where: { Self.host(host, matches: $0) }) { + return "Host \"\(host)\" is blocked by the network access policy" + } + + if let allowedHosts { + guard allowedHosts.contains(where: { Self.host(host, matches: $0) }) else { + return "Host \"\(host)\" is not in the network access policy allowlist" + } + return nil + } + + if blocksPrivateNetworks, Self.isPrivateOrLocal(host: host) { + return "Host \"\(host)\" is a loopback, private, or link-local destination blocked by the network access policy" + } + + return nil + } + + private static func normalized(host: String) -> String { + var host = host.lowercased() + if host.hasPrefix("["), host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + // A trailing root dot ("localhost.", "example.com.") denotes the same + // host to DNS; strip it so suffix and literal matching cannot be bypassed. + while host.hasSuffix(".") { + host.removeLast() + } + return host + } + + private static func host(_ host: String, matches pattern: String) -> Bool { + let pattern = normalized(host: pattern) + return host == pattern || host.hasSuffix(".\(pattern)") + } + + private static func isPrivateOrLocal(host: String) -> Bool { + if host == "localhost" + || host.hasSuffix(".localhost") + || host.hasSuffix(".local") + || host.hasSuffix(".internal") + { + return true + } + + // inet_aton accepts every numeric IPv4 form URL loaders resolve as an + // address (dotted quad, partial quads, decimal, octal, and hex), so + // encoded literals like "2130706433" cannot slip past the check. + var ipv4 = in_addr() + if host.contains(":") == false, inet_aton(host, &ipv4) != 0 { + return isPrivateOrLocal(ipv4: UInt32(bigEndian: ipv4.s_addr)) + } + + let ipv6Host = host.split(separator: "%").first.map(String.init) ?? host + var ipv6 = in6_addr() + if inet_pton(AF_INET6, ipv6Host, &ipv6) == 1 { + return isPrivateOrLocal(ipv6: ipv6) + } + + return false + } + + private static func isPrivateOrLocal(ipv4 address: UInt32) -> Bool { + let octet1 = UInt8(truncatingIfNeeded: address >> 24) + let octet2 = UInt8(truncatingIfNeeded: address >> 16) + + switch octet1 { + case 0, 10, 127, 255: + return true + case 100: + return (64...127).contains(octet2) // CGNAT 100.64.0.0/10 + case 169: + return octet2 == 254 // link-local + cloud metadata + case 172: + return (16...31).contains(octet2) + case 192: + return octet2 == 168 + default: + return false + } + } + + private static func isPrivateOrLocal(ipv6 address: in6_addr) -> Bool { + let bytes = withUnsafeBytes(of: address) { Array($0) } + + func embeddedIPv4() -> UInt32 { + UInt32(bytes[12]) << 24 | UInt32(bytes[13]) << 16 | UInt32(bytes[14]) << 8 | UInt32(bytes[15]) + } + + if bytes[0..<15].allSatisfy({ $0 == 0 }) { + return bytes[15] == 0 || bytes[15] == 1 // unspecified or loopback + } + if bytes[0] & 0xfe == 0xfc { + return true // unique local fc00::/7 + } + if bytes[0] == 0xfe, bytes[1] & 0xc0 == 0x80 { + return true // link-local fe80::/10 + } + if bytes[0..<10].allSatisfy({ $0 == 0 }), bytes[10] == 0xff, bytes[11] == 0xff { + return isPrivateOrLocal(ipv4: embeddedIPv4()) // IPv4-mapped ::ffff:a.b.c.d + } + if bytes[0..<12].allSatisfy({ $0 == 0 }) { + return isPrivateOrLocal(ipv4: embeddedIPv4()) // IPv4-compatible ::a.b.c.d (deprecated) + } + if bytes[0] == 0x00, bytes[1] == 0x64, bytes[2] == 0xff, bytes[3] == 0x9b, + bytes[4..<12].allSatisfy({ $0 == 0 }) { + return isPrivateOrLocal(ipv4: embeddedIPv4()) // NAT64 64:ff9b::/96 + } + return false + } +} diff --git a/Sources/CodeMode/Security/SystemPermissionBroker.swift b/Sources/CodeMode/Security/SystemPermissionBroker.swift index cb73e53..27f4451 100644 --- a/Sources/CodeMode/Security/SystemPermissionBroker.swift +++ b/Sources/CodeMode/Security/SystemPermissionBroker.swift @@ -426,10 +426,13 @@ public final class SystemPermissionBroker: PermissionBroker, @unchecked Sendable if Thread.isMainThread { manager.requestWhenInUseAuthorization() return locationStatus() - } else { - DispatchQueue.main.sync { - manager.requestWhenInUseAuthorization() - } + } + + // async, not sync: execution runs on a background queue, and the host may be + // blocking the main thread waiting on the result — main.sync would deadlock. + // If main never runs the request, the delegate wait below times out instead. + DispatchQueue.main.async { + manager.requestWhenInUseAuthorization() } _ = delegate.wait(timeout: 10) diff --git a/Tools/CodeModeAuthoring/Sources/CodeModeAuthoring/CodeModeMacros.swift b/Sources/CodeModeAuthoring/CodeModeMacros.swift similarity index 100% rename from Tools/CodeModeAuthoring/Sources/CodeModeAuthoring/CodeModeMacros.swift rename to Sources/CodeModeAuthoring/CodeModeMacros.swift diff --git a/Tools/CodeModeAuthoring/README.md b/Sources/CodeModeAuthoring/README.md similarity index 82% rename from Tools/CodeModeAuthoring/README.md rename to Sources/CodeModeAuthoring/README.md index 21a3b50..57069fa 100644 --- a/Tools/CodeModeAuthoring/README.md +++ b/Sources/CodeModeAuthoring/README.md @@ -1,6 +1,11 @@ # CodeModeAuthoring -`CodeModeAuthoring` contains the optional `@CodeMode` macro package. It lives outside the core `CodeMode` package so runtime clients and eval tooling do not inherit a `swift-syntax` dependency. +`CodeModeAuthoring` is the optional `@CodeMode` macro surface for host-authored +tools, shipped as a product of the main package (depend on the +`CodeModeAuthoring` product). The `CodeModeMacros` compiler plugin behind it is +the same one the core library uses internally for `@BuiltInCodeMode`; current +toolchains resolve swift-syntax as prebuilt libraries, so the build cost is +small (measured in `PLAN-registration-macros.md`). Hosts that want macro-authored providers can import `CodeModeAuthoring`: diff --git a/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift new file mode 100644 index 0000000..f93dafd --- /dev/null +++ b/Sources/CodeModeMacros/BuiltInCodeModeMacro.swift @@ -0,0 +1,310 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// No-op marker carrying the LLM-facing hint for one argument of a built-in +/// tool; `@BuiltInCodeMode` reads it while generating `codeModeArguments`. +public struct ToolParamMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} + +/// Generates the mechanical members of a `BuiltInCodeModeTool` from its nested +/// `Arguments` struct: identity statics (`codeModeCapability`, `codeModePath`, +/// `codeModeAliasPaths`), the `codeModeArguments` metadata list, and +/// `decode(arguments:)`. Curated metadata (title, summary, tags, example, +/// permissions, result summary) and `call(arguments:context:)` stay +/// hand-written. +/// +/// Field types the macro does not recognize as JSON primitives are emitted +/// through `CodeModeStringEnum`-constrained overloads +/// (`BuiltInToolArgument(_:oneOf:...)`, `CodeModeToolRawArguments.canonicalize`), +/// so the compiler — not the macro — enforces that such a type really is a +/// `CodeModeStringEnum`, and the constraint metadata is derived from the enum. +/// +/// A stored property `var raw: [String: JSONValue]` without `@ToolParam` is +/// filled with the full argument dictionary, with constrained values replaced +/// by their canonical spelling — the passthrough transitional tools use while +/// bridges still consume raw dictionaries. +public struct BuiltInCodeModeMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self), + let capabilityExpr = arguments.first(where: { $0.label == nil })?.expression.trimmedDescription, + let pathExpr = arguments.first(where: { $0.label?.text == "path" })?.expression.trimmedDescription + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode requires a capability and a path", node: Syntax(node))) + return [] + } + let aliasesExpr = arguments.first(where: { $0.label?.text == "aliases" })?.expression.trimmedDescription + + guard let argumentsStruct = declaration.memberBlock.members + .compactMap({ $0.decl.as(StructDeclSyntax.self) }) + .first(where: { $0.name.text == "Arguments" }) + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode requires a nested Arguments struct", node: Syntax(declaration))) + return [] + } + + guard let parsed = parseFields(of: argumentsStruct, context: context) else { + return [] + } + + var members: [String] = [ + "static let codeModeCapability: CapabilityID = \(capabilityExpr)", + "static let codeModePath: String = \(pathExpr)", + ] + if let aliasesExpr { + members.append("static let codeModeAliasPaths: [String] = \(aliasesExpr)") + } + members.append(argumentsMetadataSource(for: parsed.fields)) + members.append(decodeSource(for: parsed)) + + return members.map { DeclSyntax(stringLiteral: $0) } + } + + // MARK: Field parsing + + private struct ParsedArguments { + var fields: [Field] + var hasRawPassthrough: Bool + } + + private struct Field { + var name: String + var swiftType: String + var kind: FieldKind + var optional: Bool + var hint: String + } + + fileprivate enum FieldKind { + /// A JSON primitive or collection with a direct CapabilityArgumentType. + case primitive(String) + /// An unrecognized simple type, emitted through the + /// CodeModeStringEnum-constrained overloads. + case stringEnum + } + + private static func parseFields( + of argumentsStruct: StructDeclSyntax, + context: some MacroExpansionContext + ) -> ParsedArguments? { + var fields: [Field] = [] + var hasRawPassthrough = false + var rawIsLast = true + var invalid = false + + for member in argumentsStruct.memberBlock.members { + guard let variable = member.decl.as(VariableDeclSyntax.self) else { + continue + } + guard variable.bindings.count == 1, + let binding = variable.bindings.first, + binding.accessorBlock == nil + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode supports only simple stored properties in Arguments", node: Syntax(variable))) + invalid = true + continue + } + guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self), + let type = binding.typeAnnotation?.type.trimmedDescription + else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode Arguments properties need a name and an explicit type", node: Syntax(binding))) + invalid = true + continue + } + + let name = identifier.identifier.text + let hint = toolParamHint(on: variable.attributes) + + if hint == nil, name == "raw" { + guard type.replacingOccurrences(of: " ", with: "") == "[String:JSONValue]" else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode raw passthrough must be [String: JSONValue]", node: Syntax(binding))) + invalid = true + continue + } + hasRawPassthrough = true + rawIsLast = true + continue + } + rawIsLast = false + + guard let hint else { + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode Arguments properties need @ToolParam(\"hint\") (or be the raw passthrough)", node: Syntax(binding))) + invalid = true + continue + } + + let info = BuiltInTypeInfo(typeSyntax: type) + fields.append( + Field( + name: name, + swiftType: info.unwrappedType, + kind: info.kind, + optional: info.optional, + hint: hint + ) + ) + } + + if hasRawPassthrough, rawIsLast == false { + // decode constructs Arguments with the memberwise initializer, so the + // passthrough must be the last stored property. + context.diagnose(BuiltInMacroDiagnostic("@BuiltInCodeMode raw passthrough must be the last stored property of Arguments", node: Syntax(argumentsStruct.name))) + return nil + } + + return invalid ? nil : ParsedArguments(fields: fields, hasRawPassthrough: hasRawPassthrough) + } + + private static func toolParamHint(on attributes: AttributeListSyntax) -> String? { + for element in attributes { + guard case let .attribute(attribute) = element, + attribute.attributeName.trimmedDescription == "ToolParam", + let arguments = attribute.arguments?.as(LabeledExprListSyntax.self), + let first = arguments.first, + let literal = first.expression.as(StringLiteralExprSyntax.self) + else { + continue + } + return literal.segments.compactMap { segment in + segment.as(StringSegmentSyntax.self)?.content.text + }.joined() + } + return nil + } + + // MARK: Code generation + + private static func argumentsMetadataSource(for fields: [Field]) -> String { + guard fields.isEmpty == false else { + return "static let codeModeArguments: [BuiltInToolArgument] = []" + } + let entries = fields.map { field -> String in + let optionalPart = field.optional ? ", optional: true" : "" + switch field.kind { + case let .primitive(argumentType): + return "BuiltInToolArgument(\(stringLiteral(field.name)), .\(argumentType)\(optionalPart), hint: \(stringLiteral(field.hint)))," + case .stringEnum: + return "BuiltInToolArgument(\(stringLiteral(field.name)), oneOf: \(field.swiftType).self\(optionalPart), hint: \(stringLiteral(field.hint)))," + } + }.joined(separator: "\n ") + return """ + static let codeModeArguments: [BuiltInToolArgument] = [ + \(entries) + ] + """ + } + + private static func decodeSource(for parsed: ParsedArguments) -> String { + var body: [String] = [] + let enumFields = parsed.fields.filter { + if case .stringEnum = $0.kind { return true } + return false + } + + if parsed.hasRawPassthrough { + body.append(enumFields.isEmpty ? "let raw = arguments" : "var raw = arguments") + } + for field in parsed.fields { + let method = field.optional ? "optional" : "require" + body.append("let \(field.name) = try CodeModeArgumentDecoder.\(method)(\(stringLiteral(field.name)), as: \(field.swiftType).self, in: arguments)") + } + if parsed.hasRawPassthrough { + for field in enumFields { + body.append("CodeModeToolRawArguments.canonicalize(&raw, \(stringLiteral(field.name)), \(field.name))") + } + } + + var initArguments = parsed.fields.map { "\($0.name): \($0.name)" } + if parsed.hasRawPassthrough { + initArguments.append("raw: raw") + } + body.append(initArguments.isEmpty ? "return Arguments()" : "return Arguments(\(initArguments.joined(separator: ", ")))") + + return """ + func decode(arguments: [String: JSONValue]) throws -> Arguments { + \(body.joined(separator: "\n ")) + } + """ + } + + private static func stringLiteral(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" + } +} + +private struct BuiltInTypeInfo { + var unwrappedType: String + var kind: BuiltInCodeModeMacro.FieldKind + var optional: Bool + + init(typeSyntax rawType: String) { + let trimmed = rawType.replacingOccurrences(of: " ", with: "") + if trimmed.hasSuffix("?") { + optional = true + unwrappedType = String(trimmed.dropLast()) + } else if trimmed.hasPrefix("Optional<"), trimmed.hasSuffix(">") { + optional = true + unwrappedType = String(trimmed.dropFirst("Optional<".count).dropLast()) + } else { + optional = false + unwrappedType = trimmed + } + + switch unwrappedType { + case "String": + kind = .primitive("string") + case "Bool": + kind = .primitive("bool") + case "Int", "Double", "Float": + kind = .primitive("number") + case "JSONValue": + kind = .primitive("any") + default: + if unwrappedType.hasPrefix("["), unwrappedType.hasSuffix("]") { + kind = .primitive(unwrappedType.contains(":") ? "object" : "array") + } else { + // Unrecognized simple type: emitted through CodeModeStringEnum- + // constrained overloads; the compiler rejects anything else. + kind = .stringEnum + } + } + } +} + +private struct BuiltInMacroDiagnostic: DiagnosticMessage { + var message: String + var diagnosticID: MessageID + var severity: DiagnosticSeverity + var node: Syntax + + init(_ message: String, node: Syntax) { + self.message = message + self.diagnosticID = MessageID(domain: "CodeModeMacros", id: message) + self.severity = .error + self.node = node + } +} + +private extension MacroExpansionContext { + func diagnose(_ message: BuiltInMacroDiagnostic) { + diagnose(Diagnostic(node: message.node, message: message)) + } +} diff --git a/Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift b/Sources/CodeModeMacros/CodeModeMacro.swift similarity index 99% rename from Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift rename to Sources/CodeModeMacros/CodeModeMacro.swift index fe7b18b..72f6086 100644 --- a/Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift +++ b/Sources/CodeModeMacros/CodeModeMacro.swift @@ -11,6 +11,8 @@ struct CodeModePlugin: CompilerPlugin { CodeModeMacro.self, CodeModeParamMacro.self, CodeModeResultMacro.self, + BuiltInCodeModeMacro.self, + ToolParamMacro.self, ] } diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..282f76a --- /dev/null +++ b/TODO.md @@ -0,0 +1,243 @@ +# CodeMode.swift — Working TODO + +Branch: `fixes-and-improvements` (pushed on top of `main`). +Scratch tracking for the follow-up work surfaced by the repo evaluation; kept on +the branch so the remaining items travel with it. + +Everything from that evaluation is represented below, with honest status. + +## Status legend +- [x] done and pushed +- [~] done but unverified (no Swift toolchain / JavaScriptCore in this Linux env) +- [/] partially done — see sub-items +- [ ] not started + +--- + +## CRITICAL ISSUES + +### 1. Timeouts don't actually interrupt running JavaScript [/] +Root cause: synchronous bridge model settles the whole promise graph inside one +`evaluateScript` call, so the old wall-clock poll loop never ran while JS was +executing. The full "make the runtime resource-bounded" milestone is bigger than +just the watchdog: +- [x] Preemptive watchdog via `JSContextGroupSetExecutionTimeLimit` (through the + `CCodeModeJSC` shim) → real `timeoutMs`, `while(true){}` terminates. + - [x] **macOS verification found the Linux-authored version completely broken:** + on current OS releases (observed macOS 26.5) JSC does *not* re-arm the time + limit when the callback returns false — the callback fires exactly once at + 50ms and the watchdog is dead for the rest of the execution, so nothing was + ever terminated (first full `swift test` hung indefinitely with six runaway + JS threads). Root-caused with a minimal C reproduction; fixed by having the + callback re-install the time limit itself before returning false + (`codeModeWatchdogShouldTerminate` in `ExecutionWatchdog.swift`). +- [x] Real cancellation — `cancel()` interrupts in-flight JS. +- [x] Tests for the above (`ExecutionWatchdogTests.swift`) — all 8 pass on + macOS after the re-arm fix (loop, promise-chain loop, runaway getter during + serialization, cancellation, catch-proof termination, context recovery). +- [ ] **JS heap / memory cap** — nothing bounds `JSContext`/`JSContextGroup` + heap; `new Array(1e9)` can still exhaust host memory. NOT addressed. +- [ ] **Bound on concurrent executions** — `executionQueue` is `.concurrent` + with spin-waiting workers (`BridgeRuntime.swift:25-29`), so N long-running + scripts occupy N threads. No concurrency cap. NOT addressed. +- [ ] `runOnExecutionQueue` still has no task-cancellation handler wired into the + dispatched block (best-effort only). NOT addressed. + +### 2. `fetch` has no destination restrictions or size limits [x] +- [x] `NetworkAccessPolicy` on `CodeModeConfiguration`: by default requests are + limited to public hosts — private / loopback / link-local / local-network + addresses (including alternate numeric and IPv6-embedded spellings) and + `localhost`/`.local`/`.internal`/trailing-dot names are declined. +- [x] Redirect targets re-checked against the policy before being followed. +- [x] Response-size cap (default 10 MB), enforced by Content-Length pre-check + + streaming cancel. +- [x] Allow/deny host lists; `.permissive` opt-out for hosts that want the old + unrestricted behavior. +- [x] Request destinations (allowed + declined) now recorded in the audit log, + 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. + +--- + +## OTHER REAL BUGS FOUND IN EVALUATION + +- [x] **Watchdog compile blocker** (found in self-review): + `JSContextGroupSetExecutionTimeLimit`/`...Clear...` are private-header JSC + symbols; added `CCodeModeJSC` C shim so it builds against the public SDK. + - [ ] Owner decision still needed: private-but-exported symbol → App Store + review risk. Keep+document (current) vs gate vs accept. +- [x] **Spurious timeout** (self-review): poll loop discarded an already-settled + result at/just past deadline. Fixed via shared `waitForSettlement`. +- [x] **Serialization hang** (self-review): a runaway getter/`toJSON` could + occupy the thread because the watchdog was uninstalled before decode. Fixed via + rearm. +- [x] **HealthKit always denied through the default broker.** Investigated on + macOS (2026-07-11): the claimed mechanism does not exist in current code — no + built-in registration declares `.healthKit` in `requiredPermissions`, so the + registry's requested→notDetermined→denied path never fires for health. + `HealthBridge` treats `.notDetermined` as passable and performs real per-type + `requestAuthorization` itself. Added a registry test pinning the invariant + (no registration may gate on `.healthKit`, since the default broker can never + report `.granted` for it). +- [x] **Calendar-span validation drift.** Fixed: constraint validation is now + case-insensitive (matching the `lowercased()` idiom used by effectively every + bridge), and the `calendarDelete` span list includes the alias spellings + `EventKitBridge` accepts. Also removed the now-redundant lowercase + `videoQuality` duplicates. Tests added. +- [x] **`DispatchQueue.main.sync` in `requestLocationPermission`.** Fixed: + `main.async` + the existing 10s delegate wait. (The deadlock was background + execution thread → `main.sync` while the host blocks main waiting on the JS + result; the `Thread.isMainThread` branch already covered the direct case.) +- [ ] **Duplicated eval model types will drift.** `LLM.swift` is excluded from + the build; ~250 lines of report/suite types are defined twice (there and in + compiled `LLMEvalModels.swift`) with nothing keeping them in sync. NOT + addressed. + +--- + +## STRUCTURAL IMPROVEMENTS (bridge/registry metadata drift) + +Biggest maintenance risk was ~115 capabilities / ~2,540 lines of hand-written +registrations with four parallel sources of truth. Phases 1–3 of +`PLAN-registration-macros.md` landed 2026-07-11/12 (owner approved swift-syntax +in the core graph): **114 of 115 capabilities are now macro-authored +`@BuiltInCodeMode` tools**; only `networkFetch` remains on the flat init +(PHASE3-SKIP — its nested dotted-path arguments can't be expressed by the flat +tool model). +- [x] Move argument types/constraints to per-capability, co-located declarations + — `BuiltInCodeModeTool` protocol + `CodeModeStringEnum` (constrained string + args declared once: advertised values, decode, and bridge parsing all come + from the enum). All seven domains migrated; the central + `CapabilityArgumentConstraints.defaults(for:)` table now holds only + networkFetch's `options.responseEncoding` dotted-path row. + - [ ] **fail loudly instead of degrading unknown args to `.any`** + (`inferArgumentTypes`) — still pending; only networkFetch depends on it now, + so the table can be deleted once that capability is handled. +- [x] Add a test asserting registration metadata matches bridge reality — + `CapabilityMetadataGoldenTests` pins the full advertised surface of all 115 + capabilities against a committed JSON baseline; + `constrainedArgumentMetadataIsCoherentForAllRegistrations` + the span test pin + enum↔descriptor↔bridge agreement. (Golden-testing `resultSummary` against + bridge JSON encoders still open — the golden pins the string, not the encoder.) +- [x] Standardize on one registration idiom — `BuiltInCodeModeTool` / + `@BuiltInCodeMode` is now the sole idiom for built-ins; the flat descriptor + init survives only for the single networkFetch skip. The + raw-`CodeModeRegistration` and `builtInCapability:` glue idioms are gone. +- [ ] **Fifth metadata surface found during migration:** the hand-written JS + function table in `RuntimeJavaScript.swift` (e.g. `completeReminder` injects + `operation: 'complete', isCompleted: true`). Untouched by Phase 3; candidate + for generation from registrations as a follow-up. +- [ ] Unify permission ownership — `calendarRead` checks in both registry and + bridge; `calendarWrite` checks only in the bridge. +- [x] Decide the fate of `Tools/CodeModeAuthoring` — resolved 2026-07-11: owner + approved swift-syntax in the core graph; the package is folded into the root + package as the `CodeModeAuthoring` product and its `CodeModeMacros` plugin now + also backs the internal `@BuiltInCodeMode` macro (Phases 1+2 of + `PLAN-registration-macros.md` landed; EventKit is macro-authored). Remaining: + per-domain migration (Phase 3) and enum-constraint support for the + host-facing `@CodeMode`. + +--- + +## TESTING, CI, AND EVALS + +- [/] **Core policy layer has zero dedicated tests.** + - [x] `PathPolicy` (`PathPolicyTests.swift`): empty/whitespace, scoped roots, + appGroup configured/unconfigured, absolute in/out, `..` escapes vs internal + `..`, nonexistent nested paths, symlink escape vs symlink between roots. + - [x] `ArtifactStore` + `AuditLogger` (`CorePolicySupportTests.swift`). + - [ ] `SystemPermissionBroker` — not unit-testable as written: every path + terminates in a real OS framework call (CLLocationManager, EKEventStore, + CNContactStore…), so tests would prompt/flake. Needs seams (injectable + status providers) first; fold into the metadata/permission refactor. +- [/] **CI never compiles iOS/visionOS code.** + - [x] Added a `platform-build` matrix job (`xcodebuild build` for iOS + + visionOS) so the UIKit presenters and the `CCodeModeJSC` shim compile + against those SDKs on every PR/push. + - [x] Added SwiftPM build caching and `xcodebuild -version` toolchain logging + to the deterministic job. + - [x] macOS `swift test` job already existed and links the new C shim + + watchdog on macOS — this is what verifies the private-symbol link. + - [ ] Still missing: lint/format config + check, explicit Xcode/SDK pinning + (currently uses runner default), artifact upload of eval reports. + - Note: the iOS/visionOS build verifies *compilation*; the private JSC symbol's + dynamic-link resolution is exercised by the macOS `swift test` link. Full + iOS link verification would need a test bundle / host app. +- [ ] **Eval coverage gaps.** `health.*`, `vision.*`, `photos.read/export`, + `reminders.write` have zero scenarios among the 49, though the LLM prompt + advertises photos/health/home/alarms. Add catalog + execution/validation + scenarios for each. +- [ ] **Regression gate is inert in automation.** `compare` is never invoked in + CI; the `catalog` baseline EVALS.md references doesn't exist. Generate/commit + it and wire `compare` into a (gated) CI job. +- [ ] **No cost/token tracking in LLM eval reports** — "budget" is a request + count only; capture input/output tokens (+ derived cost) per run so model + comparisons can weigh accuracy against price. + +--- + +## PLACES TO GO NEXT (bigger bets) + +- [ ] **Async bridge model.** Resolve JS promises from Swift callbacks instead of + synchronous native returns, so long native ops (30s network, 15s permission + prompts) don't block inside `evaluateScript`. Prerequisite for fully real + mid-script cancellation; pairs with the watchdog as the "production-grade + runtime" milestone. +- [ ] **Structured audit pipeline.** Today's events are capability + free-text + "success" with a pull-based drain. Move to a push-based sink with argument + digests, outcomes, path/destination targets, and a per-execution correlation + ID. (Partial down payment already made: fetch destinations now recorded.) +- [ ] **`Examples/` host app.** Minimal SwiftUI demo wiring `CodeModeAgentTools` + + a real `UIKitSystemUIPresenter` + an LLM loop — concrete integration story + and a manual test bed for the UIKit presenters automation can't reach. +- [ ] **DocC + doc comments.** Zero `///` comments in `Sources/` today. Start + with the public surface: `CodeModeAgentTools`, `CodeModeConfiguration`, + `SystemUIPresenter`, `CodeModeToolError`, `CapabilityID`. +- [ ] **API polish for integrators.** Group the 19-parameter + `CodeModeConfiguration` init's service clients; clarify/merge + `allowedCapabilities` vs `allowedCapabilityKeys`; emit the valid `CapabilityID` + list as an enum in the `executeJavaScript` JSON schema so agents get + machine-checkable capability names. + +--- + +## MISC DEFERRED / NOTED (not bugs) +- [ ] Apply the network destination policy to `apple.web.present` / + `apple.auth.webAuthenticate` (these open a visible browser view; the policy + currently covers `network.fetch`). +- [ ] Data-driven address-range list in `NetworkAccessPolicy` (ranges are + hardcoded today). +- [ ] Reconcile network audit entries with the runtime's generic success/failure + audit (two entries per fetch — kept intentionally for destination detail). +- [ ] Migrate bespoke `NSLock` state (`ExecutionWatchdog`, `FetchTaskHandler`) to + `LockedBox`/`SynchronizedBox` where compound atomicity isn't needed. Cosmetic. + +--- + +## MUST-DO BEFORE MERGE +- [/] **Verify on macOS** — done 2026-07-11 (macOS 26.5, Swift 6.3.2 / Xcode 26.5): + - [x] `CCodeModeJSC` shim links and the private symbols resolve at load time + on macOS. (iOS/visionOS remain compile-verified only, via CI.) + - [x] `swift test` passes — 206 tests, 0 failures, ~5s. Note: the *first* + verification run hung indefinitely and exposed the watchdog re-arm bug + (see CRITICAL ISSUES §1); after the fix the suite is green. + - [ ] Watchdog terminates `while(true){}` on an iOS device/simulator — + verified on macOS only; JSC ships per-OS, so worth one manual check on a + simulator before tagging. + +## SUGGESTED ORDER OF ATTACK +1. Quick wins: tag `0.1.0`, fix calendar-span drift, add core-policy-layer tests, + add lint + iOS build job to CI. +2. Runtime hardening milestone: (watchdog ✔) + JS heap cap + concurrency bound + → "resource- and network-bounded runtime." +3. Metadata-consolidation refactor + macro decision, protected by new drift tests. + +## OPEN QUESTIONS FOR THE USER +- [ ] Decision on the private-but-exported JSC symbol (App Store risk)? +- [ ] After CI is green: tag `0.1.0` and restore the versioned install snippet? diff --git a/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift new file mode 100644 index 0000000..821ffbf --- /dev/null +++ b/Tests/CodeModeAuthoringTests/BuiltInCodeModeMacroExpansionTests.swift @@ -0,0 +1,191 @@ +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing +import CodeModeMacros + +private func builtInTestMacros() -> [String: Macro.Type] { + [ + "BuiltInCodeMode": BuiltInCodeModeMacro.self, + "ToolParam": ToolParamMacro.self, + ] +} + +@Test func builtInCodeModeExpansionGeneratesMetadataAndDecode() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarDelete, path: "apple.calendar.deleteEvent", aliases: ["apple.calendar.removeEvent"]) + struct CalendarDeleteEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + @ToolParam("EventKit eventIdentifier.") + var identifier: String + @ToolParam("thisEvent (default) or futureEvents.") + var span: CalendarEventSpan? + var raw: [String: JSONValue] + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct CalendarDeleteEventTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var identifier: String + var span: CalendarEventSpan? + var raw: [String: JSONValue] + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + + static let codeModeCapability: CapabilityID = .calendarDelete + + static let codeModePath: String = "apple.calendar.deleteEvent" + + static let codeModeAliasPaths: [String] = ["apple.calendar.removeEvent"] + + static let codeModeArguments: [BuiltInToolArgument] = [ + BuiltInToolArgument("identifier", .string, hint: "EventKit eventIdentifier."), + BuiltInToolArgument("span", oneOf: CalendarEventSpan.self, optional: true, hint: "thisEvent (default) or futureEvents."), + ] + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + var raw = arguments + let identifier = try CodeModeArgumentDecoder.require("identifier", as: String.self, in: arguments) + let span = try CodeModeArgumentDecoder.optional("span", as: CalendarEventSpan.self, in: arguments) + CodeModeToolRawArguments.canonicalize(&raw, "span", span) + return Arguments(identifier: identifier, span: span, raw: raw) + } + } + """, + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionSupportsNoArgumentsAndNoRaw() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.locationPermissionRequest, path: "apple.location.requestPermission") + struct LocationPermissionRequestTool: BuiltInCodeModeTool { + struct Arguments: Sendable {} + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct LocationPermissionRequestTool: BuiltInCodeModeTool { + struct Arguments: Sendable {} + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + + static let codeModeCapability: CapabilityID = .locationPermissionRequest + + static let codeModePath: String = "apple.location.requestPermission" + + static let codeModeArguments: [BuiltInToolArgument] = [] + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + return Arguments() + } + } + """, + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionDiagnosesMissingArgumentsStruct() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct BrokenTool: BuiltInCodeModeTool { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct BrokenTool: BuiltInCodeModeTool { + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@BuiltInCodeMode requires a nested Arguments struct", line: 1, column: 1), + ], + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionDiagnosesUnannotatedField() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@BuiltInCodeMode Arguments properties need @ToolParam(\"hint\") (or be the raw passthrough)", line: 4, column: 9), + ], + macros: builtInTestMacros() + ) +} + +@Test func builtInCodeModeExpansionDiagnosesRawNotLast() { + assertMacroExpansion( + """ + @BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents") + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var raw: [String: JSONValue] + @ToolParam("Max items.") + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + expandedSource: """ + struct BrokenTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var raw: [String: JSONValue] + var limit: Int? + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + .null + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@BuiltInCodeMode raw passthrough must be the last stored property of Arguments", line: 3, column: 12), + ], + macros: builtInTestMacros() + ) +} diff --git a/Tools/CodeModeAuthoring/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift b/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift similarity index 100% rename from Tools/CodeModeAuthoring/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift rename to Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift diff --git a/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift new file mode 100644 index 0000000..9798399 --- /dev/null +++ b/Tests/CodeModeTests/BuiltInCodeModeToolTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func macroGeneratedDecodeCanonicalizesConstrainedValues() throws { + let tool = CalendarDeleteEventTool(eventKit: EventKitBridge()) + + let decoded = try tool.decode(arguments: [ + "identifier": .string("E1"), + "span": .string("FUTURE_EVENTS"), + ]) + #expect(decoded.identifier == "E1") + #expect(decoded.span == .futureEvents) + #expect(decoded.raw["span"] == .string("futureEvents")) + #expect(decoded.raw["identifier"] == .string("E1")) +} + +@Test func macroGeneratedDecodeRejectsMissingRequiredAndUnknownConstrainedValues() { + let tool = CalendarDeleteEventTool(eventKit: EventKitBridge()) + + #expect(throws: (any Error).self) { + _ = try tool.decode(arguments: ["span": .string("thisEvent")]) + } + #expect(throws: (any Error).self) { + _ = try tool.decode(arguments: [ + "identifier": .string("E1"), + "span": .string("allEvents"), + ]) + } +} + +@Test func macroGeneratedMetadataMatchesHandWrittenBaseline() throws { + // The EventKit domain was first migrated with hand-written metadata and + // then converted to @BuiltInCodeMode; pin the identity pieces so a macro + // regression cannot silently change the advertised surface. + let registration = try #require( + DefaultCapabilityLoader.loadAllRegistrations().first { $0.descriptor.id == .remindersWrite } + ) + #expect(registration.jsNames == [ + "apple.reminders.createReminder", + "apple.reminders.updateReminder", + "apple.reminders.completeReminder", + ]) + #expect(registration.descriptor.requiredPermissions == [.reminders]) + #expect(registration.descriptor.requiredArguments.isEmpty) + #expect(registration.descriptor.optionalArguments.first == "operation") + #expect(registration.descriptor.argumentTypes["priority"] == .number) + #expect(registration.descriptor.argumentConstraints.allowedStringValues["operation"] == ["create", "update", "complete"]) + #expect(registration.descriptor.argumentHints["identifier"] == "EventKit calendarItemIdentifier to update or complete.") +} diff --git a/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift new file mode 100644 index 0000000..4176c9b --- /dev/null +++ b/Tests/CodeModeTests/CapabilityMetadataGoldenTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import CodeMode + +/// One registration's complete advertised surface, in a deterministic +/// encoding. This is everything an LLM or host sees about a capability. +private struct CapabilitySnapshot: Codable, Equatable { + var id: String + var jsNames: [String] + var title: String + var summary: String + var tags: [String] + var example: String + var requiredPermissions: [String] + var requiredArguments: [String] + var optionalArguments: [String] + var argumentTypes: [String: String] + var argumentHints: [String: String] + var allowedStringValues: [String: [String]] + var resultSummary: String + + init(_ registration: CapabilityRegistration) { + let descriptor = registration.descriptor + id = descriptor.id.rawValue + jsNames = registration.jsNames + title = descriptor.title + summary = descriptor.summary + tags = descriptor.tags + example = descriptor.example + requiredPermissions = descriptor.requiredPermissions.map(\.rawValue) + requiredArguments = descriptor.requiredArguments + optionalArguments = descriptor.optionalArguments + argumentTypes = descriptor.argumentTypes.mapValues(\.rawValue) + argumentHints = descriptor.argumentHints + allowedStringValues = descriptor.argumentConstraints.allowedStringValues + resultSummary = descriptor.resultSummary + } +} + +private let goldenFileURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("capability-metadata-golden.json") + +private func encodeSnapshots() throws -> Data { + let snapshots = DefaultCapabilityLoader.loadAllRegistrations() + .map(CapabilitySnapshot.init) + .sorted { ($0.id, $0.jsNames.first ?? "") < ($1.id, $1.jsNames.first ?? "") } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(snapshots) +} + +/// Golden test over the full advertised capability surface. +/// +/// Purpose: registration-idiom migrations (PLAN-registration-macros.md Phase 3) +/// must not change what capabilities advertise unless the change is intended +/// and reviewed. Any drift — a reworded hint, a lost constraint, a type that +/// silently tightened from `any` — fails here. +/// +/// To regenerate after an INTENDED change: +/// CODEMODE_REGENERATE_GOLDEN=1 swift test --filter capabilityMetadata +/// then review the diff of capability-metadata-golden.json in the commit — +/// the diff is the review artifact. +@Test func capabilityMetadataMatchesGoldenBaseline() throws { + let current = try encodeSnapshots() + + if ProcessInfo.processInfo.environment["CODEMODE_REGENERATE_GOLDEN"] == "1" { + try current.write(to: goldenFileURL) + return + } + + let golden = try Data(contentsOf: goldenFileURL) + if current != golden { + let decoder = JSONDecoder() + let currentSnapshots = try decoder.decode([CapabilitySnapshot].self, from: current) + let goldenSnapshots = try decoder.decode([CapabilitySnapshot].self, from: golden) + + let goldenByID = Dictionary(grouping: goldenSnapshots, by: \.id) + let currentByID = Dictionary(grouping: currentSnapshots, by: \.id) + for id in Set(goldenByID.keys).union(currentByID.keys).sorted() { + if goldenByID[id] == nil { + Issue.record("Capability \(id) is new (not in golden baseline)") + } else if currentByID[id] == nil { + Issue.record("Capability \(id) disappeared from the loaded registrations") + } else if goldenByID[id] != currentByID[id] { + Issue.record("Capability \(id) metadata changed vs golden baseline") + } + } + let advice = "Advertised capability metadata drifted from capability-metadata-golden.json. If intended, regenerate with CODEMODE_REGENERATE_GOLDEN=1 and review the JSON diff." + Issue.record("\(advice)") + } +} diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 0910fc2..089e4d4 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -450,7 +450,10 @@ private func jsNames(for capability: CapabilityID) -> [String] { tags: ["test"], example: "noop", requiredPermissions: [.music], - requiredArguments: ["action"] + requiredArguments: ["action"], + // Explicit constraint so the test verifies constraint-before-permission + // ordering without depending on any capability's central table row. + argumentConstraints: CapabilityArgumentConstraints(allowedStringValues: ["action": ["play", "pause"]]) ) let registry = CapabilityRegistry( @@ -496,6 +499,83 @@ private func jsNames(for capability: CapabilityID) -> [String] { #expect(network.argumentConstraints.allowedStringValues["options.responseEncoding"] == ["text", "base64"]) } +@Test func noBuiltInRegistrationGatesOnHealthKitPermission() { + // The default broker can never report .granted for HealthKit (read authorization + // is opaque by design), so any registration declaring .healthKit in + // requiredPermissions would fail closed unconditionally through the registry. + // HealthBridge performs its own per-type authorization instead. + for registration in DefaultCapabilityLoader.loadAllRegistrations() { + #expect( + registration.descriptor.requiredPermissions.contains(.healthKit) == false, + "\(registration.descriptor.id.rawValue) must not gate on .healthKit via the registry" + ) + } +} + +@Test func constraintValidationMatchesCaseInsensitively() throws { + // Exercises the generic CapabilityArgumentConstraints.validate matching, + // constructed inline so it does not depend on any capability's table row + // (those migrate to per-tool CodeModeStringEnums over time). + let constraints = CapabilityArgumentConstraints(allowedStringValues: ["mode": ["single", "multiple"]]) + + try constraints.validate(arguments: ["mode": .string("single")], capabilityName: "contacts.ui.pick") + try constraints.validate(arguments: ["mode": .string("Single")], capabilityName: "contacts.ui.pick") + try constraints.validate(arguments: ["mode": .string("MULTIPLE")], capabilityName: "contacts.ui.pick") + + #expect(throws: (any Error).self) { + try constraints.validate(arguments: ["mode": .string("triple")], capabilityName: "contacts.ui.pick") + } +} + +@Test func calendarDeleteSpanConstraintAcceptsEverySpellingTheBridgeAccepts() throws { + // The constraint now comes from CalendarEventSpan on the tool; the bridge + // parses through the same enum, so advertised and accepted cannot drift. + let registration = try #require( + DefaultCapabilityLoader.loadAllRegistrations().first { $0.descriptor.id == .calendarDelete } + ) + let constraints = registration.descriptor.argumentConstraints + let advertised = try #require(constraints.allowedStringValues["span"]) + #expect(advertised.contains("thisEvent")) + #expect(advertised.contains("futureEvents")) + #expect(advertised.contains("this_event")) + #expect(advertised.contains("future")) + + for spelling in advertised { + try constraints.validate( + arguments: ["span": .string(spelling)], + capabilityName: "calendar.delete" + ) + #expect(CalendarEventSpan.codeModeValue(matching: spelling) != nil) + #expect(CalendarEventSpan.codeModeValue(matching: spelling.uppercased()) != nil) + } + + #expect(throws: (any Error).self) { + try constraints.validate(arguments: ["span": .string("allEvents")], capabilityName: "calendar.delete") + } + #expect(CalendarEventSpan.codeModeValue(matching: "allEvents") == nil) +} + +@Test func constrainedArgumentMetadataIsCoherentForAllRegistrations() { + // Every constrained top-level argument a registration advertises must be a + // declared argument of that registration — catches metadata typos and + // constraint entries that outlive a renamed argument. + for registration in DefaultCapabilityLoader.loadAllRegistrations() { + let descriptor = registration.descriptor + let declared = Set(descriptor.requiredArguments + descriptor.optionalArguments) + for (path, allowed) in descriptor.argumentConstraints.allowedStringValues { + #expect( + allowed.isEmpty == false, + "\(descriptor.id.rawValue) advertises an empty allowed-value list for \(path)" + ) + guard path.contains(".") == false else { continue } + #expect( + declared.contains(path), + "\(descriptor.id.rawValue) constrains '\(path)' but does not declare it as an argument" + ) + } + } +} + @Test func registryValidationRejectsUnknownArguments() throws { let descriptor = CapabilityDescriptor( id: .fsRead, diff --git a/Tests/CodeModeTests/CorePolicySupportTests.swift b/Tests/CodeModeTests/CorePolicySupportTests.swift new file mode 100644 index 0000000..d0c9ebf --- /dev/null +++ b/Tests/CodeModeTests/CorePolicySupportTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func inMemoryArtifactStoreRoundTripsHandles() throws { + let store = InMemoryArtifactStore() + let url = URL(fileURLWithPath: "/tmp/example.png") + + let handle = try store.register(url: url, mimeType: "image/png") + #expect(store.resolve(handle: handle) == url) + #expect(store.resolve(handle: ArtifactHandle(id: "missing")) == nil) +} + +@Test func inMemoryArtifactStoreIssuesUniqueHandles() throws { + let store = InMemoryArtifactStore() + let url = URL(fileURLWithPath: "/tmp/example.png") + + let first = try store.register(url: url, mimeType: nil) + let second = try store.register(url: url, mimeType: nil) + #expect(first != second) + #expect(store.resolve(handle: first) == url) + #expect(store.resolve(handle: second) == url) +} + +@Test func syncAuditLoggerDrainReturnsAndClearsEventsInOrder() { + let logger = SyncAuditLogger() + let first = AuditEvent(capability: "fs.read", message: "success") + let second = AuditEvent(capability: "network.fetch", message: "allowed") + + logger.log(first) + logger.log(second) + + #expect(logger.drain() == [first, second]) + #expect(logger.drain().isEmpty) + + let third = AuditEvent(capability: "fs.write", message: "success") + logger.log(third) + #expect(logger.drain() == [third]) +} + +@Test func syncAuditLoggerIsSafeUnderConcurrentLogging() { + let logger = SyncAuditLogger() + let iterations = 200 + + DispatchQueue.concurrentPerform(iterations: iterations) { index in + logger.log(AuditEvent(capability: "cap.\(index)", message: "m")) + } + + #expect(logger.drain().count == iterations) +} diff --git a/Tests/CodeModeTests/ExecutionWatchdogTests.swift b/Tests/CodeModeTests/ExecutionWatchdogTests.swift new file mode 100644 index 0000000..1e6012d --- /dev/null +++ b/Tests/CodeModeTests/ExecutionWatchdogTests.swift @@ -0,0 +1,172 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func executeTerminatesCPUBoundInfiniteLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let started = Date() + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "while (true) {}", + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "EXECUTION_TIMEOUT") + #expect(Date().timeIntervalSince(started) < 5) +} + +@Test func executeTerminatesCPUBoundLoopInsidePromiseChain() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + await Promise.resolve(); + while (true) {} + """, + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "EXECUTION_TIMEOUT") +} + +@Test func executeTimeoutTerminationIsNotCatchableByScript() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + try { + while (true) {} + } catch (error) { + return { swallowed: true }; + } + """, + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "EXECUTION_TIMEOUT") +} + +@Test func executeRecoversWithFreshContextAfterTerminatedLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let timedOut = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "while (true) {}", + allowedCapabilities: [], + timeoutMs: 200 + ) + ) + #expect(timedOut.error?.code == "EXECUTION_TIMEOUT") + + let recovered = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return { ok: true };", + allowedCapabilities: [] + ) + ) + + let payload = try requireJSONObject(from: try #require(recovered.result)) + #expect(payload["ok"] as? Bool == true) +} + +@Test func cancelInterruptsRunningInfiniteLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: "while (true) {}", + allowedCapabilities: [], + timeoutMs: 60_000 + ) + ) + + try await Task.sleep(nanoseconds: 200_000_000) + call.cancel() + let observed = await observe(call) + + #expect(observed.result == nil) + #expect(observed.error?.code == "CANCELLED") +} + +@Test func executeReturnsResultThatSettlesRightAtDeadline() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + // A short CPU spin that finishes well under the timeout must return its + // value, not be discarded by a deadline check that races the settled state. + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + let total = 0; + for (let i = 0; i < 200000; i++) { total += i; } + return { total }; + """, + allowedCapabilities: [], + timeoutMs: 2_000 + ) + ) + + let payload = try requireJSONObject(from: try #require(observed.result)) + #expect(payload["total"] != nil) + #expect(observed.error == nil) +} + +@Test func executeTerminatesInfiniteGetterDuringResultSerialization() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + // The result settles fine, but serializing it runs a runaway getter. The + // watchdog must still bound that phase instead of hanging the thread. + let started = Date() + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return { get trap() { while (true) {} } };", + allowedCapabilities: [], + timeoutMs: 300 + ) + ) + + #expect(observed.result?.output == nil) + #expect(observed.error != nil) + #expect(Date().timeIntervalSince(started) < 5) +} + +@Test func searchTerminatesCPUBoundInfiniteLoop() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + do { + _ = try await tools.searchJavaScriptAPI( + JavaScriptAPISearchRequest( + code: "async () => { while (true) {} }" + ) + ) + Issue.record("Expected search to time out") + } catch let error as CodeModeToolError { + #expect(error.code == "SEARCH_TIMEOUT") + } +} diff --git a/Tests/CodeModeTests/NetworkAccessPolicyTests.swift b/Tests/CodeModeTests/NetworkAccessPolicyTests.swift new file mode 100644 index 0000000..8993ecf --- /dev/null +++ b/Tests/CodeModeTests/NetworkAccessPolicyTests.swift @@ -0,0 +1,272 @@ +import Foundation +import Testing +@testable import CodeMode + +private func violation(_ policy: NetworkAccessPolicy, _ urlString: String) -> String? { + guard let url = URL(string: urlString) else { + Issue.record("Invalid test URL: \(urlString)") + return nil + } + return policy.violationReason(for: url) +} + +@Test func standardPolicyBlocksLoopbackAndPrivateIPv4() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://127.0.0.1/") != nil) + #expect(violation(policy, "http://10.0.0.5/") != nil) + #expect(violation(policy, "http://172.16.9.1/") != nil) + #expect(violation(policy, "http://172.31.255.1/") != nil) + #expect(violation(policy, "http://192.168.1.1/") != nil) + #expect(violation(policy, "http://169.254.169.254/latest/meta-data/") != nil) + #expect(violation(policy, "http://100.64.0.1/") != nil) + #expect(violation(policy, "http://0.0.0.0/") != nil) +} + +@Test func standardPolicyBlocksEncodedIPv4Literals() { + let policy = NetworkAccessPolicy.standard + + // Alternate numeric spellings of 127.0.0.1 and 169.254.169.254. + #expect(violation(policy, "http://2130706433/") != nil) + #expect(violation(policy, "http://0x7f000001/") != nil) + #expect(violation(policy, "http://127.1/") != nil) + #expect(violation(policy, "http://0251.0376.0251.0376/") != nil) +} + +@Test func standardPolicyBlocksLocalHostnames() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://localhost:8080/") != nil) + #expect(violation(policy, "http://dev.localhost/") != nil) + #expect(violation(policy, "http://printer.local/") != nil) + #expect(violation(policy, "http://metadata.google.internal/computeMetadata/v1/") != nil) +} + +@Test func standardPolicyBlocksFullyQualifiedTrailingDotHosts() { + let policy = NetworkAccessPolicy.standard + + // A trailing root dot resolves to the same host and must not bypass the block. + #expect(violation(policy, "http://localhost./") != nil) + #expect(violation(policy, "http://metadata.google.internal./") != nil) + #expect(violation(policy, "http://printer.local./") != nil) +} + +@Test func allowlistMatchesTrailingDotHosts() { + let policy = NetworkAccessPolicy(allowedHosts: ["example.com"]) + + #expect(violation(policy, "https://example.com./") == nil) + #expect(violation(policy, "https://api.example.com./") == nil) +} + +@Test func standardPolicyBlocksIPv4EmbeddedIPv6Forms() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://[::7f00:1]/") != nil) // IPv4-compatible ::127.0.0.1 + #expect(violation(policy, "http://[64:ff9b::7f00:1]/") != nil) // NAT64 127.0.0.1 + #expect(violation(policy, "http://[64:ff9b::a00:1]/") != nil) // NAT64 10.0.0.1 +} + +@Test func standardPolicyBlocksPrivateIPv6() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "http://[::1]/") != nil) + #expect(violation(policy, "http://[fe80::1]/") != nil) + #expect(violation(policy, "http://[fd12:3456:789a::1]/") != nil) + #expect(violation(policy, "http://[::ffff:127.0.0.1]/") != nil) + #expect(violation(policy, "http://[::ffff:10.0.0.1]/") != nil) +} + +@Test func standardPolicyAllowsPublicDestinations() { + let policy = NetworkAccessPolicy.standard + + #expect(violation(policy, "https://example.com/path") == nil) + #expect(violation(policy, "https://api.github.com/repos") == nil) + #expect(violation(policy, "http://93.184.216.34/") == nil) + #expect(violation(policy, "https://[2606:2800:220:1:248:1893:25c8:1946]/") == nil) +} + +@Test func permissivePolicyAllowsPrivateDestinations() { + let policy = NetworkAccessPolicy.permissive + + #expect(violation(policy, "http://127.0.0.1:3000/") == nil) + #expect(violation(policy, "http://localhost/") == nil) + #expect(violation(policy, "http://169.254.169.254/") == nil) +} + +@Test func allowlistRestrictsToListedHostsAndSubdomains() { + let policy = NetworkAccessPolicy(allowedHosts: ["example.com"]) + + #expect(violation(policy, "https://example.com/") == nil) + #expect(violation(policy, "https://api.example.com/") == nil) + #expect(violation(policy, "https://notexample.com/") != nil) + #expect(violation(policy, "https://example.com.evil.net/") != nil) + #expect(violation(policy, "https://other.org/") != nil) +} + +@Test func allowlistedPrivateHostBypassesPrivateNetworkCheck() { + let policy = NetworkAccessPolicy(allowedHosts: ["localhost"]) + + #expect(violation(policy, "http://localhost:8080/") == nil) + #expect(violation(policy, "http://127.0.0.1/") != nil) +} + +@Test func blocklistTakesPrecedenceOverAllowlist() { + let policy = NetworkAccessPolicy( + allowedHosts: ["example.com"], + blockedHosts: ["blocked.example.com"] + ) + + #expect(violation(policy, "https://example.com/") == nil) + #expect(violation(policy, "https://blocked.example.com/") != nil) + #expect(violation(policy, "https://deep.blocked.example.com/") != nil) +} + +@Test func networkFetchRefusesBlockedDestinationsBeforeConnecting() throws { + let bridge = NetworkBridge() + let (context, sandbox) = try makeInvocationContext() + defer { cleanup(sandbox) } + + for urlString in [ + "http://127.0.0.1:8080/admin", + "http://169.254.169.254/latest/meta-data/", + "http://localhost/", + "http://[::1]/", + ] { + do { + _ = try bridge.fetch(arguments: ["url": .string(urlString)], context: context) + Issue.record("Expected \(urlString) to be refused") + } catch { + #expect(requireBridgeErrorCode(error) == "NETWORK_POLICY_VIOLATION") + } + } + + let auditEvents = context.auditLogger.drain() + #expect(auditEvents.contains(where: { $0.message.contains("denied") && $0.message.contains("169.254.169.254") })) +} + +@Test func executeFetchToPrivateHostFailsWithPolicyViolation() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await fetch('http://169.254.169.254/latest/meta-data/');", + allowedCapabilities: [.networkFetch] + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "NETWORK_POLICY_VIOLATION") + #expect(observed.error?.suggestions.contains(where: { $0.contains("network access policy") }) == true) +} + +/// Stateless stub keyed off the request path so tests stay safe under +/// parallel execution: `/large` returns 64 bytes, `/small` returns 8 bytes, +/// `/redirect` issues a 302 to the cloud metadata address. +private final class FixedBodyURLProtocol: URLProtocol { + static let redirectTarget = URL(string: "http://169.254.169.254/latest/meta-data/")! + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let url = request.url ?? URL(string: "https://unit.test")! + + if url.path == "/redirect" { + let response = HTTPURLResponse( + url: url, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: ["Location": Self.redirectTarget.absoluteString] + )! + client?.urlProtocol(self, wasRedirectedTo: URLRequest(url: Self.redirectTarget), redirectResponse: response) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + return + } + + let bodySize = url.path == "/large" ? 64 : 8 + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/octet-stream"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(repeating: 0x61, count: bodySize)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +@Test func networkFetchEnforcesResponseSizeCap() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FixedBodyURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge( + session: session, + policy: NetworkAccessPolicy(maxResponseBytes: 16) + ) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + do { + _ = try bridge.fetch(arguments: ["url": .string("https://unit.test/large")], context: context) + Issue.record("Expected oversized response to be refused") + } catch { + #expect(requireBridgeErrorCode(error) == "NETWORK_POLICY_VIOLATION") + } +} + +@Test func networkFetchAllowsResponsesWithinSizeCap() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FixedBodyURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge( + session: session, + policy: NetworkAccessPolicy(maxResponseBytes: 16) + ) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + let result = try bridge.fetch(arguments: ["url": .string("https://unit.test/small")], context: context) + let object = try requireObject(result) + #expect(object.bool("ok") == true) + #expect(object.string("bodyText") == "aaaaaaaa") +} + +@Test func networkFetchRefusesRedirectToPrivateDestination() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FixedBodyURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge(session: session) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + do { + _ = try bridge.fetch(arguments: ["url": .string("https://unit.test/redirect")], context: context) + Issue.record("Expected redirect to private destination to be refused") + } catch { + #expect(requireBridgeErrorCode(error) == "NETWORK_POLICY_VIOLATION") + } +} diff --git a/Tests/CodeModeTests/PathPolicyTests.swift b/Tests/CodeModeTests/PathPolicyTests.swift new file mode 100644 index 0000000..c349e7c --- /dev/null +++ b/Tests/CodeModeTests/PathPolicyTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import CodeMode + +private func makePolicy(_ sandbox: TestSandbox, appGroupRoot: URL? = nil) -> DefaultPathPolicy { + DefaultPathPolicy( + config: PathPolicyConfig( + tmpRoot: sandbox.tmp, + cachesRoot: sandbox.caches, + documentsRoot: sandbox.documents, + appGroupRoot: appGroupRoot + ) + ) +} + +private func expectPathViolation(_ body: () throws -> URL) { + do { + let url = try body() + Issue.record("Expected PATH_POLICY_VIOLATION, resolved to \(url.path)") + } catch { + #expect(requireBridgeErrorCode(error) == "PATH_POLICY_VIOLATION") + } +} + +@Test func pathPolicyRejectsEmptyAndWhitespacePaths() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + expectPathViolation { try policy.resolve(path: "") } + expectPathViolation { try policy.resolve(path: " \n") } +} + +@Test func pathPolicyResolvesRelativePathsUnderTmp() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let url = try policy.resolve(path: "notes.txt") + #expect(url.path.hasPrefix(sandbox.tmp.resolvingSymlinksInPath().path)) + #expect(url.lastPathComponent == "notes.txt") +} + +@Test func pathPolicyResolvesScopedPathsToTheirRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let tmpRoot = sandbox.tmp.resolvingSymlinksInPath().path + let cachesRoot = sandbox.caches.resolvingSymlinksInPath().path + let documentsRoot = sandbox.documents.resolvingSymlinksInPath().path + + #expect(try policy.resolve(path: "tmp:a.txt").path == tmpRoot + "/a.txt") + #expect(try policy.resolve(path: "caches:sub/b.txt").path == cachesRoot + "/sub/b.txt") + #expect(try policy.resolve(path: "documents:c.txt").path == documentsRoot + "/c.txt") +} + +@Test func pathPolicyResolvesAppGroupScopeOnlyWhenConfigured() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + + let appGroup = sandbox.root.appendingPathComponent("appGroup", isDirectory: true) + try FileManager.default.createDirectory(at: appGroup, withIntermediateDirectories: true) + + let configured = makePolicy(sandbox, appGroupRoot: appGroup) + let url = try configured.resolve(path: "appGroup:shared.txt") + #expect(url.path == appGroup.resolvingSymlinksInPath().path + "/shared.txt") + + // Without a configured app-group root the scope prefix is not recognized; + // the whole string falls back to a tmp-relative literal file name. + let unconfigured = makePolicy(sandbox) + let fallback = try unconfigured.resolve(path: "appGroup:shared.txt") + #expect(fallback.path.hasPrefix(sandbox.tmp.resolvingSymlinksInPath().path)) +} + +@Test func pathPolicyAllowsAbsolutePathsInsideAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let inside = sandbox.documents.appendingPathComponent("report.pdf").path + let url = try policy.resolve(path: inside) + #expect(url.path == sandbox.documents.resolvingSymlinksInPath().path + "/report.pdf") +} + +@Test func pathPolicyRejectsAbsolutePathsOutsideAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + expectPathViolation { try policy.resolve(path: "/etc/passwd") } + expectPathViolation { try policy.resolve(path: sandbox.root.appendingPathComponent("outside.txt").path) } +} + +@Test func pathPolicyRejectsDotDotEscapes() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + expectPathViolation { try policy.resolve(path: "tmp:../escaped.txt") } + expectPathViolation { try policy.resolve(path: "documents:a/../../escaped.txt") } + expectPathViolation { try policy.resolve(path: "../escaped.txt") } +} + +@Test func pathPolicyAllowsDotDotThatStaysInsideAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let url = try policy.resolve(path: "tmp:a/../b.txt") + #expect(url.path == sandbox.tmp.resolvingSymlinksInPath().path + "/b.txt") +} + +@Test func pathPolicyResolvesNonexistentNestedPaths() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let url = try policy.resolve(path: "tmp:new/nested/dir/file.txt") + #expect(url.path == sandbox.tmp.resolvingSymlinksInPath().path + "/new/nested/dir/file.txt") +} + +@Test func pathPolicyRejectsSymlinksEscapingAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let outside = sandbox.root.appendingPathComponent("outside", isDirectory: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + let link = sandbox.tmp.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outside) + + expectPathViolation { try policy.resolve(path: "tmp:link/secret.txt") } + expectPathViolation { try policy.resolve(path: "tmp:link") } +} + +@Test func pathPolicyAllowsSymlinksBetweenAllowedRoots() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + let policy = makePolicy(sandbox) + + let link = sandbox.tmp.appendingPathComponent("docs-link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: sandbox.documents) + + let url = try policy.resolve(path: "tmp:docs-link/file.txt") + #expect(url.path == sandbox.documents.resolvingSymlinksInPath().path + "/file.txt") +} diff --git a/Tests/CodeModeTests/capability-metadata-golden.json b/Tests/CodeModeTests/capability-metadata-golden.json new file mode 100644 index 0000000..8e51716 --- /dev/null +++ b/Tests/CodeModeTests/capability-metadata-golden.json @@ -0,0 +1,4974 @@ +[ + { + "allowedStringValues" : { + "dismissalPolicy" : [ + "default", + "immediate" + ] + }, + "argumentHints" : { + "contentState" : "Optional final adapter-defined content state.", + "dismissalPolicy" : "default (default) or immediate.", + "identifier" : "Live Activity identifier from apple.activity.list\/start." + }, + "argumentTypes" : { + "contentState" : "object", + "dismissalPolicy" : "string", + "identifier" : "string" + }, + "example" : "await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'immediate' })", + "id" : "activity.end", + "jsNames" : [ + "apple.activity.end" + ], + "optionalArguments" : [ + "contentState", + "dismissalPolicy" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/ended\/state.", + "summary" : "End a host-registered Live Activity, optionally with final content state.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "End Live Activity" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "activityType" : "Optional host-registered activity adapter type filter.", + "limit" : "Maximum activities to return." + }, + "argumentTypes" : { + "activityType" : "string", + "limit" : "number" + }, + "example" : "await apple.activity.list()", + "id" : "activity.list", + "jsNames" : [ + "apple.activity.list" + ], + "optionalArguments" : [ + "activityType", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of activities with identifier\/activityType\/state\/contentState\/attributes.", + "summary" : "List host-registered ActivityKit Live Activities visible to CodeMode.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "List Live Activities" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Live Activity identifier from apple.activity.list\/start." + }, + "argumentTypes" : { + "identifier" : "string" + }, + "example" : "await apple.activity.getPushToken({ identifier: 'activity-1' })", + "id" : "activity.pushToken.read", + "jsNames" : [ + "apple.activity.getPushToken" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/pushToken\/environment.", + "summary" : "Read a Live Activity push token when the host adapter supports push updates.", + "tags" : [ + "activitykit", + "live-activities", + "push-token" + ], + "title" : "Read Live Activity push token" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "activityType" : "Host-registered activity adapter type.", + "attributes" : "Adapter-defined immutable attributes.", + "contentState" : "Adapter-defined mutable content state.", + "pushType" : "Optional push token request mode when the adapter supports remote updates.", + "staleDate" : "Optional ISO8601 stale date." + }, + "argumentTypes" : { + "activityType" : "string", + "attributes" : "object", + "contentState" : "object", + "pushType" : "string", + "staleDate" : "string" + }, + "example" : "await apple.activity.start({ activityType: 'delivery', attributes: {}, contentState: {} })", + "id" : "activity.start", + "jsNames" : [ + "apple.activity.start" + ], + "optionalArguments" : [ + "contentState", + "pushType", + "staleDate" + ], + "requiredArguments" : [ + "activityType", + "attributes" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/activityType\/state\/pushToken when available.", + "summary" : "Start a host-registered ActivityKit Live Activity adapter.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "Start Live Activity" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "alert" : "Optional alert configuration object for the update.", + "contentState" : "Adapter-defined mutable content state.", + "identifier" : "Live Activity identifier from apple.activity.list\/start.", + "staleDate" : "Optional ISO8601 stale date." + }, + "argumentTypes" : { + "alert" : "object", + "contentState" : "object", + "identifier" : "string", + "staleDate" : "string" + }, + "example" : "await apple.activity.update({ identifier: 'activity-1', contentState: { progress: 0.7 } })", + "id" : "activity.update", + "jsNames" : [ + "apple.activity.update" + ], + "optionalArguments" : [ + "alert", + "staleDate" + ], + "requiredArguments" : [ + "identifier", + "contentState" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/updated\/state.", + "summary" : "Update an existing host-registered Live Activity content state.", + "tags" : [ + "activitykit", + "live-activities", + "host-adapter" + ], + "title" : "Update Live Activity" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Single alarm identifier UUID string.", + "identifiers" : "Array of alarm identifier UUID strings. Omit both to cancel all known alarms." + }, + "argumentTypes" : { + "identifier" : "string", + "identifiers" : "array" + }, + "example" : "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })", + "id" : "alarm.cancel", + "jsNames" : [ + "ios.alarm.cancel" + ], + "optionalArguments" : [ + "identifier", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "alarmKit" + ], + "resultSummary" : "Object with deleted\/count fields.", + "summary" : "Cancel one or more scheduled alarms by identifier, or clear all known alarms.", + "tags" : [ + "alarmkit", + "alarms", + "schedule" + ], + "title" : "Cancel scheduled alarms" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await ios.alarm.requestPermission()", + "id" : "alarm.permission.request", + "jsNames" : [ + "ios.alarm.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted fields.", + "summary" : "Request AlarmKit authorization from the user.", + "tags" : [ + "alarmkit", + "permission", + "alarms" + ], + "title" : "Request AlarmKit permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of scheduled alarms returned, default 50." + }, + "argumentTypes" : { + "limit" : "number" + }, + "example" : "await ios.alarm.list({ limit: 20 })", + "id" : "alarm.read", + "jsNames" : [ + "ios.alarm.list" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "alarmKit" + ], + "resultSummary" : "Array of scheduled alarms with identifier\/title\/timing fields.", + "summary" : "List scheduled alarms known to the bridge runtime.", + "tags" : [ + "alarmkit", + "alarms", + "schedule" + ], + "title" : "List scheduled alarms" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "fireDate" : "Optional absolute ISO8601 date; used when provided.", + "identifier" : "Optional UUID string; generated when omitted.", + "secondsFromNow" : "Fallback relative delay in seconds, default 60.", + "title" : "Alarm title shown in presentation." + }, + "argumentTypes" : { + "fireDate" : "string", + "identifier" : "string", + "secondsFromNow" : "number", + "title" : "string" + }, + "example" : "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })", + "id" : "alarm.schedule", + "jsNames" : [ + "ios.alarm.schedule" + ], + "optionalArguments" : [ + "identifier", + "secondsFromNow", + "fireDate" + ], + "requiredArguments" : [ + "title" + ], + "requiredPermissions" : [ + "alarmKit" + ], + "resultSummary" : "Object with identifier\/scheduled\/title.", + "summary" : "Schedule an AlarmKit alarm using secondsFromNow or fireDate.", + "tags" : [ + "alarmkit", + "alarms", + "schedule" + ], + "title" : "Schedule AlarmKit alarm" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Host-registered action identifier.", + "parameters" : "Structured action parameters used for the donation." + }, + "argumentTypes" : { + "identifier" : "string", + "parameters" : "object" + }, + "example" : "await apple.appIntents.donate({ identifier: 'createNote', parameters: { title: 'Draft' } })", + "id" : "appintents.donate", + "jsNames" : [ + "apple.appIntents.donate" + ], + "optionalArguments" : [ + "parameters" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with donated\/status.", + "summary" : "Ask the host to donate a supported action to Shortcuts\/Siri suggestions where feasible.", + "tags" : [ + "appintents", + "shortcuts", + "donation", + "host-adapter" + ], + "title" : "Donate host App Intent action" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "identifier" : "Optional action identifier filter.", + "limit" : "Maximum handoff events to return." + }, + "argumentTypes" : { + "afterCursor" : "string", + "identifier" : "string", + "limit" : "number" + }, + "example" : "await apple.appIntents.listHandoffs({ limit: 20 })", + "id" : "appintents.handoffs.read", + "jsNames" : [ + "apple.appIntents.listHandoffs" + ], + "optionalArguments" : [ + "identifier", + "limit", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of handoff events with cursor\/identifier\/parameters\/date\/source.", + "summary" : "Read bounded App Intent handoff events captured by the host app.", + "tags" : [ + "appintents", + "shortcuts", + "handoff", + "inbox", + "events" + ], + "title" : "Read App Intent handoff inbox" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "domain" : "Optional host-defined action domain filter.", + "limit" : "Maximum actions to return." + }, + "argumentTypes" : { + "domain" : "string", + "limit" : "number" + }, + "example" : "await apple.appIntents.list()", + "id" : "appintents.list", + "jsNames" : [ + "apple.appIntents.list" + ], + "optionalArguments" : [ + "domain", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of actions with identifier\/title\/summary\/requiredParameters.", + "summary" : "List host-registered App Intents or Shortcuts actions exposed to CodeMode.", + "tags" : [ + "appintents", + "shortcuts", + "actions", + "host-adapter" + ], + "title" : "List host App Intent adapters" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Host-registered open action identifier.", + "parameters" : "Structured parameters for the host open action." + }, + "argumentTypes" : { + "identifier" : "string", + "parameters" : "object" + }, + "example" : "await apple.appIntents.open({ identifier: 'showTask', parameters: { id: 'task-1' } })", + "id" : "appintents.open", + "jsNames" : [ + "apple.appIntents.open" + ], + "optionalArguments" : [ + "parameters" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with opened\/status.", + "summary" : "Open a host-provided App Intent, Shortcuts, or app surface for user-mediated continuation.", + "tags" : [ + "appintents", + "shortcuts", + "open", + "host-adapter" + ], + "title" : "Open host App Intent surface" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Host-registered action identifier from apple.appIntents.list.", + "parameters" : "Structured parameters validated by the host adapter.", + "timeoutMs" : "Maximum wait time in milliseconds." + }, + "argumentTypes" : { + "identifier" : "string", + "parameters" : "object", + "timeoutMs" : "number" + }, + "example" : "await apple.appIntents.run({ identifier: 'createNote', parameters: { title: 'Draft' } })", + "id" : "appintents.run", + "jsNames" : [ + "apple.appIntents.run" + ], + "optionalArguments" : [ + "parameters", + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Adapter-defined structured result.", + "summary" : "Run a host-registered App Intent adapter with structured parameters.", + "tags" : [ + "appintents", + "shortcuts", + "actions", + "host-adapter" + ], + "title" : "Run host App Intent adapter" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "callbackURLScheme" : "Optional custom URL scheme that completes the session.", + "prefersEphemeralSession" : "Whether to prefer a private browser session; default false.", + "timeoutMs" : "Optional timeout for waiting on callback\/cancellation.", + "url" : "Absolute HTTP(S) authentication URL." + }, + "argumentTypes" : { + "callbackURLScheme" : "string", + "prefersEphemeralSession" : "bool", + "timeoutMs" : "number", + "url" : "string" + }, + "example" : "await apple.auth.webAuthenticate({ url: 'https:\/\/example.com\/oauth', callbackURLScheme: 'myapp' })", + "id" : "auth.ui.webAuthenticate", + "jsNames" : [ + "apple.auth.webAuthenticate" + ], + "optionalArguments" : [ + "callbackURLScheme", + "prefersEphemeralSession", + "timeoutMs" + ], + "requiredArguments" : [ + "url" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action callback\/cancelled and callbackURL when available.", + "summary" : "Start an ASWebAuthenticationSession for OAuth-style browser authentication.", + "tags" : [ + "auth", + "oauth", + "web", + "browser", + "system-ui" + ], + "title" : "Authenticate with system web UI" + }, + { + "allowedStringValues" : { + "span" : [ + "thisEvent", + "futureEvents", + "future", + "future_events", + "this", + "this_event" + ] + }, + "argumentHints" : { + "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", + "span" : "thisEvent (default) or futureEvents for recurring events." + }, + "argumentTypes" : { + "identifier" : "string", + "span" : "string" + }, + "example" : "await apple.calendar.deleteEvent({ identifier: 'EVENT_ID', span: 'thisEvent' })", + "id" : "calendar.delete", + "jsNames" : [ + "apple.calendar.deleteEvent" + ], + "optionalArguments" : [ + "span" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "calendar" + ], + "resultSummary" : "Object with identifier\/deleted.", + "summary" : "Delete an existing EventKit event by identifier.", + "tags" : [ + "calendar", + "eventkit", + "schedule", + "delete" + ], + "title" : "Delete calendar event" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "calendarIdentifier" : "Optional EventKit calendarIdentifier to restrict results.", + "calendarIdentifiers" : "Optional array of EventKit calendarIdentifier strings to restrict results.", + "end" : "ISO8601 timestamp; defaults to start + 14 days.", + "limit" : "Max number of items, default 50.", + "start" : "ISO8601 timestamp; defaults to now." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "calendarIdentifiers" : "array", + "end" : "string", + "limit" : "number", + "start" : "string" + }, + "example" : "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })", + "id" : "calendar.read", + "jsNames" : [ + "apple.calendar.listEvents" + ], + "optionalArguments" : [ + "start", + "end", + "limit", + "calendarIdentifier", + "calendarIdentifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "calendar" + ], + "resultSummary" : "Array of events with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "summary" : "List events in a date range from EventKit.", + "tags" : [ + "calendar", + "eventkit", + "schedule" + ], + "title" : "Read calendar events" + }, + { + "allowedStringValues" : { + "displayStyle" : [ + "writable", + "all" + ], + "selectionStyle" : [ + "single", + "multiple" + ] + }, + "argumentHints" : { + "displayStyle" : "writable (default) or all.", + "selectionStyle" : "single (default) or multiple.", + "timeoutMs" : "Optional timeout for waiting on user selection." + }, + "argumentTypes" : { + "displayStyle" : "string", + "selectionStyle" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.calendar.pickCalendar({ selectionStyle: 'single' })", + "id" : "calendar.ui.pickCalendar", + "jsNames" : [ + "apple.calendar.pickCalendar" + ], + "optionalArguments" : [ + "selectionStyle", + "displayStyle", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "calendar.writeOnly" + ], + "resultSummary" : "Array of selected calendars with identifier\/title\/type\/allowsContentModifications.", + "summary" : "Present EventKit calendar chooser UI and return the user-selected writable calendars.", + "tags" : [ + "calendar", + "eventkit", + "system-ui", + "picker" + ], + "title" : "Pick calendar with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "allowsCalendarPreview" : "Whether the UI may show calendar day previews; default true.", + "allowsEditing" : "Whether the user can edit from the detail UI; default false.", + "identifier" : "EventKit eventIdentifier from apple.calendar.listEvents.", + "timeoutMs" : "Optional timeout for waiting on dismissal." + }, + "argumentTypes" : { + "allowsCalendarPreview" : "bool", + "allowsEditing" : "bool", + "identifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })", + "id" : "calendar.ui.presentEvent", + "jsNames" : [ + "apple.calendar.presentEvent" + ], + "optionalArguments" : [ + "allowsEditing", + "allowsCalendarPreview", + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "calendar" + ], + "resultSummary" : "Object with action dismissed.", + "summary" : "Present system UI for an existing calendar event identifier.", + "tags" : [ + "calendar", + "eventkit", + "system-ui", + "details" + ], + "title" : "Present calendar event details" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "end" : "Optional ISO8601 end timestamp.", + "location" : "Optional location string.", + "notes" : "Optional event notes\/body text.", + "start" : "Optional ISO8601 start timestamp.", + "timeoutMs" : "Optional timeout for waiting on user save\/cancel.", + "title" : "Optional event title shown in the editor." + }, + "argumentTypes" : { + "end" : "string", + "location" : "string", + "notes" : "string", + "start" : "string", + "timeoutMs" : "number", + "title" : "string" + }, + "example" : "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + "id" : "calendar.ui.presentNewEvent", + "jsNames" : [ + "apple.calendar.presentNewEvent" + ], + "optionalArguments" : [ + "title", + "start", + "end", + "notes", + "location", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "calendar.writeOnly" + ], + "resultSummary" : "Object with action plus identifier\/title when the user saves.", + "summary" : "Present system UI to let the user create or edit a new calendar event draft.", + "tags" : [ + "calendar", + "eventkit", + "system-ui", + "picker" + ], + "title" : "Present calendar event editor" + }, + { + "allowedStringValues" : { + "operation" : [ + "create", + "update" + ] + }, + "argumentHints" : { + "calendarIdentifier" : "Optional destination EventKit calendarIdentifier.", + "end" : "ISO8601 end timestamp.", + "identifier" : "EventKit eventIdentifier to update. Updates require full calendar access at runtime.", + "isAllDay" : "Whether the event is all-day.", + "location" : "Optional location string.", + "notes" : "Optional notes\/body string.", + "operation" : "create (default without identifier) or update (default with identifier).", + "start" : "ISO8601 start timestamp.", + "title" : "Event title string.", + "url" : "Optional absolute URL string attached to the event." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "end" : "string", + "identifier" : "string", + "isAllDay" : "bool", + "location" : "string", + "notes" : "string", + "operation" : "string", + "start" : "string", + "title" : "string", + "url" : "string" + }, + "example" : "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + "id" : "calendar.write", + "jsNames" : [ + "apple.calendar.createEvent", + "apple.calendar.updateEvent" + ], + "optionalArguments" : [ + "operation", + "identifier", + "title", + "start", + "end", + "notes", + "location", + "url", + "isAllDay", + "calendarIdentifier" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/title\/startDate\/endDate\/notes\/calendarIdentifier\/calendarTitle\/location\/url\/isAllDay.", + "summary" : "Create a calendar event or patch an existing event by identifier.", + "tags" : [ + "calendar", + "eventkit", + "schedule" + ], + "title" : "Create or update calendar event" + }, + { + "allowedStringValues" : { + "cameraDevice" : [ + "rear", + "front" + ], + "flashMode" : [ + "auto", + "on", + "off" + ], + "mediaType" : [ + "any", + "image", + "photo", + "video" + ], + "videoQuality" : [ + "high", + "medium", + "low", + "640x480", + "iFrame1280x720", + "iFrame960x540" + ] + }, + "argumentHints" : { + "allowsEditing" : "Whether the system editor is shown before returning media; default false.", + "cameraDevice" : "rear (default) or front.", + "flashMode" : "auto (default), on, or off.", + "maximumDurationSeconds" : "Optional maximum duration for video capture.", + "mediaType" : "any (default), image\/photo, or video.", + "outputDirectory" : "Optional sandbox directory for captured media; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on capture\/export.", + "videoQuality" : "UIImagePickerController quality name such as high, medium, low, 640x480, iFrame1280x720, or iFrame960x540." + }, + "argumentTypes" : { + "allowsEditing" : "bool", + "cameraDevice" : "string", + "flashMode" : "string", + "maximumDurationSeconds" : "number", + "mediaType" : "string", + "outputDirectory" : "string", + "timeoutMs" : "number", + "videoQuality" : "string" + }, + "example" : "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })", + "id" : "camera.ui.capture", + "jsNames" : [ + "apple.camera.capture" + ], + "optionalArguments" : [ + "mediaType", + "outputDirectory", + "timeoutMs", + "allowsEditing", + "cameraDevice", + "flashMode", + "videoQuality", + "maximumDurationSeconds" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path\/artifactID\/mediaType\/uniformTypeIdentifier\/bytes.", + "summary" : "Present system camera UI and export captured media into the sandbox artifact store.", + "tags" : [ + "camera", + "capture", + "photos", + "system-ui", + "artifact" + ], + "title" : "Capture photo or video with camera UI" + }, + { + "allowedStringValues" : { + "mode" : [ + "any", + "text", + "barcode" + ], + "qualityLevel" : [ + "balanced", + "fast", + "accurate" + ] + }, + "argumentHints" : { + "isGuidanceEnabled" : "Whether VisionKit guidance UI is shown; default true.", + "isHighFrameRateTrackingEnabled" : "Whether high-frame-rate tracking is enabled; default true.", + "isHighlightingEnabled" : "Whether recognized items are highlighted; default true.", + "isPinchToZoomEnabled" : "Whether pinch-to-zoom is enabled; default true.", + "languages" : "Optional text recognition language identifiers.", + "mode" : "any (default), text, or barcode.", + "qualityLevel" : "balanced (default), fast, or accurate.", + "recognizedDataTypes" : "Optional array containing text and\/or barcode; overrides mode.", + "recognizesMultipleItems" : "Whether the scanner tracks multiple items at once; default false.", + "returnsOnFirstResult" : "Whether to dismiss as soon as data is recognized; default true.", + "timeoutMs" : "Optional timeout for waiting on a scan result or cancellation." + }, + "argumentTypes" : { + "isGuidanceEnabled" : "bool", + "isHighFrameRateTrackingEnabled" : "bool", + "isHighlightingEnabled" : "bool", + "isPinchToZoomEnabled" : "bool", + "languages" : "array", + "mode" : "string", + "qualityLevel" : "string", + "recognizedDataTypes" : "array", + "recognizesMultipleItems" : "bool", + "returnsOnFirstResult" : "bool", + "timeoutMs" : "number" + }, + "example" : "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })", + "id" : "camera.ui.scanData", + "jsNames" : [ + "apple.camera.scanData" + ], + "optionalArguments" : [ + "mode", + "recognizedDataTypes", + "languages", + "qualityLevel", + "recognizesMultipleItems", + "returnsOnFirstResult", + "isGuidanceEnabled", + "isHighlightingEnabled", + "isPinchToZoomEnabled", + "isHighFrameRateTrackingEnabled", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action and items containing text transcripts or barcode payloads.", + "summary" : "Present VisionKit live data scanner UI and return recognized text or barcode payloads.", + "tags" : [ + "camera", + "scan", + "barcode", + "text", + "visionkit", + "system-ui" + ], + "title" : "Scan text or barcodes with camera UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier; defaults to the host client's configured container." + }, + "argumentTypes" : { + "containerIdentifier" : "string" + }, + "example" : "await apple.cloudkit.getAccountStatus({ containerIdentifier: 'iCloud.com.example.app' })", + "id" : "cloudkit.account.status", + "jsNames" : [ + "apple.cloudkit.getAccountStatus" + ], + "optionalArguments" : [ + "containerIdentifier" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/accountAvailable and containerIdentifier when available.", + "summary" : "Read iCloud account availability for CloudKit-backed agent state.", + "tags" : [ + "cloudkit", + "icloud", + "sync", + "account" + ], + "title" : "Read CloudKit account status" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", + "database" : "private (default), shared, or public.", + "recordName" : "CloudKit recordName to delete.", + "zoneID" : "Optional custom zone identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "recordName" : "string", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.deleteRecord({ database: 'private', recordName: 'task-1' })", + "id" : "cloudkit.record.delete", + "jsNames" : [ + "apple.cloudkit.deleteRecord" + ], + "optionalArguments" : [ + "database", + "containerIdentifier", + "zoneID" + ], + "requiredArguments" : [ + "recordName" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with recordName\/deleted\/database.", + "summary" : "Delete a CloudKit record by recordName from a configured database.", + "tags" : [ + "cloudkit", + "icloud", + "database", + "delete" + ], + "title" : "Delete CloudKit record" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", + "database" : "private (default), shared, or public.", + "fields" : "JSON object mapped by the host client into supported CloudKit field values.", + "recordName" : "Optional CloudKit recordName; omitted means create a new record.", + "recordType" : "CloudKit record type to create or update.", + "savePolicy" : "Host-supported save policy such as changedKeys or allKeys.", + "zoneID" : "Optional custom zone identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "fields" : "object", + "recordName" : "string", + "recordType" : "string", + "savePolicy" : "string", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.saveRecord({ database: 'private', recordType: 'Task', recordName: 'task-1', fields: { title: 'Review' } })", + "id" : "cloudkit.record.save", + "jsNames" : [ + "apple.cloudkit.saveRecord" + ], + "optionalArguments" : [ + "recordName", + "database", + "containerIdentifier", + "zoneID", + "savePolicy" + ], + "requiredArguments" : [ + "recordType", + "fields" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Saved record object with recordName\/recordType\/fields\/changeTag\/database.", + "summary" : "Create or update a CloudKit record in a private\/shared\/public database.", + "tags" : [ + "cloudkit", + "icloud", + "database", + "write" + ], + "title" : "Save CloudKit record" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", + "database" : "private (default), shared, or public.", + "desiredKeys" : "Optional array of field keys to return.", + "limit" : "Max records to return; default is host-defined.", + "predicate" : "Host-supported predicate object or string; keep predicates bounded and repairable.", + "recordType" : "CloudKit record type to query.", + "sortDescriptors" : "Optional array of sort descriptor objects.", + "zoneID" : "Optional custom zone identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "desiredKeys" : "array", + "limit" : "number", + "predicate" : "any", + "recordType" : "string", + "sortDescriptors" : "array", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.queryRecords({ database: 'private', recordType: 'Task', limit: 20 })", + "id" : "cloudkit.records.query", + "jsNames" : [ + "apple.cloudkit.queryRecords" + ], + "optionalArguments" : [ + "database", + "containerIdentifier", + "zoneID", + "predicate", + "sortDescriptors", + "desiredKeys", + "limit" + ], + "requiredArguments" : [ + "recordType" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of records with recordName\/recordType\/fields\/modifiedAt\/database.", + "summary" : "Query records from a private\/shared\/public CloudKit database for synced agent state.", + "tags" : [ + "cloudkit", + "icloud", + "database", + "query" + ], + "title" : "Query CloudKit records" + }, + { + "allowedStringValues" : { + "database" : [ + "private", + "shared", + "public" + ] + }, + "argumentHints" : { + "containerIdentifier" : "Optional iCloud container identifier.", + "database" : "private (default), shared, or public.", + "firesOnRecordCreation" : "Whether creation events are enqueued; default true.", + "firesOnRecordDeletion" : "Whether delete events are enqueued; default true.", + "firesOnRecordUpdate" : "Whether update events are enqueued; default true.", + "predicate" : "Host-supported predicate object or string.", + "recordType" : "CloudKit record type to observe.", + "subscriptionID" : "Stable host-visible subscription identifier.", + "zoneID" : "Optional custom zone identifier." + }, + "argumentTypes" : { + "containerIdentifier" : "string", + "database" : "string", + "firesOnRecordCreation" : "bool", + "firesOnRecordDeletion" : "bool", + "firesOnRecordUpdate" : "bool", + "predicate" : "any", + "recordType" : "string", + "subscriptionID" : "string", + "zoneID" : "string" + }, + "example" : "await apple.cloudkit.subscribe({ subscriptionID: 'tasks', database: 'private', recordType: 'Task' })", + "id" : "cloudkit.subscription.save", + "jsNames" : [ + "apple.cloudkit.subscribe" + ], + "optionalArguments" : [ + "database", + "containerIdentifier", + "zoneID", + "predicate", + "firesOnRecordCreation", + "firesOnRecordUpdate", + "firesOnRecordDeletion" + ], + "requiredArguments" : [ + "subscriptionID", + "recordType" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with subscriptionID\/saved\/database.", + "summary" : "Register a bounded CloudKit query subscription so the host can enqueue subscription events later.", + "tags" : [ + "cloudkit", + "icloud", + "subscription", + "inbox" + ], + "title" : "Save CloudKit subscription" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "limit" : "Maximum number of inbox events to read.", + "subscriptionID" : "Optional subscription filter." + }, + "argumentTypes" : { + "afterCursor" : "string", + "limit" : "number", + "subscriptionID" : "string" + }, + "example" : "await apple.cloudkit.listEvents({ subscriptionID: 'tasks', limit: 20 })", + "id" : "cloudkit.subscriptionEvents.read", + "jsNames" : [ + "apple.cloudkit.listEvents" + ], + "optionalArguments" : [ + "subscriptionID", + "limit", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of subscription event objects with cursor\/subscriptionID\/recordName\/reason\/database.", + "summary" : "Read bounded CloudKit subscription events previously received by the host.", + "tags" : [ + "cloudkit", + "icloud", + "subscription", + "inbox", + "events" + ], + "title" : "Read CloudKit subscription inbox" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifiers" : "Optional array of contact identifiers for targeted read.", + "limit" : "Max number of contacts, default 50." + }, + "argumentTypes" : { + "identifiers" : "array", + "limit" : "number" + }, + "example" : "await apple.contacts.list({ limit: 25 })", + "id" : "contacts.read", + "jsNames" : [ + "apple.contacts.list" + ], + "optionalArguments" : [ + "limit", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Array of contacts with identifier\/name\/organization\/phones\/emails.", + "summary" : "Read contacts with bounded fields.", + "tags" : [ + "contacts", + "address-book", + "people" + ], + "title" : "Read contacts" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of contacts, default 20.", + "query" : "Name text to match." + }, + "argumentTypes" : { + "limit" : "number", + "query" : "string" + }, + "example" : "await apple.contacts.search({ query: 'Alex', limit: 10 })", + "id" : "contacts.search", + "jsNames" : [ + "apple.contacts.search" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + "query" + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Array of contact objects.", + "summary" : "Search contacts by name.", + "tags" : [ + "contacts", + "search", + "people" + ], + "title" : "Search contacts" + }, + { + "allowedStringValues" : { + "mode" : [ + "single", + "multiple" + ] + }, + "argumentHints" : { + "displayedPropertyKeys" : "Optional array of CNContact property key strings to display.", + "mode" : "single (default) or multiple.", + "timeoutMs" : "Optional timeout for waiting on user selection." + }, + "argumentTypes" : { + "displayedPropertyKeys" : "array", + "mode" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })", + "id" : "contacts.ui.pick", + "jsNames" : [ + "apple.contacts.pick" + ], + "optionalArguments" : [ + "mode", + "displayedPropertyKeys", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of selected contacts with identifier\/name\/organization\/phones\/emails.", + "summary" : "Present system contact picker UI and return selected contacts without requiring full Contacts permission.", + "tags" : [ + "contacts", + "people", + "system-ui", + "picker" + ], + "title" : "Pick contacts with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "allowsActions" : "Whether built-in actions like call\/message are shown; default true.", + "allowsEditing" : "Whether the user can edit the contact; default false.", + "displayedPropertyKeys" : "Optional array of CNContact property key strings to display.", + "identifier" : "Contact identifier from apple.contacts.list\/search\/pick.", + "timeoutMs" : "Optional timeout for waiting on dismissal." + }, + "argumentTypes" : { + "allowsActions" : "bool", + "allowsEditing" : "bool", + "displayedPropertyKeys" : "array", + "identifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })", + "id" : "contacts.ui.presentContact", + "jsNames" : [ + "apple.contacts.presentContact" + ], + "optionalArguments" : [ + "allowsEditing", + "allowsActions", + "displayedPropertyKeys", + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Object with action and contact when available.", + "summary" : "Present system contact card UI for a contact identifier.", + "tags" : [ + "contacts", + "people", + "system-ui", + "details" + ], + "title" : "Present contact card" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "emailAddresses" : "Optional array of email address strings.", + "familyName" : "Optional family name to prefill.", + "givenName" : "Optional given name to prefill.", + "organization" : "Optional organization to prefill.", + "phoneNumbers" : "Optional array of phone number strings.", + "timeoutMs" : "Optional timeout for waiting on user completion." + }, + "argumentTypes" : { + "emailAddresses" : "array", + "familyName" : "string", + "givenName" : "string", + "organization" : "string", + "phoneNumbers" : "array", + "timeoutMs" : "number" + }, + "example" : "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })", + "id" : "contacts.ui.presentNewContact", + "jsNames" : [ + "apple.contacts.presentNewContact" + ], + "optionalArguments" : [ + "givenName", + "familyName", + "organization", + "phoneNumbers", + "emailAddresses", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "contacts" + ], + "resultSummary" : "Object with action and contact when the user saves.", + "summary" : "Present system UI for creating a new contact draft.", + "tags" : [ + "contacts", + "people", + "system-ui", + "create" + ], + "title" : "Present new contact editor" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "asCopy" : "Whether to export as a copy; default true.", + "path" : "Single sandbox file path to export.", + "paths" : "Optional array of sandbox file paths to export.", + "timeoutMs" : "Optional timeout for waiting on export completion." + }, + "argumentTypes" : { + "asCopy" : "bool", + "path" : "string", + "paths" : "array", + "timeoutMs" : "number" + }, + "example" : "await apple.documents.export({ path: 'tmp:report.pdf' })", + "id" : "documents.ui.export", + "jsNames" : [ + "apple.documents.export", + "apple.documents.save" + ], + "optionalArguments" : [ + "path", + "paths", + "asCopy", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/count and destination URLs when the provider returns them.", + "summary" : "Present Files export UI to save one or more sandbox files to a user-selected destination.", + "tags" : [ + "documents", + "files", + "system-ui", + "export", + "save" + ], + "title" : "Export documents with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "name" : "Optional display name for the document interaction controller.", + "path" : "Sandbox file path to hand off.", + "timeoutMs" : "Optional timeout for waiting on the Open In menu dismissal.", + "uti" : "Optional uniform type identifier override." + }, + "argumentTypes" : { + "name" : "string", + "path" : "string", + "timeoutMs" : "number", + "uti" : "string" + }, + "example" : "await apple.documents.openIn({ path: 'tmp:report.pdf' })", + "id" : "documents.ui.openIn", + "jsNames" : [ + "apple.documents.openIn" + ], + "optionalArguments" : [ + "name", + "uti", + "timeoutMs" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action and application bundle identifier when the user hands off the file.", + "summary" : "Present the system Open In menu for a sandbox file.", + "tags" : [ + "documents", + "files", + "system-ui", + "open-in", + "handoff" + ], + "title" : "Open document in another app" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "allowMultiple" : "Whether multiple files may be selected; default false.", + "contentTypes" : "Optional array of UTType identifiers; defaults to public.item.", + "outputDirectory" : "Optional sandbox directory for copied files; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on user selection\/copy." + }, + "argumentTypes" : { + "allowMultiple" : "bool", + "contentTypes" : "array", + "outputDirectory" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })", + "id" : "documents.ui.pick", + "jsNames" : [ + "apple.documents.pick" + ], + "optionalArguments" : [ + "contentTypes", + "allowMultiple", + "outputDirectory", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of selected documents with path\/artifactID\/filename\/uniformTypeIdentifier\/bytes.", + "summary" : "Present Files document picker UI and copy selected documents into the sandbox artifact store.", + "tags" : [ + "documents", + "files", + "system-ui", + "picker", + "artifact" + ], + "title" : "Pick documents with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "outputDirectory" : "Optional sandbox directory for scanned page images; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on user scanning\/export." + }, + "argumentTypes" : { + "outputDirectory" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.documents.scan({ outputDirectory: 'tmp:scans' })", + "id" : "documents.ui.scan", + "jsNames" : [ + "apple.documents.scan" + ], + "optionalArguments" : [ + "outputDirectory", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of scanned page artifacts with path\/artifactID\/pageIndex\/mediaType\/bytes.", + "summary" : "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store.", + "tags" : [ + "documents", + "scan", + "camera", + "system-ui", + "artifact" + ], + "title" : "Scan documents with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "input" : "Input text to classify or extract from.", + "instructions" : "Optional task-specific instructions.", + "schema" : "Optional JSON schema object accepted by the host adapter.", + "schemaIdentifier" : "Preferred host-defined schema identifier.", + "timeoutMs" : "Maximum extraction wait time in milliseconds." + }, + "argumentTypes" : { + "input" : "string", + "instructions" : "string", + "schema" : "object", + "schemaIdentifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.foundationModels.extract({ input: text, schemaIdentifier: 'todo' })", + "id" : "foundationModels.extract", + "jsNames" : [ + "apple.foundationModels.extract" + ], + "optionalArguments" : [ + "schema", + "schemaIdentifier", + "instructions", + "timeoutMs" + ], + "requiredArguments" : [ + "input" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with values\/classification\/confidence\/schemaIdentifier.", + "summary" : "Run host-defined structured extraction or classification with Foundation Models.", + "tags" : [ + "foundationmodels", + "apple-intelligence", + "llm", + "structured-output" + ], + "title" : "Extract structured data with Foundation Models" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "instructions" : "Optional host-approved system instructions.", + "maxTokens" : "Optional maximum output tokens.", + "prompt" : "Prompt text sent to the host Foundation Models session.", + "schemaIdentifier" : "Optional host-defined output schema identifier.", + "temperature" : "Optional sampling temperature when supported.", + "timeoutMs" : "Maximum generation wait time in milliseconds." + }, + "argumentTypes" : { + "instructions" : "string", + "maxTokens" : "number", + "prompt" : "any", + "schemaIdentifier" : "string", + "temperature" : "number", + "timeoutMs" : "number" + }, + "example" : "await apple.foundationModels.generate({ prompt: 'Summarize this note', maxTokens: 200 })", + "id" : "foundationModels.generate", + "jsNames" : [ + "apple.foundationModels.generate" + ], + "optionalArguments" : [ + "instructions", + "temperature", + "maxTokens", + "schemaIdentifier", + "timeoutMs" + ], + "requiredArguments" : [ + "prompt" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with text\/finishReason\/usage when available.", + "summary" : "Generate text locally through the host Foundation Models adapter when available.", + "tags" : [ + "foundationmodels", + "apple-intelligence", + "llm", + "generation" + ], + "title" : "Generate text with Foundation Models" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.foundationModels.getStatus()", + "id" : "foundationModels.status", + "jsNames" : [ + "apple.foundationModels.getStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with available\/status\/reason\/modelIdentifier when available.", + "summary" : "Read host Foundation Models availability\/status before attempting local generation.", + "tags" : [ + "foundationmodels", + "apple-intelligence", + "llm", + "availability" + ], + "title" : "Read Foundation Models availability" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "File or directory path to inspect." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.access({ path: 'tmp:data.json' })", + "id" : "fs.access", + "jsNames" : [ + "apple.fs.access", + "fs.promises.access" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with readable\/writable\/path.", + "summary" : "Check read\/write access for path within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Check path access" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "from" : "Source sandbox path.", + "to" : "Destination sandbox path." + }, + "argumentTypes" : { + "from" : "string", + "to" : "string" + }, + "example" : "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", + "id" : "fs.copy", + "jsNames" : [ + "apple.fs.copy", + "fs.promises.copyFile" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "from", + "to" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with from\/to resolved paths.", + "summary" : "Copy file\/directory within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Copy file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Path to file or directory.", + "recursive" : "Required as true when deleting a directory." + }, + "argumentTypes" : { + "path" : "string", + "recursive" : "bool" + }, + "example" : "await apple.fs.delete({ path: 'tmp:data.json' })", + "id" : "fs.delete", + "jsNames" : [ + "apple.fs.delete", + "fs.promises.rm" + ], + "optionalArguments" : [ + "recursive" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with deleted flag and path.", + "summary" : "Delete file\/directory within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Delete file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "File or directory path to check." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.exists({ path: 'tmp:data.json' })", + "id" : "fs.exists", + "jsNames" : [ + "apple.fs.exists" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Boolean.", + "summary" : "Check if file\/directory exists within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Check path exists" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Directory path using allowed root prefix (tmp:, caches:, documents:)." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.list({ path: 'tmp:' })", + "id" : "fs.list", + "jsNames" : [ + "apple.fs.list", + "fs.promises.readdir" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of entry objects with name\/path\/isDirectory\/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings.", + "summary" : "List files\/directories within allowed sandbox roots as entry objects.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "List directory" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Directory path to create.", + "recursive" : "Boolean; default true." + }, + "argumentTypes" : { + "path" : "string", + "recursive" : "bool" + }, + "example" : "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })", + "id" : "fs.mkdir", + "jsNames" : [ + "apple.fs.mkdir", + "fs.promises.mkdir" + ], + "optionalArguments" : [ + "recursive" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with created flag and path.", + "summary" : "Create directories within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Create directory" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "from" : "Source sandbox path.", + "to" : "Destination sandbox path." + }, + "argumentTypes" : { + "from" : "string", + "to" : "string" + }, + "example" : "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", + "id" : "fs.move", + "jsNames" : [ + "apple.fs.move", + "fs.promises.rename" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "from", + "to" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with from\/to resolved paths.", + "summary" : "Move file\/directory within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Move file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "encoding" : "utf8 (default) or base64.", + "path" : "File path using allowed root prefix." + }, + "argumentTypes" : { + "encoding" : "string", + "path" : "string" + }, + "example" : "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })", + "id" : "fs.read", + "jsNames" : [ + "apple.fs.read", + "fs.promises.readFile" + ], + "optionalArguments" : [ + "encoding" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path plus text or base64 field.", + "summary" : "Read text\/base64 file data within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Read file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "File or directory path." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.fs.stat({ path: 'tmp:data.json' })", + "id" : "fs.stat", + "jsNames" : [ + "apple.fs.stat", + "fs.promises.stat" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path\/isDirectory\/size\/createdAt\/modifiedAt.", + "summary" : "Read file metadata within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Stat path" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "data" : "UTF-8 text or base64 string depending on encoding.", + "encoding" : "utf8 (default) or base64.", + "path" : "File path using allowed root prefix." + }, + "argumentTypes" : { + "data" : "string", + "encoding" : "string", + "path" : "string" + }, + "example" : "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })", + "id" : "fs.write", + "jsNames" : [ + "apple.fs.write", + "fs.promises.writeFile" + ], + "optionalArguments" : [ + "data", + "encoding" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path and bytesWritten.", + "summary" : "Write text\/base64 file data within allowed sandbox roots.", + "tags" : [ + "filesystem", + "io", + "fs" + ], + "title" : "Write file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "readTypes" : "Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.", + "writeTypes" : "Optional array of type names to write (quantity types only)." + }, + "argumentTypes" : { + "readTypes" : "array", + "writeTypes" : "array" + }, + "example" : "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })", + "id" : "health.permission.request", + "jsNames" : [ + "apple.health.requestPermission" + ], + "optionalArguments" : [ + "readTypes", + "writeTypes" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted fields and requested type arrays.", + "summary" : "Request HealthKit authorization for requested read\/write types.", + "tags" : [ + "healthkit", + "health", + "permission" + ], + "title" : "Request HealthKit permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "end" : "Optional ISO8601 end timestamp; defaults to now.", + "limit" : "Max number of samples, default 50.", + "start" : "Optional ISO8601 start timestamp; defaults to last 24h.", + "type" : "Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.", + "unit" : "Optional unit override for quantity types (count, bpm, kcal, kg, m)." + }, + "argumentTypes" : { + "end" : "string", + "limit" : "number", + "start" : "string", + "type" : "string", + "unit" : "string" + }, + "example" : "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })", + "id" : "health.read", + "jsNames" : [ + "apple.health.read" + ], + "optionalArguments" : [ + "start", + "end", + "limit", + "unit" + ], + "requiredArguments" : [ + "type" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of samples with identifier\/type\/value and timing metadata.", + "summary" : "Read HealthKit samples for a supported type and date range.", + "tags" : [ + "healthkit", + "health", + "query" + ], + "title" : "Read HealthKit samples" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "end" : "Optional ISO8601 end timestamp; defaults to start.", + "start" : "Optional ISO8601 start timestamp; defaults to now.", + "type" : "Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.", + "unit" : "Optional unit (count, bpm, kcal, kg, m).", + "value" : "Numeric sample value." + }, + "argumentTypes" : { + "end" : "string", + "start" : "string", + "type" : "string", + "unit" : "string", + "value" : "number" + }, + "example" : "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })", + "id" : "health.write", + "jsNames" : [ + "apple.health.write" + ], + "optionalArguments" : [ + "unit", + "start", + "end" + ], + "requiredArguments" : [ + "type", + "value" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with identifier\/type\/value\/unit and written=true.", + "summary" : "Write a HealthKit quantity sample for supported writable quantity types.", + "tags" : [ + "healthkit", + "health", + "write" + ], + "title" : "Write HealthKit quantity sample" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "includeCharacteristics" : "Boolean; include characteristic details when true.", + "limit" : "Max number of homes to return, default 10." + }, + "argumentTypes" : { + "includeCharacteristics" : "bool", + "limit" : "number" + }, + "example" : "await apple.home.list({ includeCharacteristics: true, limit: 5 })", + "id" : "home.read", + "jsNames" : [ + "apple.home.list" + ], + "optionalArguments" : [ + "includeCharacteristics", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "homeKit" + ], + "resultSummary" : "Array of homes with accessories\/services snapshot.", + "summary" : "Read homes\/accessories\/services (and optional characteristics) from HomeKit.", + "tags" : [ + "homekit", + "iot", + "devices" + ], + "title" : "Read HomeKit graph" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "accessoryIdentifier" : "Accessory UUID string from home.read output.", + "characteristicType" : "Characteristic type identifier (e.g. HMCharacteristicTypePowerState).", + "serviceType" : "Optional service type filter for characteristic lookup.", + "value" : "Target value; string\/number\/bool\/null." + }, + "argumentTypes" : { + "accessoryIdentifier" : "string", + "characteristicType" : "string", + "serviceType" : "string", + "value" : "any" + }, + "example" : "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })", + "id" : "home.write", + "jsNames" : [ + "apple.home.writeCharacteristic" + ], + "optionalArguments" : [ + "serviceType" + ], + "requiredArguments" : [ + "accessoryIdentifier", + "characteristicType", + "value" + ], + "requiredPermissions" : [ + "homeKit" + ], + "resultSummary" : "Object with accessoryIdentifier\/characteristicType\/written.", + "summary" : "Write a value to a writable HomeKit characteristic for a target accessory.", + "tags" : [ + "homekit", + "iot", + "devices", + "control" + ], + "title" : "Write HomeKit characteristic" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "key" : "Logical key for value removal." + }, + "argumentTypes" : { + "key" : "string" + }, + "example" : "await apple.keychain.delete('auth_token')", + "id" : "keychain.delete", + "jsNames" : [ + "apple.keychain.delete" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "key" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object { key, deleted: true }.", + "summary" : "Delete an app-scoped Keychain value.", + "tags" : [ + "security", + "token", + "keychain" + ], + "title" : "Delete Keychain value" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "key" : "Logical key for this secret value." + }, + "argumentTypes" : { + "key" : "string" + }, + "example" : "await apple.keychain.get('auth_token')", + "id" : "keychain.read", + "jsNames" : [ + "apple.keychain.get" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "key" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object { key, value } or null when the key does not exist.", + "summary" : "Read a string value from app-scoped Keychain storage.", + "tags" : [ + "security", + "token", + "keychain" + ], + "title" : "Read Keychain value" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "key" : "Logical key for this secret value.", + "value" : "Secret string value. Defaults to empty string when omitted." + }, + "argumentTypes" : { + "key" : "string", + "value" : "string" + }, + "example" : "await apple.keychain.set('auth_token', token)", + "id" : "keychain.write", + "jsNames" : [ + "apple.keychain.set" + ], + "optionalArguments" : [ + "value" + ], + "requiredArguments" : [ + "key" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object { key, written: true }.", + "summary" : "Store or update a string value in app-scoped Keychain storage.", + "tags" : [ + "security", + "token", + "keychain" + ], + "title" : "Write Keychain value" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.location.requestPermission()", + "id" : "location.permission.request", + "jsNames" : [ + "apple.location.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Permission status string.", + "summary" : "Trigger location when-in-use permission request flow.", + "tags" : [ + "location", + "permission" + ], + "title" : "Request location permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "mode" : "permissionStatus or current (default current)." + }, + "argumentTypes" : { + "mode" : "string" + }, + "example" : "await apple.location.getCurrentPosition()", + "id" : "location.read", + "jsNames" : [ + "apple.location.getCurrentPosition", + "apple.location.getPermissionStatus" + ], + "optionalArguments" : [ + "mode" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Permission status string or coordinates object.", + "summary" : "Read location permission status or current coordinates.", + "tags" : [ + "location", + "permission", + "geospatial" + ], + "title" : "Read location state or coordinates" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "attachments" : "Optional array of { path, mimeType?, filename? } sandbox file attachments.", + "bcc" : "Optional array of BCC email strings.", + "body" : "Optional message body.", + "cc" : "Optional array of CC email strings.", + "isHTML" : "Whether body should be treated as HTML; default false.", + "subject" : "Optional subject.", + "timeoutMs" : "Optional timeout for waiting on user completion.", + "to" : "Optional array of recipient email strings." + }, + "argumentTypes" : { + "attachments" : "array", + "bcc" : "array", + "body" : "string", + "cc" : "array", + "isHTML" : "bool", + "subject" : "string", + "timeoutMs" : "number", + "to" : "array" + }, + "example" : "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })", + "id" : "mail.ui.compose", + "jsNames" : [ + "apple.mail.compose" + ], + "optionalArguments" : [ + "to", + "cc", + "bcc", + "subject", + "body", + "isHTML", + "attachments", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action sent\/saved\/cancelled\/failed.", + "summary" : "Present system mail compose UI with optional recipients, body, and sandbox file attachments.", + "tags" : [ + "mail", + "compose", + "system-ui", + "share" + ], + "title" : "Compose mail with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "address" : "Address or place text to geocode.", + "limit" : "Maximum coordinate candidates.", + "region" : "Optional search bias region object." + }, + "argumentTypes" : { + "address" : "string", + "limit" : "number", + "region" : "object" + }, + "example" : "await apple.maps.geocode({ address: '1 Infinite Loop, Cupertino', limit: 3 })", + "id" : "maps.geocode", + "jsNames" : [ + "apple.maps.geocode" + ], + "optionalArguments" : [ + "region", + "limit" + ], + "requiredArguments" : [ + "address" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of placemarks with name\/address\/latitude\/longitude.", + "summary" : "Resolve an address or place text to coordinate candidates through MapKit.", + "tags" : [ + "maps", + "mapkit", + "geocode", + "location" + ], + "title" : "Geocode address" + }, + { + "allowedStringValues" : { + "transportType" : [ + "automobile", + "walking", + "transit", + "any" + ] + }, + "argumentHints" : { + "destination" : "Optional destination object for directions.", + "latitude" : "Latitude when opening coordinates.", + "longitude" : "Longitude when opening coordinates.", + "query" : "Place\/search query to open.", + "transportType" : "automobile (default), walking, transit, or any.", + "url" : "Optional maps URL to open." + }, + "argumentTypes" : { + "destination" : "object", + "latitude" : "number", + "longitude" : "number", + "query" : "string", + "transportType" : "string", + "url" : "string" + }, + "example" : "await apple.maps.open({ query: 'Apple Park' })", + "id" : "maps.open", + "jsNames" : [ + "apple.maps.open" + ], + "optionalArguments" : [ + "query", + "url", + "latitude", + "longitude", + "destination", + "transportType" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with opened\/target.", + "summary" : "Open Apple Maps with coordinates, a query, URL, or directions in a user-mediated handoff.", + "tags" : [ + "maps", + "mapkit", + "open", + "directions" + ], + "title" : "Open Maps" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "latitude" : "Latitude in decimal degrees.", + "locale" : "Optional BCP-47 locale identifier for result localization.", + "longitude" : "Longitude in decimal degrees." + }, + "argumentTypes" : { + "latitude" : "number", + "locale" : "string", + "longitude" : "number" + }, + "example" : "await apple.maps.reverseGeocode({ latitude: 37.3318, longitude: -122.0312 })", + "id" : "maps.reverseGeocode", + "jsNames" : [ + "apple.maps.reverseGeocode" + ], + "optionalArguments" : [ + "locale" + ], + "requiredArguments" : [ + "latitude", + "longitude" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of placemarks with name\/address\/latitude\/longitude.", + "summary" : "Resolve latitude\/longitude into address candidates through MapKit.", + "tags" : [ + "maps", + "mapkit", + "reverse-geocode", + "location" + ], + "title" : "Reverse geocode coordinates" + }, + { + "allowedStringValues" : { + "transportType" : [ + "automobile", + "walking", + "transit", + "any" + ] + }, + "argumentHints" : { + "arrivalDate" : "Optional ISO8601 arrival date.", + "departureDate" : "Optional ISO8601 departure date.", + "destination" : "Coordinate or map item object.", + "origin" : "Coordinate or map item object.", + "transportType" : "automobile (default), walking, or transit when supported." + }, + "argumentTypes" : { + "arrivalDate" : "string", + "departureDate" : "string", + "destination" : "object", + "origin" : "object", + "transportType" : "string" + }, + "example" : "await apple.maps.routeEstimate({ origin: { latitude: 37.33, longitude: -122.03 }, destination: { latitude: 37.77, longitude: -122.42 }, transportType: 'automobile' })", + "id" : "maps.route.estimate", + "jsNames" : [ + "apple.maps.routeEstimate" + ], + "optionalArguments" : [ + "transportType", + "departureDate", + "arrivalDate" + ], + "requiredArguments" : [ + "origin", + "destination" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with distanceMeters\/expectedTravelTimeSeconds\/transportType.", + "summary" : "Estimate route distance and travel time between two coordinates or map items.", + "tags" : [ + "maps", + "mapkit", + "directions", + "route" + ], + "title" : "Estimate route" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Maximum map items.", + "query" : "Search query string.", + "region" : "Optional coordinate region object for local bias.", + "resultTypes" : "Optional host-supported result type filters." + }, + "argumentTypes" : { + "limit" : "number", + "query" : "string", + "region" : "object", + "resultTypes" : "array" + }, + "example" : "await apple.maps.search({ query: 'coffee near me', limit: 5 })", + "id" : "maps.search", + "jsNames" : [ + "apple.maps.search" + ], + "optionalArguments" : [ + "region", + "resultTypes", + "limit" + ], + "requiredArguments" : [ + "query" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of map items with name\/address\/category\/latitude\/longitude\/url.", + "summary" : "Search for local businesses, addresses, or points of interest through MapKit.", + "tags" : [ + "maps", + "mapkit", + "local-search", + "places" + ], + "title" : "Search local map items" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "outputPath" : "Optional output sandbox path; defaults to tmp-generated JPEG.", + "path" : "Input video sandbox path.", + "timeMs" : "Frame timestamp in milliseconds; default 0." + }, + "argumentTypes" : { + "outputPath" : "string", + "path" : "string", + "timeMs" : "number" + }, + "example" : "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })", + "id" : "media.frame.extract", + "jsNames" : [ + "apple.media.extractFrame" + ], + "optionalArguments" : [ + "timeMs", + "outputPath" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with output path and artifactID.", + "summary" : "Extract frame at time offset and persist JPEG output.", + "tags" : [ + "media", + "avfoundation", + "thumbnail" + ], + "title" : "Extract video frame" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Sandbox path like tmp:clip.mov." + }, + "argumentTypes" : { + "path" : "string" + }, + "example" : "await apple.media.metadata({ path: 'tmp:video.mov' })", + "id" : "media.metadata.read", + "jsNames" : [ + "apple.media.metadata" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with path\/durationSeconds\/tracks.", + "summary" : "Read duration and track metadata from media files.", + "tags" : [ + "media", + "avfoundation", + "metadata" + ], + "title" : "Read media metadata" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "outputPath" : "Optional output sandbox path; defaults to tmp-generated mp4.", + "path" : "Input media sandbox path.", + "preset" : "AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality." + }, + "argumentTypes" : { + "outputPath" : "string", + "path" : "string", + "preset" : "string" + }, + "example" : "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })", + "id" : "media.transcode", + "jsNames" : [ + "apple.media.transcode" + ], + "optionalArguments" : [ + "outputPath", + "preset" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with output path\/artifactID\/preset.", + "summary" : "Transcode media into MP4 with preset quality.", + "tags" : [ + "media", + "avfoundation", + "transcode" + ], + "title" : "Transcode media" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "attachments" : "Optional array of { path, filename? } sandbox file attachments.", + "body" : "Optional message body.", + "recipients" : "Optional array of phone number or address strings.", + "subject" : "Optional subject on devices\/accounts that support it.", + "timeoutMs" : "Optional timeout for waiting on user completion." + }, + "argumentTypes" : { + "attachments" : "array", + "body" : "string", + "recipients" : "array", + "subject" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })", + "id" : "messages.ui.compose", + "jsNames" : [ + "apple.messages.compose" + ], + "optionalArguments" : [ + "recipients", + "subject", + "body", + "attachments", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action sent\/cancelled\/failed.", + "summary" : "Present system Messages compose UI with optional recipients, body, and sandbox file attachments.", + "tags" : [ + "messages", + "sms", + "compose", + "system-ui", + "share" + ], + "title" : "Compose message with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "countryCode" : "Optional storefront country code.", + "identifier" : "Music catalog item identifier.", + "type" : "Catalog type such as songs, albums, artists, playlists, or stations." + }, + "argumentTypes" : { + "countryCode" : "string", + "identifier" : "string", + "type" : "string" + }, + "example" : "await apple.music.getDetails({ identifier: '123', type: 'albums' })", + "id" : "music.catalog.details", + "jsNames" : [ + "apple.music.getDetails" + ], + "optionalArguments" : [ + "type", + "countryCode" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Catalog item details object.", + "summary" : "Read details for a catalog item identifier through MusicKit.", + "tags" : [ + "music", + "musickit", + "catalog", + "details" + ], + "title" : "Read Music catalog details" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "countryCode" : "Optional storefront country code.", + "limit" : "Maximum results.", + "term" : "Catalog search term.", + "types" : "Optional array such as songs, albums, artists, playlists, stations." + }, + "argumentTypes" : { + "countryCode" : "string", + "limit" : "number", + "term" : "string", + "types" : "array" + }, + "example" : "await apple.music.search({ term: 'Miles Davis', types: ['artists', 'albums'], limit: 5 })", + "id" : "music.catalog.search", + "jsNames" : [ + "apple.music.search" + ], + "optionalArguments" : [ + "types", + "limit", + "countryCode" + ], + "requiredArguments" : [ + "term" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object grouped by result type with catalog identifiers and metadata.", + "summary" : "Search the Apple Music catalog for songs, albums, artists, playlists, or stations.", + "tags" : [ + "music", + "musickit", + "catalog", + "search" + ], + "title" : "Search Music catalog" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Maximum library items.", + "type" : "Library type such as playlists, songs, albums, or artists." + }, + "argumentTypes" : { + "limit" : "number", + "type" : "string" + }, + "example" : "await apple.music.readLibrary({ type: 'playlists', limit: 20 })", + "id" : "music.library.read", + "jsNames" : [ + "apple.music.readLibrary" + ], + "optionalArguments" : [ + "type", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "music" + ], + "resultSummary" : "Array of library items with identifiers and metadata.", + "summary" : "Read the user's Music library playlists or items after Music permission is granted.", + "tags" : [ + "music", + "musickit", + "library" + ], + "title" : "Read Music library" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.music.requestPermission()", + "id" : "music.permission.request", + "jsNames" : [ + "apple.music.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted.", + "summary" : "Request Apple Music\/media-library permission before library or playback actions.", + "tags" : [ + "music", + "musickit", + "permission" + ], + "title" : "Request Music permission" + }, + { + "allowedStringValues" : { + "action" : [ + "play", + "pause", + "stop", + "skipToNext", + "skipToPrevious", + "playCatalog", + "playLibrary" + ] + }, + "argumentHints" : { + "action" : "Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.", + "catalogID" : "Optional catalog item identifier.", + "libraryID" : "Optional library item identifier.", + "queue" : "Optional queue definition approved by the host adapter.", + "startPlaying" : "Whether playback should begin immediately; host-adapter defined." + }, + "argumentTypes" : { + "action" : "string", + "catalogID" : "string", + "libraryID" : "string", + "queue" : "object", + "startPlaying" : "bool" + }, + "example" : "await apple.music.play({ action: 'playCatalog', catalogID: 'song-id' })", + "id" : "music.playback.control", + "jsNames" : [ + "apple.music.play" + ], + "optionalArguments" : [ + "catalogID", + "libraryID", + "queue", + "startPlaying" + ], + "requiredArguments" : [ + "action" + ], + "requiredPermissions" : [ + "music" + ], + "resultSummary" : "Object with action\/status\/currentItem when available.", + "summary" : "Control Music playback queue through a user-authorized host adapter.", + "tags" : [ + "music", + "musickit", + "playback", + "queue" + ], + "title" : "Control Music playback" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "catalogIDs" : "Optional catalog song identifiers to add.", + "description" : "Optional playlist description.", + "libraryIDs" : "Optional library song identifiers to add.", + "name" : "Playlist name.", + "playlistID" : "Optional existing playlist identifier to update." + }, + "argumentTypes" : { + "catalogIDs" : "array", + "description" : "string", + "libraryIDs" : "array", + "name" : "any", + "playlistID" : "string" + }, + "example" : "await apple.music.writePlaylist({ name: 'Focus', catalogIDs: ['song-id'] })", + "id" : "music.playlist.write", + "jsNames" : [ + "apple.music.writePlaylist" + ], + "optionalArguments" : [ + "playlistID", + "catalogIDs", + "libraryIDs", + "description" + ], + "requiredArguments" : [ + "name" + ], + "requiredPermissions" : [ + "music" + ], + "resultSummary" : "Object with playlistID\/name\/written.", + "summary" : "Create or update a user-library playlist through a host MusicKit adapter.", + "tags" : [ + "music", + "musickit", + "library", + "playlist", + "write" + ], + "title" : "Write Music playlist" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.music.getSubscriptionStatus()", + "id" : "music.subscription.status", + "jsNames" : [ + "apple.music.getSubscriptionStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with canPlayCatalogContent\/hasCloudLibraryEnabled\/status.", + "summary" : "Read MusicKit subscription\/capability status exposed by the host.", + "tags" : [ + "music", + "musickit", + "subscription", + "catalog" + ], + "title" : "Read Music subscription status" + }, + { + "allowedStringValues" : { + "options.responseEncoding" : [ + "text", + "base64" + ] + }, + "argumentHints" : { + "options.body" : "UTF-8 request body string.", + "options.bodyBase64" : "Base64-encoded request body. Mutually exclusive with options.body.", + "options.headers" : "Object of header key\/value string pairs.", + "options.method" : "HTTP method; defaults to GET.", + "options.responseEncoding" : "text (default) or base64.", + "options.timeoutMs" : "Request and bridge wait timeout in milliseconds; defaults to 30000.", + "url" : "Absolute HTTP(S) URL string." + }, + "argumentTypes" : { + "options.body" : "string", + "options.bodyBase64" : "string", + "options.headers" : "object", + "options.method" : "string", + "options.responseEncoding" : "string", + "options.timeoutMs" : "number", + "url" : "string" + }, + "example" : "await fetch('https:\/\/api.example.com\/data').then(r => r.json())", + "id" : "network.fetch", + "jsNames" : [ + "fetch" + ], + "optionalArguments" : [ + "options.method", + "options.headers", + "options.body", + "options.bodyBase64", + "options.timeoutMs", + "options.responseEncoding" + ], + "requiredArguments" : [ + "url" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with ok\/status\/statusText\/headers\/bodyText or bodyBase64.", + "summary" : "Perform HTTP(S) requests through URLSession via a fetch-compatible API.", + "tags" : [ + "network", + "http", + "fetch" + ], + "title" : "Fetch HTTP resource" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "categories" : "Array of category definitions with identifier\/actions\/options approved by the host." + }, + "argumentTypes" : { + "categories" : "array" + }, + "example" : "await apple.notifications.setCategories({ categories: [{ identifier: 'task', actions: [{ identifier: 'done', title: 'Done' }] }] })", + "id" : "notifications.categories.set", + "jsNames" : [ + "apple.notifications.setCategories" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "categories" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with registered category identifiers.", + "summary" : "Register host-approved notification categories and actions for push\/local response handling.", + "tags" : [ + "notifications", + "apns", + "categories", + "actions" + ], + "title" : "Set notification categories" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Single delivered notification identifier to remove.", + "identifiers" : "Array of delivered notification identifiers to remove. Omit both to clear all delivered notifications." + }, + "argumentTypes" : { + "identifier" : "string", + "identifiers" : "array" + }, + "example" : "await apple.notifications.removeDelivered({ identifiers: ['codemode.1', 'codemode.2'] })", + "id" : "notifications.delivered.delete", + "jsNames" : [ + "apple.notifications.removeDelivered" + ], + "optionalArguments" : [ + "identifier", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Object with deleted\/count fields.", + "summary" : "Delete delivered notifications by identifier list or clear all.", + "tags" : [ + "notifications", + "local", + "delivered", + "delete" + ], + "title" : "Delete delivered local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of delivered notifications to return, default 50." + }, + "argumentTypes" : { + "limit" : "number" + }, + "example" : "await apple.notifications.listDelivered({ limit: 20 })", + "id" : "notifications.delivered.read", + "jsNames" : [ + "apple.notifications.listDelivered" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Array of delivered notifications with identifiers\/content\/date metadata.", + "summary" : "List notifications currently delivered in Notification Center.", + "tags" : [ + "notifications", + "local", + "delivered" + ], + "title" : "List delivered local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Single pending request identifier to remove.", + "identifiers" : "Array of pending request identifiers to remove. Omit both to clear all pending requests." + }, + "argumentTypes" : { + "identifier" : "string", + "identifiers" : "array" + }, + "example" : "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })", + "id" : "notifications.pending.delete", + "jsNames" : [ + "apple.notifications.cancelPending" + ], + "optionalArguments" : [ + "identifier", + "identifiers" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Object with deleted\/count fields.", + "summary" : "Delete pending local notifications by identifier list or clear all.", + "tags" : [ + "notifications", + "local", + "schedule" + ], + "title" : "Delete pending local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Max number of pending requests to return, default 50." + }, + "argumentTypes" : { + "limit" : "number" + }, + "example" : "await apple.notifications.listPending({ limit: 20 })", + "id" : "notifications.pending.read", + "jsNames" : [ + "apple.notifications.listPending" + ], + "optionalArguments" : [ + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Array of pending requests with identifiers\/content\/trigger metadata.", + "summary" : "List pending local notification requests.", + "tags" : [ + "notifications", + "local", + "schedule" + ], + "title" : "List pending local notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.notifications.requestPermission()", + "id" : "notifications.permission.request", + "jsNames" : [ + "apple.notifications.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted fields.", + "summary" : "Request local notification authorization from the user.", + "tags" : [ + "notifications", + "permission" + ], + "title" : "Request notification permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "types" : "Optional host-supported notification types; APNs provider sending is intentionally out of scope." + }, + "argumentTypes" : { + "types" : "array" + }, + "example" : "await apple.notifications.registerRemote()", + "id" : "notifications.remote.register", + "jsNames" : [ + "apple.notifications.registerRemote" + ], + "optionalArguments" : [ + "types" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with registered\/status and deviceToken when already available.", + "summary" : "Ask the host app to register with APNs and record the client device-token lifecycle.", + "tags" : [ + "notifications", + "apns", + "remote", + "registration" + ], + "title" : "Register for remote notifications" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.notifications.getRemoteToken()", + "id" : "notifications.remote.token.read", + "jsNames" : [ + "apple.notifications.getRemoteToken" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with token\/environment\/updatedAt or null token when registration has not completed.", + "summary" : "Read the latest APNs device token captured by the host app.", + "tags" : [ + "notifications", + "apns", + "remote", + "token" + ], + "title" : "Read APNs device token" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "actionIdentifier" : "Optional action filter.", + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "categoryIdentifier" : "Optional category filter.", + "limit" : "Maximum number of response events to read." + }, + "argumentTypes" : { + "actionIdentifier" : "string", + "afterCursor" : "string", + "categoryIdentifier" : "string", + "limit" : "number" + }, + "example" : "await apple.notifications.listResponses({ limit: 20 })", + "id" : "notifications.responses.read", + "jsNames" : [ + "apple.notifications.listResponses" + ], + "optionalArguments" : [ + "limit", + "categoryIdentifier", + "actionIdentifier", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of response events with cursor\/identifier\/actionIdentifier\/categoryIdentifier\/userText\/userInfo\/date.", + "summary" : "Read bounded notification action\/open responses captured by the host for later agent handling.", + "tags" : [ + "notifications", + "apns", + "response", + "inbox", + "events" + ], + "title" : "Read notification response inbox" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "badge" : "Optional app icon badge number.", + "body" : "Optional body text.", + "categoryIdentifier" : "Optional category identifier for notification actions.", + "fireDate" : "Optional ISO8601 timestamp for calendar trigger.", + "identifier" : "Optional request identifier; defaults to codemode UUID.", + "repeats" : "Boolean repeat flag (time interval requires >= 60 seconds).", + "secondsFromNow" : "Delay in seconds for time interval trigger (default 5).", + "sound" : "default (default), none, or a bundled custom sound name.", + "subtitle" : "Optional subtitle text.", + "threadIdentifier" : "Optional thread identifier for notification grouping.", + "title" : "Notification title text.", + "userInfo" : "Optional property-list-safe userInfo object." + }, + "argumentTypes" : { + "badge" : "number", + "body" : "string", + "categoryIdentifier" : "string", + "fireDate" : "string", + "identifier" : "string", + "repeats" : "bool", + "secondsFromNow" : "number", + "sound" : "string", + "subtitle" : "string", + "threadIdentifier" : "string", + "title" : "string", + "userInfo" : "object" + }, + "example" : "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })", + "id" : "notifications.schedule", + "jsNames" : [ + "apple.notifications.schedule" + ], + "optionalArguments" : [ + "identifier", + "subtitle", + "body", + "secondsFromNow", + "fireDate", + "repeats", + "sound", + "badge", + "userInfo", + "threadIdentifier", + "categoryIdentifier" + ], + "requiredArguments" : [ + "title" + ], + "requiredPermissions" : [ + "notifications" + ], + "resultSummary" : "Object with identifier\/scheduled\/repeats.", + "summary" : "Schedule a local notification using time interval or fireDate trigger.", + "tags" : [ + "notifications", + "local", + "schedule" + ], + "title" : "Schedule local notification" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.notifications.getSettings()", + "id" : "notifications.settings.read", + "jsNames" : [ + "apple.notifications.getSettings" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with authorizationStatus\/alert\/badge\/sound\/criticalAlert\/providesAppNotificationSettings.", + "summary" : "Read UserNotifications\/APNs client settings exposed by the host.", + "tags" : [ + "notifications", + "apns", + "settings", + "permission" + ], + "title" : "Read notification settings" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "confirmed" : "Must be true after explicit user-visible confirmation before presenting Apple Pay.", + "paymentRequestID" : "Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript.", + "timeoutMs" : "Optional timeout for user-mediated Apple Pay UI." + }, + "argumentTypes" : { + "confirmed" : "bool", + "paymentRequestID" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.wallet.presentPayment({ paymentRequestID: 'checkout-123', confirmed: true })", + "id" : "passkit.applePay.present", + "jsNames" : [ + "apple.wallet.presentPayment" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + "paymentRequestID", + "confirmed" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/authorized\/paymentRequestID\/status.", + "summary" : "Present a host-defined Apple Pay payment request using host merchant configuration after explicit user-visible confirmation.", + "tags" : [ + "passkit", + "apple-pay", + "payments", + "system-ui", + "safety" + ], + "title" : "Present Apple Pay request" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "networks" : "Optional supported payment networks to test against host merchant configuration." + }, + "argumentTypes" : { + "networks" : "array" + }, + "example" : "await apple.wallet.canMakePayments()", + "id" : "passkit.applePay.status", + "jsNames" : [ + "apple.wallet.canMakePayments" + ], + "optionalArguments" : [ + "networks" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with canMakePayments\/canMakePaymentsUsingNetworks.", + "summary" : "Read Apple Pay capability using host merchant configuration; no merchant setup is accepted from JavaScript.", + "tags" : [ + "passkit", + "apple-pay", + "payments", + "safety" + ], + "title" : "Read Apple Pay status" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Sandbox path to a .pkpass file.", + "timeoutMs" : "Optional timeout for user-mediated add-pass UI." + }, + "argumentTypes" : { + "path" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' })", + "id" : "passkit.pass.add", + "jsNames" : [ + "apple.wallet.addPass" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/added\/passTypeIdentifier\/serialNumber.", + "summary" : "Present user-mediated UI to add a sandbox .pkpass file to Wallet.", + "tags" : [ + "passkit", + "wallet", + "passes", + "add", + "system-ui" + ], + "title" : "Add Wallet pass" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "Accessible Wallet pass identifier from apple.wallet.listPasses.", + "timeoutMs" : "Optional timeout for user-mediated pass presentation." + }, + "argumentTypes" : { + "identifier" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.wallet.presentPass({ identifier: 'pass-id' })", + "id" : "passkit.pass.present", + "jsNames" : [ + "apple.wallet.presentPass" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/presented\/identifier.", + "summary" : "Present user-mediated details for an accessible Wallet pass.", + "tags" : [ + "passkit", + "wallet", + "passes", + "system-ui" + ], + "title" : "Present Wallet pass" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "limit" : "Maximum accessible passes.", + "passTypeIdentifier" : "Optional pass type filter.", + "serialNumber" : "Optional serial number filter." + }, + "argumentTypes" : { + "limit" : "number", + "passTypeIdentifier" : "string", + "serialNumber" : "string" + }, + "example" : "await apple.wallet.listPasses({ limit: 20 })", + "id" : "passkit.passes.read", + "jsNames" : [ + "apple.wallet.listPasses" + ], + "optionalArguments" : [ + "passTypeIdentifier", + "serialNumber", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of passes with passTypeIdentifier\/serialNumber\/organizationName\/description.", + "summary" : "List Wallet passes accessible to the host app through PassKit.", + "tags" : [ + "passkit", + "wallet", + "passes", + "read" + ], + "title" : "List Wallet passes" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.wallet.getStatus()", + "id" : "passkit.wallet.status", + "jsNames" : [ + "apple.wallet.getStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with available\/canAddPasses\/canPresentPasses.", + "summary" : "Read PassKit Wallet availability and pass-library access status.", + "tags" : [ + "passkit", + "wallet", + "passes" + ], + "title" : "Read Wallet status" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "localIdentifier" : "PHAsset localIdentifier from photos.read result.", + "outputPath" : "Optional sandbox output path; defaults to tmp-generated file." + }, + "argumentTypes" : { + "localIdentifier" : "string", + "outputPath" : "string" + }, + "example" : "await apple.photos.export({ localIdentifier: 'ABC\/L0\/001', outputPath: 'tmp:exported.jpg' })", + "id" : "photos.export", + "jsNames" : [ + "apple.photos.export" + ], + "optionalArguments" : [ + "outputPath" + ], + "requiredArguments" : [ + "localIdentifier" + ], + "requiredPermissions" : [ + "photoLibrary" + ], + "resultSummary" : "Object with path\/artifactID\/localIdentifier\/mediaType\/bytes.", + "summary" : "Export a photo\/video asset to sandbox file path and register artifact handle.", + "tags" : [ + "photos", + "photo-library", + "artifact" + ], + "title" : "Export photo library asset" + }, + { + "allowedStringValues" : { + "mediaType" : [ + "any", + "image", + "photo", + "video" + ] + }, + "argumentHints" : { + "limit" : "Max number of results, default 50.", + "mediaType" : "any (default), image, or video." + }, + "argumentTypes" : { + "limit" : "number", + "mediaType" : "string" + }, + "example" : "await apple.photos.list({ mediaType: 'image', limit: 20 })", + "id" : "photos.read", + "jsNames" : [ + "apple.photos.list" + ], + "optionalArguments" : [ + "mediaType", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "photoLibrary" + ], + "resultSummary" : "Array of assets with localIdentifier\/mediaType\/dimensions\/date metadata.", + "summary" : "List photos\/videos from the user photo library.", + "tags" : [ + "photos", + "photo-library", + "media" + ], + "title" : "List photo library assets" + }, + { + "allowedStringValues" : { + "mediaType" : [ + "any", + "image", + "photo", + "video" + ] + }, + "argumentHints" : { + "limit" : "Maximum number of selectable items; default 1.", + "mediaType" : "any (default), image\/photo, or video.", + "outputDirectory" : "Optional sandbox directory for exported picker files; defaults to tmp:.", + "timeoutMs" : "Optional timeout for waiting on user selection\/export." + }, + "argumentTypes" : { + "limit" : "number", + "mediaType" : "string", + "outputDirectory" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })", + "id" : "photos.ui.pick", + "jsNames" : [ + "apple.photos.pick" + ], + "optionalArguments" : [ + "mediaType", + "limit", + "outputDirectory", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of selected assets with path\/artifactID\/mediaType\/uniformTypeIdentifier\/bytes.", + "summary" : "Present system photo picker UI and export selected assets into the sandbox artifact store.", + "tags" : [ + "photos", + "photo-library", + "system-ui", + "picker", + "artifact" + ], + "title" : "Pick photos with system UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "timeoutMs" : "Optional timeout for waiting on the limited-library picker completion." + }, + "argumentTypes" : { + "timeoutMs" : "number" + }, + "example" : "await apple.photos.presentLimitedLibraryPicker()", + "id" : "photos.ui.presentLimitedLibraryPicker", + "jsNames" : [ + "apple.photos.presentLimitedLibraryPicker" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/status and selectedIdentifiers when the limited selection changes.", + "summary" : "Present the Photos limited-library management UI so the user can update the app's selected photo set.", + "tags" : [ + "photos", + "photo-library", + "system-ui", + "permission", + "picker" + ], + "title" : "Present limited Photos picker" + }, + { + "allowedStringValues" : { + "outputType" : [ + "general", + "photo", + "grayscale" + ] + }, + "argumentHints" : { + "jobName" : "Optional print job name.", + "outputType" : "general (default), photo, or grayscale.", + "path" : "Single sandbox file path to print.", + "paths" : "Optional array of sandbox file paths to print.", + "showsNumberOfCopies" : "Whether copy count controls are shown; default true.", + "timeoutMs" : "Optional timeout for waiting on print completion\/cancellation." + }, + "argumentTypes" : { + "jobName" : "string", + "outputType" : "string", + "path" : "string", + "paths" : "array", + "showsNumberOfCopies" : "bool", + "timeoutMs" : "number" + }, + "example" : "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })", + "id" : "print.ui.present", + "jsNames" : [ + "apple.print.present" + ], + "optionalArguments" : [ + "path", + "paths", + "jobName", + "outputType", + "showsNumberOfCopies", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/completed for the print interaction.", + "summary" : "Present the system print sheet for one or more sandbox files.", + "tags" : [ + "print", + "documents", + "system-ui", + "export" + ], + "title" : "Present print UI" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "path" : "Single sandbox file path to preview.", + "paths" : "Optional array of sandbox file paths to preview.", + "timeoutMs" : "Optional timeout for waiting on dismissal." + }, + "argumentTypes" : { + "path" : "string", + "paths" : "array", + "timeoutMs" : "number" + }, + "example" : "await apple.quicklook.preview({ path: 'tmp:report.pdf' })", + "id" : "quicklook.ui.preview", + "jsNames" : [ + "apple.quicklook.preview" + ], + "optionalArguments" : [ + "path", + "paths", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action dismissed and count.", + "summary" : "Present Quick Look preview UI for one or more sandbox file artifacts.", + "tags" : [ + "quicklook", + "preview", + "documents", + "system-ui" + ], + "title" : "Preview files with Quick Look" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "identifier" : "EventKit calendarItemIdentifier from apple.reminders.listReminders." + }, + "argumentTypes" : { + "identifier" : "string" + }, + "example" : "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })", + "id" : "reminders.delete", + "jsNames" : [ + "apple.reminders.deleteReminder" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "identifier" + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Object with identifier\/deleted.", + "summary" : "Delete an existing EventKit reminder by identifier.", + "tags" : [ + "reminders", + "eventkit", + "task", + "delete" + ], + "title" : "Delete reminder" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "calendarIdentifier" : "Optional EventKit reminder calendarIdentifier to restrict results.", + "calendarIdentifiers" : "Optional array of EventKit reminder calendarIdentifier strings to restrict results.", + "end" : "Optional ISO8601 due-date upper bound.", + "includeCompleted" : "Whether completed reminders are included; default false.", + "limit" : "Max number of reminder items, default 50.", + "start" : "Optional ISO8601 due-date lower bound." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "calendarIdentifiers" : "array", + "end" : "string", + "includeCompleted" : "bool", + "limit" : "number", + "start" : "string" + }, + "example" : "await apple.reminders.listReminders({ limit: 20 })", + "id" : "reminders.read", + "jsNames" : [ + "apple.reminders.listReminders" + ], + "optionalArguments" : [ + "start", + "end", + "includeCompleted", + "calendarIdentifier", + "calendarIdentifiers", + "limit" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Array of reminders with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "summary" : "Read incomplete reminders from EventKit.", + "tags" : [ + "reminders", + "eventkit", + "task" + ], + "title" : "Read reminders" + }, + { + "allowedStringValues" : { + "operation" : [ + "create", + "update", + "complete" + ] + }, + "argumentHints" : { + "calendarIdentifier" : "Optional destination EventKit reminders calendarIdentifier.", + "dueDate" : "Optional ISO8601 due date timestamp.", + "identifier" : "EventKit calendarItemIdentifier to update or complete.", + "isCompleted" : "Completion state for update or completeReminder; default true for completeReminder.", + "notes" : "Optional reminder notes.", + "operation" : "create (default without identifier), update, or complete.", + "priority" : "EventKit reminder priority integer.", + "title" : "Reminder title string." + }, + "argumentTypes" : { + "calendarIdentifier" : "string", + "dueDate" : "string", + "identifier" : "string", + "isCompleted" : "bool", + "notes" : "string", + "operation" : "string", + "priority" : "number", + "title" : "string" + }, + "example" : "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })", + "id" : "reminders.write", + "jsNames" : [ + "apple.reminders.createReminder", + "apple.reminders.updateReminder", + "apple.reminders.completeReminder" + ], + "optionalArguments" : [ + "operation", + "identifier", + "title", + "dueDate", + "notes", + "isCompleted", + "priority", + "calendarIdentifier" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "reminders" + ], + "resultSummary" : "Object with identifier\/title\/isCompleted\/dueDate\/completionDate\/notes\/priority\/calendarIdentifier\/calendarTitle.", + "summary" : "Create a reminder or patch an existing reminder by identifier.", + "tags" : [ + "reminders", + "eventkit", + "task" + ], + "title" : "Create or update reminder" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "timeoutMs" : "Optional timeout for waiting on UIApplication.open completion." + }, + "argumentTypes" : { + "timeoutMs" : "number" + }, + "example" : "await apple.settings.open()", + "id" : "settings.ui.open", + "jsNames" : [ + "apple.settings.open" + ], + "optionalArguments" : [ + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action opened\/failed and opened boolean.", + "summary" : "Open the host app's Settings page so the user can recover denied permissions.", + "tags" : [ + "settings", + "permissions", + "system-ui" + ], + "title" : "Open app settings" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "excludedActivityTypes" : "Optional array of UIActivity.ActivityType raw value strings to hide.", + "path" : "Optional single sandbox file path to share.", + "paths" : "Optional array of sandbox file paths to share.", + "subject" : "Optional subject for services that support it.", + "text" : "Optional text item to share.", + "timeoutMs" : "Optional timeout for waiting on share completion.", + "url" : "Optional absolute HTTP(S) URL to share." + }, + "argumentTypes" : { + "excludedActivityTypes" : "array", + "path" : "string", + "paths" : "array", + "subject" : "string", + "text" : "string", + "timeoutMs" : "number", + "url" : "string" + }, + "example" : "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })", + "id" : "share.ui.present", + "jsNames" : [ + "apple.share.present" + ], + "optionalArguments" : [ + "text", + "url", + "path", + "paths", + "subject", + "excludedActivityTypes", + "timeoutMs" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with completed\/activityType\/action.", + "summary" : "Present system share sheet for text, URLs, and sandbox file artifacts.", + "tags" : [ + "share", + "export", + "system-ui" + ], + "title" : "Present share sheet" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "locale" : "BCP-47 locale identifier such as en-US.", + "path" : "Sandbox audio file path.", + "requiresOnDeviceRecognition" : "Whether to require on-device recognition when the host supports it.", + "taskHint" : "Speech task hint such as dictation, search, or confirmation.", + "timeoutMs" : "Maximum transcription wait time in milliseconds." + }, + "argumentTypes" : { + "locale" : "string", + "path" : "string", + "requiresOnDeviceRecognition" : "bool", + "taskHint" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.speech.transcribeFile({ path: 'tmp:meeting.m4a', locale: 'en-US', timeoutMs: 60000 })", + "id" : "speech.file.transcribe", + "jsNames" : [ + "apple.speech.transcribeFile" + ], + "optionalArguments" : [ + "locale", + "timeoutMs", + "requiresOnDeviceRecognition", + "taskHint" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + "speech.recognition" + ], + "resultSummary" : "Object with transcript\/segments\/locale\/isFinal\/durationSeconds.", + "summary" : "Transcribe a sandbox audio file through the host Speech adapter.", + "tags" : [ + "speech", + "transcription", + "audio" + ], + "title" : "Transcribe audio file" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "locale" : "BCP-47 locale identifier such as en-US.", + "partialResults" : "Whether partial transcripts may be returned.", + "requiresOnDeviceRecognition" : "Whether to require on-device recognition when the host supports it.", + "taskHint" : "Speech task hint such as dictation, search, or confirmation.", + "timeoutMs" : "Maximum microphone capture\/transcription time in milliseconds." + }, + "argumentTypes" : { + "locale" : "string", + "partialResults" : "bool", + "requiresOnDeviceRecognition" : "bool", + "taskHint" : "string", + "timeoutMs" : "number" + }, + "example" : "await apple.speech.transcribeMicrophone({ locale: 'en-US', timeoutMs: 15000 })", + "id" : "speech.microphone.transcribe", + "jsNames" : [ + "apple.speech.transcribeMicrophone" + ], + "optionalArguments" : [ + "locale", + "timeoutMs", + "requiresOnDeviceRecognition", + "taskHint", + "partialResults" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + "speech.recognition", + "microphone" + ], + "resultSummary" : "Object with transcript\/segments\/locale\/isFinal\/timedOut.", + "summary" : "Run live microphone transcription through a host-mediated session with an explicit timeout.", + "tags" : [ + "speech", + "transcription", + "audio", + "microphone" + ], + "title" : "Transcribe microphone audio" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.speech.requestPermission()", + "id" : "speech.permission.request", + "jsNames" : [ + "apple.speech.requestPermission" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted.", + "summary" : "Trigger the Speech recognition permission prompt.", + "tags" : [ + "speech", + "permission", + "transcription" + ], + "title" : "Request speech recognition permission" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.speech.getStatus()", + "id" : "speech.status", + "jsNames" : [ + "apple.speech.getStatus" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/granted.", + "summary" : "Read Speech recognition permission status without starting capture.", + "tags" : [ + "speech", + "permission", + "transcription" + ], + "title" : "Read speech recognition permission status" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + + }, + "argumentTypes" : { + + }, + "example" : "await apple.storekit.listEntitlements()", + "id" : "storekit.entitlements.read", + "jsNames" : [ + "apple.storekit.listEntitlements" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of current entitlements with productID\/transactionID\/expirationDate.", + "summary" : "Read current StoreKit entitlements and verified transaction state.", + "tags" : [ + "storekit", + "commerce", + "entitlements" + ], + "title" : "Read StoreKit entitlements" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "productIDs" : "Array of host-configured StoreKit product identifiers." + }, + "argumentTypes" : { + "productIDs" : "array" + }, + "example" : "await apple.storekit.listProducts({ productIDs: ['pro.monthly'] })", + "id" : "storekit.products.read", + "jsNames" : [ + "apple.storekit.listProducts" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "productIDs" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of products with id\/displayName\/description\/price\/type.", + "summary" : "Read StoreKit product metadata for host-configured product identifiers.", + "tags" : [ + "storekit", + "commerce", + "products" + ], + "title" : "Read StoreKit products" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "appAccountToken" : "Optional UUID string for StoreKit appAccountToken.", + "confirmed" : "Must be true after explicit user-visible confirmation before purchase UI is presented.", + "productID" : "Host-configured StoreKit product identifier." + }, + "argumentTypes" : { + "appAccountToken" : "string", + "confirmed" : "bool", + "productID" : "string" + }, + "example" : "await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true })", + "id" : "storekit.purchase", + "jsNames" : [ + "apple.storekit.purchase" + ], + "optionalArguments" : [ + "appAccountToken" + ], + "requiredArguments" : [ + "productID", + "confirmed" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with status\/productID\/transactionID when completed.", + "summary" : "Present StoreKit purchase UI for a host-configured product after explicit user-visible confirmation.", + "tags" : [ + "storekit", + "commerce", + "purchase", + "safety" + ], + "title" : "Purchase StoreKit product" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "confirmed" : "Must be true after explicit user-visible confirmation before restore starts." + }, + "argumentTypes" : { + "confirmed" : "bool" + }, + "example" : "await apple.storekit.restore({ confirmed: true })", + "id" : "storekit.restore", + "jsNames" : [ + "apple.storekit.restore" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "confirmed" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with restored\/status.", + "summary" : "Trigger StoreKit restore\/sync after explicit user-visible confirmation.", + "tags" : [ + "storekit", + "commerce", + "restore", + "safety" + ], + "title" : "Restore StoreKit purchases" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "afterCursor" : "Optional host-provided cursor for incremental reads.", + "limit" : "Maximum transaction updates to return.", + "productID" : "Optional product filter." + }, + "argumentTypes" : { + "afterCursor" : "string", + "limit" : "number", + "productID" : "string" + }, + "example" : "await apple.storekit.listTransactions({ limit: 20 })", + "id" : "storekit.transactions.read", + "jsNames" : [ + "apple.storekit.listTransactions" + ], + "optionalArguments" : [ + "limit", + "productID", + "afterCursor" + ], + "requiredArguments" : [ + + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Array of transaction events with cursor\/productID\/transactionID\/status\/date.", + "summary" : "Read bounded transaction updates captured by the host StoreKit adapter.", + "tags" : [ + "storekit", + "commerce", + "transactions", + "inbox", + "events" + ], + "title" : "Read StoreKit transaction inbox" + }, + { + "allowedStringValues" : { + "preferredStyle" : [ + "alert", + "actionSheet", + "actionsheet" + ] + }, + "argumentHints" : { + "buttons" : "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "message" : "Optional alert message.", + "preferredStyle" : "alert (default) or actionSheet.", + "sourceRect" : "Optional { x, y, width, height } anchor for action sheets.", + "timeoutMs" : "Optional timeout for waiting on user selection.", + "title" : "Optional alert title." + }, + "argumentTypes" : { + "buttons" : "array", + "message" : "string", + "preferredStyle" : "string", + "sourceRect" : "object", + "timeoutMs" : "number", + "title" : "string" + }, + "example" : "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })", + "id" : "ui.alert.present", + "jsNames" : [ + "apple.ui.presentAlert" + ], + "optionalArguments" : [ + "title", + "message", + "preferredStyle", + "sourceRect", + "timeoutMs" + ], + "requiredArguments" : [ + "buttons" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action\/buttonID\/buttonTitle\/buttonIndex\/style for the selected button.", + "summary" : "Present a system alert or action sheet and return the button the user selects.", + "tags" : [ + "ui", + "alert", + "dialog", + "system-ui" + ], + "title" : "Present alert with custom buttons" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "buttons" : "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "fields" : "Array of { id?, placeholder?, text?\/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", + "message" : "Optional alert message.", + "timeoutMs" : "Optional timeout for waiting on user selection.", + "title" : "Optional alert title." + }, + "argumentTypes" : { + "buttons" : "array", + "fields" : "array", + "message" : "string", + "timeoutMs" : "number", + "title" : "string" + }, + "example" : "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })", + "id" : "ui.prompt.present", + "jsNames" : [ + "apple.ui.presentPrompt" + ], + "optionalArguments" : [ + "title", + "message", + "timeoutMs" + ], + "requiredArguments" : [ + "fields", + "buttons" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with selected button metadata and values keyed by field id.", + "summary" : "Present a system alert with one or more text fields and custom buttons.", + "tags" : [ + "ui", + "alert", + "prompt", + "input", + "system-ui" + ], + "title" : "Present prompt with text fields" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "features" : "Optional array including labels\/text\/barcodes.", + "maxResults" : "Max observations returned per feature, default 5.", + "path" : "Sandbox image path to analyze." + }, + "argumentTypes" : { + "features" : "array", + "maxResults" : "number", + "path" : "string" + }, + "example" : "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })", + "id" : "vision.image.analyze", + "jsNames" : [ + "apple.vision.analyzeImage" + ], + "optionalArguments" : [ + "features", + "maxResults" + ], + "requiredArguments" : [ + "path" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object containing requested analysis sections such as labels\/text\/barcodes.", + "summary" : "Run on-device image analysis for labels\/text\/barcodes on sandbox image paths.", + "tags" : [ + "vision", + "image-analysis", + "ml" + ], + "title" : "Analyze image with Vision" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "latitude" : "Latitude in decimal degrees.", + "longitude" : "Longitude in decimal degrees." + }, + "argumentTypes" : { + "latitude" : "number", + "longitude" : "number" + }, + "example" : "await apple.weather.getCurrentWeather({ latitude: 37.77, longitude: -122.41 })", + "id" : "weather.read", + "jsNames" : [ + "apple.weather.getCurrentWeather" + ], + "optionalArguments" : [ + + ], + "requiredArguments" : [ + "latitude", + "longitude" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with temperatureCelsius\/condition\/symbolName\/date.", + "summary" : "Fetch current weather for a latitude\/longitude pair.", + "tags" : [ + "weather", + "forecast", + "weatherkit" + ], + "title" : "Read WeatherKit weather" + }, + { + "allowedStringValues" : { + + }, + "argumentHints" : { + "entersReaderIfAvailable" : "Whether Safari may enter Reader automatically; default false.", + "timeoutMs" : "Optional timeout for waiting on dismissal.", + "url" : "Absolute HTTP(S) URL to present." + }, + "argumentTypes" : { + "entersReaderIfAvailable" : "bool", + "timeoutMs" : "number", + "url" : "string" + }, + "example" : "await apple.web.present({ url: 'https:\/\/example.com' })", + "id" : "web.ui.present", + "jsNames" : [ + "apple.web.present" + ], + "optionalArguments" : [ + "entersReaderIfAvailable", + "timeoutMs" + ], + "requiredArguments" : [ + "url" + ], + "requiredPermissions" : [ + + ], + "resultSummary" : "Object with action dismissed.", + "summary" : "Present an HTTP(S) URL with the system Safari view controller.", + "tags" : [ + "web", + "safari", + "browser", + "system-ui" + ], + "title" : "Present web page with system UI" + } +] \ No newline at end of file diff --git a/Tools/CodeModeAuthoring/Package.swift b/Tools/CodeModeAuthoring/Package.swift deleted file mode 100644 index f51a879..0000000 --- a/Tools/CodeModeAuthoring/Package.swift +++ /dev/null @@ -1,49 +0,0 @@ -// swift-tools-version: 6.1 -import PackageDescription -import CompilerPluginSupport - -let package = Package( - name: "CodeModeAuthoring", - platforms: [ - .iOS(.v18), - .macOS(.v15), - .visionOS(.v2), - ], - products: [ - .library( - name: "CodeModeAuthoring", - targets: ["CodeModeAuthoring"] - ), - ], - dependencies: [ - .package(name: "codemode-ios", path: "../.."), - .package(url: "https://github.com/swiftlang/swift-syntax.git", exact: "601.0.1"), - ], - targets: [ - .target( - name: "CodeModeAuthoring", - dependencies: [ - .product(name: "CodeMode", package: "codemode-ios"), - "CodeModeMacros", - ] - ), - .macro( - name: "CodeModeMacros", - dependencies: [ - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - .product(name: "SwiftDiagnostics", package: "swift-syntax"), - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - ] - ), - .testTarget( - name: "CodeModeAuthoringTests", - dependencies: [ - "CodeModeAuthoring", - "CodeModeMacros", - .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), - ] - ), - ] -) diff --git a/Tools/CodeModeEval/Package.resolved b/Tools/CodeModeEval/Package.resolved index 8ac29a5..524d693 100644 --- a/Tools/CodeModeEval/Package.resolved +++ b/Tools/CodeModeEval/Package.resolved @@ -9,6 +9,15 @@ "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", "version" : "1.7.1" } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } } ], "version" : 3