From ee03e70de7b18e7cae3767c6c3a83b2d68af90b0 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 02:25:13 +0300 Subject: [PATCH 01/27] feat: introduce intentcall_cli and intentcall_platform_sync packages for framework-neutral CLI and manifest handling --- .../adr_0019_closure_plan_762cf2a1.plan.md | 406 +++++++++++ .release-please-manifest.json | 4 +- CONTRIBUTING.md | 2 +- PUBLISHING.md | 5 +- README.md | 4 +- docs/DESIGN_FAQ.mdx | 6 + docs/DX_FAQ.mdx | 6 +- docs/NORTH_STAR.mdx | 2 +- .../0019-framework-neutral-intentcall-cli.md | 123 ++++ docs/decisions/README.md | 3 +- docs/start_here/choose_your_path.mdx | 2 +- justfile | 19 +- packages/intentcall_android/pubspec.yaml | 2 +- packages/intentcall_apple/pubspec.yaml | 2 +- packages/intentcall_cli/CHANGELOG.md | 0 packages/intentcall_cli/analysis_options.yaml | 1 + packages/intentcall_cli/bin/intentcall.dart | 7 + .../intentcall_cli/lib/intentcall_cli.dart | 5 + .../lib/src/catalog/catalog_loader.dart | 155 ++++ .../lib/src/command_runner.dart | 675 ++++++++++++++++++ .../commands/apple_app_intents_testing.dart | 422 +++++++++++ .../lib/src/config/host_profiles.dart | 64 ++ .../lib/src/config/intentcall_config.dart | 201 ++++++ .../lib/src/mcp/stdio_mcp_server.dart | 58 ++ .../lib/src/utils/cli_utils.dart | 95 +++ packages/intentcall_cli/pubspec.yaml | 34 + .../test/command_runner_test.dart | 169 +++++ .../fixtures/flutter_project/intentcall.yaml | 13 + .../flutter_project/web/agent_manifest.json | 49 ++ .../web/intentcall_webmcp.generated.js | 214 ++++++ .../flutter_project/web/manifest.json | 22 + .../jaspr_web_project/intentcall.yaml | 9 + .../jaspr_web_project/web/agent_manifest.json | 52 ++ .../web/intentcall_webmcp.generated.js | 217 ++++++ .../jaspr_web_project/web/manifest.json | 22 + .../test/manifest_registry_parity_test.dart | 66 ++ packages/intentcall_codegen/build.yaml | 19 + packages/intentcall_codegen/intentcall.yaml | 10 + packages/intentcall_codegen/lib/builder.dart | 10 + .../lib/generated/agent_catalog.g.dart | 11 + .../lib/intentcall_codegen.dart | 1 + .../lib/src/agent_projection.dart | 15 + .../generators/agent_catalog_generator.dart | 112 +++ .../generators/agent_manifest_generator.dart | 136 ++++ .../src/generators/agent_tool_generator.dart | 42 +- packages/intentcall_codegen/pubspec.yaml | 7 +- .../web/agent_manifest.json | 149 ++++ packages/intentcall_core/pubspec.yaml | 2 +- packages/intentcall_gemma/pubspec.yaml | 2 +- packages/intentcall_mcp/pubspec.yaml | 2 +- packages/intentcall_platform/README.md | 20 +- .../intentcall_platform/analysis_options.yaml | 2 +- .../lib/intentcall_platform.dart | 16 +- .../src/flutter/intentcall_flutter_host.dart | 4 +- .../src/flutter/intentcall_host_events.dart | 2 +- .../intentcall_pending_invocations.dart | 2 +- .../intentcall_pending_invocations_stub.dart | 2 +- .../templates/platform_hook_templates.dart | 36 - packages/intentcall_platform/pubspec.yaml | 5 +- .../intentcall_platform_sync/CHANGELOG.md | 3 + packages/intentcall_platform_sync/LICENSE | 21 + .../analysis_options.yaml | 13 + .../lib/intentcall_platform_sync.dart | 20 + .../lib/src/agent_manifest.dart | 25 + .../lib/src/agent_manifest_generator.dart | 0 .../bootstrap/agent_web_mcp_bootstrap.dart | 0 .../agent_web_mcp_bootstrap_stub.dart | 0 .../agent_web_mcp_bootstrap_web.dart | 0 .../src/catalog/agent_registry_catalog.dart | 19 + .../android_shortcuts_xml_emitter.dart | 0 .../apple_app_intents_testing_emitter.dart | 0 .../apple_dart_extension_inline_emitter.dart | 0 .../apple_swift_app_intents_emitter.dart | 0 .../lib/src/emitters/emitter_utils.dart | 0 .../emitters/linux_desktop_entry_emitter.dart | 0 .../src/emitters/web_manifest_emitter.dart | 0 .../lib/src/emitters/web_mcp_js_emitter.dart | 0 .../emitters/windows_protocol_emitter.dart | 0 .../lib/src/init/platform_hooks_init.dart | 3 +- .../src/invocation/intentcall_invocation.dart | 0 .../lib/src/projection/manifest_merger.dart | 163 +++++ .../lib/src/projection/projection_policy.dart | 154 ++++ .../sync/apple_info_plist_protocol_sync.dart | 0 .../src/sync/apple_xcode_project_sync.dart | 0 .../lib/src/sync/platform_sync.dart | 2 +- .../templates/platform_hook_templates.dart | 71 ++ .../intentcall_platform_sync/pubspec.yaml | 28 + .../test/agent_web_mcp_bootstrap_test.dart | 2 +- .../test/apple_xcode_project_sync_test.dart | 2 +- .../test/intentcall_invocation_test.dart | 2 +- .../test/manifest_merger_test.dart | 45 ++ .../test/native_emitters_test.dart | 2 +- .../test/native_platform_sync_test.dart | 2 +- .../test/platform_hooks_init_test.dart | 2 +- .../test/platform_sync_test.dart | 2 +- .../test/web_emitters_test.dart | 2 +- packages/intentcall_schema/pubspec.yaml | 2 +- packages/intentcall_session/pubspec.yaml | 2 +- packages/intentcall_testing/pubspec.yaml | 2 +- packages/intentcall_webmcp/pubspec.yaml | 2 +- pubspec.lock | 2 +- pubspec.yaml | 4 +- release-please-config.json | 14 +- steward.yaml | 69 +- .../intentcall.adapter-contract.yaml | 13 +- tool/intentcall/bin/intentcall.dart | 9 +- tool/intentcall/bin/release_train.dart | 2 + tool/intentcall/pubspec.yaml | 4 +- .../test/publish_preflight_test.dart | 2 + 109 files changed, 4324 insertions(+), 129 deletions(-) create mode 100644 .cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md create mode 100644 docs/decisions/0019-framework-neutral-intentcall-cli.md create mode 100644 packages/intentcall_cli/CHANGELOG.md create mode 100644 packages/intentcall_cli/analysis_options.yaml create mode 100644 packages/intentcall_cli/bin/intentcall.dart create mode 100644 packages/intentcall_cli/lib/intentcall_cli.dart create mode 100644 packages/intentcall_cli/lib/src/catalog/catalog_loader.dart create mode 100644 packages/intentcall_cli/lib/src/command_runner.dart create mode 100644 packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart create mode 100644 packages/intentcall_cli/lib/src/config/host_profiles.dart create mode 100644 packages/intentcall_cli/lib/src/config/intentcall_config.dart create mode 100644 packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart create mode 100644 packages/intentcall_cli/lib/src/utils/cli_utils.dart create mode 100644 packages/intentcall_cli/pubspec.yaml create mode 100644 packages/intentcall_cli/test/command_runner_test.dart create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/intentcall.yaml create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/web/manifest.json create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/intentcall.yaml create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/web/manifest.json create mode 100644 packages/intentcall_cli/test/manifest_registry_parity_test.dart create mode 100644 packages/intentcall_codegen/intentcall.yaml create mode 100644 packages/intentcall_codegen/lib/generated/agent_catalog.g.dart create mode 100644 packages/intentcall_codegen/lib/src/agent_projection.dart create mode 100644 packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart create mode 100644 packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart create mode 100644 packages/intentcall_codegen/web/agent_manifest.json delete mode 100644 packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart create mode 100644 packages/intentcall_platform_sync/CHANGELOG.md create mode 100644 packages/intentcall_platform_sync/LICENSE create mode 100644 packages/intentcall_platform_sync/analysis_options.yaml create mode 100644 packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/agent_manifest.dart (96%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/agent_manifest_generator.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/bootstrap/agent_web_mcp_bootstrap.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart (100%) create mode 100644 packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/android_shortcuts_xml_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/apple_app_intents_testing_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/apple_dart_extension_inline_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/apple_swift_app_intents_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/emitter_utils.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/linux_desktop_entry_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/web_manifest_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/web_mcp_js_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/emitters/windows_protocol_emitter.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/init/platform_hooks_init.dart (97%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/invocation/intentcall_invocation.dart (100%) create mode 100644 packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart create mode 100644 packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/sync/apple_info_plist_protocol_sync.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/sync/apple_xcode_project_sync.dart (100%) rename packages/{intentcall_platform => intentcall_platform_sync}/lib/src/sync/platform_sync.dart (99%) create mode 100644 packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart create mode 100644 packages/intentcall_platform_sync/pubspec.yaml rename packages/{intentcall_platform => intentcall_platform_sync}/test/agent_web_mcp_bootstrap_test.dart (92%) rename packages/{intentcall_platform => intentcall_platform_sync}/test/apple_xcode_project_sync_test.dart (99%) rename packages/{intentcall_platform => intentcall_platform_sync}/test/intentcall_invocation_test.dart (99%) create mode 100644 packages/intentcall_platform_sync/test/manifest_merger_test.dart rename packages/{intentcall_platform => intentcall_platform_sync}/test/native_emitters_test.dart (99%) rename packages/{intentcall_platform => intentcall_platform_sync}/test/native_platform_sync_test.dart (99%) rename packages/{intentcall_platform => intentcall_platform_sync}/test/platform_hooks_init_test.dart (96%) rename packages/{intentcall_platform => intentcall_platform_sync}/test/platform_sync_test.dart (95%) rename packages/{intentcall_platform => intentcall_platform_sync}/test/web_emitters_test.dart (99%) diff --git a/.cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md b/.cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md new file mode 100644 index 0000000..ef3c5aa --- /dev/null +++ b/.cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md @@ -0,0 +1,406 @@ +--- +name: ADR 0019 closure plan +overview: "Close all MoE-identified gaps for ADR 0019: fix intentcall_codegen lib/example mixing, adopt single-writer manifest pipeline (catalog via build_runner, manifest via shared exporter), harden validation gates in harness/CI, and clean up surface keys, CatalogLoader, and legacy generators—with parallel subagent workstreams." +todos: + - id: ws-a-codegen-layout + content: "Subagent A: Migrate intentcall_codegen to pure library; create example/ mini-host + CLI codegen_dart_project fixture; default generators to lib/** only" + status: pending + - id: ws-b-manifest-exporter + content: "Subagent B: ManifestExporter; catalog row EntryProjection; @AgentProjection in catalog builder; yaml defaults-only; delete projection yaml paths; remove agent_manifest builder" + status: pending + - id: ws-c-surface-catalog-legacy + content: "Subagent C: Hybrid surface aliases + fail loud; remove CatalogLoader manifest fallback; delete legacy generateWebAgentManifest + migrate tests" + status: pending + - id: ws-d-harness-ci + content: "Subagent D: Add manifest-export-check + adr-gates to just/steward/CI; fix command_runner_test setUp; strengthen parity tests; fix intentcall.validate schema path" + status: pending + - id: ws-e-docs + content: "Subagent E: Update DX_FAQ, codegen README, hook PATH note; document mcp_flutter follow-up" + status: pending + - id: ws-integrate-verify + content: "Parent: Merge subagents, run just adr-gates + just test + steward probe --profile quick; MoE re-audit" + status: pending +isProject: false +--- + +# ADR 0019 closure — full remediation plan + +## Recommended architecture (DX + AX + maintainability) + +**Manifest pipeline: single writer with shared kernel.** + +Hooks already enforce the real spine ([`platform_hook_templates.dart`](packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart)): + +```mermaid +flowchart LR + subgraph build_runner [build_runner] + AgentTool["@AgentTool parts"] + Catalog["lib/generated/agent_catalog.g.dart"] + AgentTool --> Catalog + end + subgraph cli [intentcall CLI] + Export["manifest export"] + Sync["platform sync"] + end + subgraph sync_pkg [intentcall_platform_sync] + Merger["ManifestMerger + loadExportContext"] + Emitters["PlatformSync emitters"] + end + Catalog --> Export + Export --> Merger + Merger --> Manifest["web/agent_manifest.json"] + Manifest --> Sync + Sync --> Emitters +``` + +| Choice | Why | +|--------|-----| +| **Drop `agent_manifest` builder** | Customization lives in `@AgentProjection` + `intentcall.yaml` defaults only — one merge path via CLI | +| **Shared `ManifestExportContext` in `intentcall_platform_sync`** | Both agents and humans run the same merge; codegen package has zero CLI dependency. | +| **Example mini-host + CLI fixture** | Library package stays pure; gates use deterministic fixture; `example/` remains human-runnable docs. | +| **Hybrid surface aliases + fail loud** | Ergonomic `webMcp` in yaml; unknown keys throw with valid-key hint. | + +--- + +## Authoring model — what is generated vs hand-written + +**Yes:** manifest **entries** (tools) are **generated from code**, then **enhanced** with projection settings at `intentcall manifest export` time. + +```mermaid +flowchart TB + subgraph code [From code - build_runner] + AgentTool["@AgentTool functions"] + Parts["*.g.dart AgentCallEntry getters"] + Catalog["lib/generated/agent_catalog.g.dart"] + AgentTool --> Parts --> Catalog + end + subgraph policy [From policy - Dart + yaml defaults] + IntentYaml["intentcall.yaml defaults only"] + Annotation["@AgentProjection on tool"] + CatalogProj["projection on catalog row"] + end + subgraph generated [Generated at export] + Manifest["web/agent_manifest.json"] + end + Catalog --> Merger["ManifestMerger.mergeManifest"] + IntentYaml --> Merger + Annotation --> CatalogProj + CatalogProj --> Merger + Merger --> Manifest +``` + +### Layer responsibilities + +| Artifact | Generated? | Contains | +|----------|------------|----------| +| **`@AgentTool` + `*.g.dart`** | Yes (build_runner) | Semantic truth: namespace, name, description, inputSchema, handler | +| **`lib/generated/agent_catalog.g.dart`** | Yes (build_runner) | Registry rows pointing at `*CallEntry` getters | +| **`intentcall.yaml`** | **No** — host + **global defaults only** | `host`, `protocolScheme`, `layout`, `platforms.enabled`, `defaults`. **No per-tool keys.** | +| **Per-tool projection** | **Dart only** | `@AgentProjection` on `@AgentTool`, or `EntryProjection` on `AgentRegistryCatalogEntry` for handwritten entries | +| **`web/agent_manifest.json`** | **Yes** (`intentcall manifest export`) | `merge(catalog, policy)` — full tool rows with schema + projection | +| **Platform artifacts** (`web/manifest.json`, `*.generated.js`, …) | Yes (`intentcall platform sync`) | Emitted from committed manifest | + +### Merge precedence (per tool) + +1. Built-in surface defaults +2. `intentcall.yaml` → `defaults` +3. **Catalog row projection** (`@AgentProjection` baked into `agent_catalog.g.dart`, or hand-written `EntryProjection` on catalog entry) + +### What you never write + +- Per-tool projection in any YAML file +- `.intentcall/projection.yaml` + +--- + +## MoE decision — projection unification (code-only per tool) + +**User choice:** Eliminate per-tool YAML entirely. Projection is authored in Dart only. + +### Expert consensus (updated) + +| Lens | Verdict | +|------|---------| +| **Architecture Skeptic** | Delete `.intentcall/projection.yaml`, `projectionOverlay`, and **`intentcall.yaml` per-tool `projection:` map** | +| **Pain Tutor** | Minimum writes: optional `defaults:` once in yaml + `@AgentProjection` colocated on each customizing `@AgentTool` | + +### Authoring model (code-only per tool) + +| Layer | Where | Generated? | +|-------|--------|--------------| +| Semantic truth | `@AgentTool` / `AgentCallEntry` | `.g.dart`, catalog | +| **Per-tool projection** | **`@AgentProjection` on `@AgentTool`** or **`EntryProjection` on catalog row** | Catalog carries projection into export | +| App-wide defaults | `intentcall.yaml` → `defaults` only | No | +| Host wiring | `intentcall.yaml` → `host`, `layout`, `platforms`, … | No | +| Manifest | `intentcall manifest export` | Yes | + +```yaml +# intentcall.yaml — settings only, NO per-tool projection keys +host: flutter +protocolScheme: myapp +defaults: + dispatchMode: openApp + surfaces: + web.webMcp: true +``` + +```dart +@AgentTool(namespace: 'app', name: 'cart_total', description: '...') +@AgentProjection( + dispatchMode: 'openApp', + surfaces: {'web.webMcp': true, 'apple.appShortcuts': false}, +) +Future cartTotal(...) async { ... } +``` + +### Handwritten `AgentCallEntry` path (no yaml escape hatch) + +Extend [`AgentRegistryCatalogEntry`](packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart) with optional `EntryProjection? projection`: + +```dart +AgentRegistryCatalogEntry( + registryKey: 'app_legacy_ping', + entry: legacyPingCallEntry, + projection: const EntryProjection( + dispatchMode: AgentManifestDispatchMode.queueOnly, + surfaces: {AgentManifestSurface.webMcp: false}, + ), +) +``` + +Codegen catalog builder embeds `@AgentProjection` into each catalog row at generation time — export reads projection from catalog probe, not yaml. + +### Merge precedence (single rule) + +``` +effective(row) = + catalog[row].projection # from @AgentProjection or hand-written catalog + ?? intentcall.yaml defaults + ?? built-in defaultSurfaceInclude() +``` + +### Delete entirely + +- `.intentcall/projection.yaml` and `.intentcall/` convention for projection +- `projectionOverlay` config key +- `loadOverlayFile` for projection (or keep only for one-release migration with deprecation warning) +- Per-tool keys in `intentcall.yaml` +- `scanProjectionYaml` in catalog_loader + +### Workstream B additions + +1. Add `EntryProjection? projection` to `AgentRegistryCatalogEntry` +2. `AgentCatalogGenerator` — read `@AgentProjection` and emit projection in catalog rows (or companion `agent_projection.g.dart` probed alongside catalog) +3. `CatalogLoader` probe exports `projection` per row +4. `ManifestExporter` / `mergeManifest` — `overlayFor` reads from catalog row projection first, then yaml defaults +5. Collision: if `@AgentProjection` missing and no catalog projection, use defaults only (not an error) + +### ADR 0019 amendment text + +> Projection policy: `@AgentProjection` on annotated tools, or `EntryProjection` on handwritten catalog rows. Global defaults in `intentcall.yaml` `defaults` only. **No per-tool YAML.** + +--- + +## Workstream A — `intentcall_codegen` lib/example separation + +**Problem (investigated):** Demo `@AgentTool` in [`example/demo_ping_tool.dart`](packages/intentcall_codegen/example/demo_ping_tool.dart) feeds committed [`lib/generated/agent_catalog.g.dart`](packages/intentcall_codegen/lib/generated/agent_catalog.g.dart) via `import '../../example/...'` — published `lib/` depends on non-library code. Root [`intentcall.yaml`](packages/intentcall_codegen/intentcall.yaml) treats the library as a host. + +**Target layout:** + +``` +packages/intentcall_codegen/ # library only + lib/src/ # annotations + generators (no @AgentTool) + lib/builder.dart + build.yaml # agent_tool + agent_catalog only (remove agent_manifest) + test/fixtures/demo_ping_tool.dart # sole unit-test source (1 tool) + +packages/intentcall_codegen/example/ # runnable mini-host + pubspec.yaml + intentcall.yaml + lib/tools/demo_ping_tool.dart + lib/generated/agent_catalog.g.dart + web/agent_manifest.json + web/manifest.json # base for platform sync --check + build.yaml # generate_for: lib/** only (no example/** scan in parent) + +packages/intentcall_cli/test/fixtures/codegen_dart_project/ # gate fixture + pubspec.yaml, intentcall.yaml + lib/tools/demo_ping_tool.dart + lib/generated/agent_catalog.g.dart # committed + web/agent_manifest.json, web/manifest.json, web/*.generated.js +``` + +**Generator default change** ([`agent_catalog_generator.dart`](packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart), [`agent_manifest_generator.dart`](packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart) if kept temporarily): + +- Default `_toolSources` → **`lib/**.dart` only** +- Opt-in `example/**` via `build.yaml` `builder_options` for demo hosts only +- Remove root-level committed `lib/generated/`, `web/`, `intentcall.yaml` from library package + +**Subagent A scope:** layout migration, update [`test/agent_tool_generator_test.dart`](packages/intentcall_codegen/test/agent_tool_generator_test.dart) to use `test/fixtures/`, README, delete duplicate `example/demo_ping_tool.dart` at old path after move. + +--- + +## Workstream B — Unified manifest export kernel + +**Problem:** [`AgentManifestAssetGenerator`](packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart) ignores `intentcall.yaml`; CLI [`_ManifestExportCommand`](packages/intentcall_cli/lib/src/command_runner.dart) ignores `@AgentProjection`; overlay discovery inconsistent. + +**Implementation:** + +1. Add to [`manifest_merger.dart`](packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart): + - `loadFullProjectionPolicy(projectRoot, {annotationOverlays})` — yaml `defaults` + inline `projection:` map + annotation overlays with **collision detection** (no separate overlay file) + - `readPlatformLabel(projectRoot)` — `jaspr` → `web`, else `unified` + - `readManifestRelativePath(projectRoot)` — `layout.manifest` default `web/agent_manifest.json` + - `loadExportContext(projectRoot, {annotationOverlays})` → bundles policy + protocol + platform + path + +2. Add **`ManifestExporter`** (new file under `intentcall_platform_sync/lib/src/projection/`): + - `Future buildManifest({projectRoot, catalog})` — uses `CatalogLoader` pattern without CLI dep: accept catalog list as param + - `Future exportToFile({projectRoot, catalog, checkOnly})` — encode + write or compare + +3. Refactor CLI `_ManifestExportCommand` to call `ManifestExporter`; delete `_platformLabel` duplicate. + +4. **Remove `agent_manifest` builder** from [`build.yaml`](packages/intentcall_codegen/build.yaml) and delete [`agent_manifest_generator.dart`](packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart) after parity tests pass. + +5. **ADR 0019 amendment** ([`docs/decisions/0019-framework-neutral-intentcall-cli.md`](docs/decisions/0019-framework-neutral-intentcall-cli.md)): Gate 1 = `build_runner` (catalog) + `intentcall manifest export --check` (manifest); catalog builder only in codegen package. + +**Subagent B scope:** platform_sync API + CLI wiring + tests in [`manifest_merger_test.dart`](packages/intentcall_platform_sync/test/manifest_merger_test.dart). + +--- + +## Workstream C — Surface keys, CatalogLoader, legacy cleanup + +### C1 — Surface alias resolution (hybrid) + +In [`lookupAgentManifestSurface`](packages/intentcall_platform_sync/lib/src/agent_manifest.dart): + +- Match `manifestKey` (`web.webMcp`) **or** enum `name` (`webMcp`) +- In [`ProjectionPolicy.fromYamlMap`](packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart): collect unknown keys → `FormatException` listing valid keys +- Update fixture [`flutter_project/intentcall.yaml`](packages/intentcall_cli/test/fixtures/flutter_project/intentcall.yaml) to dotted keys **or** prove aliases work with test asserting `webMcp: false` actually opts out + +### C2 — CatalogLoader fail-loud + +In [`catalog_loader.dart`](packages/intentcall_cli/lib/src/catalog/catalog_loader.dart): + +- Remove `_loadFromProjectionAndManifest` circular fallback +- If `lib/generated/agent_catalog.g.dart` missing or probe fails → clear error: *run `dart run build_runner build`* +- Add committed `agent_catalog.g.dart` to CLI fixtures (minimal 1-tool stub matching manifest) + +### C3 — Legacy deletion + +| Delete / deprecate | Condition | +|--------------------|-----------| +| [`intentcall_platform_sync/lib/src/agent_manifest_generator.dart`](packages/intentcall_platform_sync/lib/src/agent_manifest_generator.dart) `generateWebAgentManifest` | Migrate [`web_emitters_test.dart`](packages/intentcall_platform_sync/test/web_emitters_test.dart) line ~309 to `ManifestMerger` | +| Stale `intentcall_platform/lib/src/{emitters,sync,...}` if still on disk | Confirm re-export-only; finish deletion | +| Apple/Android `agent_manifest_generator.dart` | Deprecate exports; add ADR note if external consumers unknown | + +**Subagent C scope:** surface + CatalogLoader + legacy tests; no hook changes. + +--- + +## Workstream D — Harness, tests, CI (three gates) + +### D1 — New just/steward recipes + +[`justfile`](justfile): + +```just +manifest-export-check: + cd packages/intentcall_codegen/example && dart run build_runner build + dart run intentcall_cli:intentcall manifest export --check --project-dir packages/intentcall_codegen/example + dart run intentcall_cli:intentcall manifest export --check --project-dir packages/intentcall_cli/test/fixtures/codegen_dart_project + +adr-gates: + just manifest-export-check + just manifest-parity + just platform-sync-check +``` + +[`steward.yaml`](steward.yaml): add `intentcall.manifest-export-check`; extend `probes.quick.actions`. + +[`steward/scenarios/intentcall.adapter-contract.yaml`](steward/scenarios/intentcall.adapter-contract.yaml): add `manifest-export-check`, `manifest-parity`, `platform-sync-check` to `required_actions`. + +### D2 — Strengthen tests + +| File | Fix | +|------|-----| +| [`command_runner_test.dart`](packages/intentcall_cli/test/command_runner_test.dart) | Remove `_normalizeManifest` / `_ensureSyncedWebArtifacts` from `setUpAll`; commit correct fixture artifacts | +| [`manifest_registry_parity_test.dart`](packages/intentcall_cli/test/manifest_registry_parity_test.dart) | Bidirectional catalog ↔ manifest qualifiedName parity on `codegen_dart_project`; load catalog via `CatalogLoader` probe | +| New `manifest_export_parity_test.dart` | `build_runner` + `export --check` on example mini-host without rewriting files | + +### D3 — CI + +[`.github/workflows/ci.yml`](.github/workflows/ci.yml): + +- Ensure `intentcall_platform_sync` + `intentcall_cli` in test step (or `just test`) +- Add `just adr-gates` job step after `dart pub get` + +### D4 — Fix `intentcall.validate` blocker + +Investigate `pubspec.yaml not found for package: intentcall_schema` (steward probe failure) — likely workspace path resolution in [`tool/intentcall`](tool/intentcall); fix so `steward probe --profile quick` passes. + +**Subagent D scope:** justfile, steward, CI, test fixes, validate fix. + +--- + +## Workstream E — Docs and follow-ups (non-blocking) + +- Update [`DX_FAQ.mdx`](docs/DX_FAQ.mdx), [`packages/intentcall_codegen/README.md`](packages/intentcall_codegen/README.md) with example mini-host workflow +- Document mcp_flutter delegation as **open follow-up** ([ADR §mcp_flutter](docs/decisions/0019-framework-neutral-intentcall-cli.md)); optional tracking issue +- Hook templates: consider `dart run intentcall_cli:intentcall` instead of bare `intentcall` on PATH (separate small PR) + +--- + +## Subagent dispatch map + +```mermaid +flowchart TB + Parent[Parent agent synthesis] + A[Subagent A: codegen layout] + B[Subagent B: ManifestExporter kernel] + C[Subagent C: surface keys + CatalogLoader + legacy] + D[Subagent D: harness CI tests] + E[Subagent E: docs ADR amend] + Parent --> A + Parent --> B + Parent --> C + Parent --> D + B --> D + A --> D + C --> D + Parent --> E + A --> E + B --> E +``` + +| Subagent | Owns | Depends on | +|----------|------|------------| +| **A** | example mini-host, CLI fixture scaffold, generator `lib/**` default | — | +| **B** | `ManifestExporter`, `loadExportContext`, remove manifest builder | — | +| **C** | surface aliases, CatalogLoader, legacy generator removal | B partial (overlay API) | +| **D** | just/steward/CI, parity tests, validate fix | A + B + C | +| **E** | ADR 0019 amendment, README/DX_FAQ | A + B | + +**Merge order:** A + B + C in parallel → D integrates → E polishes → parent runs `just adr-gates` + `just test` + `steward probe --profile quick`. + +--- + +## Success criteria (claim completion) + +| Gate | Proof command | +|------|---------------| +| Gate 1 | `just manifest-export-check` passes on example + CLI fixture | +| Gate 2 | `just platform-sync-check` passes (web); codegen fixture has base `web/manifest.json` | +| Gate 3 | Parity test: catalog qualifiedNames == manifest qualifiedNames (bidirectional) | +| CI | `.github/workflows/ci.yml` runs `just adr-gates` | +| Library purity | `intentcall_codegen` root has no `lib/generated/`, no `web/`, no `intentcall.yaml` | +| Steward | `steward probe --profile quick` → passed | + +--- + +## Risks and mitigations + +| Risk | Mitigation | +|------|------------| +| ADR says two builders | Amend §Registry-backed manifest to catalog builder + CLI export | +| Fixture maintenance burden | Minimal 1-tool `codegen_dart_project`; example hosts 3 tools for richer docs | +| mcp_flutter not delegated | Document; hooks work with `dart run intentcall_cli:intentcall` | +| Breaking apps using manifest builder output | Builder removal only after export parity tests green | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a0c40c9..654d4df 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -7,6 +7,8 @@ "packages/intentcall_apple": "0.6.0", "packages/intentcall_android": "0.6.0", "packages/intentcall_codegen": "0.6.0", + "packages/intentcall_platform_sync": "0.6.0", + "packages/intentcall_cli": "0.6.0", "packages/intentcall_platform": "0.6.0", "packages/intentcall_testing": "0.6.0" -} +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd02043..b3adc20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest! IntentCall is a pre-release platform library. Contribu ## Prerequisites -- [Dart SDK](https://dart.dev/get-dart) `^3.11.0` +- [Dart SDK](https://dart.dev/get-dart) `^3.12.0` - [Flutter SDK](https://flutter.dev/docs/get-started/install) (stable) — required for `intentcall_platform` - [just](https://github.com/casey/just) task runner (recommended) - [Node.js](https://nodejs.org/) `>=18` and [pnpm](https://pnpm.io/) `>=9` — for `just docs-check` (docs.page link validation) diff --git a/PUBLISHING.md b/PUBLISHING.md index 120ce2d..1133627 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -72,8 +72,9 @@ uses GitHub OIDC through `dart-lang/setup-dart`. 2. `intentcall_core` 3. `intentcall_session` 4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` -5. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) -6. `intentcall_testing` +5. `intentcall_platform_sync`, `intentcall_cli` +6. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) +7. `intentcall_testing` ## Commands diff --git a/README.md b/README.md index 83a6e3b..1722dec 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,9 @@ GitHub: [Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) | `intentcall_webmcp` | [![pub package](https://img.shields.io/pub/v/intentcall_webmcp.svg?include_prereleases)](https://pub.dev/packages/intentcall_webmcp) [![pub points](https://img.shields.io/pub/points/intentcall_webmcp.svg)](https://pub.dev/packages/intentcall_webmcp/score) | WebMCP hot-sync adapter | | `intentcall_apple` | [![pub package](https://img.shields.io/pub/v/intentcall_apple.svg?include_prereleases)](https://pub.dev/packages/intentcall_apple) [![pub points](https://img.shields.io/pub/points/intentcall_apple.svg)](https://pub.dev/packages/intentcall_apple/score) | Apple manifest projection for App Intents / Shortcuts dispatch artifacts and first typed-entity/indexing direction | | `intentcall_android` | [![pub package](https://img.shields.io/pub/v/intentcall_android.svg?include_prereleases)](https://pub.dev/packages/intentcall_android) [![pub points](https://img.shields.io/pub/points/intentcall_android.svg)](https://pub.dev/packages/intentcall_android/score) | Android manifest codegen for shortcut and deep-link artifacts | -| `intentcall_platform` | [![pub package](https://img.shields.io/pub/v/intentcall_platform.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform) [![pub points](https://img.shields.io/pub/points/intentcall_platform.svg)](https://pub.dev/packages/intentcall_platform/score) | Native/web emitters, protocol fallback artifacts, and Flutter plugin | +| `intentcall_platform_sync` | [![pub package](https://img.shields.io/pub/v/intentcall_platform_sync.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform_sync) | Dart-only manifest, emitters, and `PlatformSync` | +| `intentcall_cli` | [![pub package](https://img.shields.io/pub/v/intentcall_cli.svg?include_prereleases)](https://pub.dev/packages/intentcall_cli) | Framework-neutral CLI: manifest export, platform sync, MCP serve | +| `intentcall_platform` | [![pub package](https://img.shields.io/pub/v/intentcall_platform.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform) [![pub points](https://img.shields.io/pub/points/intentcall_platform.svg)](https://pub.dev/packages/intentcall_platform/score) | Flutter plugin and native bridge (re-exports `intentcall_platform_sync`) | | `intentcall_codegen` | [![pub package](https://img.shields.io/pub/v/intentcall_codegen.svg?include_prereleases)](https://pub.dev/packages/intentcall_codegen) [![pub points](https://img.shields.io/pub/points/intentcall_codegen.svg)](https://pub.dev/packages/intentcall_codegen/score) | Optional `@AgentTool` codegen | | `intentcall_testing` | [![pub package](https://img.shields.io/pub/v/intentcall_testing.svg?include_prereleases)](https://pub.dev/packages/intentcall_testing) [![pub points](https://img.shields.io/pub/points/intentcall_testing.svg)](https://pub.dev/packages/intentcall_testing/score) | Contract / invoke test helpers | diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index 375fed4..643686a 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -50,6 +50,12 @@ A: Test helpers have `test` as a dependency, which must not bleed into productio **Q: Why is there an `intentcall_codegen` package?** A: Codegen is strictly optional — many adopters will register intents by hand. Keeping it separate means the core registry has zero `build_runner` dependency, and users who opt in to `@AgentTool` get a clean code-generation surface without it affecting tree-shaking for others. +**Q: Why `intentcall_cli` instead of `flutter-mcp-toolkit` for platform sync?** +A: Platform projection (`agent_manifest.json`, native emitters, `PlatformSync`) is framework-agnostic. `intentcall_cli` owns that contract; [mcp_flutter](https://github.com/Arenukvern/mcp_flutter) delegates to it. See [ADR 0019](decisions/0019-framework-neutral-intentcall-cli.md). + +**Q: Why is `agent_manifest.json` generated rather than hand-edited?** +A: Manifest rows are a projection cache for platform emitters, not a second runtime catalog. Semantic truth lives in `AgentRegistry`; projection policy lives in `@AgentProjection` or `.intentcall/projection.yaml`. CI proves manifest freshness with `intentcall manifest export --check`. + **Q: Why is IntentPack direction separate from today's registry API?** A: `AgentRegistry`, `AgentCallEntry`, and `RegisteredAgentIntent` are the current shipped authoring/runtime model. IntentPack is the proposed portable packaging layer above them: one app-level unit that can carry entries, schemas, examples, side-effect metadata, safety policy, platform projection hints, adapter hints, and compatibility metadata. Keeping that distinction explicit lets the repo document the destination without pretending the stable public API already exists. diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 52699cf..6ce0b13 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -436,7 +436,7 @@ For iOS and macOS open-app App Intents, the readiness checklist is: 1. `agent_manifest.json` has the app-owned `protocolScheme`. 2. Apple entries that should appear in Shortcuts use `"dispatchMode": "openApp"` and curated `"surfaces": { "apple.appShortcuts": { "include": true } }`. -3. `codegen sync --platform ios,macos --check` passes, proving generated Swift, +3. `intentcall platform sync --platform ios,macos --check` passes, proving generated Swift, Runner target membership, and `Runner/Info.plist` `CFBundleURLTypes` drift. 4. The app is signed and installed before claiming manual Shortcuts or Spotlight product proof. @@ -468,12 +468,12 @@ Generate the XCTest UI-test source from the same `agent_manifest.json` that drives platform sync: ```bash -dart run tool/intentcall/bin/intentcall.dart apple-appintents-testing generate-fixtures \ +dart run intentcall_cli:intentcall apple-appintents-testing generate-fixtures \ --manifest path/to/agent_manifest.json \ --sample-arguments-output path/to/appintents_arguments.json \ --entity-fixtures-output path/to/appintents_entities.json -dart run tool/intentcall/bin/intentcall.dart apple-appintents-testing generate-tests \ +dart run intentcall_cli:intentcall apple-appintents-testing generate-tests \ --manifest path/to/agent_manifest.json \ --bundle-id com.example.app \ --sample-arguments path/to/appintents_arguments.json \ diff --git a/docs/NORTH_STAR.mdx b/docs/NORTH_STAR.mdx index 8512a40..cd91d51 100644 --- a/docs/NORTH_STAR.mdx +++ b/docs/NORTH_STAR.mdx @@ -6,7 +6,7 @@ IntentCall is a **transport-agnostic agent intent platform** for Dart/Flutter. I The north star is **define intent truth once, then project it into the strongest available platform surface**. Dart remains the preferred home for application business logic, action handlers, and domain snapshots. Platform projections should publish native metadata, collect supported parameters, wake or route into the app when needed, and dispatch an invocation envelope back to the Dart `AgentRegistry` unless a platform has separately proven native execution support. -Today, the repository implements the registry/runtime foundation, contract-tested MCP/WebMCP adapters, Dart-first invocation primitives, and platform artifact emitters for `web`, `android`, `ios`, `macos`, `linux`, and `windows`. L3 extends that direction with additive actions, typed app entities, and indexing lifecycle docs: Dart owns the snapshots and source-of-truth, while platform code may keep a durable native projection cache for query/indexing paths that run while Flutter is cold. Artifact emitters, generated schemas, native caches, and sync helpers are not the same as live OS/runtime proof. Full registry-backed generation remains a target state: some platform artifacts, including `agent_manifest.json`, are currently checked in and refreshed by sync tooling rather than generated live from `AgentRegistry`. +Today, the repository implements the registry/runtime foundation, contract-tested MCP/WebMCP adapters, Dart-first invocation primitives, and platform artifact emitters for `web`, `android`, `ios`, `macos`, `linux`, and `windows`. L3 extends that direction with additive actions, typed app entities, and indexing lifecycle docs: Dart owns the snapshots and source-of-truth, while platform code may keep a durable native projection cache for query/indexing paths that run while Flutter is cold. Artifact emitters, generated schemas, native caches, and sync helpers are not the same as live OS/runtime proof. `agent_manifest.json` is a **generated projection artifact** refreshed by `build_runner` and `intentcall manifest export --check`; use `intentcall_cli` for framework-neutral platform sync (Flutter, Jaspr, and other Dart hosts). IntentCall is also the canonical home for IntentCall philosophy, platform projection semantics, agentic experience (AX), developer experience (DX), and IntentPack direction. Consumer repositories such as `mcp_flutter` prove and document integration, but do not define IntentCall's architecture or platform contract. diff --git a/docs/decisions/0019-framework-neutral-intentcall-cli.md b/docs/decisions/0019-framework-neutral-intentcall-cli.md new file mode 100644 index 0000000..c0145320 --- /dev/null +++ b/docs/decisions/0019-framework-neutral-intentcall-cli.md @@ -0,0 +1,123 @@ +# 0019. Framework-Neutral IntentCall CLI and Registry-Backed Manifest Generation + +Date: 2026-07-07 + +## Status + +Accepted + +## Context + +IntentCall's north star is *register intent truth once, project everywhere*. +Platform projection (`PlatformSync`, emitters, `agent_manifest.json`) is +framework-agnostic logic, but today's developer experience hardcodes +**mcp_flutter's** `flutter-mcp-toolkit codegen sync` in build hooks and lives +inside `intentcall_platform`, which requires the Flutter SDK for the plugin. +That blocks Jaspr, plain Dart CLIs, and MCP servers from using platform sync +without pulling Flutter. + +`agent_manifest.json` is hand-maintained while [NORTH_STAR.mdx](../NORTH_STAR.mdx) +targets registry-backed generation — creating **two catalogs** (registry for +MCP, manifest for native) with no parity checks. + +`@AgentTool` codegen emits `AgentCallEntry` but not manifest. Projection +metadata (`dispatchMode`, `surfaces`, `inlineRuntime`) is manifest-local per +[ADR 0016](0016-dispatch-mode-handoff-contract.md) — it must be **authored once** +alongside code, not duplicated in per-tool YAML rows. + +[mcp_flutter](https://github.com/Arenukvern/mcp_flutter) is a **product harness** +([ADR 0010](0010-adopt-intentcall-product-name.md)), not the owner of platform +contracts. + +## Decision + +### Framework-neutral CLI and package split + +1. **Publish `intentcall_cli`** — framework-neutral consumer CLI (`intentcall` + executable). +2. **Extract `intentcall_platform_sync`** — Dart-only package owning manifest + parsing, emitters, `PlatformSync`, hook templates, invocation envelope types, + and `ManifestMerger` (no Flutter SDK). +3. **Keep `intentcall_platform`** — Flutter plugin + `IntentCallFlutterHost` + runtime bridge only; re-export `intentcall_platform_sync` for backward + compatibility. +4. **`intentcall.yaml` is host wiring only:** `host`, `protocolScheme`, `layout`, + `platforms.enabled`, `hooks.syncCommand`, global projection defaults. **No** + per-tool descriptor rows. +5. **`flutter-mcp-toolkit`** delegates `codegen sync` and `init intentcall-platform` + to `intentcall`; it does not own manifest or sync semantics. + +### Registry-backed manifest (single truth) + +1. **`agent_manifest.json` is a generated artifact** — committed like `.g.dart`, + refreshed by `build_runner` + `intentcall manifest export --check`. +2. **Authoring surface:** + - Semantic truth: `AgentCallEntry` / `@AgentTool` (namespace, name, + description, schema, handler). + - Projection policy: `@AgentProjection` on annotated tools OR keyed + `.intentcall/projection.yaml` overlay for handwritten entries + (`dispatchMode`, `surfaces`, `inlineRuntime` only — no schema duplication). +3. **`intentcall_codegen` gains two builders:** + - `AgentCatalogBuilder` → `lib/generated/agent_catalog.g.dart` (aggregates + all registrations). + - `AgentManifestBuilder` → path from `intentcall.yaml` (default + `web/agent_manifest.json`). +4. **`ManifestMerger`** in `intentcall_platform_sync` merges catalog entries, + projection policy, and entity types into one canonical manifest. + +### Three validation gates + +| Gate | Proves | Command | +|------|--------|---------| +| Manifest freshness | Committed manifest == merge(catalog, projection) | `build_runner` then `intentcall manifest export --check` | +| Artifact freshness | Native/web files == emit(manifest) | `intentcall platform sync --check` | +| Descriptor parity | Every manifest entry ⊆ registry; no orphan tools | `manifest_registry_parity_test` | + +### Per-host workflow + +| Host | build_runner | manifest | platform sync | Runtime | +|------|-------------|----------|---------------|---------| +| Flutter | required | generated, committed | hooks run export --check + sync | registry + Flutter plugin | +| Jaspr | required | generated (web) | `intentcall platform sync --platform web --check` | registry + WebMCP | +| MCP server | optional | skip | skip | registry + `intentcall_mcp` | +| Dart CLI | optional | skip | skip | registry invoke | + +## Consequences + +Good: + +- One platform contract for Flutter, Jaspr, MCP servers, and plain Dart hosts. +- No duplicate catalogs; CI can prove manifest/registry parity. +- Hook templates use `intentcall` — no Flutter harness required for codegen. + +Tradeoffs: + +- Two new packages in the release train. +- Existing apps must migrate hand-edited manifest descriptor rows to generated + output + projection overlay. +- mcp_flutter needs a follow-up PR to delegate (documented contract). + +## Non-goals + +- Runtime isolate/reflection manifest export as the primary path. +- Putting `dispatchMode` in `intentcall_schema` wire types. +- Mandatory codegen for dynamic MCP hosts that register at runtime. +- Replacing mcp_flutter harness (VM, inspector, Flutter init UX). +- Live OS proof (Shortcuts/Siri) — still consuming-app responsibility. + +### mcp_flutter delegation (follow-up PR) + +`flutter-mcp-toolkit` should delegate without re-owning manifest semantics: + +```bash +flutter-mcp-toolkit codegen sync "$@" → intentcall platform sync --host flutter "$@" +flutter-mcp-toolkit init intentcall-platform → intentcall platform hooks init --host flutter "$@" +``` + +Add `intentcall_cli` as a hosted dependency in [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). + +## Links + +- [ADR 0016 — Dispatch Mode Handoff Contract](0016-dispatch-mode-handoff-contract.md) +- [ADR 0010 — Adopt IntentCall product name](0010-adopt-intentcall-product-name.md) +- [NORTH_STAR.mdx](../NORTH_STAR.mdx) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2df9376..2661345 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,7 +3,7 @@ Architecture Decision Records (ADRs) for IntentCall. Format: [MADR](https://adr.github.io/madr/) — see any existing ADR for the template. -Next ADR number: **0019** +Next ADR number: **0020** --- @@ -20,6 +20,7 @@ Next ADR number: **0019** | [0016](0016-dispatch-mode-handoff-contract.md) | accepted | Dispatch Mode Handoff Contract | 2026-06-28 | | [0017](0017-apple-inline-runtime-tracks.md) | accepted | Apple Inline Runtime Tracks | 2026-06-28 | | [0018](0018-additive-actions-typed-entities-indexing-lifecycle.md) | accepted | Additive Actions, Typed Entities, and Indexing Lifecycle | 2026-06-29 | +| [0019](0019-framework-neutral-intentcall-cli.md) | accepted | Framework-Neutral IntentCall CLI and Registry-Backed Manifest Generation | 2026-07-07 | --- diff --git a/docs/start_here/choose_your_path.mdx b/docs/start_here/choose_your_path.mdx index a8575b4..d6b5bc2 100644 --- a/docs/start_here/choose_your_path.mdx +++ b/docs/start_here/choose_your_path.mdx @@ -8,7 +8,7 @@ Use this page when you know what you want to build, but not which IntentCall pac | Publish MCP tools/resources | `intentcall_mcp`, plus `intentcall_core` and `intentcall_schema` | Read registry entries, publish existing entries, listen to registry events, route calls back to `registry.invoke(...)`. | Add or extend `verifyNativeAdapterContract(...)`, then run `steward benchmark --scenario intentcall.adapter-contract --json`. | [DX FAQ](/DX_FAQ#writing-a-new-adapter) | | Browser WebMCP registration | `intentcall_webmcp`, `intentcall_platform` when using the Dart-first bootstrap | Register WebMCP tools from Dart and treat older `navigator.modelContext` behavior as compatibility only. | WebMCP emitter/bootstrap tests plus `steward probe --json --profile quick`; live browser-host interoperability needs separate proof. | [Platform support](/start_here/platform_support) | | Runtime sessions for tools or CLIs | `intentcall_session`, `intentcall_core` | Implement `IntentSessionConnector`, compose `IntentSessionManager` and `IntentSessionExecutor`. | Package tests around connector selection, persistence, and executor behavior. | [DX FAQ](/DX_FAQ#runtime-sessions) | -| Native/web artifacts and fallback invoke routes | `intentcall_platform`, optional `intentcall_apple` / `intentcall_android` | Generate or sync platform artifacts, then dispatch invocation envelopes back to Dart. | IntentCall emitter/artifact tests, repo probes, host-specific sync checks, and live target proof before claiming OS integration. Flutter MCP Toolkit consumers may also run `flutter-mcp-toolkit codegen sync --check`. | [Platform support](/start_here/platform_support) | +| Native/web artifacts and fallback invoke routes | `intentcall_platform_sync`, `intentcall_cli`, optional `intentcall_platform` (Flutter bridge) | Run `build_runner`, `intentcall manifest export --check`, then `intentcall platform sync --check`. | Emitter tests, fixture parity, platform sync check, adapter contract tests. | [Platform support](/start_here/platform_support) | | Optional typed registration helpers | `intentcall_codegen`, `build_runner`, `intentcall_core`, `intentcall_schema` | Annotate stable top-level functions with `@AgentTool`; keep handwritten entries first-class. | Generator fixture tests and normal package tests for generated registrations. | [DX FAQ](/DX_FAQ#codegen-agenttool) | | Adapter contract proof | `intentcall_testing` | Use `verifyNativeAdapterContract(...)` in adapter tests. | `steward benchmark --scenario intentcall.adapter-contract --json`. | [intentcall_testing README](https://github.com/Arenukvern/intentcall/blob/main/packages/intentcall_testing/README.md) | diff --git a/justfile b/justfile index cc2546e..aa81969 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,16 @@ default: # Run tests for all packages in the workspace test: - dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform packages/intentcall_codegen packages/intentcall_testing tool/intentcall + dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform_sync packages/intentcall_codegen packages/intentcall_cli packages/intentcall_platform packages/intentcall_testing tool/intentcall + +# Verify platform artifact sync on fixture projects +platform-sync-check: + dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/flutter_project --platform web --check + dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/jaspr_web_project --platform web --check + +# Manifest/registry parity gate +manifest-parity: + dart test packages/intentcall_cli/test/manifest_registry_parity_test.dart # Analyze the Dart code in the workspace analyze: @@ -16,15 +25,15 @@ analyze: # This proves the local Xcode SDK/framework shape only; live runtime proof still # requires a signed consuming app and an XCTest UI-test target. apple-appintents-testing-typecheck xcode_app="/Applications/Xcode-beta.app": - dart run tool/intentcall/bin/intentcall.dart apple-appintents-testing typecheck --xcode "{{xcode_app}}" + dart run intentcall_cli:intentcall apple-appintents-testing typecheck --xcode "{{xcode_app}}" # Generate an XCTest UI-test scaffold for AppIntentsTesting runtime proof. apple-appintents-testing-generate manifest bundle_id output: - dart run tool/intentcall/bin/intentcall.dart apple-appintents-testing generate-tests --manifest "{{manifest}}" --bundle-id "{{bundle_id}}" --output "{{output}}" + dart run intentcall_cli:intentcall apple-appintents-testing generate-tests --manifest "{{manifest}}" --bundle-id "{{bundle_id}}" --output "{{output}}" # Generate starter JSON fixtures for AppIntentsTesting sample arguments/entities. apple-appintents-testing-fixtures manifest sample_arguments_output entity_fixtures_output: - dart run tool/intentcall/bin/intentcall.dart apple-appintents-testing generate-fixtures --manifest "{{manifest}}" --sample-arguments-output "{{sample_arguments_output}}" --entity-fixtures-output "{{entity_fixtures_output}}" + dart run intentcall_cli:intentcall apple-appintents-testing generate-fixtures --manifest "{{manifest}}" --sample-arguments-output "{{sample_arguments_output}}" --entity-fixtures-output "{{entity_fixtures_output}}" # Dry-run publishing all packages in order (default) publish-dry-run: @@ -93,7 +102,7 @@ validate: # Run the shared native adapter and platform bridge contract tests adapter-contract-test: - dart test packages/intentcall_testing/test/adapter_contract_test.dart packages/intentcall_mcp/test/mcp_adapter_contract_test.dart packages/intentcall_webmcp/test/webmcp_adapter_contract_test.dart packages/intentcall_gemma/test/gemma_adapter_contract_test.dart packages/intentcall_platform/test/intentcall_invocation_test.dart packages/intentcall_platform/test/web_emitters_test.dart packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart packages/intentcall_platform/test/native_emitters_test.dart packages/intentcall_platform/test/native_platform_sync_test.dart packages/intentcall_platform/test/intentcall_flutter_host_test.dart packages/intentcall_platform/test/intentcall_entity_index_test.dart + dart test packages/intentcall_testing/test/adapter_contract_test.dart packages/intentcall_mcp/test/mcp_adapter_contract_test.dart packages/intentcall_webmcp/test/webmcp_adapter_contract_test.dart packages/intentcall_gemma/test/gemma_adapter_contract_test.dart packages/intentcall_platform_sync/test/intentcall_invocation_test.dart packages/intentcall_platform_sync/test/web_emitters_test.dart packages/intentcall_platform_sync/test/agent_web_mcp_bootstrap_test.dart packages/intentcall_platform_sync/test/native_emitters_test.dart packages/intentcall_platform_sync/test/native_platform_sync_test.dart packages/intentcall_platform/test/intentcall_flutter_host_test.dart packages/intentcall_platform/test/intentcall_entity_index_test.dart # List custom agent skills defined in this repository list-skills: diff --git a/packages/intentcall_android/pubspec.yaml b/packages/intentcall_android/pubspec.yaml index 3183cb7..1518cd3 100644 --- a/packages/intentcall_android/pubspec.yaml +++ b/packages/intentcall_android/pubspec.yaml @@ -11,7 +11,7 @@ topics: - agents environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_apple/pubspec.yaml b/packages/intentcall_apple/pubspec.yaml index 02c8b0f..17598c5 100644 --- a/packages/intentcall_apple/pubspec.yaml +++ b/packages/intentcall_apple/pubspec.yaml @@ -11,7 +11,7 @@ topics: - macos environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_cli/CHANGELOG.md b/packages/intentcall_cli/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/intentcall_cli/analysis_options.yaml b/packages/intentcall_cli/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/intentcall_cli/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/intentcall_cli/bin/intentcall.dart b/packages/intentcall_cli/bin/intentcall.dart new file mode 100644 index 0000000..1e77b91 --- /dev/null +++ b/packages/intentcall_cli/bin/intentcall.dart @@ -0,0 +1,7 @@ +import 'dart:io'; + +import 'package:intentcall_cli/src/command_runner.dart'; + +Future main(final List arguments) async { + exit(await IntentCallCommandRunner().run(arguments) ?? 64); +} diff --git a/packages/intentcall_cli/lib/intentcall_cli.dart b/packages/intentcall_cli/lib/intentcall_cli.dart new file mode 100644 index 0000000..5a277d3 --- /dev/null +++ b/packages/intentcall_cli/lib/intentcall_cli.dart @@ -0,0 +1,5 @@ +library; + +export 'src/command_runner.dart'; +export 'src/config/host_profiles.dart'; +export 'src/config/intentcall_config.dart'; diff --git a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart new file mode 100644 index 0000000..5774ce6 --- /dev/null +++ b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import '../utils/cli_utils.dart'; + +/// Loads registry catalog rows for [ManifestMerger]. +final class CatalogLoader { + const CatalogLoader(); + + static const catalogRelativePath = 'lib/generated/agent_catalog.g.dart'; + + Future> load({ + required final String projectRoot, + required final File manifestOut, + }) async { + final catalogFile = File(p.join(projectRoot, catalogRelativePath)); + if (catalogFile.existsSync()) { + final fromProbe = await _loadFromGeneratedCatalog(projectRoot); + if (fromProbe != null) { + return fromProbe; + } + } + return _loadFromProjectionAndManifest( + projectRoot: projectRoot, + manifestOut: manifestOut, + ); + } + + Future?> _loadFromGeneratedCatalog( + final String projectRoot, + ) async { + final packageName = readPackageName(projectRoot); + if (packageName == null) { + return null; + } + + final probeDir = Directory(p.join(projectRoot, '.dart_tool')); + probeDir.createSync(recursive: true); + final probeFile = File(p.join(probeDir.path, 'intentcall_catalog_probe.dart')); + probeFile.writeAsStringSync(''' +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'package:$packageName/generated/agent_catalog.g.dart'; + +void main() { + final rows = >[]; + for (final row in agentCatalogEntries) { + final descriptor = row.resolveDescriptor(); + rows.add({ + 'registryKey': row.registryKey, + 'qualifiedName': descriptor.qualifiedName, + 'namespace': descriptor.namespace, + 'name': descriptor.name, + 'description': descriptor.description, + 'kind': descriptor.kind.name, + 'inputSchema': descriptor.inputSchema, + if (descriptor.resourceUri != null) 'resourceUri': descriptor.resourceUri, + }); + } + print(jsonEncode(rows)); +} +'''); + + final result = await Process.run( + 'dart', + ['run', probeFile.path], + workingDirectory: projectRoot, + runInShell: true, + ); + if (result.exitCode != 0) { + return null; + } + final stdoutText = '${result.stdout}'.trim(); + if (stdoutText.isEmpty) { + return const []; + } + final decoded = jsonDecode(stdoutText); + if (decoded is! List) { + return null; + } + return decoded.map((final row) { + final map = (row as Map).cast(); + return AgentRegistryCatalogEntry( + registryKey: '${map['registryKey']}', + descriptor: AgentIntentDescriptor( + namespace: '${map['namespace']}', + name: '${map['name']}', + description: '${map['description']}', + kind: AgentIntentKind.values.byName('${map['kind']}'), + inputSchema: Map.from( + (map['inputSchema'] as Map?)?.cast() ?? + const {'type': 'object'}, + ), + resourceUri: map['resourceUri']?.toString(), + ), + ); + }).toList(); + } + + List _loadFromProjectionAndManifest({ + required final String projectRoot, + required final File manifestOut, + }) { + final merger = ManifestMerger(); + final overlayPaths = [ + if (loadIntentCallConfig(projectRoot)?.projectionOverlay case final String path) + p.isAbsolute(path) ? path : p.join(projectRoot, path), + p.join(projectRoot, '.intentcall', 'projection.yaml'), + ]; + + final overlayKeys = {}; + for (final overlayPath in overlayPaths) { + final overlays = merger.loadOverlayFile(overlayPath); + overlayKeys.addAll(overlays.keys); + } + + if (!manifestOut.existsSync()) { + return const []; + } + + final manifest = AgentManifest.parse(manifestOut.readAsStringSync()); + final tools = overlayKeys.isEmpty + ? manifest.tools + : manifest.tools.where( + (final tool) => overlayKeys.contains(tool.qualifiedName), + ); + + return tools + .map( + (final tool) => AgentRegistryCatalogEntry( + registryKey: tool.qualifiedName, + descriptor: tool.toDescriptor(), + ), + ) + .toList(); + } +} + +/// Reads optional `.intentcall/projection.yaml` for diagnostics. +Map scanProjectionYaml(final String projectRoot) { + final file = File(p.join(projectRoot, '.intentcall', 'projection.yaml')); + if (!file.existsSync()) { + return const {}; + } + final doc = loadYaml(file.readAsStringSync()); + if (doc is! YamlMap) { + return const {}; + } + return doc.cast(); +} diff --git a/packages/intentcall_cli/lib/src/command_runner.dart b/packages/intentcall_cli/lib/src/command_runner.dart new file mode 100644 index 0000000..8de93df --- /dev/null +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -0,0 +1,675 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; + +import 'catalog/catalog_loader.dart'; +import 'commands/apple_app_intents_testing.dart'; +import 'config/host_profiles.dart'; +import 'config/intentcall_config.dart'; +import 'mcp/stdio_mcp_server.dart'; +import 'utils/cli_utils.dart'; + +/// Framework-neutral IntentCall CLI entry point. +final class IntentCallCommandRunner extends CommandRunner { + IntentCallCommandRunner() + : super( + 'intentcall', + 'Framework-neutral IntentCall CLI for manifest export and platform sync.', + ) { + addCommand(_DoctorCommand()); + addCommand(_ConfigCommand()); + addCommand(_ManifestCommand()); + addCommand(_PlatformCommand()); + addCommand(_CodegenCommand()); + addCommand(_McpCommand()); + addCommand(_AppleAppIntentsTestingCommand()); + } +} + +final class _ProjectDirOption { + static void add(final ArgParser parser) { + parser.addOption( + 'project-dir', + help: 'Project root directory.', + defaultsTo: defaultProjectDir(), + ); + } + + static String read(final ArgResults results) { + final value = results['project-dir']; + if (value != null) { + return p.normalize(p.absolute('$value')); + } + return defaultProjectDir(); + } +} + +final class _DoctorCommand extends Command { + @override + String get name => 'doctor'; + + @override + String get description => 'Check developer environment health.'; + + @override + Future run() async => + _runDoctor(asJson: argResults!['json'] as bool? ?? false); + + @override + ArgParser get argParser => ArgParser()..addFlag('json', negatable: false); + + Future _runDoctor({required final bool asJson}) async { + final checks = >[]; + var healthy = true; + + Future checkTool( + final String id, + final List command, { + final bool required = true, + }) async { + try { + final result = await Process.run(command.first, command.sublist(1)); + final ok = result.exitCode == 0; + if (!ok && required) { + healthy = false; + } + checks.add({ + 'id': id, + 'ok': ok, + 'required': required, + 'detail': '${result.stdout}${result.stderr}'.trim(), + }); + } catch (error) { + if (required) { + healthy = false; + } + checks.add({ + 'id': id, + 'ok': false, + 'required': required, + 'detail': '$error', + }); + } + } + + await checkTool('dart', ['dart', '--version']); + await checkTool('flutter', ['flutter', '--version'], required: false); + await checkTool('just', ['just', '--version'], required: false); + + final lockExists = File('pubspec.lock').existsSync(); + if (!lockExists) { + healthy = false; + } + checks.add({ + 'id': 'pubspec.lock', + 'ok': lockExists, + 'required': true, + 'detail': lockExists ? 'present' : 'missing — run dart pub get', + }); + + if (asJson) { + printJson({ + 'healthy': healthy, + 'checks': checks, + }); + } else { + stdout.writeln('== IntentCall Doctor =='); + for (final check in checks) { + final mark = (check['ok'] as bool) ? '✓' : '✗'; + stdout.writeln('$mark ${check['id']}: ${check['detail']}'); + } + stdout.writeln('\nStatus: ${healthy ? 'HEALTHY' : 'UNHEALTHY'}'); + } + return healthy ? 0 : 1; + } +} + +final class _ConfigCommand extends Command { + _ConfigCommand() { + addSubcommand(_ConfigShowCommand()); + } + + @override + String get name => 'config'; + + @override + String get description => 'Show intentcall.yaml host wiring.'; +} + +final class _ConfigShowCommand extends Command { + @override + String get name => 'show'; + + @override + String get description => 'Print parsed intentcall.yaml.'; + + @override + int run() => _runShow(argResults!); + + int _runShow(final ArgResults results) { + final projectRoot = _ProjectDirOption.read(results); + final config = loadIntentCallConfig(projectRoot); + if (config == null) { + printUsageError('intentcall.yaml not found under $projectRoot'); + return inputMissingExitCode(); + } + final enriched = Map.from(config.toJson()) + ..['resolvedPlatforms'] = resolveEnabledPlatforms(config); + if (results['json'] as bool? ?? false) { + printJson(enriched); + } else { + stdout.writeln('# intentcall.yaml (${config.sourcePath})'); + stdout.writeln(encodePrettyJson(enriched)); + } + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser.addFlag('json', negatable: false); + return parser; + } +} + +final class _ManifestCommand extends Command { + _ManifestCommand() { + addSubcommand(_ManifestValidateCommand()); + addSubcommand(_ManifestExportCommand()); + } + + @override + String get name => 'manifest'; + + @override + String get description => 'Validate and export agent_manifest.json.'; +} + +final class _ManifestValidateCommand extends Command { + @override + String get name => 'validate'; + + @override + String get description => 'Parse and validate agent_manifest.json.'; + + @override + int run() { + final results = argResults!; + final projectRoot = _ProjectDirOption.read(results); + final config = loadIntentCallConfig(projectRoot); + final manifestPath = results['manifest'] == null + ? resolveManifestOutput(projectRoot, config: config) + : resolveProjectPath(projectRoot, '${results['manifest']}'); + + if (!manifestPath.existsSync()) { + printUsageError('manifest not found: ${manifestPath.path}'); + return inputMissingExitCode(); + } + + try { + final manifest = AgentManifest.parse(manifestPath.readAsStringSync()); + if (results['json'] as bool? ?? false) { + printJson({ + 'ok': true, + 'path': manifestPath.path, + 'toolCount': manifest.tools.length, + 'entityTypeCount': manifest.entityTypes.length, + }); + } else { + stdout.writeln('OK: valid manifest at ${manifestPath.path}'); + stdout.writeln( + ' tools=${manifest.tools.length} entityTypes=${manifest.entityTypes.length}', + ); + } + return 0; + } on FormatException catch (error) { + printUsageError('invalid manifest: ${error.message}'); + return dataErrorExitCode(); + } + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser + ..addOption('manifest', help: 'Path to agent_manifest.json.') + ..addFlag('json', negatable: false); + return parser; + } +} + +final class _ManifestExportCommand extends Command { + @override + String get name => 'export'; + + @override + String get description => + 'Merge catalog + projection into agent_manifest.json.'; + + @override + Future run() async { + final results = argResults!; + final projectRoot = _ProjectDirOption.read(results); + final config = loadIntentCallConfig(projectRoot); + final outPath = results['out'] == null + ? resolveManifestOutput(projectRoot, config: config) + : resolveProjectPath(projectRoot, '${results['out']}'); + final checkOnly = results['check'] as bool? ?? false; + + final merger = ManifestMerger(); + final policy = merger.loadProjectionPolicy(projectRoot: projectRoot); + final protocolScheme = + config?.protocolScheme ?? merger.readProtocolScheme(projectRoot); + final catalog = await const CatalogLoader().load( + projectRoot: projectRoot, + manifestOut: outPath, + ); + + final manifest = merger.mergeManifest( + catalog: catalog, + policy: policy, + protocolScheme: protocolScheme, + platform: _platformLabel(config), + ); + final encoded = merger.encodeManifest(manifest); + + if (checkOnly) { + if (!outPath.existsSync()) { + printUsageError('manifest missing at ${outPath.path}'); + return inputMissingExitCode(); + } + final current = outPath.readAsStringSync(); + if (current == encoded) { + stdout.writeln('OK: manifest is fresh (${outPath.path})'); + return 0; + } + printUsageError( + 'manifest drift at ${outPath.path} — run intentcall manifest export', + ); + return 1; + } + + outPath.parent.createSync(recursive: true); + outPath.writeAsStringSync(encoded); + stdout.writeln('OK: wrote manifest to ${outPath.path}'); + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser + ..addFlag('check', negatable: false, help: 'Verify manifest freshness.') + ..addOption('out', help: 'Output manifest path.') + ..addFlag('json', negatable: false); + return parser; + } +} + +String _platformLabel(final IntentCallConfig? config) { + return switch (config?.host) { + IntentCallHost.jaspr => 'web', + IntentCallHost.flutter => 'unified', + _ => 'unified', + }; +} + +final class _PlatformCommand extends Command { + _PlatformCommand() { + addSubcommand(_PlatformSyncCommand()); + addSubcommand(_PlatformHooksCommand()); + } + + @override + String get name => 'platform'; + + @override + String get description => + 'Sync native/web artifacts from agent_manifest.json.'; +} + +final class _PlatformSyncCommand extends Command { + @override + String get name => 'sync'; + + @override + String get description => 'Emit platform artifacts from agent_manifest.json.'; + + @override + int run() => runPlatformSync(argResults!); + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser + ..addMultiOption( + 'platform', + abbr: 'p', + help: 'Platform target(s): web, android, ios, macos, linux, windows.', + valueHelp: 'LIST', + ) + ..addFlag('check', negatable: false, help: 'Verify artifact freshness.') + ..addFlag('dry-run', negatable: false, help: 'Report changes only.') + ..addOption('host', help: 'Host profile hint (flutter|jaspr).'); + return parser; + } +} + +int runPlatformSync(final ArgResults results) { + final projectRoot = _ProjectDirOption.read(results); + final config = loadIntentCallConfig(projectRoot); + final platforms = parsePlatformList(results['platform'] as List); + final resolved = platforms.isEmpty + ? resolveEnabledPlatforms( + config ?? const IntentCallConfig(host: IntentCallHost.flutter), + ) + : platforms; + + if (resolved.isEmpty) { + printUsageError( + '--platform is required when intentcall.yaml has no defaults.', + ); + return usageExitCode(); + } + + const sync = PlatformSync(); + final checkOnly = results['check'] as bool? ?? false; + final dryRun = results['dry-run'] as bool? ?? false; + + try { + if (checkOnly) { + final ok = sync.checkPlatforms(projectRoot, resolved); + if (ok) { + stdout.writeln('OK: platform artifacts are fresh ($resolved)'); + return 0; + } + printUsageError( + 'platform artifact drift for $resolved — run intentcall platform sync', + ); + return 1; + } + + final result = sync.syncPlatforms( + projectRoot: projectRoot, + platforms: resolved, + dryRun: dryRun, + ); + if (dryRun) { + stdout.writeln( + 'Dry run: ${result.artifacts.where((final a) => a.changed).length} ' + 'artifact(s) would change.', + ); + } else { + stdout.writeln('OK: synced platforms $resolved'); + } + return 0; + } on ArgumentError catch (error) { + printUsageError('$error'); + return usageExitCode(); + } on StateError catch (error) { + printUsageError('$error'); + return dataErrorExitCode(); + } +} + +final class _PlatformHooksCommand extends Command { + _PlatformHooksCommand() { + addSubcommand(_PlatformHooksInitCommand()); + addSubcommand(_PlatformHooksPrintCommand()); + } + + @override + String get name => 'hooks'; + + @override + String get description => 'Initialize or print platform hook templates.'; +} + +final class _PlatformHooksInitCommand extends Command { + @override + String get name => 'init'; + + @override + String get description => 'Patch Flutter/Jaspr hook files once.'; + + @override + Future run() async { + final results = argResults!; + final projectRoot = _ProjectDirOption.read(results); + final host = normalizeHostName('${results['host']}'); + final checkOnly = results['check'] as bool? ?? false; + + if (host == IntentCallHost.jaspr.name) { + return _initJasprHooks( + projectRoot: projectRoot, + checkOnly: checkOnly, + ); + } + + final report = await const PlatformHooksInit().run( + projectRoot: projectRoot, + checkOnly: checkOnly, + ); + for (final target in report.targets) { + final mark = target.ok ? 'OK' : 'FAIL'; + stdout.writeln('$mark ${target.id}: ${target.path}'); + if (target.message != null) { + stdout.writeln(' ${target.message}'); + } + } + return report.ok ? 0 : 1; + } + + Future _initJasprHooks({ + required final String projectRoot, + required final bool checkOnly, + }) async { + final hookFile = File( + p.join(projectRoot, '.intentcall', 'web_build_hook.sh'), + ); + final expected = '${kJasprWebCodegenHook.trim()}\n'; + final exists = hookFile.existsSync(); + final current = exists ? hookFile.readAsStringSync() : ''; + final ok = exists && current.contains('intentcall-platform: begin'); + + if (checkOnly) { + if (ok) { + stdout.writeln('OK: Jaspr web hook present (${hookFile.path})'); + return 0; + } + printUsageError('missing Jaspr web hook at ${hookFile.path}'); + return 1; + } + + if (!ok) { + hookFile.parent.createSync(recursive: true); + hookFile.writeAsStringSync(expected); + stdout.writeln('OK: wrote Jaspr web hook to ${hookFile.path}'); + } else { + stdout.writeln('OK: Jaspr web hook already present'); + } + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser + ..addOption( + 'host', + help: 'Host profile: flutter or jaspr.', + defaultsTo: 'flutter', + allowed: ['flutter', 'jaspr'], + ) + ..addFlag('check', negatable: false); + return parser; + } +} + +final class _PlatformHooksPrintCommand extends Command { + @override + String get name => 'print'; + + @override + String get description => 'Print hook template snippets.'; + + @override + int run() { + final results = argResults!; + final host = normalizeHostName('${results['host']}'); + final platform = '${results['platform'] ?? ''}'.trim().toLowerCase(); + + final snippets = { + 'android': kAndroidGradleCodegenHook, + 'ios': kAppleXcodeCodegenRunScript, + 'macos': kAppleXcodeCodegenRunScript, + 'jaspr': kJasprWebCodegenHook, + 'web': kJasprWebCodegenHook, + }; + + if (platform.isNotEmpty) { + final snippet = snippets[platform]; + if (snippet == null) { + printUsageError('unknown platform "$platform" for hook print.'); + return usageExitCode(); + } + stdout.writeln(snippet.trim()); + return 0; + } + + final keys = host == IntentCallHost.jaspr.name + ? ['jaspr', 'web'] + : ['android', 'ios', 'macos']; + for (final key in keys) { + stdout.writeln('== $key =='); + stdout.writeln(snippets[key]!.trim()); + stdout.writeln(); + } + return 0; + } + + @override + ArgParser get argParser => ArgParser() + ..addOption('host', defaultsTo: 'flutter') + ..addOption('platform', help: 'Print one platform snippet.'); +} + +final class _CodegenCommand extends Command { + _CodegenCommand() { + addSubcommand(_CodegenSyncCommand()); + } + + @override + String get name => 'codegen'; + + @override + String get description => 'Alias for platform sync.'; +} + +final class _CodegenSyncCommand extends Command { + @override + String get name => 'sync'; + + @override + String get description => 'Alias of platform sync.'; + + @override + int run() => runPlatformSync(argResults!); + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser + ..addMultiOption('platform', abbr: 'p') + ..addFlag('check', negatable: false) + ..addFlag('dry-run', negatable: false) + ..addOption('host'); + return parser; + } +} + +final class _McpCommand extends Command { + _McpCommand() { + addSubcommand(_McpServeCommand()); + } + + @override + String get name => 'mcp'; + + @override + String get description => 'Run IntentCall MCP transports.'; +} + +final class _McpServeCommand extends Command { + @override + String get name => 'serve'; + + @override + String get description => 'Start stdio MCP server (dogfood).'; + + @override + Future run() async { + final results = argResults!; + final entrypoint = '${results['entrypoint'] ?? ''}'.trim(); + if (entrypoint.isNotEmpty) { + stderr.writeln( + 'Note: dynamic --entrypoint loading is not implemented yet; ' + 'starting empty registry host.', + ); + } + await runIntentCallStdioMcpServer(); + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser.addOption( + 'entrypoint', + help: 'Optional Dart entrypoint with AgentModule registrations.', + ); + return parser; + } +} + +final class _AppleAppIntentsTestingCommand extends Command { + @override + String get name => 'apple-appintents-testing'; + + @override + String get description => + 'Generate/typecheck AppIntentsTesting live proof scaffolds.'; + + @override + Future run() async { + final subcommand = argResults!.command; + if (subcommand == null) { + printUsageError( + 'apple-appintents-testing requires generate-tests, generate-fixtures, or typecheck.', + ); + return usageExitCode(); + } + final projectRoot = _ProjectDirOption.read(argResults!); + return runAppleAppIntentsTesting(subcommand, projectRoot); + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + for (final entry in buildAppleAppIntentsTestingParser().commands.entries) { + parser.addCommand(entry.key, entry.value); + } + return parser; + } +} diff --git a/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart b/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart new file mode 100644 index 0000000..c456ff8 --- /dev/null +++ b/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart @@ -0,0 +1,422 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; + +import '../utils/cli_utils.dart'; + +Future runAppleAppIntentsTesting( + final ArgResults command, + final String projectRoot, +) async { + final subcommand = command.command; + if (subcommand == null) { + printUsageError( + 'apple-appintents-testing requires generate-tests, generate-fixtures, or typecheck.', + ); + return usageExitCode(); + } + + return switch (subcommand.name) { + 'generate-tests' => _generateTests(projectRoot, subcommand), + 'generate-fixtures' => _generateFixtures(projectRoot, subcommand), + 'typecheck' => _typecheck(subcommand), + _ => usageExitCode(), + }; +} + +Future _generateTests( + final String projectRoot, + final ArgResults command, +) async { + final bundleIdentifier = '${command['bundle-id'] ?? ''}'.trim(); + if (bundleIdentifier.isEmpty) { + printUsageError( + '--bundle-id is required for IntentDefinitions(bundleIdentifier:).', + ); + return usageExitCode(); + } + + final manifestPath = resolveProjectPath(projectRoot, '${command['manifest']}'); + if (!manifestPath.existsSync()) { + printUsageError('manifest not found: ${manifestPath.path}'); + return inputMissingExitCode(); + } + + final sampleArgumentsPath = '${command['sample-arguments'] ?? ''}'.trim(); + final entityFixturesPath = '${command['entity-fixtures'] ?? ''}'.trim(); + final outputPath = '${command['output'] ?? ''}'.trim(); + + try { + final swift = _emitAppleAppIntentsTestingScaffold( + manifestFile: manifestPath, + bundleIdentifier: bundleIdentifier, + testClassName: '${command['test-class']}', + sampleArgumentsFile: sampleArgumentsPath.isEmpty + ? null + : resolveProjectPath(projectRoot, sampleArgumentsPath), + entityFixturesFile: entityFixturesPath.isEmpty + ? null + : resolveProjectPath(projectRoot, entityFixturesPath), + ); + + if (outputPath.isEmpty) { + stdout.write(swift); + } else { + final output = resolveProjectPath(projectRoot, outputPath); + output.parent.createSync(recursive: true); + output.writeAsStringSync(swift); + stdout.writeln( + 'OK: wrote AppIntentsTesting XCTest scaffold to ${output.path}', + ); + } + stdout.writeln( + 'Proof label: generated AppIntentsTesting scaffold only. Run the scaffold in an XCTest UI-test bundle for runtime proof.', + ); + return 0; + } on FormatException catch (error) { + printUsageError('invalid AppIntentsTesting input: ${error.message}'); + return dataErrorExitCode(); + } on UnsupportedError catch (error) { + printUsageError('cannot generate AppIntentsTesting scaffold: $error'); + return dataErrorExitCode(); + } on StateError catch (error) { + printUsageError('cannot generate AppIntentsTesting scaffold: $error'); + return dataErrorExitCode(); + } +} + +Future _generateFixtures( + final String projectRoot, + final ArgResults command, +) async { + final sampleArgumentsOutput = '${command['sample-arguments-output'] ?? ''}' + .trim(); + final entityFixturesOutput = '${command['entity-fixtures-output'] ?? ''}' + .trim(); + if (sampleArgumentsOutput.isEmpty && entityFixturesOutput.isEmpty) { + printUsageError( + 'generate-fixtures requires --sample-arguments-output, --entity-fixtures-output, or both.', + ); + return usageExitCode(); + } + + final manifestPath = resolveProjectPath(projectRoot, '${command['manifest']}'); + if (!manifestPath.existsSync()) { + printUsageError('manifest not found: ${manifestPath.path}'); + return inputMissingExitCode(); + } + + try { + final manifest = AgentManifest.parse(manifestPath.readAsStringSync()); + if (sampleArgumentsOutput.isNotEmpty) { + final output = resolveProjectPath(projectRoot, sampleArgumentsOutput); + output.parent.createSync(recursive: true); + output.writeAsStringSync( + '${encodePrettyJson(_sampleArgumentsTemplate(manifest))}\n', + ); + stdout.writeln( + 'OK: wrote AppIntentsTesting sample arguments to ${output.path}', + ); + } + if (entityFixturesOutput.isNotEmpty) { + final output = resolveProjectPath(projectRoot, entityFixturesOutput); + output.parent.createSync(recursive: true); + output.writeAsStringSync( + '${encodePrettyJson(_entityFixturesTemplate(manifest))}\n', + ); + stdout.writeln( + 'OK: wrote AppIntentsTesting entity fixtures to ${output.path}', + ); + } + stdout.writeln( + 'Proof label: fixture template generation only. Replace placeholder values with seeded UI-test data before claiming runtime proof.', + ); + return 0; + } on FormatException catch (error) { + printUsageError('invalid AppIntentsTesting input: ${error.message}'); + return dataErrorExitCode(); + } on UnsupportedError catch (error) { + printUsageError('cannot generate AppIntentsTesting fixtures: $error'); + return dataErrorExitCode(); + } +} + +Future _typecheck(final ArgResults command) async { + final xcodeApp = Directory('${command['xcode']}'); + final developerDir = Directory( + p.join(xcodeApp.path, 'Contents', 'Developer'), + ); + final frameworkDir = Directory( + p.join( + developerDir.path, + 'Platforms', + 'MacOSX.platform', + 'Developer', + 'Library', + 'Frameworks', + ), + ); + final framework = Directory( + p.join(frameworkDir.path, 'AppIntentsTesting.framework'), + ); + + if (!developerDir.existsSync()) { + printUsageError('Xcode developer dir not found: ${developerDir.path}'); + return inputMissingExitCode(); + } + if (!framework.existsSync()) { + printUsageError( + 'AppIntentsTesting.framework not found at ${framework.path}. ' + 'Install/select a full Xcode that contains Apple 27+ SDK testing frameworks.', + ); + return inputMissingExitCode(); + } + + final tempDir = Directory.systemTemp.createTempSync( + 'intentcall_appintentstesting_', + ); + try { + final probe = File(p.join(tempDir.path, 'probe.swift')) + ..writeAsStringSync('import AppIntentsTesting\nimport XCTest\n'); + final moduleCache = Directory(p.join(tempDir.path, 'module-cache')) + ..createSync(); + final swiftc = await Process.start( + 'xcrun', + [ + 'swiftc', + '-typecheck', + '-F', + frameworkDir.path, + '-module-cache-path', + moduleCache.path, + probe.path, + ], + environment: {'DEVELOPER_DIR': developerDir.path}, + mode: ProcessStartMode.inheritStdio, + ); + final swiftcCode = await swiftc.exitCode; + if (swiftcCode != 0) { + printUsageError( + 'AppIntentsTesting import typecheck failed with exit code $swiftcCode.', + ); + return swiftcCode; + } + + final xcodebuild = await Process.run( + 'xcrun', + ['xcodebuild', '-version'], + environment: {'DEVELOPER_DIR': developerDir.path}, + ); + stdout.write(xcodebuild.stdout); + stderr.write(xcodebuild.stderr); + if (xcodebuild.exitCode != 0) { + return xcodebuild.exitCode; + } + stdout.writeln( + 'Proof label: AppIntentsTesting import compile proof only. This does not execute generated intents.', + ); + return 0; + } finally { + tempDir.deleteSync(recursive: true); + } +} + +String _emitAppleAppIntentsTestingScaffold({ + required final File manifestFile, + required final String bundleIdentifier, + required final String testClassName, + final File? sampleArgumentsFile, + final File? entityFixturesFile, +}) { + final manifest = AgentManifest.parse(manifestFile.readAsStringSync()); + return AppleAppIntentsTestingEmitter( + bundleIdentifier: bundleIdentifier, + testClassName: testClassName, + sampleArguments: sampleArgumentsFile == null + ? const >{} + : _readSampleArguments(sampleArgumentsFile), + entityFixtures: entityFixturesFile == null + ? const {} + : _readEntityFixtures(entityFixturesFile), + ).emitUiTests(manifest); +} + +Map> _sampleArgumentsTemplate( + final AgentManifest manifest, +) { + final out = >{}; + for (final tool in manifest.tools) { + final properties = tool.inputSchema['properties']; + if (properties is! Map) { + continue; + } + final required = { + ...((tool.inputSchema['required'] is List) + ? tool.inputSchema['required'] as List + : const []) + .whereType(), + }; + final sample = {}; + for (final entry in properties.entries) { + final parameterName = '${entry.key}'; + if (!required.contains(parameterName)) { + continue; + } + final schema = entry.value; + if (schema is! Map) { + continue; + } + sample[parameterName] = _sampleValueForSchema( + '${schema['type']}', + qualifiedName: tool.qualifiedName, + parameterName: parameterName, + ); + } + if (sample.isNotEmpty) { + out[tool.qualifiedName] = sample; + } + } + return out; +} + +Map> _entityFixturesTemplate( + final AgentManifest manifest, +) { + final out = >{}; + for (final entityType in manifest.entityTypes) { + out[entityType.qualifiedName] = { + 'identifier': '<${entityType.idKey}>', + 'search': '', + 'expectedTitle': '<${entityType.displayName} title>', + }; + } + return out; +} + +Object? _sampleValueForSchema( + final String schemaType, { + required final String qualifiedName, + required final String parameterName, +}) => + switch (schemaType) { + 'string' => '', + 'integer' => 1, + 'number' => 1.0, + 'boolean' => true, + _ => throw UnsupportedError( + 'AppIntentsTesting fixture templates support only primitive ' + 'string/integer/number/boolean parameters in $qualifiedName; ' + '"$parameterName" has unsupported type "$schemaType".', + ), + }; + +Map> _readSampleArguments(final File file) { + final raw = readJsonObjectFile(file); + return raw.map((final key, final value) { + final values = switch (value) { + final Map typed => typed, + final Map map => map.cast(), + _ => throw FormatException( + 'sample argument fixture "$key" must be an object.', + ), + }; + return MapEntry(key, values); + }); +} + +Map _readEntityFixtures( + final File file, +) { + final raw = readJsonObjectFile(file); + return raw.map((final key, final value) { + final values = switch (value) { + final Map typed => typed, + final Map map => map.cast(), + _ => throw FormatException('entity fixture "$key" must be an object.'), + }; + final identifier = '${values['identifier'] ?? ''}'.trim(); + final search = '${values['search'] ?? ''}'.trim(); + final expectedTitle = '${values['expectedTitle'] ?? ''}'.trim(); + if (identifier.isEmpty || search.isEmpty || expectedTitle.isEmpty) { + throw FormatException( + 'entity fixture "$key" requires identifier, search, and expectedTitle.', + ); + } + return MapEntry( + key, + AppleAppIntentsTestingEntityFixture( + identifier: identifier, + search: search, + expectedTitle: expectedTitle, + ), + ); + }); +} + +ArgParser buildAppleAppIntentsTestingParser() { + return ArgParser() + ..addCommand( + 'generate-tests', + ArgParser() + ..addOption( + 'manifest', + abbr: 'm', + help: 'Path to agent_manifest.json.', + defaultsTo: 'web/agent_manifest.json', + ) + ..addOption( + 'bundle-id', + help: 'Bundle identifier for IntentDefinitions lookup.', + ) + ..addOption( + 'output', + abbr: 'o', + help: 'Swift output path. Defaults to stdout.', + ) + ..addOption( + 'test-class', + help: 'Generated XCTest class name.', + defaultsTo: 'IntentCallAppIntentsLiveInvocationTests', + ) + ..addOption( + 'sample-arguments', + help: + 'Optional JSON file keyed by manifest qualifiedName with primitive App Intent argument fixtures.', + ) + ..addOption( + 'entity-fixtures', + help: + 'Optional JSON file keyed by entity qualifiedName with identifier/search/expectedTitle fixtures.', + ), + ) + ..addCommand( + 'generate-fixtures', + ArgParser() + ..addOption( + 'manifest', + abbr: 'm', + help: 'Path to agent_manifest.json.', + defaultsTo: 'web/agent_manifest.json', + ) + ..addOption( + 'sample-arguments-output', + help: + 'JSON output path for generated primitive argument fixtures.', + ) + ..addOption( + 'entity-fixtures-output', + help: 'JSON output path for generated AppEntity query fixtures.', + ), + ) + ..addCommand( + 'typecheck', + ArgParser() + ..addOption( + 'xcode', + help: 'Xcode.app path containing AppIntentsTesting.framework.', + defaultsTo: '/Applications/Xcode-beta.app', + ), + ); +} diff --git a/packages/intentcall_cli/lib/src/config/host_profiles.dart b/packages/intentcall_cli/lib/src/config/host_profiles.dart new file mode 100644 index 0000000..23d39d9 --- /dev/null +++ b/packages/intentcall_cli/lib/src/config/host_profiles.dart @@ -0,0 +1,64 @@ +import 'intentcall_config.dart'; + +/// Default enabled platforms per host profile when `intentcall.yaml` omits them. +final class HostProfile { + const HostProfile({ + required this.host, + required this.defaultPlatforms, + required this.hookTemplateKeys, + }); + + final IntentCallHost host; + final List defaultPlatforms; + final List hookTemplateKeys; +} + +const kHostProfiles = { + IntentCallHost.flutter: HostProfile( + host: IntentCallHost.flutter, + defaultPlatforms: [ + 'web', + 'android', + 'ios', + 'macos', + 'linux', + 'windows', + ], + hookTemplateKeys: [ + 'android', + 'ios', + 'macos', + ], + ), + IntentCallHost.jaspr: HostProfile( + host: IntentCallHost.jaspr, + defaultPlatforms: ['web'], + hookTemplateKeys: ['jaspr'], + ), + IntentCallHost.dart: HostProfile( + host: IntentCallHost.dart, + defaultPlatforms: const [], + hookTemplateKeys: const [], + ), + IntentCallHost.custom: HostProfile( + host: IntentCallHost.custom, + defaultPlatforms: const [], + hookTemplateKeys: const [], + ), +}; + +HostProfile hostProfileFor(final IntentCallConfig config) => + kHostProfiles[config.host] ?? kHostProfiles[IntentCallHost.custom]!; + +/// Resolves enabled platforms from config, falling back to host defaults. +List resolveEnabledPlatforms(final IntentCallConfig config) { + if (config.platforms.enabled.isNotEmpty) { + return List.from(config.platforms.enabled); + } + return List.from(hostProfileFor(config).defaultPlatforms); +} + +String normalizeHostName(final String? value) { + final host = tryParseIntentCallHost(value); + return host?.name ?? IntentCallHost.custom.name; +} diff --git a/packages/intentcall_cli/lib/src/config/intentcall_config.dart b/packages/intentcall_cli/lib/src/config/intentcall_config.dart new file mode 100644 index 0000000..017243d --- /dev/null +++ b/packages/intentcall_cli/lib/src/config/intentcall_config.dart @@ -0,0 +1,201 @@ +import 'package:yaml/yaml.dart'; + +/// Parsed `intentcall.yaml` v1 host wiring (no per-tool descriptor rows). +final class IntentCallConfig { + const IntentCallConfig({ + this.host = IntentCallHost.custom, + this.protocolScheme, + this.layout = const IntentCallLayout(), + this.platforms = const IntentCallPlatforms(), + this.defaults = const IntentCallProjectionDefaults(), + this.projectionOverlay, + this.hooks = const IntentCallHooks(), + this.sourcePath, + }); + + factory IntentCallConfig.fromYamlMap( + final Map yaml, { + final String? sourcePath, + }) { + final hostName = yaml['host']?.toString().trim(); + final host = tryParseIntentCallHost(hostName) ?? IntentCallHost.custom; + + final layoutRaw = yaml['layout']; + final layout = layoutRaw is Map + ? IntentCallLayout.fromYamlMap(layoutRaw) + : const IntentCallLayout(); + + final platformsRaw = yaml['platforms']; + final platforms = platformsRaw is Map + ? IntentCallPlatforms.fromYamlMap(platformsRaw) + : const IntentCallPlatforms(); + + final defaultsRaw = yaml['defaults']; + final defaults = defaultsRaw is Map + ? IntentCallProjectionDefaults.fromYamlMap(defaultsRaw) + : const IntentCallProjectionDefaults(); + + final hooksRaw = yaml['hooks']; + final hooks = hooksRaw is Map + ? IntentCallHooks.fromYamlMap(hooksRaw) + : const IntentCallHooks(); + + final overlay = yaml['projectionOverlay']?.toString().trim(); + return IntentCallConfig( + host: host, + protocolScheme: _nonEmpty(yaml['protocolScheme']?.toString()), + layout: layout, + platforms: platforms, + defaults: defaults, + projectionOverlay: overlay?.isEmpty ?? true ? null : overlay, + hooks: hooks, + sourcePath: sourcePath, + ); + } + + factory IntentCallConfig.parse(final String yamlText, {final String? sourcePath}) { + final doc = loadYaml(yamlText); + if (doc is! YamlMap) { + throw FormatException('intentcall.yaml must be a YAML mapping.'); + } + return IntentCallConfig.fromYamlMap(doc, sourcePath: sourcePath); + } + + final IntentCallHost host; + final String? protocolScheme; + final IntentCallLayout layout; + final IntentCallPlatforms platforms; + final IntentCallProjectionDefaults defaults; + final String? projectionOverlay; + final IntentCallHooks hooks; + final String? sourcePath; + + Map toJson() => { + 'host': host.name, + if (protocolScheme != null) 'protocolScheme': protocolScheme, + 'layout': layout.toJson(), + 'platforms': platforms.toJson(), + 'defaults': defaults.toJson(), + if (projectionOverlay != null) 'projectionOverlay': projectionOverlay, + 'hooks': hooks.toJson(), + if (sourcePath != null) 'sourcePath': sourcePath, + }; +} + +enum IntentCallHost { flutter, jaspr, dart, custom } + +IntentCallHost? tryParseIntentCallHost(final String? value) { + if (value == null || value.isEmpty) { + return null; + } + return switch (value.toLowerCase()) { + 'flutter' => IntentCallHost.flutter, + 'jaspr' => IntentCallHost.jaspr, + 'dart' => IntentCallHost.dart, + 'custom' => IntentCallHost.custom, + _ => null, + }; +} + +/// Layout paths for generated artifacts. +final class IntentCallLayout { + const IntentCallLayout({ + this.manifest = 'web/agent_manifest.json', + this.webDir = 'web', + }); + + factory IntentCallLayout.fromYamlMap(final Map yaml) => + IntentCallLayout( + manifest: yaml['manifest']?.toString() ?? 'web/agent_manifest.json', + webDir: yaml['webDir']?.toString() ?? 'web', + ); + + final String manifest; + final String webDir; + + Map toJson() => { + 'manifest': manifest, + 'webDir': webDir, + }; +} + +/// Enabled platform targets for sync hooks. +final class IntentCallPlatforms { + const IntentCallPlatforms({this.enabled = const []}); + + factory IntentCallPlatforms.fromYamlMap(final Map yaml) { + final enabledRaw = yaml['enabled']; + final enabled = []; + if (enabledRaw is YamlList) { + for (final value in enabledRaw) { + final name = value?.toString().trim(); + if (name != null && name.isNotEmpty) { + enabled.add(name.toLowerCase()); + } + } + } + return IntentCallPlatforms(enabled: enabled); + } + + final List enabled; + + Map toJson() => { + 'enabled': enabled, + }; +} + +/// Global projection defaults merged into [ProjectionPolicy]. +final class IntentCallProjectionDefaults { + const IntentCallProjectionDefaults({ + this.dispatchMode, + this.surfaces = const {}, + }); + + factory IntentCallProjectionDefaults.fromYamlMap( + final Map yaml, + ) { + final surfaces = {}; + final surfacesRaw = yaml['surfaces']; + if (surfacesRaw is Map) { + for (final entry in surfacesRaw.entries) { + if (entry.value is bool) { + surfaces[entry.key.toString()] = entry.value as bool; + } + } + } + return IntentCallProjectionDefaults( + dispatchMode: yaml['dispatchMode']?.toString(), + surfaces: surfaces, + ); + } + + final String? dispatchMode; + final Map surfaces; + + Map toJson() => { + if (dispatchMode != null) 'dispatchMode': dispatchMode, + if (surfaces.isNotEmpty) 'surfaces': surfaces, + }; +} + +/// Build hook command wiring. +final class IntentCallHooks { + const IntentCallHooks({this.syncCommand}); + + factory IntentCallHooks.fromYamlMap(final Map yaml) => + IntentCallHooks(syncCommand: _nonEmpty(yaml['syncCommand']?.toString())); + + final String? syncCommand; + + Map toJson() => { + if (syncCommand != null) 'syncCommand': syncCommand, + }; +} + +String? _nonEmpty(final String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + return trimmed; +} diff --git a/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart b/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart new file mode 100644 index 0000000..9460b80 --- /dev/null +++ b/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:io' as io; + +import 'package:dart_mcp/server.dart'; +import 'package:dart_mcp/stdio.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_mcp/intentcall_mcp.dart'; + +/// Minimal stdio MCP host wiring [McpPublishAdapter] to [ToolsSupport]. +Future runIntentCallStdioMcpServer({ + final AgentRegistry? registry, + final List modules = const [], +}) async { + IntentCallStdioMcpServer? serverRef; + final adapter = McpPublishAdapter( + publishTool: (final tool, final impl) => + serverRef!.registerTool(tool, impl), + unpublishTool: (final name) => serverRef!.unregisterTool(name), + publishResource: (final resource, final impl) => + serverRef!.addResource(resource, impl), + unpublishResource: (final uri) => serverRef!.removeResource(uri), + publishResourceTemplate: (final template, final impl) => + serverRef!.addResourceTemplate(template, impl), + ); + + serverRef = IntentCallStdioMcpServer( + stdioChannel(input: io.stdin, output: io.stdout), + adapter: adapter, + ); + final server = serverRef; + + final runtime = AgentRuntime( + registry: registry, + modules: modules, + adapters: [adapter], + ); + await runtime.start(); + await server.initialized; + await server.done; + await runtime.stop(); +} + +base class IntentCallStdioMcpServer extends MCPServer + with ToolsSupport, ResourcesSupport { + IntentCallStdioMcpServer( + super.channel, { + required this.adapter, + }) : super.fromStreamChannel( + implementation: Implementation( + name: 'intentcall', + version: '0.6.0', + ), + instructions: + 'IntentCall registry-backed MCP server (minimal dogfood host).', + ); + + final McpPublishAdapter adapter; +} diff --git a/packages/intentcall_cli/lib/src/utils/cli_utils.dart b/packages/intentcall_cli/lib/src/utils/cli_utils.dart new file mode 100644 index 0000000..7660170 --- /dev/null +++ b/packages/intentcall_cli/lib/src/utils/cli_utils.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../config/intentcall_config.dart'; + +/// Resolves a project-relative or absolute path against [projectRoot]. +File resolveProjectPath(final String projectRoot, final String path) { + final normalized = p.normalize(path); + if (p.isAbsolute(normalized)) { + return File(normalized); + } + return File(p.join(projectRoot, normalized)); +} + +Directory resolveProjectDirectory(final String projectRoot, final String path) { + final normalized = p.normalize(path); + if (p.isAbsolute(normalized)) { + return Directory(normalized); + } + return Directory(p.join(projectRoot, normalized)); +} + +String defaultProjectDir() => Directory.current.path; + +IntentCallConfig? loadIntentCallConfig(final String projectRoot) { + final configFile = File(p.join(projectRoot, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return null; + } + return IntentCallConfig.parse( + configFile.readAsStringSync(), + sourcePath: configFile.path, + ); +} + +File resolveManifestOutput(final String projectRoot, {final IntentCallConfig? config}) { + final rel = config?.layout.manifest ?? 'web/agent_manifest.json'; + return resolveProjectPath(projectRoot, rel); +} + +String encodePrettyJson(final Object? value) => + const JsonEncoder.withIndent(' ').convert(value); + +Map readJsonObjectFile(final File file) { + if (!file.existsSync()) { + throw FormatException('JSON file not found: ${file.path}'); + } + final decoded = jsonDecode(file.readAsStringSync()); + return switch (decoded) { + final Map typed => typed, + final Map map => map.cast(), + _ => throw FormatException('JSON file must contain an object: ${file.path}'), + }; +} + +void printJson(final Object? value) { + stdout.writeln(encodePrettyJson(value)); +} + +void printUsageError(final String message) { + stderr.writeln('FAIL: $message'); +} + +int usageExitCode() => 64; + +int dataErrorExitCode() => 65; + +int inputMissingExitCode() => 66; + +List parsePlatformList(final Iterable values) { + final out = {}; + for (final value in values) { + for (final part in value.split(',')) { + final trimmed = part.trim().toLowerCase(); + if (trimmed.isNotEmpty) { + out.add(trimmed); + } + } + } + return out.toList()..sort(); +} + +String? readPackageName(final String projectRoot) { + final pubspec = File(p.join(projectRoot, 'pubspec.yaml')); + if (!pubspec.existsSync()) { + return null; + } + final match = RegExp( + r'^name:\s*(\S+)', + multiLine: true, + ).firstMatch(pubspec.readAsStringSync()); + return match?.group(1); +} diff --git a/packages/intentcall_cli/pubspec.yaml b/packages/intentcall_cli/pubspec.yaml new file mode 100644 index 0000000..6f3105e --- /dev/null +++ b/packages/intentcall_cli/pubspec.yaml @@ -0,0 +1,34 @@ +name: intentcall_cli +description: PRE-RELEASE — Framework-neutral IntentCall CLI for manifest export and platform sync. +version: 0.6.0 +license: MIT +repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_cli +issue_tracker: https://github.com/Arenukvern/intentcall/issues +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_cli +topics: + - mcp + - agents + - cli + +environment: + sdk: ">=3.12.0 <4.0.0" +resolution: workspace + +executables: + intentcall: + +dependencies: + args: ^2.7.0 + dart_mcp: ^0.5.0 + intentcall_core: ^0.6.0 + intentcall_mcp: ^0.6.0 + intentcall_platform_sync: ^0.6.0 + intentcall_schema: ^0.6.0 + meta: ^1.17.0 + path: ^1.9.1 + yaml: ^3.1.3 + +dev_dependencies: + lints: ^6.1.0 + test: ^1.31.1 + xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_cli/test/command_runner_test.dart b/packages/intentcall_cli/test/command_runner_test.dart new file mode 100644 index 0000000..7e716b1 --- /dev/null +++ b/packages/intentcall_cli/test/command_runner_test.dart @@ -0,0 +1,169 @@ +import 'dart:io'; + +import 'package:intentcall_cli/src/catalog/catalog_loader.dart'; +import 'package:intentcall_cli/src/command_runner.dart'; +import 'package:intentcall_cli/src/config/intentcall_config.dart'; +import 'package:intentcall_cli/src/utils/cli_utils.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + late Directory flutterFixture; + late Directory jasprFixture; + + setUpAll(() async { + flutterFixture = _fixtureRoot('flutter_project'); + jasprFixture = _fixtureRoot('jaspr_web_project'); + await _prepareFixture(flutterFixture.path); + await _prepareFixture(jasprFixture.path); + }); + + group('IntentCallCommandRunner', () { + test('prints usage for missing command', () async { + final runner = IntentCallCommandRunner(); + expect(await runner.run([]) ?? 64, 64); + }); + + test('config show reads intentcall.yaml', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'config', + 'show', + '--json', + '--project-dir', + flutterFixture.path, + ]); + expect(exitCode, 0); + }); + + test('manifest validate accepts fixture manifest', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'manifest', + 'validate', + '--project-dir', + flutterFixture.path, + ]); + expect(exitCode, 0); + }); + + test('manifest export --check passes for synced fixture manifest', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'manifest', + 'export', + '--check', + '--project-dir', + flutterFixture.path, + ]); + expect(exitCode, 0); + }); + + test('platform sync --check passes for flutter fixture', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'platform', + 'sync', + '--platform', + 'web', + '--check', + '--project-dir', + flutterFixture.path, + ]); + expect(exitCode, 0); + }); + + test('platform sync --check passes for jaspr fixture', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'platform', + 'sync', + '--platform', + 'web', + '--check', + '--project-dir', + jasprFixture.path, + ]); + expect(exitCode, 0); + }); + + test('codegen sync aliases platform sync', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'codegen', + 'sync', + '--platform', + 'web', + '--check', + '--project-dir', + jasprFixture.path, + ]); + expect(exitCode, 0); + }); + + test('platform hooks print emits android snippet', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'platform', + 'hooks', + 'print', + '--platform', + 'android', + ]); + expect(exitCode, 0); + }); + }); + + group('IntentCallConfig', () { + test('parses host and layout fields', () { + final file = File(p.join(flutterFixture.path, 'intentcall.yaml')); + final config = IntentCallConfig.parse(file.readAsStringSync()); + expect(config.host, IntentCallHost.flutter); + expect(config.protocolScheme, 'demoapp'); + expect(config.layout.manifest, 'web/agent_manifest.json'); + }); + }); +} + +Directory _fixtureRoot(final String name) { + final candidates = [ + p.join(Directory.current.path, 'test', 'fixtures', name), + p.join(Directory.current.path, 'packages/intentcall_cli/test/fixtures', name), + ]; + for (final candidate in candidates) { + final root = Directory(candidate); + if (root.existsSync()) { + return root; + } + } + throw StateError('Missing fixture directory for $name'); +} + +Future _prepareFixture(final String projectRoot) async { + _ensureSyncedWebArtifacts(projectRoot); + await _normalizeManifest(projectRoot); +} + +void _ensureSyncedWebArtifacts(final String projectRoot) { + const sync = PlatformSync(); + sync.syncWeb(projectRoot: projectRoot); +} + +Future _normalizeManifest(final String projectRoot) async { + final config = loadIntentCallConfig(projectRoot); + final outPath = resolveManifestOutput(projectRoot, config: config); + final merger = ManifestMerger(); + final policy = merger.loadProjectionPolicy(projectRoot: projectRoot); + final catalog = await const CatalogLoader().load( + projectRoot: projectRoot, + manifestOut: outPath, + ); + final manifest = merger.mergeManifest( + catalog: catalog, + policy: policy, + protocolScheme: config?.protocolScheme ?? merger.readProtocolScheme(projectRoot), + platform: config?.host == IntentCallHost.jaspr ? 'web' : 'unified', + ); + outPath.writeAsStringSync(merger.encodeManifest(manifest)); +} diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/intentcall.yaml b/packages/intentcall_cli/test/fixtures/flutter_project/intentcall.yaml new file mode 100644 index 0000000..ba7b799 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/intentcall.yaml @@ -0,0 +1,13 @@ +host: flutter +protocolScheme: demoapp +layout: + manifest: web/agent_manifest.json + webDir: web +platforms: + enabled: + - web +defaults: + dispatchMode: openApp + surfaces: + webMcp: true + webManifestShortcuts: true diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json new file mode 100644 index 0000000..9f2eed9 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "platform": "unified", + "tools": [ + { + "qualifiedName": "app_cart_total", + "namespace": "app", + "name": "cart_total", + "description": "Return cart total", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "currency": { + "type": "string" + } + } + } + } + ], + "protocolScheme": "demoapp" +} diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js new file mode 100644 index 0000000..c5338fd --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js @@ -0,0 +1,214 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 'use strict'; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' + ? doc.modelContext + : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + "name": "app_cart_total", + "description": "Return cart total", + "inputSchema": { + "type": "object", + "properties": { + "currency": { + "type": "string" + } + } + } + } +]; + + function validationError(message) { + return { ok: false, code: 'validation_error', message: message }; + } + + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError(path + ' must be at least ' + schema.minimum + '.'); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + ' must be at most ' + schema.maximum + '.'); + } + return null; + } + + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case 'string': + if (typeof value !== 'string') return validationError(path + ' must be a string.'); + return null; + case 'integer': + if (typeof value !== 'number' || value % 1 !== 0) { + return validationError(path + ' must be an integer.'); + } + return validateNumericBounds(path, schema, value); + case 'number': + if (typeof value !== 'number') return validationError(path + ' must be a number.'); + return validateNumericBounds(path, schema, value); + case 'boolean': + if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); + return null; + case 'object': + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return validationError(path + ' must be an object.'); + } + return null; + case 'array': + if (!Array.isArray(value)) return validationError(path + ' must be an array.'); + var arrayPath = path; + if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && + arrayPath.charAt(arrayPath.length - 1) === '"') { + arrayPath = arrayPath.slice(1, -1); + } + return validateArrayItems(arrayPath, schema, value); + default: + return null; + } + } + + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Unknown property "' + key + '"' + at + '.'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Missing required property "' + name + '"' + atReq + '.'); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; + var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); + if (propErr) return propErr; + } + return null; + } + + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== 'object' || Array.isArray(items)) return null; + if (items.type !== 'object') return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + '[' + i + ']'; + if (typeof element !== 'object' || element === null || Array.isArray(element)) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } + + function validateInput(schema, args) { + if (!schema || schema.type !== 'object') return null; + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, reqKey) || + args[reqKey] === undefined || args[reqKey] === null) { + return validationError('Missing required property: ' + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } + + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: 'runtime_unavailable', + message: 'No Dart WebMCP runtime registered for ' + name + '.', + }); + } + return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args || {}), + }).then(function (response) { + return response.json(); + }); + } + + tools.forEach(function (tool) { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: function (args) { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === 'function') { + return Promise.resolve(dart(tool.name, args || {})).then(function (result) { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== 'undefined' ? globalThis : window); diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/web/manifest.json b/packages/intentcall_cli/test/fixtures/flutter_project/web/manifest.json new file mode 100644 index 0000000..d819b89 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "demo_flutter", + "start_url": ".", + "shortcuts": [ + { + "name": "Cart Total", + "short_name": "Cart Total", + "description": "Return cart total", + "url": "/agent/invoke?name=app_cart_total" + } + ], + "protocol_handlers": [ + { + "protocol": "web+intentcall", + "url": "/agent/invoke?protocol=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_cart_total&payload=%s" + } + ] +} diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/intentcall.yaml b/packages/intentcall_cli/test/fixtures/jaspr_web_project/intentcall.yaml new file mode 100644 index 0000000..8b13326 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/intentcall.yaml @@ -0,0 +1,9 @@ +host: jaspr +protocolScheme: jasprdemo +layout: + manifest: web/agent_manifest.json +platforms: + enabled: + - web +defaults: + dispatchMode: openApp diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json new file mode 100644 index 0000000..9e9b5bc --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "shop_search", + "namespace": "shop", + "name": "search", + "description": "Search catalog", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ] + } + } + ], + "protocolScheme": "jasprdemo" +} diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js new file mode 100644 index 0000000..9d8ea15 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js @@ -0,0 +1,217 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 'use strict'; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' + ? doc.modelContext + : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + "name": "shop_search", + "description": "Search catalog", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ] + } + } +]; + + function validationError(message) { + return { ok: false, code: 'validation_error', message: message }; + } + + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError(path + ' must be at least ' + schema.minimum + '.'); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + ' must be at most ' + schema.maximum + '.'); + } + return null; + } + + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case 'string': + if (typeof value !== 'string') return validationError(path + ' must be a string.'); + return null; + case 'integer': + if (typeof value !== 'number' || value % 1 !== 0) { + return validationError(path + ' must be an integer.'); + } + return validateNumericBounds(path, schema, value); + case 'number': + if (typeof value !== 'number') return validationError(path + ' must be a number.'); + return validateNumericBounds(path, schema, value); + case 'boolean': + if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); + return null; + case 'object': + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return validationError(path + ' must be an object.'); + } + return null; + case 'array': + if (!Array.isArray(value)) return validationError(path + ' must be an array.'); + var arrayPath = path; + if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && + arrayPath.charAt(arrayPath.length - 1) === '"') { + arrayPath = arrayPath.slice(1, -1); + } + return validateArrayItems(arrayPath, schema, value); + default: + return null; + } + } + + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Unknown property "' + key + '"' + at + '.'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Missing required property "' + name + '"' + atReq + '.'); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; + var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); + if (propErr) return propErr; + } + return null; + } + + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== 'object' || Array.isArray(items)) return null; + if (items.type !== 'object') return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + '[' + i + ']'; + if (typeof element !== 'object' || element === null || Array.isArray(element)) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } + + function validateInput(schema, args) { + if (!schema || schema.type !== 'object') return null; + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, reqKey) || + args[reqKey] === undefined || args[reqKey] === null) { + return validationError('Missing required property: ' + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } + + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: 'runtime_unavailable', + message: 'No Dart WebMCP runtime registered for ' + name + '.', + }); + } + return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args || {}), + }).then(function (response) { + return response.json(); + }); + } + + tools.forEach(function (tool) { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: function (args) { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === 'function') { + return Promise.resolve(dart(tool.name, args || {})).then(function (result) { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== 'undefined' ? globalThis : window); diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/manifest.json b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/manifest.json new file mode 100644 index 0000000..f641b8c --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "demo_jaspr", + "start_url": ".", + "shortcuts": [ + { + "name": "Search", + "short_name": "Search", + "description": "Search catalog", + "url": "/agent/invoke?name=shop_search" + } + ], + "protocol_handlers": [ + { + "protocol": "web+intentcall", + "url": "/agent/invoke?protocol=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=shop_search&payload=%s" + } + ] +} diff --git a/packages/intentcall_cli/test/manifest_registry_parity_test.dart b/packages/intentcall_cli/test/manifest_registry_parity_test.dart new file mode 100644 index 0000000..36a2a93 --- /dev/null +++ b/packages/intentcall_cli/test/manifest_registry_parity_test.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +String fixtureRoot() { + final candidates = [ + p.join('packages', 'intentcall_cli', 'test', 'fixtures', 'flutter_project'), + p.join('test', 'fixtures', 'flutter_project'), + ]; + for (final candidate in candidates) { + if (File(p.join(candidate, 'web', 'agent_manifest.json')).existsSync()) { + return candidate; + } + } + throw StateError( + 'flutter_project fixture not found from ${Directory.current.path}', + ); +} + +void main() { + test('manifest tools are subset of fixture registry descriptors', () { + final fixtureRootPath = fixtureRoot(); + final manifestFile = File( + p.join(fixtureRootPath, 'web', 'agent_manifest.json'), + ); + final manifest = AgentManifest.parse(manifestFile.readAsStringSync()); + final registry = InMemoryAgentRegistry(); + registry.register( + AgentCallEntry.tool( + namespace: 'app', + name: 'cart_total', + description: 'Return cart total', + inputSchema: const {'type': 'object'}, + handler: (_) async => AgentResult.success(), + ).toRegistration(), + ); + + final registryKeys = registry.listEntries().map((final e) => e.key).toSet(); + for (final entry in manifest.tools) { + expect( + registryKeys.contains(entry.qualifiedName), + isTrue, + reason: 'manifest tool ${entry.qualifiedName} missing from registry', + ); + } + }); + + test('manifest export --check fixture is valid JSON manifest', () { + final fixtureRootPath = fixtureRoot(); + final manifestFile = File( + p.join(fixtureRootPath, 'web', 'agent_manifest.json'), + ); + final json = + jsonDecode(manifestFile.readAsStringSync()) as Map; + expect(json['version'], 1); + expect( + AgentManifest.parse(manifestFile.readAsStringSync()).tools, + isNotEmpty, + ); + }); +} diff --git a/packages/intentcall_codegen/build.yaml b/packages/intentcall_codegen/build.yaml index 594b5fa..1d702d6 100644 --- a/packages/intentcall_codegen/build.yaml +++ b/packages/intentcall_codegen/build.yaml @@ -4,6 +4,13 @@ targets: intentcall_codegen|agent_tool: generate_for: - example/** + intentcall_codegen|agent_catalog: + generate_for: + - lib/** + - example/** + intentcall_codegen|agent_manifest: + generate_for: + - $package$ builders: agent_tool: @@ -12,3 +19,15 @@ builders: build_extensions: {".dart": [".g.dart"]} auto_apply: dependents build_to: source + agent_catalog: + import: "package:intentcall_codegen/builder.dart" + builder_factories: ["agentCatalogBuilder"] + build_extensions: {r'$lib$': ['generated/agent_catalog.g.dart']} + auto_apply: dependents + build_to: source + agent_manifest: + import: "package:intentcall_codegen/builder.dart" + builder_factories: ["agentManifestBuilder"] + build_extensions: {r'$package$': ['web/agent_manifest.json']} + auto_apply: dependents + build_to: source diff --git a/packages/intentcall_codegen/intentcall.yaml b/packages/intentcall_codegen/intentcall.yaml new file mode 100644 index 0000000..57346c7 --- /dev/null +++ b/packages/intentcall_codegen/intentcall.yaml @@ -0,0 +1,10 @@ +host: dart +protocolScheme: intentcallcodegen +layout: + manifest: web/agent_manifest.json + webDir: web +platforms: + enabled: + - web +defaults: + dispatchMode: openApp diff --git a/packages/intentcall_codegen/lib/builder.dart b/packages/intentcall_codegen/lib/builder.dart index 403be6a..d57e4ec 100644 --- a/packages/intentcall_codegen/lib/builder.dart +++ b/packages/intentcall_codegen/lib/builder.dart @@ -1,6 +1,8 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; +import 'src/generators/agent_catalog_generator.dart'; +import 'src/generators/agent_manifest_generator.dart'; import 'src/generators/agent_tool_generator.dart'; /// build_runner builder for `@AgentTool` → `.g.dart` registration factories. @@ -12,3 +14,11 @@ Builder agentToolBuilder(final BuilderOptions options) => PartBuilder( // ignore_for_file: type=lint ''', ); + +/// Aggregates all `@AgentTool` registrations into `lib/generated/agent_catalog.g.dart`. +Builder agentCatalogBuilder(final BuilderOptions options) => + AgentCatalogGenerator(); + +/// Writes `web/agent_manifest.json` from catalog + `@AgentProjection` metadata. +Builder agentManifestBuilder(final BuilderOptions options) => + AgentManifestAssetGenerator(options); diff --git a/packages/intentcall_codegen/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/lib/generated/agent_catalog.g.dart new file mode 100644 index 0000000..8a4d876 --- /dev/null +++ b/packages/intentcall_codegen/lib/generated/agent_catalog.g.dart @@ -0,0 +1,11 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import '../../example/demo_ping_tool.dart'; + +const List agentCatalogEntries = + [ + AgentRegistryCatalogEntry(registryKey: 'app_demo_ping', entry: demoPingCallEntry), + AgentRegistryCatalogEntry(registryKey: 'app_demo_cart', entry: demoCartCallEntry), + AgentRegistryCatalogEntry(registryKey: 'app_demo_required_named', entry: demoRequiredNamedCallEntry), +]; diff --git a/packages/intentcall_codegen/lib/intentcall_codegen.dart b/packages/intentcall_codegen/lib/intentcall_codegen.dart index af836ef..a104068 100644 --- a/packages/intentcall_codegen/lib/intentcall_codegen.dart +++ b/packages/intentcall_codegen/lib/intentcall_codegen.dart @@ -1,4 +1,5 @@ library; export 'src/agent_param.dart'; +export 'src/agent_projection.dart'; export 'src/agent_tool.dart'; diff --git a/packages/intentcall_codegen/lib/src/agent_projection.dart b/packages/intentcall_codegen/lib/src/agent_projection.dart new file mode 100644 index 0000000..7da50ac --- /dev/null +++ b/packages/intentcall_codegen/lib/src/agent_projection.dart @@ -0,0 +1,15 @@ +/// Platform projection hints for generated [agent_manifest.json] rows. +/// +/// Types resolve through [intentcall_platform_sync] at build time. +class AgentProjection { + const AgentProjection({ + this.dispatchMode = 'openApp', + this.surfaces = const {}, + }); + + /// `openApp`, `inlineRuntime`, or `queueOnly`. + final String dispatchMode; + + /// Surface keys such as `web.webMcp`, `apple.appShortcuts`. + final Map surfaces; +} diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart new file mode 100644 index 0000000..ec69b60 --- /dev/null +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -0,0 +1,112 @@ +import 'package:build/build.dart'; +import 'package:glob/glob.dart'; +import 'package:path/path.dart' as p; +import 'package:source_gen/source_gen.dart'; + +import '../agent_tool.dart'; + +/// Emits `lib/generated/agent_catalog.g.dart` listing all tool call entries. +class AgentCatalogGenerator implements Builder { + static const _toolChecker = TypeChecker.typeNamed(AgentTool); + + @override + final Map> buildExtensions = const { + r'$lib$': ['generated/agent_catalog.g.dart'], + }; + + @override + Future build(final BuildStep buildStep) async { + final outputPath = buildStep.allowedOutputs.single.path; + final imports = {}; + final entries = []; + + await for (final input in _toolSources(buildStep)) { + final library = await buildStep.resolver.libraryFor(input); + final fileEntries = []; + + for (final element in library.topLevelFunctions) { + for (final annotation in element.metadata.annotations) { + final reader = ConstantReader(annotation.computeConstantValue()); + if (!reader.isNull && reader.instanceOf(_toolChecker)) { + final namespace = reader.read('namespace').stringValue; + final name = reader.read('name').stringValue; + final registryKey = '${namespace}_$name'; + final entryGetter = '${_toCamelCase(name)}CallEntry'; + fileEntries.add( + " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryGetter),", + ); + } + } + } + + if (fileEntries.isEmpty) { + continue; + } + + final relative = p + .relative(input.path, from: p.dirname(outputPath)) + .replaceAll(r'\', '/'); + imports.add("import '$relative';"); + entries.addAll(fileEntries); + } + + final header = + ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +${imports.join('\n')} +'''; + + if (entries.isEmpty) { + await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' +$header +/// Empty catalog — add @AgentTool functions under lib/ or example/. +const List agentCatalogEntries = + []; +'''); + return; + } + + await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' +$header +const List agentCatalogEntries = + [ +${entries.join('\n')} +]; +'''); + } + + Stream _toolSources(final BuildStep buildStep) async* { + for (final pattern in ['lib/**.dart', 'example/**.dart']) { + await for (final input in buildStep.findAssets(Glob(pattern))) { + if (_isInternalSource(input.path)) { + continue; + } + yield input; + } + } + } + + bool _isInternalSource(final String path) => + path.contains('.g.dart') || + path.contains('generated/') || + path.contains('lib/src/') || + path.endsWith('lib/builder.dart') || + path.endsWith('lib/intentcall_codegen.dart'); + + String _toCamelCase(final String value) { + if (value.isEmpty) { + return value; + } + final parts = value.split('_'); + final first = parts.first; + final rest = parts.skip(1).map((final part) { + if (part.isEmpty) { + return part; + } + return part[0].toUpperCase() + part.substring(1); + }); + return first + rest.join(); + } +} diff --git a/packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart new file mode 100644 index 0000000..c8d5531 --- /dev/null +++ b/packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart @@ -0,0 +1,136 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:build/build.dart'; +import 'package:glob/glob.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:source_gen/source_gen.dart'; + +import '../agent_projection.dart'; +import '../agent_tool.dart'; +import 'agent_tool_generator.dart'; + +/// Writes `web/agent_manifest.json` from `@AgentTool` and `@AgentProjection`. +class AgentManifestAssetGenerator implements Builder { + AgentManifestAssetGenerator(this.options); + + final BuilderOptions options; + + static const _toolChecker = TypeChecker.typeNamed(AgentTool); + static const _projectionChecker = TypeChecker.typeNamed(AgentProjection); + + @override + Map> get buildExtensions => { + r'$package$': [ + options.config['manifest_output'] as String? ?? 'web/agent_manifest.json', + ], + }; + + @override + Future build(final BuildStep buildStep) async { + final catalog = []; + final overlays = {}; + final toolGen = AgentToolGenerator(); + + await for (final input in _toolSources(buildStep)) { + final library = await buildStep.resolver.libraryFor(input); + for (final element in library.topLevelFunctions) { + ConstantReader? toolReader; + for (final annotation in element.metadata.annotations) { + final reader = ConstantReader(annotation.computeConstantValue()); + if (!reader.isNull && reader.instanceOf(_toolChecker)) { + toolReader = reader; + } + if (!reader.isNull && reader.instanceOf(_projectionChecker)) { + final namespace = _toolNamespace(element, toolReader); + final name = _toolName(element, toolReader); + if (namespace != null && name != null) { + final qualified = '${namespace}_$name'; + overlays[qualified] = _entryProjectionFromReader(reader); + } + } + } + if (toolReader == null) { + continue; + } + final namespace = toolReader.read('namespace').stringValue; + final name = toolReader.read('name').stringValue; + final description = toolReader.read('description').stringValue; + final schema = toolGen.inputSchemaMapFor(element); + catalog.add( + AgentRegistryCatalogEntry( + registryKey: '${namespace}_$name', + descriptor: AgentIntentDescriptor( + namespace: namespace, + name: name, + description: description, + kind: AgentIntentKind.tool, + inputSchema: schema, + ), + ), + ); + } + } + + const merger = ManifestMerger(); + final policy = ProjectionPolicy(overlays: overlays); + final protocolScheme = options.config['protocol_scheme'] as String?; + final manifest = merger.mergeManifest( + catalog: catalog, + policy: policy, + protocolScheme: protocolScheme, + ); + await buildStep.writeAsString( + buildStep.allowedOutputs.single, + merger.encodeManifest(manifest), + ); + } + + Stream _toolSources(final BuildStep buildStep) async* { + for (final pattern in ['lib/**.dart', 'example/**.dart']) { + await for (final input in buildStep.findAssets(Glob(pattern))) { + if (_isInternalSource(input.path)) { + continue; + } + yield input; + } + } + } + + bool _isInternalSource(final String path) => + path.contains('.g.dart') || + path.contains('generated/') || + path.contains('lib/src/') || + path.endsWith('lib/builder.dart') || + path.endsWith('lib/intentcall_codegen.dart'); + + String? _toolNamespace( + final TopLevelFunctionElement element, + final ConstantReader? toolReader, + ) => toolReader?.read('namespace').stringValue; + + String? _toolName( + final TopLevelFunctionElement element, + final ConstantReader? toolReader, + ) => toolReader?.read('name').stringValue; + + EntryProjection _entryProjectionFromReader(final ConstantReader reader) { + final dispatchName = reader.read('dispatchMode').stringValue; + final surfacesRaw = reader.read('surfaces').mapValue; + final surfaces = {}; + for (final entry in surfacesRaw.entries) { + final key = entry.key?.toStringValue(); + final value = entry.value?.toBoolValue(); + if (key == null || value == null) { + continue; + } + final surface = lookupAgentManifestSurface(key); + if (surface != null) { + surfaces[surface] = value; + } + } + return EntryProjection( + dispatchMode: AgentManifestDispatchMode.values.byName(dispatchName), + surfaces: surfaces, + ); + } +} diff --git a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart index 2080b27..2bf7ef9 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart @@ -41,7 +41,8 @@ class AgentToolGenerator extends GeneratorForAnnotation { final registrationGetter = '${_toCamelCase(name)}Registration'; final entryGetter = '${_toCamelCase(name)}CallEntry'; - final schema = _buildInputSchema(element); + final schemaMap = inputSchemaMapFor(element); + final schema = _formatInputSchema(schemaMap); final handlerArgs = _buildHandlerArgs(element); return ''' @@ -74,8 +75,11 @@ $handlerArgs return inner.getDisplayString() == 'AgentResult'; } - String _buildInputSchema(final TopLevelFunctionElement element) { - final properties = []; + /// Builds JSON-schema-shaped input metadata for manifest and registration. + Map inputSchemaMapFor( + final TopLevelFunctionElement element, + ) { + final properties = {}; final required = []; for (final param in element.formalParameters) { @@ -102,24 +106,40 @@ $handlerArgs element: param, ); } - properties.add(''' - ${_literalString(paramName)}: { - 'type': ${_literalString(jsonType)}, - 'description': ${_literalString(description)}, - },'''); + properties[paramName] = { + 'type': jsonType, + 'description': description, + }; if (isRequired) { - required.add(_literalString(paramName)); + required.add(paramName); } } + return { + 'type': 'object', + 'properties': properties, + 'required': required, + }; + } + + String _formatInputSchema(final Map schema) { + final properties = schema['properties']! as Map; + final required = (schema['required']! as List).cast(); + final propertyLines = properties.entries + .map( + (final entry) => + " ${_literalString(entry.key)}: {'type': ${_literalString((entry.value! as Map)['type'] as String)}, 'description': ${_literalString((entry.value! as Map)['description'] as String)}},", + ) + .join('\n'); + final requiredLines = required.map(_literalString).join(', '); return ''' { 'type': 'object', 'properties': { -${properties.join('\n')} +$propertyLines }, - 'required': [${required.join(', ')}], + 'required': [$requiredLines], }'''; } diff --git a/packages/intentcall_codegen/pubspec.yaml b/packages/intentcall_codegen/pubspec.yaml index ab0f2bf..8e63526 100644 --- a/packages/intentcall_codegen/pubspec.yaml +++ b/packages/intentcall_codegen/pubspec.yaml @@ -12,16 +12,17 @@ topics: - build-runner environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: analyzer: ^8.0.0 build: ^4.0.0 intentcall_core: ^0.6.0 - intentcall_schema: ^0.6.0 - meta: ^1.17.0 + intentcall_platform_sync: ^0.6.0 + path: ^1.9.1 source_gen: ^4.0.0 + glob: ^2.1.3 dev_dependencies: build_runner: ^2.15.0 diff --git a/packages/intentcall_codegen/web/agent_manifest.json b/packages/intentcall_codegen/web/agent_manifest.json new file mode 100644 index 0000000..7a6560b --- /dev/null +++ b/packages/intentcall_codegen/web/agent_manifest.json @@ -0,0 +1,149 @@ +{ + "version": 1, + "platform": "unified", + "tools": [ + { + "qualifiedName": "app_demo_ping", + "namespace": "app", + "name": "demo_ping", + "description": "Returns pong for a message", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to echo" + } + }, + "required": [ + "message" + ] + } + }, + { + "qualifiedName": "app_demo_cart", + "namespace": "app", + "name": "demo_cart", + "description": "Returns a cart total", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "description": "Currency code" + }, + "includeTax": { + "type": "boolean", + "description": "Include tax" + } + }, + "required": [ + "currency" + ] + } + }, + { + "qualifiedName": "app_demo_required_named", + "namespace": "app", + "name": "demo_required_named", + "description": "Returns a required named parameter", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "Mode" + } + }, + "required": [ + "mode" + ] + } + } + ], + "protocolScheme": "intentcallcodegen" +} diff --git a/packages/intentcall_core/pubspec.yaml b/packages/intentcall_core/pubspec.yaml index 34550f0..2e30e40 100644 --- a/packages/intentcall_core/pubspec.yaml +++ b/packages/intentcall_core/pubspec.yaml @@ -11,7 +11,7 @@ topics: - dart environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_gemma/pubspec.yaml b/packages/intentcall_gemma/pubspec.yaml index 48d75a8..bd4a940 100644 --- a/packages/intentcall_gemma/pubspec.yaml +++ b/packages/intentcall_gemma/pubspec.yaml @@ -12,7 +12,7 @@ topics: - agents environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_mcp/pubspec.yaml b/packages/intentcall_mcp/pubspec.yaml index 3c82bb0..e4ed62b 100644 --- a/packages/intentcall_mcp/pubspec.yaml +++ b/packages/intentcall_mcp/pubspec.yaml @@ -11,7 +11,7 @@ topics: - dart environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_platform/README.md b/packages/intentcall_platform/README.md index 4331a74..015d2de 100644 --- a/packages/intentcall_platform/README.md +++ b/packages/intentcall_platform/README.md @@ -257,23 +257,25 @@ wrap that API for their own product workflow. For example, Flutter MCP Toolkit consumers can run: ```bash -flutter-mcp-toolkit codegen sync \ +intentcall platform sync \ --platform web,android,ios,macos,linux,windows \ --project-dir ``` -Use the same command with `--check` in CI. `--check` reports whether any -generated artifact, native project membership, or Apple URL-scheme plist -configuration would change without writing files. +Use the same command with `--check` in CI. -### One-time hooks +Flutter MCP Toolkit consumers may delegate: -Flutter MCP Toolkit consumer example: +```bash +flutter-mcp-toolkit codegen sync --platform web,ios,macos --project-dir +``` + +### One-time hooks ```bash -flutter-mcp-toolkit init intentcall-platform --project-dir +intentcall platform hooks init --host flutter --project-dir ``` -### Future +### Manifest generation -Registry-backed `generateWebAgentManifest` is deferred — edit `agent_manifest.json`, then `codegen sync`. +Run `dart run build_runner build`, then `intentcall manifest export --check`. Do not hand-edit descriptor rows in `agent_manifest.json`. diff --git a/packages/intentcall_platform/analysis_options.yaml b/packages/intentcall_platform/analysis_options.yaml index 1226f52..1c3da69 100644 --- a/packages/intentcall_platform/analysis_options.yaml +++ b/packages/intentcall_platform/analysis_options.yaml @@ -1,5 +1,5 @@ # Flutter plugin — app-level lint set. -include: package:xsoulspace_lints/app.yaml +include: package:xsoulspace_lints/library.yaml analyzer: language: diff --git a/packages/intentcall_platform/lib/intentcall_platform.dart b/packages/intentcall_platform/lib/intentcall_platform.dart index 680b6df..602cef0 100644 --- a/packages/intentcall_platform/lib/intentcall_platform.dart +++ b/packages/intentcall_platform/lib/intentcall_platform.dart @@ -1,17 +1,3 @@ library; -export 'src/agent_manifest.dart'; -export 'src/agent_manifest_generator.dart'; -export 'src/bootstrap/agent_web_mcp_bootstrap.dart'; -export 'src/emitters/android_shortcuts_xml_emitter.dart'; -export 'src/emitters/apple_app_intents_testing_emitter.dart'; -export 'src/emitters/apple_dart_extension_inline_emitter.dart'; -export 'src/emitters/apple_swift_app_intents_emitter.dart'; -export 'src/emitters/linux_desktop_entry_emitter.dart'; -export 'src/emitters/web_manifest_emitter.dart'; -export 'src/emitters/web_mcp_js_emitter.dart'; -export 'src/emitters/windows_protocol_emitter.dart'; -export 'src/init/platform_hooks_init.dart'; -export 'src/invocation/intentcall_invocation.dart'; -export 'src/sync/platform_sync.dart'; -export 'src/templates/platform_hook_templates.dart'; +export 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart index d182c58..c8e94c2 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; -import '../bootstrap/agent_web_mcp_bootstrap.dart'; -import '../invocation/intentcall_invocation.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + import 'intentcall_host_events.dart'; import 'intentcall_invoke_link_stub.dart' if (dart.library.ui) 'intentcall_invoke_link.dart'; diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart index da55685..7a288a3 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart @@ -1,6 +1,6 @@ import 'package:intentcall_schema/intentcall_schema.dart'; -import '../invocation/intentcall_invocation.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; /// Reason a pending native invocation drain was requested. enum IntentCallDrainTrigger { start, resume, deepLink, manual } diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart index 620caf1..13510ff 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart @@ -1,6 +1,6 @@ import 'package:flutter/services.dart'; -import '../invocation/intentcall_invocation.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; final class IntentCallPendingInvocations { const IntentCallPendingInvocations({ diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations_stub.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations_stub.dart index 3db0363..59d1b1d 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations_stub.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations_stub.dart @@ -1,4 +1,4 @@ -import '../invocation/intentcall_invocation.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; /// VM-safe fallback for host tests and non-Flutter analysis. final class IntentCallPendingInvocations { diff --git a/packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart b/packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart deleted file mode 100644 index cfab9d2..0000000 --- a/packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart +++ /dev/null @@ -1,36 +0,0 @@ -/// Gradle `preBuild` hook — inject into `android/app/build.gradle.kts` once. -const kAndroidGradleCodegenHook = ''' -// intentcall-platform: begin -tasks.named("preBuild").configure { - doFirst { - exec { - workingDir = rootProject.layout.projectDirectory.dir("../../").asFile - commandLine( - "flutter-mcp-toolkit", - "codegen", - "sync", - "--platform", - "android", - ) - } - } -} -// intentcall-platform: end -'''; - -/// Xcode Run Script build phase — add to iOS/macOS target once. -/// -/// The sync command writes generated Swift and maintains target membership. -const kAppleXcodeCodegenRunScript = r''' -# intentcall-platform: begin -cd "${SRCROOT}/.." -flutter-mcp-toolkit codegen sync --platform ios,macos || exit 1 -# intentcall-platform: end -'''; - -/// Documents where hook templates live for `init intentcall-platform`. -const kPlatformHookTemplatePaths = { - 'android': 'intentcall_platform Gradle hook (kAndroidGradleCodegenHook)', - 'ios': 'intentcall_platform Xcode Run Script (kAppleXcodeCodegenRunScript)', - 'macos': 'intentcall_platform Xcode Run Script (kAppleXcodeCodegenRunScript)', -}; diff --git a/packages/intentcall_platform/pubspec.yaml b/packages/intentcall_platform/pubspec.yaml index 7ce5ad5..0478342 100644 --- a/packages/intentcall_platform/pubspec.yaml +++ b/packages/intentcall_platform/pubspec.yaml @@ -11,7 +11,7 @@ topics: - agents environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" flutter: ">=3.24.0" resolution: workspace @@ -20,8 +20,9 @@ dependencies: flutter: sdk: flutter intentcall_core: ^0.6.0 + intentcall_platform_sync: ^0.6.0 intentcall_schema: ^0.6.0 - meta: ^1.17.0 + meta: ^1.18.0 path: ^1.9.1 web: ^1.1.1 diff --git a/packages/intentcall_platform_sync/CHANGELOG.md b/packages/intentcall_platform_sync/CHANGELOG.md new file mode 100644 index 0000000..8deb76e --- /dev/null +++ b/packages/intentcall_platform_sync/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Unreleased \ No newline at end of file diff --git a/packages/intentcall_platform_sync/LICENSE b/packages/intentcall_platform_sync/LICENSE new file mode 100644 index 0000000..ec57a7f --- /dev/null +++ b/packages/intentcall_platform_sync/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anton Malofeev (Arenukvern) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/intentcall_platform_sync/analysis_options.yaml b/packages/intentcall_platform_sync/analysis_options.yaml new file mode 100644 index 0000000..1c3da69 --- /dev/null +++ b/packages/intentcall_platform_sync/analysis_options.yaml @@ -0,0 +1,13 @@ +# Flutter plugin — app-level lint set. +include: package:xsoulspace_lints/library.yaml + +analyzer: + language: + strict-casts: true + errors: + always_use_package_imports: ignore + lines_longer_than_80_chars: ignore + public_member_api_docs: ignore + unnecessary_library_directive: ignore + exclude: + - "**/*.g.dart" diff --git a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart new file mode 100644 index 0000000..30ece70 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart @@ -0,0 +1,20 @@ +library; + +export 'src/agent_manifest.dart'; +export 'src/agent_manifest_generator.dart'; +export 'src/bootstrap/agent_web_mcp_bootstrap.dart'; +export 'src/catalog/agent_registry_catalog.dart'; +export 'src/emitters/android_shortcuts_xml_emitter.dart'; +export 'src/emitters/apple_app_intents_testing_emitter.dart'; +export 'src/emitters/apple_dart_extension_inline_emitter.dart'; +export 'src/emitters/apple_swift_app_intents_emitter.dart'; +export 'src/emitters/linux_desktop_entry_emitter.dart'; +export 'src/emitters/web_manifest_emitter.dart'; +export 'src/emitters/web_mcp_js_emitter.dart'; +export 'src/emitters/windows_protocol_emitter.dart'; +export 'src/init/platform_hooks_init.dart'; +export 'src/invocation/intentcall_invocation.dart'; +export 'src/projection/manifest_merger.dart'; +export 'src/projection/projection_policy.dart'; +export 'src/sync/platform_sync.dart'; +export 'src/templates/platform_hook_templates.dart'; diff --git a/packages/intentcall_platform/lib/src/agent_manifest.dart b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart similarity index 96% rename from packages/intentcall_platform/lib/src/agent_manifest.dart rename to packages/intentcall_platform_sync/lib/src/agent_manifest.dart index 42b8bd7..98d8395 100644 --- a/packages/intentcall_platform/lib/src/agent_manifest.dart +++ b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart @@ -379,6 +379,20 @@ final class AgentManifest { Iterable get tools => entries.where((final entry) => entry.kind == AgentIntentKind.tool); + + Map toJson() => { + 'version': version, + 'platform': platform, + 'tools': entries.map((final e) => e.toJson()).toList(growable: false), + if (entityTypes.isNotEmpty) + 'entityTypes': entityTypes + .map((final e) => e.toJson()) + .toList(growable: false), + if (protocolScheme != null) 'protocolScheme': protocolScheme, + }; + + String encode({final String indent = ' '}) => + const JsonEncoder.withIndent(' ').convert(toJson()); } List _readEntityTypes(final Object? value) { @@ -724,3 +738,14 @@ extension AgentManifestSurfaceKey on AgentManifestSurface { AgentManifestSurface.linuxSchemeHandler => 'linux.schemeHandler', }; } + +/// Resolves a manifest surface key without throwing. +AgentManifestSurface? lookupAgentManifestSurface(final String key) { + final trimmed = key.trim(); + for (final surface in AgentManifestSurface.values) { + if (surface.manifestKey == trimmed) { + return surface; + } + } + return null; +} diff --git a/packages/intentcall_platform/lib/src/agent_manifest_generator.dart b/packages/intentcall_platform_sync/lib/src/agent_manifest_generator.dart similarity index 100% rename from packages/intentcall_platform/lib/src/agent_manifest_generator.dart rename to packages/intentcall_platform_sync/lib/src/agent_manifest_generator.dart diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap.dart similarity index 100% rename from packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart rename to packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap.dart diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart similarity index 100% rename from packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart rename to packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart similarity index 100% rename from packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart rename to packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart diff --git a/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart new file mode 100644 index 0000000..f77cdd5 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart @@ -0,0 +1,19 @@ +import 'package:intentcall_core/intentcall_core.dart'; + +/// One registry-backed row aggregated for manifest generation. +final class AgentRegistryCatalogEntry { + const AgentRegistryCatalogEntry({ + required this.registryKey, + this.descriptor, + this.entry, + }) : assert(descriptor != null || entry != null); + + final String registryKey; + final AgentIntentDescriptor? descriptor; + final AgentCallEntry? entry; + + AgentIntentDescriptor resolveDescriptor() => + descriptor ?? entry!.toRegistration().descriptor; + + String get qualifiedName => resolveDescriptor().qualifiedName; +} diff --git a/packages/intentcall_platform/lib/src/emitters/android_shortcuts_xml_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/android_shortcuts_xml_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/android_shortcuts_xml_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/android_shortcuts_xml_emitter.dart diff --git a/packages/intentcall_platform/lib/src/emitters/apple_app_intents_testing_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_app_intents_testing_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/apple_app_intents_testing_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/apple_app_intents_testing_emitter.dart diff --git a/packages/intentcall_platform/lib/src/emitters/apple_dart_extension_inline_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_dart_extension_inline_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/apple_dart_extension_inline_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/apple_dart_extension_inline_emitter.dart diff --git a/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart diff --git a/packages/intentcall_platform/lib/src/emitters/emitter_utils.dart b/packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/emitter_utils.dart rename to packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart diff --git a/packages/intentcall_platform/lib/src/emitters/linux_desktop_entry_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/linux_desktop_entry_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/linux_desktop_entry_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/linux_desktop_entry_emitter.dart diff --git a/packages/intentcall_platform/lib/src/emitters/web_manifest_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/web_manifest_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/web_manifest_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/web_manifest_emitter.dart diff --git a/packages/intentcall_platform/lib/src/emitters/web_mcp_js_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/web_mcp_js_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart diff --git a/packages/intentcall_platform/lib/src/emitters/windows_protocol_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/windows_protocol_emitter.dart similarity index 100% rename from packages/intentcall_platform/lib/src/emitters/windows_protocol_emitter.dart rename to packages/intentcall_platform_sync/lib/src/emitters/windows_protocol_emitter.dart diff --git a/packages/intentcall_platform/lib/src/init/platform_hooks_init.dart b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart similarity index 97% rename from packages/intentcall_platform/lib/src/init/platform_hooks_init.dart rename to packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart index 2bf3d52..547b52a 100644 --- a/packages/intentcall_platform/lib/src/init/platform_hooks_init.dart +++ b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart @@ -130,7 +130,8 @@ final class PlatformHooksInit { final ok = scriptOk || (content.contains(_markerBegin) && - content.contains('flutter-mcp-toolkit codegen sync')); + (content.contains(kIntentcallPlatformSyncMarker) || + content.contains(kLegacyFlutterMcpToolkitSyncMarker))); return PlatformHookTargetResult( id: id, path: path, diff --git a/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart b/packages/intentcall_platform_sync/lib/src/invocation/intentcall_invocation.dart similarity index 100% rename from packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart rename to packages/intentcall_platform_sync/lib/src/invocation/intentcall_invocation.dart diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart new file mode 100644 index 0000000..9a47458 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import '../agent_manifest.dart'; +import '../catalog/agent_registry_catalog.dart'; +import 'projection_policy.dart'; + +/// Merges registry catalog rows and projection policy into [agent_manifest.json]. +final class ManifestMerger { + const ManifestMerger(); + + AgentManifest mergeManifest({ + required final Iterable catalog, + required final ProjectionPolicy policy, + final String? protocolScheme, + final Iterable entityTypeDescriptors = const [], + final Iterable> entityTypes = const [], + final String platform = 'unified', + }) { + final defaultSurfaces = policy.resolvedDefaultSurfaces(); + final entries = []; + for (final row in catalog) { + final descriptor = row.resolveDescriptor(); + final overlay = policy.overlayFor(row.qualifiedName); + final dispatchMode = overlay?.dispatchMode ?? policy.defaultDispatchMode; + final inlineRuntime = overlay?.inlineRuntime; + if (dispatchMode == AgentManifestDispatchMode.inlineRuntime && + inlineRuntime == null) { + throw FormatException( + 'dispatchMode inlineRuntime requires inlineRuntime for ' + '"${row.qualifiedName}".', + ); + } + final surfaces = + overlay?.resolveSurfaces(defaults: defaultSurfaces) ?? + defaultSurfaces; + entries.add( + AgentManifestEntry( + qualifiedName: row.qualifiedName, + namespace: descriptor.namespace, + name: descriptor.name, + description: descriptor.description, + kind: descriptor.kind, + inputSchema: descriptor.inputSchema, + dispatchMode: dispatchMode, + inlineRuntime: inlineRuntime, + surfaces: surfaces, + resourceUri: descriptor.resourceUri, + ), + ); + } + + final mergedEntities = [ + ...entityTypeDescriptors.map(_entityFromDescriptor), + ...entityTypes.map(AgentManifestEntityType.fromJson), + ]; + + return AgentManifest( + version: kAgentManifestSchemaVersion, + platform: platform, + entries: entries, + entityTypes: mergedEntities, + protocolScheme: protocolScheme, + ); + } + + String encodeManifest(final AgentManifest manifest) => + '${manifest.encode()}\n'; + + /// Loads `.intentcall/projection.yaml` or path from [overlayPath]. + Map loadOverlayFile(final String overlayPath) { + final file = File(overlayPath); + if (!file.existsSync()) { + return const {}; + } + final doc = loadYaml(file.readAsStringSync()); + if (doc is! YamlMap) { + return const {}; + } + final out = {}; + for (final entry in doc.entries) { + final key = entry.key.toString(); + final value = entry.value; + if (value is YamlMap) { + out[key] = EntryProjection.fromYamlMap(value); + } + } + return out; + } + + ProjectionPolicy loadProjectionPolicy({ + required final String projectRoot, + final String configFileName = 'intentcall.yaml', + }) { + final configFile = File(p.join(projectRoot, configFileName)); + if (!configFile.existsSync()) { + return const ProjectionPolicy(); + } + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return const ProjectionPolicy(); + } + var policy = ProjectionPolicy.fromYamlMap(doc); + final overlayRel = doc['projectionOverlay']?.toString(); + if (overlayRel != null && overlayRel.isNotEmpty) { + final overlayPath = p.isAbsolute(overlayRel) + ? overlayRel + : p.join(projectRoot, overlayRel); + policy = policy.mergeOverlays(loadOverlayFile(overlayPath)); + } + final protocolScheme = doc['protocolScheme']?.toString(); + if (protocolScheme != null && protocolScheme.isNotEmpty) { + // Caller applies protocolScheme when merging. + } + return policy; + } + + String? readProtocolScheme(final String projectRoot) { + final configFile = File(p.join(projectRoot, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return null; + } + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return null; + } + final scheme = doc['protocolScheme']?.toString().trim(); + return scheme == null || scheme.isEmpty ? null : scheme; + } +} + +AgentManifestEntityType _entityFromDescriptor( + final AgentEntityTypeDescriptor descriptor, +) { + final json = + jsonDecode(generateEntityManifestJson(descriptor)) + as Map; + return AgentManifestEntityType.fromJson(json); +} + +/// Shared entity projection JSON for one descriptor. +String generateEntityManifestJson(final AgentEntityTypeDescriptor descriptor) { + final displayProperties = descriptor.displayProperties.toList(); + final searchableProperties = descriptor.searchableProperties.toList(); + final titleKey = displayProperties.isNotEmpty + ? displayProperties.first.name + : 'title'; + return const JsonEncoder.withIndent(' ').convert({ + 'qualifiedName': descriptor.qualifiedName, + 'namespace': descriptor.namespace, + 'name': descriptor.name, + 'displayName': descriptor.displayName ?? descriptor.name, + 'idKey': descriptor.identifierName, + 'titleKey': titleKey, + 'subtitleKey': 'subtitle', + 'keywordsKey': 'keywords', + 'snapshotSchema': const {'type': 'object'}, + }); +} diff --git a/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart new file mode 100644 index 0000000..26dae05 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart @@ -0,0 +1,154 @@ +import '../agent_manifest.dart'; + +/// Default surface inclusion when no per-entry override exists. +bool defaultSurfaceInclude(final AgentManifestSurface surface) => + switch (surface) { + AgentManifestSurface.appleAppShortcuts => false, + AgentManifestSurface.androidShortcuts || + AgentManifestSurface.webManifestShortcuts || + AgentManifestSurface.webProtocolHandlers || + AgentManifestSurface.webMcp || + AgentManifestSurface.windowsProtocolActivation || + AgentManifestSurface.windowsMsixProtocol || + AgentManifestSurface.linuxSchemeHandler => true, + }; + +/// Per-entry projection overlay (dispatch + surfaces only). +final class EntryProjection { + const EntryProjection({ + this.dispatchMode, + this.inlineRuntime, + this.surfaces = const {}, + }); + + factory EntryProjection.fromYamlMap(final Map yaml) { + final dispatchName = yaml['dispatchMode']?.toString(); + final dispatchMode = dispatchName == null + ? null + : AgentManifestDispatchMode.values.byName(dispatchName); + final surfaces = {}; + final surfacesRaw = yaml['surfaces']; + if (surfacesRaw is Map) { + for (final entry in surfacesRaw.entries) { + final surface = lookupAgentManifestSurface(entry.key.toString()); + if (surface != null && entry.value is bool) { + surfaces[surface] = entry.value as bool; + } + } + } + return EntryProjection( + dispatchMode: dispatchMode, + inlineRuntime: _readInlineRuntimeFromYaml(yaml['inlineRuntime']), + surfaces: surfaces, + ); + } + + final AgentManifestDispatchMode? dispatchMode; + final AgentManifestInlineRuntime? inlineRuntime; + final Map surfaces; + + AgentManifestSurfacePolicy resolveSurfaces({ + required final AgentManifestSurfacePolicy defaults, + }) { + if (surfaces.isEmpty) { + return defaults; + } + final overrides = + Map.from( + defaults.overrides, + ); + for (final entry in surfaces.entries) { + overrides[entry.key] = AgentManifestSurfaceExposure(include: entry.value); + } + return AgentManifestSurfacePolicy(overrides); + } +} + +/// Global defaults + per-qualified-name overlays for manifest merge. +final class ProjectionPolicy { + const ProjectionPolicy({ + this.defaultDispatchMode = AgentManifestDispatchMode.openApp, + this.defaultSurfaces = const AgentManifestSurfacePolicy( + {}, + ), + this.overlays = const {}, + }); + + factory ProjectionPolicy.fromYamlMap(final Map yaml) { + final defaultsRaw = yaml['defaults']; + var dispatchMode = AgentManifestDispatchMode.openApp; + var defaultSurfaces = const AgentManifestSurfacePolicy( + {}, + ); + if (defaultsRaw is Map) { + final dispatchName = defaultsRaw['dispatchMode']?.toString(); + if (dispatchName != null) { + dispatchMode = AgentManifestDispatchMode.values.byName(dispatchName); + } + final surfacesRaw = defaultsRaw['surfaces']; + if (surfacesRaw is Map) { + final overrides = + {}; + for (final entry in surfacesRaw.entries) { + final surface = lookupAgentManifestSurface(entry.key.toString()); + if (surface != null && entry.value is bool) { + overrides[surface] = AgentManifestSurfaceExposure( + include: entry.value as bool, + ); + } + } + defaultSurfaces = AgentManifestSurfacePolicy(overrides); + } + } + + final overlays = {}; + final overlayPath = yaml['projectionOverlay']?.toString(); + if (overlayPath != null && overlayPath.isNotEmpty) { + // Overlay file parsed separately by [ProjectionPolicy.loadOverlayFile]. + } + return ProjectionPolicy( + defaultDispatchMode: dispatchMode, + defaultSurfaces: defaultSurfaces, + overlays: overlays, + ); + } + + final AgentManifestDispatchMode defaultDispatchMode; + final AgentManifestSurfacePolicy defaultSurfaces; + final Map overlays; + + ProjectionPolicy mergeOverlays(final Map more) => + ProjectionPolicy( + defaultDispatchMode: defaultDispatchMode, + defaultSurfaces: defaultSurfaces, + overlays: {...overlays, ...more}, + ); + + EntryProjection? overlayFor(final String qualifiedName) => + overlays[qualifiedName]; + + AgentManifestSurfacePolicy resolvedDefaultSurfaces() { + if (!defaultSurfaces.isEmpty) { + return defaultSurfaces; + } + final overrides = {}; + for (final surface in AgentManifestSurface.values) { + overrides[surface] = AgentManifestSurfaceExposure( + include: defaultSurfaceInclude(surface), + ); + } + return AgentManifestSurfacePolicy(overrides); + } +} + +AgentManifestInlineRuntime? _readInlineRuntimeFromYaml(final Object? value) { + if (value is! Map) { + return null; + } + final kindName = value['kind']?.toString(); + if (kindName == null) { + return null; + } + final kind = AgentManifestInlineRuntimeKind.values.byName(kindName); + return AgentManifestInlineRuntime(kind: kind); +} diff --git a/packages/intentcall_platform/lib/src/sync/apple_info_plist_protocol_sync.dart b/packages/intentcall_platform_sync/lib/src/sync/apple_info_plist_protocol_sync.dart similarity index 100% rename from packages/intentcall_platform/lib/src/sync/apple_info_plist_protocol_sync.dart rename to packages/intentcall_platform_sync/lib/src/sync/apple_info_plist_protocol_sync.dart diff --git a/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart b/packages/intentcall_platform_sync/lib/src/sync/apple_xcode_project_sync.dart similarity index 100% rename from packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart rename to packages/intentcall_platform_sync/lib/src/sync/apple_xcode_project_sync.dart diff --git a/packages/intentcall_platform/lib/src/sync/platform_sync.dart b/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart similarity index 99% rename from packages/intentcall_platform/lib/src/sync/platform_sync.dart rename to packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart index d6fe7c5..f0579ab 100644 --- a/packages/intentcall_platform/lib/src/sync/platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart @@ -156,7 +156,7 @@ final class PlatformSync { throw StateError( 'Missing $manifestFileName at ${manifestFile.path}. ' 'Maintain web/agent_manifest.json (or project-root copy) from your ' - 'agent descriptor list, or run `flutter-mcp-toolkit codegen sync`.', + 'agent descriptor list, or run `intentcall platform sync`.', ); } return AgentManifest.parse(manifestFile.readAsStringSync()); diff --git a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart new file mode 100644 index 0000000..cfebdf7 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart @@ -0,0 +1,71 @@ +/// Gradle `preBuild` hook — inject into `android/app/build.gradle.kts` once. +const kAndroidGradleCodegenHook = ''' +// intentcall-platform: begin +tasks.named("preBuild").configure { + doFirst { + exec { + workingDir = rootProject.layout.projectDirectory.dir("../../").asFile + commandLine( + "dart", + "run", + "build_runner", + "build", + "--delete-conflicting-outputs", + ) + } + exec { + workingDir = rootProject.layout.projectDirectory.dir("../../").asFile + commandLine( + "intentcall", + "manifest", + "export", + "--check", + ) + } + exec { + workingDir = rootProject.layout.projectDirectory.dir("../../").asFile + commandLine( + "intentcall", + "platform", + "sync", + "--platform", + "android", + ) + } + } +} +// intentcall-platform: end +'''; + +/// Xcode Run Script build phase — add to iOS/macOS target once. +const kAppleXcodeCodegenRunScript = r''' +# intentcall-platform: begin +cd "${SRCROOT}/.." +dart run build_runner build --delete-conflicting-outputs +intentcall manifest export --check +intentcall platform sync --platform ios,macos || exit 1 +# intentcall-platform: end +'''; + +/// Jaspr / web-only hook snippet for CI or custom build steps. +const kJasprWebCodegenHook = ''' +# intentcall-platform: begin +dart run build_runner build --delete-conflicting-outputs +intentcall manifest export --check +intentcall platform sync --platform web || exit 1 +# intentcall-platform: end +'''; + +/// Documents where hook templates live for `init intentcall-platform`. +const kPlatformHookTemplatePaths = { + 'android': 'intentcall_platform Gradle hook (kAndroidGradleCodegenHook)', + 'ios': 'intentcall_platform Xcode Run Script (kAppleXcodeCodegenRunScript)', + 'macos': 'intentcall_platform Xcode Run Script (kAppleXcodeCodegenRunScript)', + 'jaspr': 'intentcall_platform web hook (kJasprWebCodegenHook)', +}; + +/// Legacy hook marker still accepted during migration. +const kLegacyFlutterMcpToolkitSyncMarker = 'flutter-mcp-toolkit codegen sync'; + +/// Current hook marker for [PlatformHooksInit] detection. +const kIntentcallPlatformSyncMarker = 'intentcall platform sync'; diff --git a/packages/intentcall_platform_sync/pubspec.yaml b/packages/intentcall_platform_sync/pubspec.yaml new file mode 100644 index 0000000..2adab84 --- /dev/null +++ b/packages/intentcall_platform_sync/pubspec.yaml @@ -0,0 +1,28 @@ +name: intentcall_platform_sync +description: PRE-RELEASE — Dart-only platform manifest, emitters, and sync for IntentCall. +version: 0.6.0 +license: MIT +repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync +issue_tracker: https://github.com/Arenukvern/intentcall/issues +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync +topics: + - mcp + - agents + - codegen + +environment: + sdk: ">=3.12.0 <4.0.0" +resolution: workspace + +dependencies: + intentcall_core: ^0.6.0 + intentcall_schema: ^0.6.0 + meta: ^1.17.0 + path: ^1.9.1 + web: ^1.1.1 + yaml: ^3.1.3 + +dev_dependencies: + lints: ^6.1.0 + test: ^1.31.1 + xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart b/packages/intentcall_platform_sync/test/agent_web_mcp_bootstrap_test.dart similarity index 92% rename from packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart rename to packages/intentcall_platform_sync/test/agent_web_mcp_bootstrap_test.dart index 09d31ff..381c9a7 100644 --- a/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart +++ b/packages/intentcall_platform_sync/test/agent_web_mcp_bootstrap_test.dart @@ -1,5 +1,5 @@ import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart b/packages/intentcall_platform_sync/test/apple_xcode_project_sync_test.dart similarity index 99% rename from packages/intentcall_platform/test/apple_xcode_project_sync_test.dart rename to packages/intentcall_platform_sync/test/apple_xcode_project_sync_test.dart index 49ff29b..34ea1aa 100644 --- a/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart +++ b/packages/intentcall_platform_sync/test/apple_xcode_project_sync_test.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:intentcall_platform/src/sync/apple_xcode_project_sync.dart'; +import 'package:intentcall_platform_sync/src/sync/apple_xcode_project_sync.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; diff --git a/packages/intentcall_platform/test/intentcall_invocation_test.dart b/packages/intentcall_platform_sync/test/intentcall_invocation_test.dart similarity index 99% rename from packages/intentcall_platform/test/intentcall_invocation_test.dart rename to packages/intentcall_platform_sync/test/intentcall_invocation_test.dart index dbccb59..0936998 100644 --- a/packages/intentcall_platform/test/intentcall_invocation_test.dart +++ b/packages/intentcall_platform_sync/test/intentcall_invocation_test.dart @@ -1,5 +1,5 @@ import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:test/test.dart'; diff --git a/packages/intentcall_platform_sync/test/manifest_merger_test.dart b/packages/intentcall_platform_sync/test/manifest_merger_test.dart new file mode 100644 index 0000000..3dc8de2 --- /dev/null +++ b/packages/intentcall_platform_sync/test/manifest_merger_test.dart @@ -0,0 +1,45 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:test/test.dart'; + +void main() { + test('ManifestMerger applies defaults and overlays', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy( + overlays: { + 'app_ping': EntryProjection( + dispatchMode: AgentManifestDispatchMode.queueOnly, + surfaces: {AgentManifestSurface.webMcp: false}, + ), + }, + ); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app.ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + protocolScheme: 'myapp', + ); + expect(manifest.protocolScheme, 'myapp'); + expect( + manifest.entries.single.dispatchMode, + AgentManifestDispatchMode.queueOnly, + ); + expect( + manifest.entries.single.surfaces.includes( + AgentManifestSurface.webMcp, + defaultValue: true, + ), + isFalse, + ); + }); +} diff --git a/packages/intentcall_platform/test/native_emitters_test.dart b/packages/intentcall_platform_sync/test/native_emitters_test.dart similarity index 99% rename from packages/intentcall_platform/test/native_emitters_test.dart rename to packages/intentcall_platform_sync/test/native_emitters_test.dart index 15bb869..60a5e82 100644 --- a/packages/intentcall_platform/test/native_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/native_emitters_test.dart @@ -1,4 +1,4 @@ -import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/intentcall_platform/test/native_platform_sync_test.dart b/packages/intentcall_platform_sync/test/native_platform_sync_test.dart similarity index 99% rename from packages/intentcall_platform/test/native_platform_sync_test.dart rename to packages/intentcall_platform_sync/test/native_platform_sync_test.dart index 0646f09..2652faf 100644 --- a/packages/intentcall_platform/test/native_platform_sync_test.dart +++ b/packages/intentcall_platform_sync/test/native_platform_sync_test.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; diff --git a/packages/intentcall_platform/test/platform_hooks_init_test.dart b/packages/intentcall_platform_sync/test/platform_hooks_init_test.dart similarity index 96% rename from packages/intentcall_platform/test/platform_hooks_init_test.dart rename to packages/intentcall_platform_sync/test/platform_hooks_init_test.dart index 4d33eac..2718bf3 100644 --- a/packages/intentcall_platform/test/platform_hooks_init_test.dart +++ b/packages/intentcall_platform_sync/test/platform_hooks_init_test.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; diff --git a/packages/intentcall_platform/test/platform_sync_test.dart b/packages/intentcall_platform_sync/test/platform_sync_test.dart similarity index 95% rename from packages/intentcall_platform/test/platform_sync_test.dart rename to packages/intentcall_platform_sync/test/platform_sync_test.dart index 7592655..7a5ea52 100644 --- a/packages/intentcall_platform/test/platform_sync_test.dart +++ b/packages/intentcall_platform_sync/test/platform_sync_test.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; diff --git a/packages/intentcall_platform/test/web_emitters_test.dart b/packages/intentcall_platform_sync/test/web_emitters_test.dart similarity index 99% rename from packages/intentcall_platform/test/web_emitters_test.dart rename to packages/intentcall_platform_sync/test/web_emitters_test.dart index 305a3d4..a35fd4f 100644 --- a/packages/intentcall_platform/test/web_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/web_emitters_test.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/intentcall_schema/pubspec.yaml b/packages/intentcall_schema/pubspec.yaml index 36415c2..d1ca2b4 100644 --- a/packages/intentcall_schema/pubspec.yaml +++ b/packages/intentcall_schema/pubspec.yaml @@ -11,7 +11,7 @@ topics: - dart environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_session/pubspec.yaml b/packages/intentcall_session/pubspec.yaml index 80e6ab7..b445e45 100644 --- a/packages/intentcall_session/pubspec.yaml +++ b/packages/intentcall_session/pubspec.yaml @@ -11,7 +11,7 @@ topics: - dart environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_testing/pubspec.yaml b/packages/intentcall_testing/pubspec.yaml index fc8f52e..9ef35e0 100644 --- a/packages/intentcall_testing/pubspec.yaml +++ b/packages/intentcall_testing/pubspec.yaml @@ -11,7 +11,7 @@ topics: - agents environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/packages/intentcall_webmcp/pubspec.yaml b/packages/intentcall_webmcp/pubspec.yaml index f9f78f2..0088ba1 100644 --- a/packages/intentcall_webmcp/pubspec.yaml +++ b/packages/intentcall_webmcp/pubspec.yaml @@ -11,7 +11,7 @@ topics: - agents environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: diff --git a/pubspec.lock b/pubspec.lock index 90b9e62..e4d86a7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -633,5 +633,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.12.0 <4.0.0" flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 890bc09..96ead89 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ publish_to: none description: Standalone intentcall monorepo environment: - sdk: ^3.11.0 + sdk: ^3.12.0 workspace: - packages/intentcall_schema @@ -14,7 +14,9 @@ workspace: - packages/intentcall_gemma - packages/intentcall_apple - packages/intentcall_android + - packages/intentcall_platform_sync - packages/intentcall_platform + - packages/intentcall_cli - packages/intentcall_testing - packages/intentcall_session - tool/intentcall diff --git a/release-please-config.json b/release-please-config.json index 49fbcb7..70fac9d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -15,6 +15,8 @@ "intentcall_apple", "intentcall_android", "intentcall_codegen", + "intentcall_platform_sync", + "intentcall_cli", "intentcall_platform", "intentcall_testing" ] @@ -56,6 +58,16 @@ "package-name": "intentcall_codegen", "component": "intentcall_codegen" }, + "packages/intentcall_platform_sync": { + "release-type": "dart", + "package-name": "intentcall_platform_sync", + "component": "intentcall_platform_sync" + }, + "packages/intentcall_cli": { + "release-type": "dart", + "package-name": "intentcall_cli", + "component": "intentcall_cli" + }, "packages/intentcall_platform": { "release-type": "dart", "package-name": "intentcall_platform", @@ -72,4 +84,4 @@ "component": "intentcall_session" } } -} +} \ No newline at end of file diff --git a/steward.yaml b/steward.yaml index 7c378e0..816e3f5 100644 --- a/steward.yaml +++ b/steward.yaml @@ -111,6 +111,8 @@ actions: - packages/intentcall_mcp/** - packages/intentcall_webmcp/** - packages/intentcall_gemma/** + - packages/intentcall_platform_sync/** + - packages/intentcall_cli/** - packages/intentcall_platform/** - pubspec.yaml - pubspec.lock @@ -135,6 +137,71 @@ actions: evidence: redaction: steward/redaction/v1 summary_fields: [exit_code, duration_ms, output_digest] + intentcall.manifest-parity: + kind: command + desc: Verify manifest fixture rows are present in a matching registry snapshot. + command: + argv: [just, manifest-parity] + shell: false + cwd: . + effects: + fs_read: + - justfile + - packages/intentcall_cli/** + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 200000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] + intentcall.platform-sync-check: + kind: command + desc: Verify generated platform artifacts match emitters on fixture projects. + command: + argv: [just, platform-sync-check] + shell: false + cwd: . + effects: + fs_read: + - justfile + - packages/intentcall_cli/** + - packages/intentcall_platform_sync/** + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 200000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] intentcall.docs-check: kind: command desc: Validate docs.page config (docs.json) and internal documentation links. @@ -175,7 +242,7 @@ actions: probes: quick: profile: quick - actions: [intentcall.validate, intentcall.adapter-contract-test] + actions: [intentcall.validate, intentcall.adapter-contract-test, intentcall.manifest-parity, intentcall.platform-sync-check] diagnostics: cases: {} unknown_cases: diff --git a/steward/scenarios/intentcall.adapter-contract.yaml b/steward/scenarios/intentcall.adapter-contract.yaml index 5d66914..7859b5e 100644 --- a/steward/scenarios/intentcall.adapter-contract.yaml +++ b/steward/scenarios/intentcall.adapter-contract.yaml @@ -17,6 +17,11 @@ artifacts: required: true durability: input - id: intentcall_cli + kind: dart + path: packages/intentcall_cli/bin/intentcall.dart + required: true + durability: input + - id: intentcall_workspace_tool kind: dart path: tool/intentcall/bin/intentcall.dart required: true @@ -43,22 +48,22 @@ artifacts: durability: behavior - id: platform_invocation_policy_test kind: dart - path: packages/intentcall_platform/test/intentcall_invocation_test.dart + path: packages/intentcall_platform_sync/test/intentcall_invocation_test.dart required: true durability: behavior - id: platform_webmcp_emitter_test kind: dart - path: packages/intentcall_platform/test/web_emitters_test.dart + path: packages/intentcall_platform_sync/test/web_emitters_test.dart required: true durability: behavior - id: platform_webmcp_bootstrap_test kind: dart - path: packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart + path: packages/intentcall_platform_sync/test/agent_web_mcp_bootstrap_test.dart required: true durability: behavior - id: platform_native_emitter_test kind: dart - path: packages/intentcall_platform/test/native_emitters_test.dart + path: packages/intentcall_platform_sync/test/native_emitters_test.dart required: true durability: behavior - id: platform_flutter_host_test diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index df79c6a..532b0da 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; + import 'package:args/args.dart'; import 'package:intentcall_platform/intentcall_platform.dart'; import 'package:path/path.dart' as p; @@ -17,6 +18,8 @@ const publishOrder = [ 'intentcall_apple', 'intentcall_android', 'intentcall_codegen', + 'intentcall_platform_sync', + 'intentcall_cli', 'intentcall_platform', 'intentcall_testing', ]; @@ -235,8 +238,10 @@ void main(List arguments) async { exit(0); case 'apple-appintents-testing': - final code = await runAppleAppIntentsTesting(repoRoot, results.command!); - exit(code); + stderr.writeln( + 'Moved to intentcall_cli. Run: dart run intentcall_cli:intentcall apple-appintents-testing ...', + ); + exit(64); case 'publish-all': final cmdResults = results.command!; diff --git a/tool/intentcall/bin/release_train.dart b/tool/intentcall/bin/release_train.dart index 89ca293..93db0d9 100644 --- a/tool/intentcall/bin/release_train.dart +++ b/tool/intentcall/bin/release_train.dart @@ -10,6 +10,8 @@ const publishablePackages = [ 'intentcall_apple', 'intentcall_android', 'intentcall_codegen', + 'intentcall_platform_sync', + 'intentcall_cli', 'intentcall_platform', 'intentcall_testing', ]; diff --git a/tool/intentcall/pubspec.yaml b/tool/intentcall/pubspec.yaml index 79628b1..6ca8342 100644 --- a/tool/intentcall/pubspec.yaml +++ b/tool/intentcall/pubspec.yaml @@ -1,10 +1,10 @@ -name: intentcall_cli +name: intentcall_workspace_tool description: CLI tool for IntentCall repository maintenance. publish_to: none version: 0.1.0 environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace diff --git a/tool/intentcall/test/publish_preflight_test.dart b/tool/intentcall/test/publish_preflight_test.dart index 428e6ba..272fd45 100644 --- a/tool/intentcall/test/publish_preflight_test.dart +++ b/tool/intentcall/test/publish_preflight_test.dart @@ -561,6 +561,8 @@ Future _createReleaseTrainFixture() async { "packages/intentcall_apple": "0.6.0", "packages/intentcall_android": "0.6.0", "packages/intentcall_codegen": "0.6.0", + "packages/intentcall_platform_sync": "0.6.0", + "packages/intentcall_cli": "0.6.0", "packages/intentcall_platform": "0.6.0", "packages/intentcall_testing": "0.6.0" } From f91e96828562c4a1bc62aa91c7061f85254a2f6b Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 02:44:19 +0300 Subject: [PATCH 02/27] feat: implement manifest export checks and validation gates for ADR 0019 --- .../adr_0019_closure_plan_762cf2a1.plan.md | 12 +- .github/workflows/ci.yml | 5 +- analysis_options.yaml | 1 - docs/DX_FAQ.mdx | 8 + .../0019-framework-neutral-intentcall-cli.md | 15 +- justfile | 18 + .../lib/src/catalog/catalog_loader.dart | 116 ++--- .../lib/src/command_runner.dart | 29 +- .../test/command_runner_test.dart | 37 +- .../fixtures/codegen_dart_project/build.yaml | 9 + .../codegen_dart_project/intentcall.yaml | 10 + .../lib/generated/agent_catalog.g.dart | 9 + .../lib/tools/demo_ping_tool.dart | 10 + .../lib/tools/demo_ping_tool.g.dart | 37 ++ .../codegen_dart_project/pubspec.lock | 401 +++++++++++++++++ .../codegen_dart_project/pubspec.yaml | 27 ++ .../web/agent_manifest.json | 53 +++ .../web/intentcall_webmcp.generated.js | 218 +++++++++ .../codegen_dart_project/web/manifest.json | 22 + .../test/fixtures/flutter_project/build.yaml | 9 + .../lib/generated/agent_catalog.g.dart | 9 + .../lib/tools/cart_total_tool.dart | 10 + .../lib/tools/cart_total_tool.g.dart | 37 ++ .../fixtures/flutter_project/pubspec.lock | 401 +++++++++++++++++ .../fixtures/flutter_project/pubspec.yaml | 27 ++ .../flutter_project/web/agent_manifest.json | 26 +- .../web/intentcall_webmcp.generated.js | 8 +- .../fixtures/jaspr_web_project/build.yaml | 9 + .../lib/generated/agent_catalog.g.dart | 9 + .../lib/tools/shop_search_tool.dart | 9 + .../lib/tools/shop_search_tool.g.dart | 34 ++ .../fixtures/jaspr_web_project/pubspec.lock | 401 +++++++++++++++++ .../fixtures/jaspr_web_project/pubspec.yaml | 27 ++ .../jaspr_web_project/web/agent_manifest.json | 3 +- .../web/intentcall_webmcp.generated.js | 3 +- .../test/manifest_registry_parity_test.dart | 78 ++-- packages/intentcall_codegen/README.md | 26 +- packages/intentcall_codegen/build.yaml | 16 +- .../example/analysis_options.yaml | 10 + .../intentcall_codegen/example/build.yaml | 9 + .../{ => example}/intentcall.yaml | 0 .../lib/generated/agent_catalog.g.dart | 4 +- .../{ => lib/tools}/demo_ping_tool.dart | 0 .../{ => lib/tools}/demo_ping_tool.g.dart | 0 .../intentcall_codegen/example/pubspec.lock | 417 ++++++++++++++++++ .../intentcall_codegen/example/pubspec.yaml | 29 ++ .../{ => example}/web/agent_manifest.json | 0 .../web/intentcall_webmcp.generated.js | 254 +++++++++++ .../example/web/manifest.json | 42 ++ packages/intentcall_codegen/lib/builder.dart | 7 +- .../generators/agent_catalog_generator.dart | 88 +++- .../generators/agent_manifest_generator.dart | 136 ------ .../test/agent_tool_generator_test.dart | 2 +- .../lib/intentcall_platform_sync.dart | 2 +- .../lib/src/agent_manifest.dart | 14 +- .../lib/src/agent_manifest_generator.dart | 126 ------ .../src/catalog/agent_registry_catalog.dart | 6 + .../lib/src/projection/manifest_exporter.dart | 54 +++ .../lib/src/projection/manifest_merger.dart | 89 ++-- .../lib/src/projection/projection_policy.dart | 22 +- .../templates/platform_hook_templates.dart | 4 + .../test/manifest_merger_test.dart | 17 +- .../test/web_emitters_test.dart | 17 +- steward.yaml | 35 +- .../intentcall.adapter-contract.yaml | 3 + tool/intentcall/bin/intentcall.dart | 20 +- 66 files changed, 3014 insertions(+), 572 deletions(-) create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/build.yaml create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/intentcall.yaml create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.dart create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.lock create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.yaml create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js create mode 100644 packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/build.yaml create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.dart create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/pubspec.lock create mode 100644 packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/build.yaml create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.dart create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock create mode 100644 packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml create mode 100644 packages/intentcall_codegen/example/analysis_options.yaml create mode 100644 packages/intentcall_codegen/example/build.yaml rename packages/intentcall_codegen/{ => example}/intentcall.yaml (100%) rename packages/intentcall_codegen/{ => example}/lib/generated/agent_catalog.g.dart (82%) rename packages/intentcall_codegen/example/{ => lib/tools}/demo_ping_tool.dart (100%) rename packages/intentcall_codegen/example/{ => lib/tools}/demo_ping_tool.g.dart (100%) create mode 100644 packages/intentcall_codegen/example/pubspec.lock create mode 100644 packages/intentcall_codegen/example/pubspec.yaml rename packages/intentcall_codegen/{ => example}/web/agent_manifest.json (100%) create mode 100644 packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js create mode 100644 packages/intentcall_codegen/example/web/manifest.json delete mode 100644 packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart delete mode 100644 packages/intentcall_platform_sync/lib/src/agent_manifest_generator.dart create mode 100644 packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart diff --git a/.cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md b/.cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md index ef3c5aa..27fd8dc 100644 --- a/.cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md +++ b/.cursor/plans/adr_0019_closure_plan_762cf2a1.plan.md @@ -4,22 +4,22 @@ overview: "Close all MoE-identified gaps for ADR 0019: fix intentcall_codegen li todos: - id: ws-a-codegen-layout content: "Subagent A: Migrate intentcall_codegen to pure library; create example/ mini-host + CLI codegen_dart_project fixture; default generators to lib/** only" - status: pending + status: completed - id: ws-b-manifest-exporter content: "Subagent B: ManifestExporter; catalog row EntryProjection; @AgentProjection in catalog builder; yaml defaults-only; delete projection yaml paths; remove agent_manifest builder" - status: pending + status: completed - id: ws-c-surface-catalog-legacy content: "Subagent C: Hybrid surface aliases + fail loud; remove CatalogLoader manifest fallback; delete legacy generateWebAgentManifest + migrate tests" - status: pending + status: completed - id: ws-d-harness-ci content: "Subagent D: Add manifest-export-check + adr-gates to just/steward/CI; fix command_runner_test setUp; strengthen parity tests; fix intentcall.validate schema path" - status: pending + status: completed - id: ws-e-docs content: "Subagent E: Update DX_FAQ, codegen README, hook PATH note; document mcp_flutter follow-up" - status: pending + status: completed - id: ws-integrate-verify content: "Parent: Merge subagents, run just adr-gates + just test + steward probe --profile quick; MoE re-audit" - status: pending + status: completed isProject: false --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d762945..3263bcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,10 @@ jobs: run: dart pub get - name: test - run: dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform packages/intentcall_codegen packages/intentcall_testing tool/intentcall + run: just test + + - name: ADR 0019 validation gates + run: just adr-gates - name: analyze run: dart analyze . diff --git a/analysis_options.yaml b/analysis_options.yaml index a09b6b8..6e3da7e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,7 +8,6 @@ analyzer: # Incremental cleanup: style infos remain visible but do not fail CI. lines_longer_than_80_chars: ignore public_member_api_docs: ignore - unnecessary_library_directive: ignore exclude: - "**/*.g.dart" - packages/intentcall_codegen/example/** diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 6ce0b13..7d53fdb 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -375,8 +375,16 @@ Then annotate your intent class with `@AgentTool` and run: ```bash dart run build_runner build --delete-conflicting-outputs +intentcall manifest export --check ``` +`build_runner` writes `lib/generated/agent_catalog.g.dart`. `manifest export` +merges the catalog with `intentcall.yaml` defaults (and optional `@AgentProjection` +per tool) into committed `web/agent_manifest.json`. + +Per-tool projection is **Dart-only** (`@AgentProjection` or `EntryProjection` on +catalog rows). `intentcall.yaml` carries host wiring and global `defaults` only. + **Q: Is codegen required?** No. You can register `AgentCallEntry` objects manually. Codegen is a convenience layer. diff --git a/docs/decisions/0019-framework-neutral-intentcall-cli.md b/docs/decisions/0019-framework-neutral-intentcall-cli.md index c0145320..5e7c5a9 100644 --- a/docs/decisions/0019-framework-neutral-intentcall-cli.md +++ b/docs/decisions/0019-framework-neutral-intentcall-cli.md @@ -54,14 +54,15 @@ contracts. 2. **Authoring surface:** - Semantic truth: `AgentCallEntry` / `@AgentTool` (namespace, name, description, schema, handler). - - Projection policy: `@AgentProjection` on annotated tools OR keyed - `.intentcall/projection.yaml` overlay for handwritten entries - (`dispatchMode`, `surfaces`, `inlineRuntime` only — no schema duplication). -3. **`intentcall_codegen` gains two builders:** + - Projection policy: `@AgentProjection` on annotated tools, or + `EntryProjection` on handwritten `AgentRegistryCatalogEntry` rows. + Global defaults in `intentcall.yaml` `defaults` only — **no per-tool YAML**. +3. **`intentcall_codegen` gains one aggregate builder:** - `AgentCatalogBuilder` → `lib/generated/agent_catalog.g.dart` (aggregates - all registrations). - - `AgentManifestBuilder` → path from `intentcall.yaml` (default - `web/agent_manifest.json`). + registrations and optional per-row projection from `@AgentProjection`). + - **`agent_manifest.json` is written by `intentcall manifest export`** (shared + `ManifestExporter` in `intentcall_platform_sync`) — not a second build_runner + builder. 4. **`ManifestMerger`** in `intentcall_platform_sync` merges catalog entries, projection policy, and entity types into one canonical manifest. diff --git a/justfile b/justfile index aa81969..49d4755 100644 --- a/justfile +++ b/justfile @@ -8,10 +8,28 @@ default: test: dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform_sync packages/intentcall_codegen packages/intentcall_cli packages/intentcall_platform packages/intentcall_testing tool/intentcall +# Manifest freshness gate (build_runner catalog + export --check) +manifest-export-check: + cd packages/intentcall_codegen/example && dart pub get && dart run build_runner build + dart run intentcall_cli:intentcall manifest export --check --project-dir packages/intentcall_codegen/example + cd packages/intentcall_cli/test/fixtures/codegen_dart_project && dart pub get && dart run build_runner build + dart run intentcall_cli:intentcall manifest export --check --project-dir packages/intentcall_cli/test/fixtures/codegen_dart_project + cd packages/intentcall_cli/test/fixtures/flutter_project && dart pub get && dart run build_runner build + dart run intentcall_cli:intentcall manifest export --check --project-dir packages/intentcall_cli/test/fixtures/flutter_project + cd packages/intentcall_cli/test/fixtures/jaspr_web_project && dart pub get && dart run build_runner build + dart run intentcall_cli:intentcall manifest export --check --project-dir packages/intentcall_cli/test/fixtures/jaspr_web_project + +# ADR 0019 validation gates +adr-gates: + just manifest-export-check + just manifest-parity + just platform-sync-check + # Verify platform artifact sync on fixture projects platform-sync-check: dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/flutter_project --platform web --check dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/jaspr_web_project --platform web --check + dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/codegen_dart_project --platform web --check # Manifest/registry parity gate manifest-parity: diff --git a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart index 5774ce6..4364d5f 100644 --- a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart +++ b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; import '../utils/cli_utils.dart'; @@ -16,18 +15,23 @@ final class CatalogLoader { Future> load({ required final String projectRoot, - required final File manifestOut, }) async { - final catalogFile = File(p.join(projectRoot, catalogRelativePath)); - if (catalogFile.existsSync()) { - final fromProbe = await _loadFromGeneratedCatalog(projectRoot); - if (fromProbe != null) { - return fromProbe; - } + final root = p.normalize(p.absolute(projectRoot)); + final catalogFile = File(p.join(root, catalogRelativePath)); + if (!catalogFile.existsSync()) { + throw CatalogLoadException( + 'Missing $catalogRelativePath — run ' + '`dart run build_runner build --delete-conflicting-outputs`.', + ); + } + final fromProbe = await _loadFromGeneratedCatalog(root); + if (fromProbe != null) { + return fromProbe; } - return _loadFromProjectionAndManifest( - projectRoot: projectRoot, - manifestOut: manifestOut, + throw CatalogLoadException( + 'Failed to load $catalogRelativePath — run ' + '`dart run build_runner build --delete-conflicting-outputs` and ensure ' + 'the package compiles.', ); } @@ -46,12 +50,13 @@ final class CatalogLoader { // ignore_for_file: avoid_print import 'dart:convert'; import 'package:$packageName/generated/agent_catalog.g.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; void main() { final rows = >[]; for (final row in agentCatalogEntries) { final descriptor = row.resolveDescriptor(); - rows.add({ + rows.add({ 'registryKey': row.registryKey, 'qualifiedName': descriptor.qualifiedName, 'namespace': descriptor.namespace, @@ -60,10 +65,22 @@ void main() { 'kind': descriptor.kind.name, 'inputSchema': descriptor.inputSchema, if (descriptor.resourceUri != null) 'resourceUri': descriptor.resourceUri, + if (row.projection != null) 'projection': _projectionJson(row.projection!), }); } print(jsonEncode(rows)); } + +Map _projectionJson(final EntryProjection projection) { + return { + if (projection.dispatchMode != null) + 'dispatchMode': projection.dispatchMode!.name, + 'surfaces': { + for (final entry in projection.surfaces.entries) + entry.key.manifestKey: entry.value, + }, + }; +} '''); final result = await Process.run( @@ -85,6 +102,13 @@ void main() { } return decoded.map((final row) { final map = (row as Map).cast(); + EntryProjection? projection; + final projectionRaw = map['projection']; + if (projectionRaw is Map) { + projection = _projectionFromJson( + projectionRaw.cast(), + ); + } return AgentRegistryCatalogEntry( registryKey: '${map['registryKey']}', descriptor: AgentIntentDescriptor( @@ -98,58 +122,34 @@ void main() { ), resourceUri: map['resourceUri']?.toString(), ), + projection: projection, ); }).toList(); } - List _loadFromProjectionAndManifest({ - required final String projectRoot, - required final File manifestOut, - }) { - final merger = ManifestMerger(); - final overlayPaths = [ - if (loadIntentCallConfig(projectRoot)?.projectionOverlay case final String path) - p.isAbsolute(path) ? path : p.join(projectRoot, path), - p.join(projectRoot, '.intentcall', 'projection.yaml'), - ]; - - final overlayKeys = {}; - for (final overlayPath in overlayPaths) { - final overlays = merger.loadOverlayFile(overlayPath); - overlayKeys.addAll(overlays.keys); - } - - if (!manifestOut.existsSync()) { - return const []; + EntryProjection _projectionFromJson(final Map json) { + final dispatchName = json['dispatchMode']?.toString(); + final dispatchMode = dispatchName == null + ? null + : AgentManifestDispatchMode.values.byName(dispatchName); + final surfaces = {}; + final surfacesRaw = json['surfaces']; + if (surfacesRaw is Map) { + for (final entry in surfacesRaw.entries) { + surfaces[resolveAgentManifestSurface('${entry.key}')] = + entry.value as bool; + } } - - final manifest = AgentManifest.parse(manifestOut.readAsStringSync()); - final tools = overlayKeys.isEmpty - ? manifest.tools - : manifest.tools.where( - (final tool) => overlayKeys.contains(tool.qualifiedName), - ); - - return tools - .map( - (final tool) => AgentRegistryCatalogEntry( - registryKey: tool.qualifiedName, - descriptor: tool.toDescriptor(), - ), - ) - .toList(); + return EntryProjection(dispatchMode: dispatchMode, surfaces: surfaces); } } -/// Reads optional `.intentcall/projection.yaml` for diagnostics. -Map scanProjectionYaml(final String projectRoot) { - final file = File(p.join(projectRoot, '.intentcall', 'projection.yaml')); - if (!file.existsSync()) { - return const {}; - } - final doc = loadYaml(file.readAsStringSync()); - if (doc is! YamlMap) { - return const {}; - } - return doc.cast(); +/// Thrown when the generated catalog cannot be loaded. +final class CatalogLoadException implements Exception { + CatalogLoadException(this.message); + + final String message; + + @override + String toString() => message; } diff --git a/packages/intentcall_cli/lib/src/command_runner.dart b/packages/intentcall_cli/lib/src/command_runner.dart index 8de93df..0e206b8 100644 --- a/packages/intentcall_cli/lib/src/command_runner.dart +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -118,7 +118,7 @@ final class _DoctorCommand extends Command { } else { stdout.writeln('== IntentCall Doctor =='); for (final check in checks) { - final mark = (check['ok'] as bool) ? '✓' : '✗'; + final mark = (check['ok']! as bool) ? '✓' : '✗'; stdout.writeln('$mark ${check['id']}: ${check['detail']}'); } stdout.writeln('\nStatus: ${healthy ? 'HEALTHY' : 'UNHEALTHY'}'); @@ -261,22 +261,13 @@ final class _ManifestExportCommand extends Command { : resolveProjectPath(projectRoot, '${results['out']}'); final checkOnly = results['check'] as bool? ?? false; - final merger = ManifestMerger(); - final policy = merger.loadProjectionPolicy(projectRoot: projectRoot); - final protocolScheme = - config?.protocolScheme ?? merger.readProtocolScheme(projectRoot); - final catalog = await const CatalogLoader().load( - projectRoot: projectRoot, - manifestOut: outPath, - ); + const exporter = ManifestExporter(); + final context = exporter.loadExportContext(projectRoot: projectRoot); + final catalog = await const CatalogLoader().load(projectRoot: projectRoot); - final manifest = merger.mergeManifest( - catalog: catalog, - policy: policy, - protocolScheme: protocolScheme, - platform: _platformLabel(config), + final encoded = exporter.encodeManifest( + exporter.buildManifest(catalog: catalog, context: context), ); - final encoded = merger.encodeManifest(manifest); if (checkOnly) { if (!outPath.existsSync()) { @@ -312,14 +303,6 @@ final class _ManifestExportCommand extends Command { } } -String _platformLabel(final IntentCallConfig? config) { - return switch (config?.host) { - IntentCallHost.jaspr => 'web', - IntentCallHost.flutter => 'unified', - _ => 'unified', - }; -} - final class _PlatformCommand extends Command { _PlatformCommand() { addSubcommand(_PlatformSyncCommand()); diff --git a/packages/intentcall_cli/test/command_runner_test.dart b/packages/intentcall_cli/test/command_runner_test.dart index 7e716b1..2d538ba 100644 --- a/packages/intentcall_cli/test/command_runner_test.dart +++ b/packages/intentcall_cli/test/command_runner_test.dart @@ -1,10 +1,7 @@ import 'dart:io'; -import 'package:intentcall_cli/src/catalog/catalog_loader.dart'; import 'package:intentcall_cli/src/command_runner.dart'; import 'package:intentcall_cli/src/config/intentcall_config.dart'; -import 'package:intentcall_cli/src/utils/cli_utils.dart'; -import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -12,11 +9,9 @@ void main() { late Directory flutterFixture; late Directory jasprFixture; - setUpAll(() async { + setUpAll(() { flutterFixture = _fixtureRoot('flutter_project'); jasprFixture = _fixtureRoot('jaspr_web_project'); - await _prepareFixture(flutterFixture.path); - await _prepareFixture(jasprFixture.path); }); group('IntentCallCommandRunner', () { @@ -134,36 +129,8 @@ Directory _fixtureRoot(final String name) { for (final candidate in candidates) { final root = Directory(candidate); if (root.existsSync()) { - return root; + return Directory(p.normalize(p.absolute(candidate))); } } throw StateError('Missing fixture directory for $name'); } - -Future _prepareFixture(final String projectRoot) async { - _ensureSyncedWebArtifacts(projectRoot); - await _normalizeManifest(projectRoot); -} - -void _ensureSyncedWebArtifacts(final String projectRoot) { - const sync = PlatformSync(); - sync.syncWeb(projectRoot: projectRoot); -} - -Future _normalizeManifest(final String projectRoot) async { - final config = loadIntentCallConfig(projectRoot); - final outPath = resolveManifestOutput(projectRoot, config: config); - final merger = ManifestMerger(); - final policy = merger.loadProjectionPolicy(projectRoot: projectRoot); - final catalog = await const CatalogLoader().load( - projectRoot: projectRoot, - manifestOut: outPath, - ); - final manifest = merger.mergeManifest( - catalog: catalog, - policy: policy, - protocolScheme: config?.protocolScheme ?? merger.readProtocolScheme(projectRoot), - platform: config?.host == IntentCallHost.jaspr ? 'web' : 'unified', - ); - outPath.writeAsStringSync(merger.encodeManifest(manifest)); -} diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/build.yaml b/packages/intentcall_cli/test/fixtures/codegen_dart_project/build.yaml new file mode 100644 index 0000000..ae936ad --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/build.yaml @@ -0,0 +1,9 @@ +targets: + $default: + builders: + intentcall_codegen|agent_tool: + generate_for: + - lib/** + intentcall_codegen|agent_catalog: + generate_for: + - lib/** diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/intentcall.yaml b/packages/intentcall_cli/test/fixtures/codegen_dart_project/intentcall.yaml new file mode 100644 index 0000000..5aa2bfd --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/intentcall.yaml @@ -0,0 +1,10 @@ +host: dart +protocolScheme: codegenfixture +layout: + manifest: web/agent_manifest.json + webDir: web +platforms: + enabled: + - web +defaults: + dispatchMode: openApp diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart new file mode 100644 index 0000000..fa7df58 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart @@ -0,0 +1,9 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import '../tools/demo_ping_tool.dart'; + +final List agentCatalogEntries = + [ + AgentRegistryCatalogEntry(registryKey: 'app_demo_ping', entry: demoPingCallEntry), +]; diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.dart b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.dart new file mode 100644 index 0000000..104d7f6 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.dart @@ -0,0 +1,10 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'demo_ping_tool.g.dart'; + +@AgentTool(name: 'demo_ping', description: 'Returns pong for a message') +Future demoPing( + @AgentParam('Message to echo') final String message, +) async => AgentResult.success(data: {'pong': message}); diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart new file mode 100644 index 0000000..ee80782 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ignore_for_file: type=lint + +part of 'demo_ping_tool.dart'; + +// ************************************************************************** +// AgentToolGenerator +// ************************************************************************** + +const _demo_pingInputSchema = { + 'type': 'object', + 'properties': { + 'message': { + 'type': 'string', + 'description': 'Message to echo', + }, + }, + 'required': ['message'], +}; + +RegisteredAgentIntent get demoPingRegistration => + demoPingCallEntry.toRegistration(); + +AgentCallEntry get demoPingCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_ping', + description: 'Returns pong for a message', + inputSchema: _demo_pingInputSchema, + handler: (final args) async { + final result = Function.apply(demoPing, [ + args['message'] as String, + ], {}); + return await (result as Future); + }, +); diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.lock b/packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.lock new file mode 100644 index 0000000..ff7e152 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.lock @@ -0,0 +1,401 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intentcall_codegen: + dependency: "direct main" + description: + path: "../../../../intentcall_codegen" + relative: true + source: path + version: "0.6.0" + intentcall_core: + dependency: "direct main" + description: + path: "../../../../intentcall_core" + relative: true + source: path + version: "0.6.0" + intentcall_platform_sync: + dependency: "direct main" + description: + path: "../../../../intentcall_platform_sync" + relative: true + source: path + version: "0.6.0" + intentcall_schema: + dependency: "direct main" + description: + path: "../../../../intentcall_schema" + relative: true + source: path + version: "0.6.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.yaml b/packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.yaml new file mode 100644 index 0000000..ec8f58c --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/pubspec.yaml @@ -0,0 +1,27 @@ +name: codegen_dart_project +description: ADR 0019 gate fixture — plain Dart host with generated catalog. +publish_to: none + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + intentcall_codegen: + path: ../../../../intentcall_codegen + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync + intentcall_schema: + path: ../../../../intentcall_schema + +dev_dependencies: + build_runner: ^2.15.0 + +dependency_overrides: + intentcall_schema: + path: ../../../../intentcall_schema + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json new file mode 100644 index 0000000..b8d67d4 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "platform": "unified", + "tools": [ + { + "qualifiedName": "app_demo_ping", + "namespace": "app", + "name": "demo_ping", + "description": "Returns pong for a message", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to echo" + } + }, + "required": [ + "message" + ] + } + } + ], + "protocolScheme": "codegenfixture" +} diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js new file mode 100644 index 0000000..ac15bc7 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js @@ -0,0 +1,218 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 'use strict'; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' + ? doc.modelContext + : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + "name": "app_demo_ping", + "description": "Returns pong for a message", + "inputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to echo" + } + }, + "required": [ + "message" + ] + } + } +]; + + function validationError(message) { + return { ok: false, code: 'validation_error', message: message }; + } + + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError(path + ' must be at least ' + schema.minimum + '.'); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + ' must be at most ' + schema.maximum + '.'); + } + return null; + } + + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case 'string': + if (typeof value !== 'string') return validationError(path + ' must be a string.'); + return null; + case 'integer': + if (typeof value !== 'number' || value % 1 !== 0) { + return validationError(path + ' must be an integer.'); + } + return validateNumericBounds(path, schema, value); + case 'number': + if (typeof value !== 'number') return validationError(path + ' must be a number.'); + return validateNumericBounds(path, schema, value); + case 'boolean': + if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); + return null; + case 'object': + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return validationError(path + ' must be an object.'); + } + return null; + case 'array': + if (!Array.isArray(value)) return validationError(path + ' must be an array.'); + var arrayPath = path; + if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && + arrayPath.charAt(arrayPath.length - 1) === '"') { + arrayPath = arrayPath.slice(1, -1); + } + return validateArrayItems(arrayPath, schema, value); + default: + return null; + } + } + + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Unknown property "' + key + '"' + at + '.'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Missing required property "' + name + '"' + atReq + '.'); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; + var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); + if (propErr) return propErr; + } + return null; + } + + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== 'object' || Array.isArray(items)) return null; + if (items.type !== 'object') return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + '[' + i + ']'; + if (typeof element !== 'object' || element === null || Array.isArray(element)) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } + + function validateInput(schema, args) { + if (!schema || schema.type !== 'object') return null; + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, reqKey) || + args[reqKey] === undefined || args[reqKey] === null) { + return validationError('Missing required property: ' + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } + + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: 'runtime_unavailable', + message: 'No Dart WebMCP runtime registered for ' + name + '.', + }); + } + return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args || {}), + }).then(function (response) { + return response.json(); + }); + } + + tools.forEach(function (tool) { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: function (args) { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === 'function') { + return Promise.resolve(dart(tool.name, args || {})).then(function (result) { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== 'undefined' ? globalThis : window); diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json new file mode 100644 index 0000000..8109e44 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "codegen_dart_project", + "start_url": ".", + "shortcuts": [ + { + "name": "Demo Ping", + "short_name": "Demo Ping", + "description": "Returns pong for a message", + "url": "/agent/invoke?name=app_demo_ping" + } + ], + "protocol_handlers": [ + { + "protocol": "web+intentcall", + "url": "/agent/invoke?protocol=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_ping&payload=%s" + } + ] +} \ No newline at end of file diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/build.yaml b/packages/intentcall_cli/test/fixtures/flutter_project/build.yaml new file mode 100644 index 0000000..ae936ad --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/build.yaml @@ -0,0 +1,9 @@ +targets: + $default: + builders: + intentcall_codegen|agent_tool: + generate_for: + - lib/** + intentcall_codegen|agent_catalog: + generate_for: + - lib/** diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart b/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart new file mode 100644 index 0000000..99126de --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart @@ -0,0 +1,9 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import '../tools/cart_total_tool.dart'; + +final List agentCatalogEntries = + [ + AgentRegistryCatalogEntry(registryKey: 'app_cart_total', entry: cartTotalCallEntry), +]; diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.dart b/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.dart new file mode 100644 index 0000000..c3561da --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.dart @@ -0,0 +1,10 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'cart_total_tool.g.dart'; + +@AgentTool(name: 'cart_total', description: 'Return cart total') +Future cartTotal( + @AgentParam('Currency code') final String currency, +) async => AgentResult.success(data: {'currency': currency}); diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart b/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart new file mode 100644 index 0000000..fde2b39 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ignore_for_file: type=lint + +part of 'cart_total_tool.dart'; + +// ************************************************************************** +// AgentToolGenerator +// ************************************************************************** + +const _cart_totalInputSchema = { + 'type': 'object', + 'properties': { + 'currency': { + 'type': 'string', + 'description': 'Currency code', + }, + }, + 'required': ['currency'], +}; + +RegisteredAgentIntent get cartTotalRegistration => + cartTotalCallEntry.toRegistration(); + +AgentCallEntry get cartTotalCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'cart_total', + description: 'Return cart total', + inputSchema: _cart_totalInputSchema, + handler: (final args) async { + final result = Function.apply(cartTotal, [ + args['currency'] as String, + ], {}); + return await (result as Future); + }, +); diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.lock b/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.lock new file mode 100644 index 0000000..ff7e152 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.lock @@ -0,0 +1,401 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intentcall_codegen: + dependency: "direct main" + description: + path: "../../../../intentcall_codegen" + relative: true + source: path + version: "0.6.0" + intentcall_core: + dependency: "direct main" + description: + path: "../../../../intentcall_core" + relative: true + source: path + version: "0.6.0" + intentcall_platform_sync: + dependency: "direct main" + description: + path: "../../../../intentcall_platform_sync" + relative: true + source: path + version: "0.6.0" + intentcall_schema: + dependency: "direct main" + description: + path: "../../../../intentcall_schema" + relative: true + source: path + version: "0.6.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml b/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml new file mode 100644 index 0000000..a1c0c1a --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml @@ -0,0 +1,27 @@ +name: flutter_project_fixture +description: CLI test fixture with generated catalog. +publish_to: none + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + intentcall_codegen: + path: ../../../../intentcall_codegen + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync + intentcall_schema: + path: ../../../../intentcall_schema + +dev_dependencies: + build_runner: ^2.15.0 + +dependency_overrides: + intentcall_schema: + path: ../../../../intentcall_schema + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json index 9f2eed9..1fad67c 100644 --- a/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json @@ -10,38 +10,24 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { - "apple.appShortcuts": { - "include": false - }, - "android.shortcuts": { - "include": true - }, "web.manifestShortcuts": { "include": true }, - "web.protocolHandlers": { - "include": true - }, "web.webMcp": { "include": true - }, - "windows.protocolActivation": { - "include": true - }, - "windows.msixProtocol": { - "include": true - }, - "linux.schemeHandler": { - "include": true } }, "inputSchema": { "type": "object", "properties": { "currency": { - "type": "string" + "type": "string", + "description": "Currency code" } - } + }, + "required": [ + "currency" + ] } } ], diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js index c5338fd..4f78863 100644 --- a/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js @@ -22,9 +22,13 @@ "type": "object", "properties": { "currency": { - "type": "string" + "type": "string", + "description": "Currency code" } - } + }, + "required": [ + "currency" + ] } } ]; diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/build.yaml b/packages/intentcall_cli/test/fixtures/jaspr_web_project/build.yaml new file mode 100644 index 0000000..ae936ad --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/build.yaml @@ -0,0 +1,9 @@ +targets: + $default: + builders: + intentcall_codegen|agent_tool: + generate_for: + - lib/** + intentcall_codegen|agent_catalog: + generate_for: + - lib/** diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart new file mode 100644 index 0000000..f9a2c3b --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart @@ -0,0 +1,9 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import '../tools/shop_search_tool.dart'; + +final List agentCatalogEntries = + [ + AgentRegistryCatalogEntry(registryKey: 'shop_search', entry: searchCallEntry), +]; diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.dart b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.dart new file mode 100644 index 0000000..c2a6c8c --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.dart @@ -0,0 +1,9 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'shop_search_tool.g.dart'; + +@AgentTool(namespace: 'shop', name: 'search', description: 'Search catalog') +Future shopSearch(@AgentParam('Query') final String query) async => + AgentResult.success(data: {'query': query}); diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart new file mode 100644 index 0000000..b19ba0c --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart @@ -0,0 +1,34 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ignore_for_file: type=lint + +part of 'shop_search_tool.dart'; + +// ************************************************************************** +// AgentToolGenerator +// ************************************************************************** + +const _searchInputSchema = { + 'type': 'object', + 'properties': { + 'query': {'type': 'string', 'description': 'Query'}, + }, + 'required': ['query'], +}; + +RegisteredAgentIntent get searchRegistration => + searchCallEntry.toRegistration(); + +AgentCallEntry get searchCallEntry => AgentCallEntry.tool( + namespace: 'shop', + name: 'search', + description: 'Search catalog', + inputSchema: _searchInputSchema, + handler: (final args) async { + final result = Function.apply(shopSearch, [ + args['query'] as String, + ], {}); + return await (result as Future); + }, +); diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock new file mode 100644 index 0000000..ff7e152 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock @@ -0,0 +1,401 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intentcall_codegen: + dependency: "direct main" + description: + path: "../../../../intentcall_codegen" + relative: true + source: path + version: "0.6.0" + intentcall_core: + dependency: "direct main" + description: + path: "../../../../intentcall_core" + relative: true + source: path + version: "0.6.0" + intentcall_platform_sync: + dependency: "direct main" + description: + path: "../../../../intentcall_platform_sync" + relative: true + source: path + version: "0.6.0" + intentcall_schema: + dependency: "direct main" + description: + path: "../../../../intentcall_schema" + relative: true + source: path + version: "0.6.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml new file mode 100644 index 0000000..613243a --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml @@ -0,0 +1,27 @@ +name: jaspr_web_project_fixture +description: CLI test fixture with generated catalog. +publish_to: none + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + intentcall_codegen: + path: ../../../../intentcall_codegen + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync + intentcall_schema: + path: ../../../../intentcall_schema + +dev_dependencies: + build_runner: ^2.15.0 + +dependency_overrides: + intentcall_schema: + path: ../../../../intentcall_schema + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json index 9e9b5bc..1d3edf5 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json @@ -39,7 +39,8 @@ "type": "object", "properties": { "query": { - "type": "string" + "type": "string", + "description": "Query" } }, "required": [ diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js index 9d8ea15..db4085b 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js @@ -22,7 +22,8 @@ "type": "object", "properties": { "query": { - "type": "string" + "type": "string", + "description": "Query" } }, "required": [ diff --git a/packages/intentcall_cli/test/manifest_registry_parity_test.dart b/packages/intentcall_cli/test/manifest_registry_parity_test.dart index 36a2a93..919e8e2 100644 --- a/packages/intentcall_cli/test/manifest_registry_parity_test.dart +++ b/packages/intentcall_cli/test/manifest_registry_parity_test.dart @@ -1,66 +1,58 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_cli/src/catalog/catalog_loader.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; -import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; -String fixtureRoot() { +String fixtureRoot(final String name) { final candidates = [ - p.join('packages', 'intentcall_cli', 'test', 'fixtures', 'flutter_project'), - p.join('test', 'fixtures', 'flutter_project'), + p.join('packages', 'intentcall_cli', 'test', 'fixtures', name), + p.join('test', 'fixtures', name), ]; for (final candidate in candidates) { - if (File(p.join(candidate, 'web', 'agent_manifest.json')).existsSync()) { - return candidate; + final dir = Directory(candidate); + if (dir.existsSync()) { + return p.normalize(p.absolute(candidate)); } } throw StateError( - 'flutter_project fixture not found from ${Directory.current.path}', + 'fixture $name not found from ${Directory.current.path}', ); } void main() { - test('manifest tools are subset of fixture registry descriptors', () { - final fixtureRootPath = fixtureRoot(); - final manifestFile = File( - p.join(fixtureRootPath, 'web', 'agent_manifest.json'), - ); + test('catalog and manifest qualified names match bidirectionally', () async { + final projectRoot = fixtureRoot('codegen_dart_project'); + final manifestFile = File(p.join(projectRoot, 'web', 'agent_manifest.json')); final manifest = AgentManifest.parse(manifestFile.readAsStringSync()); - final registry = InMemoryAgentRegistry(); - registry.register( - AgentCallEntry.tool( - namespace: 'app', - name: 'cart_total', - description: 'Return cart total', - inputSchema: const {'type': 'object'}, - handler: (_) async => AgentResult.success(), - ).toRegistration(), - ); + final catalog = await const CatalogLoader().load(projectRoot: projectRoot); - final registryKeys = registry.listEntries().map((final e) => e.key).toSet(); - for (final entry in manifest.tools) { - expect( - registryKeys.contains(entry.qualifiedName), - isTrue, - reason: 'manifest tool ${entry.qualifiedName} missing from registry', - ); - } + final catalogNames = catalog.map((final row) => row.qualifiedName).toSet(); + final manifestNames = manifest.tools.map((final t) => t.qualifiedName).toSet(); + + expect(catalogNames, isNotEmpty); + expect(manifestNames.difference(catalogNames), isEmpty); + expect(catalogNames.difference(manifestNames), isEmpty); + }); + + test('flutter fixture catalog matches manifest tools', () async { + final projectRoot = fixtureRoot('flutter_project'); + final manifestFile = File(p.join(projectRoot, 'web', 'agent_manifest.json')); + final manifest = AgentManifest.parse(manifestFile.readAsStringSync()); + final catalog = await const CatalogLoader().load(projectRoot: projectRoot); + + final catalogNames = catalog.map((final row) => row.qualifiedName).toSet(); + final manifestNames = manifest.tools.map((final t) => t.qualifiedName).toSet(); + + expect(catalogNames, manifestNames); }); test('manifest export --check fixture is valid JSON manifest', () { - final fixtureRootPath = fixtureRoot(); - final manifestFile = File( - p.join(fixtureRootPath, 'web', 'agent_manifest.json'), - ); - final json = - jsonDecode(manifestFile.readAsStringSync()) as Map; - expect(json['version'], 1); - expect( - AgentManifest.parse(manifestFile.readAsStringSync()).tools, - isNotEmpty, - ); + final projectRoot = fixtureRoot('flutter_project'); + final manifestFile = File(p.join(projectRoot, 'web', 'agent_manifest.json')); + final parsed = AgentManifest.parse(manifestFile.readAsStringSync()); + expect(parsed.version, 1); + expect(parsed.tools, isNotEmpty); }); } diff --git a/packages/intentcall_codegen/README.md b/packages/intentcall_codegen/README.md index cac8c18..e98003b 100644 --- a/packages/intentcall_codegen/README.md +++ b/packages/intentcall_codegen/README.md @@ -41,7 +41,16 @@ Future demoPing(@AgentParam('Message') String message) async { dart run build_runner build --delete-conflicting-outputs ``` -4. Register generated intent: +This emits `lib/generated/agent_catalog.g.dart` (all `@AgentTool` registrations) +and per-file `*.g.dart` part files. + +4. Export the platform manifest (host project with `intentcall.yaml`): + +```bash +dart run intentcall_cli:intentcall manifest export --check +``` + +5. Register generated intents: ```dart registry.register(demoPingRegistration); @@ -49,6 +58,21 @@ registry.register(demoPingRegistration); registerAll(registry, {demoPingCallEntry}); ``` +## Runnable example + +See [`example/`](example/) for a self-contained Dart host (`lib/tools/`, +`lib/generated/agent_catalog.g.dart`, `web/agent_manifest.json`): + +```bash +cd example +dart pub get +dart run build_runner build +dart run intentcall_cli:intentcall manifest export --check --project-dir . +``` + +The library package root intentionally has **no** committed catalog or manifest — +only annotations and builders. + ## Generated output For each `@AgentTool` function, `.g.dart` emits: diff --git a/packages/intentcall_codegen/build.yaml b/packages/intentcall_codegen/build.yaml index 1d702d6..e13da46 100644 --- a/packages/intentcall_codegen/build.yaml +++ b/packages/intentcall_codegen/build.yaml @@ -2,15 +2,9 @@ targets: $default: builders: intentcall_codegen|agent_tool: - generate_for: - - example/** + enabled: false intentcall_codegen|agent_catalog: - generate_for: - - lib/** - - example/** - intentcall_codegen|agent_manifest: - generate_for: - - $package$ + enabled: false builders: agent_tool: @@ -25,9 +19,3 @@ builders: build_extensions: {r'$lib$': ['generated/agent_catalog.g.dart']} auto_apply: dependents build_to: source - agent_manifest: - import: "package:intentcall_codegen/builder.dart" - builder_factories: ["agentManifestBuilder"] - build_extensions: {r'$package$': ['web/agent_manifest.json']} - auto_apply: dependents - build_to: source diff --git a/packages/intentcall_codegen/example/analysis_options.yaml b/packages/intentcall_codegen/example/analysis_options.yaml new file mode 100644 index 0000000..61a6f2b --- /dev/null +++ b/packages/intentcall_codegen/example/analysis_options.yaml @@ -0,0 +1,10 @@ +# Flutter plugin — app-level lint set. +include: package:xsoulspace_lints/app.yaml + +analyzer: + language: + strict-casts: true + errors: + lines_longer_than_80_chars: ignore + exclude: + - "**/*.g.dart" diff --git a/packages/intentcall_codegen/example/build.yaml b/packages/intentcall_codegen/example/build.yaml new file mode 100644 index 0000000..ae936ad --- /dev/null +++ b/packages/intentcall_codegen/example/build.yaml @@ -0,0 +1,9 @@ +targets: + $default: + builders: + intentcall_codegen|agent_tool: + generate_for: + - lib/** + intentcall_codegen|agent_catalog: + generate_for: + - lib/** diff --git a/packages/intentcall_codegen/intentcall.yaml b/packages/intentcall_codegen/example/intentcall.yaml similarity index 100% rename from packages/intentcall_codegen/intentcall.yaml rename to packages/intentcall_codegen/example/intentcall.yaml diff --git a/packages/intentcall_codegen/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart similarity index 82% rename from packages/intentcall_codegen/lib/generated/agent_catalog.g.dart rename to packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart index 8a4d876..1cf098c 100644 --- a/packages/intentcall_codegen/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -1,9 +1,9 @@ // GENERATED CODE - DO NOT MODIFY BY HAND import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; -import '../../example/demo_ping_tool.dart'; +import '../tools/demo_ping_tool.dart'; -const List agentCatalogEntries = +final List agentCatalogEntries = [ AgentRegistryCatalogEntry(registryKey: 'app_demo_ping', entry: demoPingCallEntry), AgentRegistryCatalogEntry(registryKey: 'app_demo_cart', entry: demoCartCallEntry), diff --git a/packages/intentcall_codegen/example/demo_ping_tool.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart similarity index 100% rename from packages/intentcall_codegen/example/demo_ping_tool.dart rename to packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart diff --git a/packages/intentcall_codegen/example/demo_ping_tool.g.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart similarity index 100% rename from packages/intentcall_codegen/example/demo_ping_tool.g.dart rename to packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart diff --git a/packages/intentcall_codegen/example/pubspec.lock b/packages/intentcall_codegen/example/pubspec.lock new file mode 100644 index 0000000..9e2b9a6 --- /dev/null +++ b/packages/intentcall_codegen/example/pubspec.lock @@ -0,0 +1,417 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intentcall_codegen: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.6.0" + intentcall_core: + dependency: "direct main" + description: + path: "../../intentcall_core" + relative: true + source: path + version: "0.6.0" + intentcall_platform_sync: + dependency: "direct main" + description: + path: "../../intentcall_platform_sync" + relative: true + source: path + version: "0.6.0" + intentcall_schema: + dependency: "direct main" + description: + path: "../../intentcall_schema" + relative: true + source: path + version: "0.6.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + xsoulspace_lints: + dependency: "direct dev" + description: + name: xsoulspace_lints + sha256: fc56073d7a17b4b0e8ef9efbc5e1948b2b4eb8e0fabdbfe702e9887151250c34 + url: "https://pub.dev" + source: hosted + version: "0.1.2" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" diff --git a/packages/intentcall_codegen/example/pubspec.yaml b/packages/intentcall_codegen/example/pubspec.yaml new file mode 100644 index 0000000..d55b402 --- /dev/null +++ b/packages/intentcall_codegen/example/pubspec.yaml @@ -0,0 +1,29 @@ +name: intentcall_codegen_example +description: Runnable dogfood host for intentcall_codegen. +publish_to: none + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + intentcall_codegen: + path: .. + intentcall_core: + path: ../../intentcall_core + intentcall_platform_sync: + path: ../../intentcall_platform_sync + intentcall_schema: + path: ../../intentcall_schema + +dev_dependencies: + build_runner: ^2.15.0 + lints: ^6.1.0 + xsoulspace_lints: ^0.1.2 + +dependency_overrides: + intentcall_schema: + path: ../../intentcall_schema + intentcall_core: + path: ../../intentcall_core + intentcall_platform_sync: + path: ../../intentcall_platform_sync diff --git a/packages/intentcall_codegen/web/agent_manifest.json b/packages/intentcall_codegen/example/web/agent_manifest.json similarity index 100% rename from packages/intentcall_codegen/web/agent_manifest.json rename to packages/intentcall_codegen/example/web/agent_manifest.json diff --git a/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js new file mode 100644 index 0000000..c8d74a7 --- /dev/null +++ b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js @@ -0,0 +1,254 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 'use strict'; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' + ? doc.modelContext + : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + "name": "app_demo_ping", + "description": "Returns pong for a message", + "inputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to echo" + } + }, + "required": [ + "message" + ] + } + }, + { + "name": "app_demo_cart", + "description": "Returns a cart total", + "inputSchema": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "description": "Currency code" + }, + "includeTax": { + "type": "boolean", + "description": "Include tax" + } + }, + "required": [ + "currency" + ] + } + }, + { + "name": "app_demo_required_named", + "description": "Returns a required named parameter", + "inputSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "Mode" + } + }, + "required": [ + "mode" + ] + } + } +]; + + function validationError(message) { + return { ok: false, code: 'validation_error', message: message }; + } + + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError(path + ' must be at least ' + schema.minimum + '.'); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + ' must be at most ' + schema.maximum + '.'); + } + return null; + } + + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case 'string': + if (typeof value !== 'string') return validationError(path + ' must be a string.'); + return null; + case 'integer': + if (typeof value !== 'number' || value % 1 !== 0) { + return validationError(path + ' must be an integer.'); + } + return validateNumericBounds(path, schema, value); + case 'number': + if (typeof value !== 'number') return validationError(path + ' must be a number.'); + return validateNumericBounds(path, schema, value); + case 'boolean': + if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); + return null; + case 'object': + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return validationError(path + ' must be an object.'); + } + return null; + case 'array': + if (!Array.isArray(value)) return validationError(path + ' must be an array.'); + var arrayPath = path; + if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && + arrayPath.charAt(arrayPath.length - 1) === '"') { + arrayPath = arrayPath.slice(1, -1); + } + return validateArrayItems(arrayPath, schema, value); + default: + return null; + } + } + + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Unknown property "' + key + '"' + at + '.'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; + return validationError('Missing required property "' + name + '"' + atReq + '.'); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; + var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); + if (propErr) return propErr; + } + return null; + } + + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== 'object' || Array.isArray(items)) return null; + if (items.type !== 'object') return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + '[' + i + ']'; + if (typeof element !== 'object' || element === null || Array.isArray(element)) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } + + function validateInput(schema, args) { + if (!schema || schema.type !== 'object') return null; + args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.prototype.hasOwnProperty.call(args, key) && + !Object.prototype.hasOwnProperty.call(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if (!Object.prototype.hasOwnProperty.call(args, reqKey) || + args[reqKey] === undefined || args[reqKey] === null) { + return validationError('Missing required property: ' + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; + if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } + + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: 'runtime_unavailable', + message: 'No Dart WebMCP runtime registered for ' + name + '.', + }); + } + return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args || {}), + }).then(function (response) { + return response.json(); + }); + } + + tools.forEach(function (tool) { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: function (args) { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === 'function') { + return Promise.resolve(dart(tool.name, args || {})).then(function (result) { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== 'undefined' ? globalThis : window); diff --git a/packages/intentcall_codegen/example/web/manifest.json b/packages/intentcall_codegen/example/web/manifest.json new file mode 100644 index 0000000..9f16cc4 --- /dev/null +++ b/packages/intentcall_codegen/example/web/manifest.json @@ -0,0 +1,42 @@ +{ + "name": "intentcall_codegen_example", + "start_url": ".", + "shortcuts": [ + { + "name": "Demo Ping", + "short_name": "Demo Ping", + "description": "Returns pong for a message", + "url": "/agent/invoke?name=app_demo_ping" + }, + { + "name": "Demo Cart", + "short_name": "Demo Cart", + "description": "Returns a cart total", + "url": "/agent/invoke?name=app_demo_cart" + }, + { + "name": "Demo Required Named", + "short_name": "Demo Required Named", + "description": "Returns a required named parameter", + "url": "/agent/invoke?name=app_demo_required_named" + } + ], + "protocol_handlers": [ + { + "protocol": "web+intentcall", + "url": "/agent/invoke?protocol=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_ping&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_cart&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_required_named&payload=%s" + } + ] +} \ No newline at end of file diff --git a/packages/intentcall_codegen/lib/builder.dart b/packages/intentcall_codegen/lib/builder.dart index d57e4ec..49b7ff1 100644 --- a/packages/intentcall_codegen/lib/builder.dart +++ b/packages/intentcall_codegen/lib/builder.dart @@ -2,7 +2,6 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'src/generators/agent_catalog_generator.dart'; -import 'src/generators/agent_manifest_generator.dart'; import 'src/generators/agent_tool_generator.dart'; /// build_runner builder for `@AgentTool` → `.g.dart` registration factories. @@ -17,8 +16,4 @@ Builder agentToolBuilder(final BuilderOptions options) => PartBuilder( /// Aggregates all `@AgentTool` registrations into `lib/generated/agent_catalog.g.dart`. Builder agentCatalogBuilder(final BuilderOptions options) => - AgentCatalogGenerator(); - -/// Writes `web/agent_manifest.json` from catalog + `@AgentProjection` metadata. -Builder agentManifestBuilder(final BuilderOptions options) => - AgentManifestAssetGenerator(options); + AgentCatalogGenerator(options); diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart index ec69b60..742abc3 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -3,17 +3,25 @@ import 'package:glob/glob.dart'; import 'package:path/path.dart' as p; import 'package:source_gen/source_gen.dart'; +import '../agent_projection.dart'; import '../agent_tool.dart'; /// Emits `lib/generated/agent_catalog.g.dart` listing all tool call entries. class AgentCatalogGenerator implements Builder { + AgentCatalogGenerator(this.options); + + final BuilderOptions options; + static const _toolChecker = TypeChecker.typeNamed(AgentTool); + static const _projectionChecker = TypeChecker.typeNamed(AgentProjection); @override - final Map> buildExtensions = const { + Map> get buildExtensions => const { r'$lib$': ['generated/agent_catalog.g.dart'], }; + bool get _scanExample => options.config['scan_example'] as bool? ?? false; + @override Future build(final BuildStep buildStep) async { final outputPath = buildStep.allowedOutputs.single.path; @@ -25,18 +33,32 @@ class AgentCatalogGenerator implements Builder { final fileEntries = []; for (final element in library.topLevelFunctions) { + ConstantReader? toolReader; + ConstantReader? projectionReader; for (final annotation in element.metadata.annotations) { final reader = ConstantReader(annotation.computeConstantValue()); if (!reader.isNull && reader.instanceOf(_toolChecker)) { - final namespace = reader.read('namespace').stringValue; - final name = reader.read('name').stringValue; - final registryKey = '${namespace}_$name'; - final entryGetter = '${_toCamelCase(name)}CallEntry'; - fileEntries.add( - " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryGetter),", - ); + toolReader = reader; + } + if (!reader.isNull && reader.instanceOf(_projectionChecker)) { + projectionReader = reader; } } + if (toolReader == null) { + continue; + } + final namespace = toolReader.read('namespace').stringValue; + final name = toolReader.read('name').stringValue; + final registryKey = '${namespace}_$name'; + final entryGetter = '${_toCamelCase(name)}CallEntry'; + final projectionLiteral = projectionReader == null + ? null + : _projectionLiteral(projectionReader); + fileEntries.add( + projectionLiteral == null + ? " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryGetter)," + : " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryGetter, projection: $projectionLiteral),", + ); } if (fileEntries.isEmpty) { @@ -61,8 +83,8 @@ ${imports.join('\n')} if (entries.isEmpty) { await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header -/// Empty catalog — add @AgentTool functions under lib/ or example/. -const List agentCatalogEntries = +/// Empty catalog — add @AgentTool functions under lib/. +final List agentCatalogEntries = []; '''); return; @@ -70,7 +92,7 @@ const List agentCatalogEntries = await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header -const List agentCatalogEntries = +final List agentCatalogEntries = [ ${entries.join('\n')} ]; @@ -78,7 +100,11 @@ ${entries.join('\n')} } Stream _toolSources(final BuildStep buildStep) async* { - for (final pattern in ['lib/**.dart', 'example/**.dart']) { + final patterns = ['lib/**.dart']; + if (_scanExample) { + patterns.add('example/**.dart'); + } + for (final pattern in patterns) { await for (final input in buildStep.findAssets(Glob(pattern))) { if (_isInternalSource(input.path)) { continue; @@ -95,6 +121,44 @@ ${entries.join('\n')} path.endsWith('lib/builder.dart') || path.endsWith('lib/intentcall_codegen.dart'); + String _projectionLiteral(final ConstantReader reader) { + final dispatchName = reader.read('dispatchMode').stringValue; + final surfacesRaw = reader.read('surfaces').mapValue; + final surfaceEntries = []; + for (final entry in surfacesRaw.entries) { + final key = entry.key?.toStringValue(); + final value = entry.value?.toBoolValue(); + if (key == null || value == null) { + continue; + } + surfaceEntries.add( + 'AgentManifestSurface.${_surfaceEnumName(key)}: $value', + ); + } + final surfacesBlock = surfaceEntries.isEmpty + ? 'const {}' + : '{${surfaceEntries.join(', ')}}'; + return ''' +EntryProjection( + dispatchMode: AgentManifestDispatchMode.$dispatchName, + surfaces: $surfacesBlock, +)'''; + } + + String _surfaceEnumName(final String key) { + const dottedToEnum = { + 'web.webMcp': 'webMcp', + 'web.manifestShortcuts': 'webManifestShortcuts', + 'web.protocolHandlers': 'webProtocolHandlers', + 'apple.appShortcuts': 'appleAppShortcuts', + 'android.shortcuts': 'androidShortcuts', + 'windows.protocolActivation': 'windowsProtocolActivation', + 'windows.msixProtocol': 'windowsMsixProtocol', + 'linux.schemeHandler': 'linuxSchemeHandler', + }; + return dottedToEnum[key] ?? key; + } + String _toCamelCase(final String value) { if (value.isEmpty) { return value; diff --git a/packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart deleted file mode 100644 index c8d5531..0000000 --- a/packages/intentcall_codegen/lib/src/generators/agent_manifest_generator.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:analyzer/dart/element/element.dart'; -import 'package:build/build.dart'; -import 'package:glob/glob.dart'; -import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; -import 'package:source_gen/source_gen.dart'; - -import '../agent_projection.dart'; -import '../agent_tool.dart'; -import 'agent_tool_generator.dart'; - -/// Writes `web/agent_manifest.json` from `@AgentTool` and `@AgentProjection`. -class AgentManifestAssetGenerator implements Builder { - AgentManifestAssetGenerator(this.options); - - final BuilderOptions options; - - static const _toolChecker = TypeChecker.typeNamed(AgentTool); - static const _projectionChecker = TypeChecker.typeNamed(AgentProjection); - - @override - Map> get buildExtensions => { - r'$package$': [ - options.config['manifest_output'] as String? ?? 'web/agent_manifest.json', - ], - }; - - @override - Future build(final BuildStep buildStep) async { - final catalog = []; - final overlays = {}; - final toolGen = AgentToolGenerator(); - - await for (final input in _toolSources(buildStep)) { - final library = await buildStep.resolver.libraryFor(input); - for (final element in library.topLevelFunctions) { - ConstantReader? toolReader; - for (final annotation in element.metadata.annotations) { - final reader = ConstantReader(annotation.computeConstantValue()); - if (!reader.isNull && reader.instanceOf(_toolChecker)) { - toolReader = reader; - } - if (!reader.isNull && reader.instanceOf(_projectionChecker)) { - final namespace = _toolNamespace(element, toolReader); - final name = _toolName(element, toolReader); - if (namespace != null && name != null) { - final qualified = '${namespace}_$name'; - overlays[qualified] = _entryProjectionFromReader(reader); - } - } - } - if (toolReader == null) { - continue; - } - final namespace = toolReader.read('namespace').stringValue; - final name = toolReader.read('name').stringValue; - final description = toolReader.read('description').stringValue; - final schema = toolGen.inputSchemaMapFor(element); - catalog.add( - AgentRegistryCatalogEntry( - registryKey: '${namespace}_$name', - descriptor: AgentIntentDescriptor( - namespace: namespace, - name: name, - description: description, - kind: AgentIntentKind.tool, - inputSchema: schema, - ), - ), - ); - } - } - - const merger = ManifestMerger(); - final policy = ProjectionPolicy(overlays: overlays); - final protocolScheme = options.config['protocol_scheme'] as String?; - final manifest = merger.mergeManifest( - catalog: catalog, - policy: policy, - protocolScheme: protocolScheme, - ); - await buildStep.writeAsString( - buildStep.allowedOutputs.single, - merger.encodeManifest(manifest), - ); - } - - Stream _toolSources(final BuildStep buildStep) async* { - for (final pattern in ['lib/**.dart', 'example/**.dart']) { - await for (final input in buildStep.findAssets(Glob(pattern))) { - if (_isInternalSource(input.path)) { - continue; - } - yield input; - } - } - } - - bool _isInternalSource(final String path) => - path.contains('.g.dart') || - path.contains('generated/') || - path.contains('lib/src/') || - path.endsWith('lib/builder.dart') || - path.endsWith('lib/intentcall_codegen.dart'); - - String? _toolNamespace( - final TopLevelFunctionElement element, - final ConstantReader? toolReader, - ) => toolReader?.read('namespace').stringValue; - - String? _toolName( - final TopLevelFunctionElement element, - final ConstantReader? toolReader, - ) => toolReader?.read('name').stringValue; - - EntryProjection _entryProjectionFromReader(final ConstantReader reader) { - final dispatchName = reader.read('dispatchMode').stringValue; - final surfacesRaw = reader.read('surfaces').mapValue; - final surfaces = {}; - for (final entry in surfacesRaw.entries) { - final key = entry.key?.toStringValue(); - final value = entry.value?.toBoolValue(); - if (key == null || value == null) { - continue; - } - final surface = lookupAgentManifestSurface(key); - if (surface != null) { - surfaces[surface] = value; - } - } - return EntryProjection( - dispatchMode: AgentManifestDispatchMode.values.byName(dispatchName), - surfaces: surfaces, - ); - } -} diff --git a/packages/intentcall_codegen/test/agent_tool_generator_test.dart b/packages/intentcall_codegen/test/agent_tool_generator_test.dart index 82c049f..f718a45 100644 --- a/packages/intentcall_codegen/test/agent_tool_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_tool_generator_test.dart @@ -2,7 +2,7 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:test/test.dart'; -import '../example/demo_ping_tool.dart'; +import '../example/lib/tools/demo_ping_tool.dart'; void main() { test('generated demoPingRegistration validates before execute', () { diff --git a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart index 30ece70..1767568 100644 --- a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart @@ -1,7 +1,6 @@ library; export 'src/agent_manifest.dart'; -export 'src/agent_manifest_generator.dart'; export 'src/bootstrap/agent_web_mcp_bootstrap.dart'; export 'src/catalog/agent_registry_catalog.dart'; export 'src/emitters/android_shortcuts_xml_emitter.dart'; @@ -14,6 +13,7 @@ export 'src/emitters/web_mcp_js_emitter.dart'; export 'src/emitters/windows_protocol_emitter.dart'; export 'src/init/platform_hooks_init.dart'; export 'src/invocation/intentcall_invocation.dart'; +export 'src/projection/manifest_exporter.dart'; export 'src/projection/manifest_merger.dart'; export 'src/projection/projection_policy.dart'; export 'src/sync/platform_sync.dart'; diff --git a/packages/intentcall_platform_sync/lib/src/agent_manifest.dart b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart index 98d8395..10cf168 100644 --- a/packages/intentcall_platform_sync/lib/src/agent_manifest.dart +++ b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart @@ -743,9 +743,21 @@ extension AgentManifestSurfaceKey on AgentManifestSurface { AgentManifestSurface? lookupAgentManifestSurface(final String key) { final trimmed = key.trim(); for (final surface in AgentManifestSurface.values) { - if (surface.manifestKey == trimmed) { + if (surface.manifestKey == trimmed || surface.name == trimmed) { return surface; } } return null; } + +/// Resolves a surface key or throws with valid key hints. +AgentManifestSurface resolveAgentManifestSurface(final String key) { + final surface = lookupAgentManifestSurface(key); + if (surface != null) { + return surface; + } + final valid = AgentManifestSurface.values + .map((final s) => '${s.manifestKey} (${s.name})') + .join(', '); + throw FormatException('Unknown surface key "$key". Valid keys: $valid'); +} diff --git a/packages/intentcall_platform_sync/lib/src/agent_manifest_generator.dart b/packages/intentcall_platform_sync/lib/src/agent_manifest_generator.dart deleted file mode 100644 index aa88831..0000000 --- a/packages/intentcall_platform_sync/lib/src/agent_manifest_generator.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'dart:convert'; - -import 'package:intentcall_core/intentcall_core.dart'; - -import 'agent_manifest.dart'; - -/// Builds `agent_manifest.json` for web platform sync. -String generateWebAgentManifest( - final Iterable descriptors, { - final Iterable entityTypeDescriptors = const [], - final Iterable> entityTypes = const [], -}) { - final tools = >[]; - for (final descriptor in descriptors) { - tools.add({ - 'qualifiedName': descriptor.qualifiedName, - 'namespace': descriptor.namespace, - 'name': descriptor.name, - 'description': descriptor.description, - 'kind': descriptor.kind.name, - if (descriptor.kind == AgentIntentKind.resource) - 'resourceUri': descriptor.effectiveResourceUri, - 'inputSchema': descriptor.inputSchema, - }); - } - final entities = >[ - ...entityTypeDescriptors.map(_entityTypeDescriptorManifest), - ...entityTypes.map(Map.from), - ]; - return const JsonEncoder.withIndent(' ').convert({ - 'version': kAgentManifestSchemaVersion, - 'platform': 'web', - 'tools': tools, - if (entities.isNotEmpty) 'entityTypes': entities, - }); -} - -Map _entityTypeDescriptorManifest( - final AgentEntityTypeDescriptor descriptor, -) { - final displayProperties = descriptor.displayProperties.toList(); - final searchableProperties = descriptor.searchableProperties.toList(); - final titleKey = displayProperties.isNotEmpty - ? displayProperties.first.name - : 'title'; - final subtitleKey = displayProperties.length > 1 - ? displayProperties[1].name - : _firstOrNull( - searchableProperties - .where((final property) => property.name != titleKey) - .map((final property) => property.name), - ) ?? - 'subtitle'; - final keywordsKey = - _firstOrNull( - searchableProperties - .where( - (final property) => - property.valueType == AgentEntityPropertyValueType.array, - ) - .map((final property) => property.name), - ) ?? - 'keywords'; - return { - 'qualifiedName': descriptor.qualifiedName, - 'namespace': descriptor.namespace, - 'name': descriptor.name, - 'displayName': descriptor.displayName ?? _humanizeName(descriptor.name), - 'idKey': descriptor.identifierName, - 'titleKey': titleKey, - 'subtitleKey': subtitleKey, - 'keywordsKey': keywordsKey, - 'snapshotSchema': _snapshotSchema(descriptor), - }; -} - -Map _snapshotSchema( - final AgentEntityTypeDescriptor descriptor, -) { - final properties = { - descriptor.identifierName: const {'type': 'string'}, - }; - for (final property in descriptor.properties) { - properties[property.name] = { - 'type': _jsonSchemaType(property.valueType), - if (property.description.isNotEmpty) 'description': property.description, - if (property.isDisplay) 'x-intentcall-display': true, - if (property.isSearchable) 'x-intentcall-searchable': true, - if (property.isIndexed) 'x-intentcall-indexed': true, - if (property.privacy != null) - 'x-intentcall-privacy': property.privacy!.name, - }; - } - return { - 'type': 'object', - 'required': [descriptor.identifierName], - 'properties': properties, - }; -} - -String? _firstOrNull(final Iterable values) { - final iterator = values.iterator; - return iterator.moveNext() ? iterator.current : null; -} - -String _jsonSchemaType(final AgentEntityPropertyValueType type) => - switch (type) { - AgentEntityPropertyValueType.string => 'string', - AgentEntityPropertyValueType.integer => 'integer', - AgentEntityPropertyValueType.number => 'number', - AgentEntityPropertyValueType.boolean => 'boolean', - AgentEntityPropertyValueType.object => 'object', - AgentEntityPropertyValueType.array => 'array', - }; - -String _humanizeName(final String name) { - final parts = name - .split(RegExp(r'[_\s-]+')) - .where((final part) => part.trim().isNotEmpty); - if (parts.isEmpty) { - return name; - } - return parts - .map((final part) => '${part[0].toUpperCase()}${part.substring(1)}') - .join(' '); -} diff --git a/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart index f77cdd5..8ce1f06 100644 --- a/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart +++ b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart @@ -1,17 +1,23 @@ import 'package:intentcall_core/intentcall_core.dart'; +import '../projection/projection_policy.dart'; + /// One registry-backed row aggregated for manifest generation. final class AgentRegistryCatalogEntry { const AgentRegistryCatalogEntry({ required this.registryKey, this.descriptor, this.entry, + this.projection, }) : assert(descriptor != null || entry != null); final String registryKey; final AgentIntentDescriptor? descriptor; final AgentCallEntry? entry; + /// Per-tool projection from `@AgentProjection` or a handwritten catalog row. + final EntryProjection? projection; + AgentIntentDescriptor resolveDescriptor() => descriptor ?? entry!.toRegistration().descriptor; diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart new file mode 100644 index 0000000..2e69374 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import '../agent_manifest.dart'; +import '../catalog/agent_registry_catalog.dart'; +import 'manifest_merger.dart'; + +/// Shared manifest writer used by the CLI and tests. +final class ManifestExporter { + const ManifestExporter(); + + final _merger = const ManifestMerger(); + + ManifestExportContext loadExportContext({ + required final String projectRoot, + }) => _merger.loadExportContext(projectRoot: projectRoot); + + AgentManifest buildManifest({ + required final Iterable catalog, + required final ManifestExportContext context, + }) => _merger.mergeManifest( + catalog: catalog, + policy: context.policy, + protocolScheme: context.protocolScheme, + platform: context.platform, + ); + + String encodeManifest(final AgentManifest manifest) => + _merger.encodeManifest(manifest); + + /// Writes or checks [outPath] against merge(catalog, policy). + /// + /// Returns `0` on success, `1` on drift or missing file when [checkOnly]. + int exportToFile({ + required final Iterable catalog, + required final ManifestExportContext context, + required final File outPath, + final bool checkOnly = false, + }) { + final encoded = encodeManifest( + buildManifest(catalog: catalog, context: context), + ); + + if (checkOnly) { + if (!outPath.existsSync()) { + return 1; + } + return outPath.readAsStringSync() == encoded ? 0 : 1; + } + + outPath.parent.createSync(recursive: true); + outPath.writeAsStringSync(encoded); + return 0; + } +} diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart index 9a47458..56a349f 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart @@ -9,6 +9,21 @@ import '../agent_manifest.dart'; import '../catalog/agent_registry_catalog.dart'; import 'projection_policy.dart'; +/// Inputs for [ManifestMerger.mergeManifest] loaded from host config. +final class ManifestExportContext { + const ManifestExportContext({ + required this.policy, + required this.platform, + required this.manifestRelativePath, + this.protocolScheme, + }); + + final ProjectionPolicy policy; + final String? protocolScheme; + final String platform; + final String manifestRelativePath; +} + /// Merges registry catalog rows and projection policy into [agent_manifest.json]. final class ManifestMerger { const ManifestMerger(); @@ -25,7 +40,7 @@ final class ManifestMerger { final entries = []; for (final row in catalog) { final descriptor = row.resolveDescriptor(); - final overlay = policy.overlayFor(row.qualifiedName); + final overlay = row.projection ?? policy.overlayFor(row.qualifiedName); final dispatchMode = overlay?.dispatchMode ?? policy.defaultDispatchMode; final inlineRuntime = overlay?.inlineRuntime; if (dispatchMode == AgentManifestDispatchMode.inlineRuntime && @@ -71,27 +86,6 @@ final class ManifestMerger { String encodeManifest(final AgentManifest manifest) => '${manifest.encode()}\n'; - /// Loads `.intentcall/projection.yaml` or path from [overlayPath]. - Map loadOverlayFile(final String overlayPath) { - final file = File(overlayPath); - if (!file.existsSync()) { - return const {}; - } - final doc = loadYaml(file.readAsStringSync()); - if (doc is! YamlMap) { - return const {}; - } - final out = {}; - for (final entry in doc.entries) { - final key = entry.key.toString(); - final value = entry.value; - if (value is YamlMap) { - out[key] = EntryProjection.fromYamlMap(value); - } - } - return out; - } - ProjectionPolicy loadProjectionPolicy({ required final String projectRoot, final String configFileName = 'intentcall.yaml', @@ -104,21 +98,50 @@ final class ManifestMerger { if (doc is! YamlMap) { return const ProjectionPolicy(); } - var policy = ProjectionPolicy.fromYamlMap(doc); - final overlayRel = doc['projectionOverlay']?.toString(); - if (overlayRel != null && overlayRel.isNotEmpty) { - final overlayPath = p.isAbsolute(overlayRel) - ? overlayRel - : p.join(projectRoot, overlayRel); - policy = policy.mergeOverlays(loadOverlayFile(overlayPath)); + return ProjectionPolicy.fromYamlMap(doc); + } + + String readPlatformLabel(final String projectRoot) { + final configFile = File(p.join(projectRoot, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return 'unified'; } - final protocolScheme = doc['protocolScheme']?.toString(); - if (protocolScheme != null && protocolScheme.isNotEmpty) { - // Caller applies protocolScheme when merging. + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return 'unified'; + } + final host = doc['host']?.toString().trim(); + return host == 'jaspr' ? 'web' : 'unified'; + } + + String readManifestRelativePath(final String projectRoot) { + final configFile = File(p.join(projectRoot, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return 'web/agent_manifest.json'; } - return policy; + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return 'web/agent_manifest.json'; + } + final layout = doc['layout']; + if (layout is YamlMap) { + final manifest = layout['manifest']?.toString().trim(); + if (manifest != null && manifest.isNotEmpty) { + return manifest; + } + } + return 'web/agent_manifest.json'; } + ManifestExportContext loadExportContext({ + required final String projectRoot, + }) => ManifestExportContext( + policy: loadProjectionPolicy(projectRoot: projectRoot), + protocolScheme: readProtocolScheme(projectRoot), + platform: readPlatformLabel(projectRoot), + manifestRelativePath: readManifestRelativePath(projectRoot), + ); + String? readProtocolScheme(final String projectRoot) { final configFile = File(p.join(projectRoot, 'intentcall.yaml')); if (!configFile.existsSync()) { diff --git a/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart index 26dae05..2d7b8bd 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart @@ -30,10 +30,11 @@ final class EntryProjection { final surfacesRaw = yaml['surfaces']; if (surfacesRaw is Map) { for (final entry in surfacesRaw.entries) { - final surface = lookupAgentManifestSurface(entry.key.toString()); - if (surface != null && entry.value is bool) { - surfaces[surface] = entry.value as bool; + if (entry.value is! bool) { + continue; } + surfaces[resolveAgentManifestSurface(entry.key.toString())] = + entry.value as bool; } } return EntryProjection( @@ -90,26 +91,19 @@ final class ProjectionPolicy { final overrides = {}; for (final entry in surfacesRaw.entries) { - final surface = lookupAgentManifestSurface(entry.key.toString()); - if (surface != null && entry.value is bool) { - overrides[surface] = AgentManifestSurfaceExposure( - include: entry.value as bool, - ); + if (entry.value is! bool) { + continue; } + overrides[resolveAgentManifestSurface(entry.key.toString())] = + AgentManifestSurfaceExposure(include: entry.value as bool); } defaultSurfaces = AgentManifestSurfacePolicy(overrides); } } - final overlays = {}; - final overlayPath = yaml['projectionOverlay']?.toString(); - if (overlayPath != null && overlayPath.isNotEmpty) { - // Overlay file parsed separately by [ProjectionPolicy.loadOverlayFile]. - } return ProjectionPolicy( defaultDispatchMode: dispatchMode, defaultSurfaces: defaultSurfaces, - overlays: overlays, ); } diff --git a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart index cfebdf7..88593c2 100644 --- a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart +++ b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart @@ -48,6 +48,10 @@ intentcall platform sync --platform ios,macos || exit 1 '''; /// Jaspr / web-only hook snippet for CI or custom build steps. +/// +/// Hooks call bare `intentcall` on PATH; for local development use +/// `dart run intentcall_cli:intentcall` or `dart pub global activate intentcall_cli`. +/// mcp_flutter delegation: see ADR 0019. const kJasprWebCodegenHook = ''' # intentcall-platform: begin dart run build_runner build --delete-conflicting-outputs diff --git a/packages/intentcall_platform_sync/test/manifest_merger_test.dart b/packages/intentcall_platform_sync/test/manifest_merger_test.dart index 3dc8de2..ec2d4a6 100644 --- a/packages/intentcall_platform_sync/test/manifest_merger_test.dart +++ b/packages/intentcall_platform_sync/test/manifest_merger_test.dart @@ -3,20 +3,13 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:test/test.dart'; void main() { - test('ManifestMerger applies defaults and overlays', () { + test('ManifestMerger applies defaults and catalog row projection', () { const merger = ManifestMerger(); - const policy = ProjectionPolicy( - overlays: { - 'app_ping': EntryProjection( - dispatchMode: AgentManifestDispatchMode.queueOnly, - surfaces: {AgentManifestSurface.webMcp: false}, - ), - }, - ); + const policy = ProjectionPolicy(); final manifest = merger.mergeManifest( catalog: [ AgentRegistryCatalogEntry( - registryKey: 'app.ping', + registryKey: 'app_ping', descriptor: AgentIntentDescriptor( namespace: 'app', name: 'ping', @@ -24,6 +17,10 @@ void main() { kind: AgentIntentKind.tool, inputSchema: const {'type': 'object'}, ), + projection: const EntryProjection( + dispatchMode: AgentManifestDispatchMode.queueOnly, + surfaces: {AgentManifestSurface.webMcp: false}, + ), ), ], policy: policy, diff --git a/packages/intentcall_platform_sync/test/web_emitters_test.dart b/packages/intentcall_platform_sync/test/web_emitters_test.dart index a35fd4f..5e64ec3 100644 --- a/packages/intentcall_platform_sync/test/web_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/web_emitters_test.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:io'; -import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:test/test.dart'; @@ -305,9 +304,10 @@ void main() { ); }); - test('generateWebAgentManifest passes through raw entityTypes', () { - final json = generateWebAgentManifest( - [], + test('mergeManifest passes through raw entityTypes', () { + final manifest = const ManifestMerger().mergeManifest( + catalog: const [], + policy: const ProjectionPolicy(), entityTypes: [ { 'qualifiedName': 'app_project', @@ -316,13 +316,12 @@ void main() { 'displayName': 'Project', }, ], + platform: 'web', ); - final map = jsonDecode(json) as Map; - expect(map['platform'], 'web'); - final entityTypes = map['entityTypes']! as List; - expect(entityTypes, hasLength(1)); - expect((entityTypes.first as Map)['qualifiedName'], 'app_project'); + expect(manifest.platform, 'web'); + expect(manifest.entityTypes, hasLength(1)); + expect(manifest.entityTypes.single.qualifiedName, 'app_project'); }); test('reads shortcuts and intents arrays', () { diff --git a/steward.yaml b/steward.yaml index 816e3f5..a9978c4 100644 --- a/steward.yaml +++ b/steward.yaml @@ -137,6 +137,39 @@ actions: evidence: redaction: steward/redaction/v1 summary_fields: [exit_code, duration_ms, output_digest] + intentcall.manifest-export-check: + kind: command + desc: Verify committed agent_manifest.json matches merge(catalog, projection) after build_runner. + command: + argv: [just, manifest-export-check] + shell: false + cwd: . + effects: + fs_read: + - justfile + - packages/intentcall_codegen/** + - packages/intentcall_cli/** + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 400000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] intentcall.manifest-parity: kind: command desc: Verify manifest fixture rows are present in a matching registry snapshot. @@ -242,7 +275,7 @@ actions: probes: quick: profile: quick - actions: [intentcall.validate, intentcall.adapter-contract-test, intentcall.manifest-parity, intentcall.platform-sync-check] + actions: [intentcall.validate, intentcall.adapter-contract-test, intentcall.manifest-export-check, intentcall.manifest-parity, intentcall.platform-sync-check] diagnostics: cases: {} unknown_cases: diff --git a/steward/scenarios/intentcall.adapter-contract.yaml b/steward/scenarios/intentcall.adapter-contract.yaml index 7859b5e..12d8e17 100644 --- a/steward/scenarios/intentcall.adapter-contract.yaml +++ b/steward/scenarios/intentcall.adapter-contract.yaml @@ -10,6 +10,9 @@ safe_first_probe: intentcall.validate required_actions: - intentcall.validate - intentcall.adapter-contract-test + - intentcall.manifest-export-check + - intentcall.manifest-parity + - intentcall.platform-sync-check artifacts: - id: steward_contract kind: yaml diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index 532b0da..287f64d 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -278,18 +278,24 @@ void main(List arguments) async { } Directory findRepoRoot() { - var dir = Directory(p.dirname(Platform.script.toFilePath())); + var dir = Directory.current; while (dir.path != dir.parent.path) { final pubspec = File(p.join(dir.path, 'pubspec.yaml')); - if (pubspec.existsSync()) { - final content = pubspec.readAsStringSync(); - if (content.contains('name: intentcall_workspace')) { - return dir; - } + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: intentcall_workspace')) { + return dir; + } + dir = dir.parent; + } + dir = Directory(p.dirname(Platform.script.toFilePath())); + while (dir.path != dir.parent.path) { + final pubspec = File(p.join(dir.path, 'pubspec.yaml')); + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: intentcall_workspace')) { + return dir; } dir = dir.parent; } - // Fallback to current directory return Directory.current; } From d4d6fd18cd1c82e4d67a2eba478fe60f93d68f03 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 04:18:23 +0300 Subject: [PATCH 03/27] feat: update pubspec.lock with new dependencies and versions; enhance documentation for instance-bound tools and manifest export --- docs/DX_FAQ.mdx | 29 ++ .../lib/tools/demo_ping_tool.g.dart | 2 +- .../codegen_dart_project/web/manifest.json | 42 +-- .../lib/tools/cart_total_tool.g.dart | 2 +- .../lib/tools/shop_search_tool.g.dart | 2 +- packages/intentcall_codegen/README.md | 101 ++++++- packages/intentcall_codegen/example/README.md | 0 .../lib/catalog/handwritten_entries.dart | 18 ++ .../lib/generated/agent_catalog.g.dart | 12 +- .../intentcall_codegen/example/lib/main.dart | 32 +++ .../example/lib/tools/demo_host_tools.dart | 76 +++++ .../example/lib/tools/demo_host_tools.g.dart | 33 +++ .../example/lib/tools/demo_ping_tool.dart | 3 + .../example/lib/tools/demo_ping_tool.g.dart | 2 +- .../intentcall_codegen/example/pubspec.lock | 122 +++++++- .../intentcall_codegen/example/pubspec.yaml | 2 + .../test/manifest_projection_test.dart | 60 ++++ .../example/web/agent_manifest.json | 140 +++++++++- packages/intentcall_codegen/lib/builder.dart | 18 +- .../lib/intentcall_codegen.dart | 2 - .../generators/agent_catalog_generator.dart | 250 +++++++++++++++-- .../src/generators/agent_tool_generator.dart | 261 ++++++++++++++++-- packages/intentcall_codegen/pubspec.yaml | 2 + .../test/agent_catalog_generator_test.dart | 41 +++ .../test/agent_tool_codegen_test.dart | 96 +++++++ .../test/agent_tool_generator_test.dart | 14 + packages/intentcall_codegen/test/build.yaml | 11 + .../test/fixtures/host_instance_tool.dart | 21 ++ .../fixtures/instance_without_host_tool.dart | 19 ++ .../test/fixtures/static_method_tool.dart | 17 ++ pubspec.lock | 24 ++ skills/register-intents/SKILL.md | 77 +++++- 32 files changed, 1445 insertions(+), 86 deletions(-) create mode 100644 packages/intentcall_codegen/example/README.md create mode 100644 packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart create mode 100644 packages/intentcall_codegen/example/lib/main.dart create mode 100644 packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart create mode 100644 packages/intentcall_codegen/example/lib/tools/demo_host_tools.g.dart create mode 100644 packages/intentcall_codegen/example/test/manifest_projection_test.dart create mode 100644 packages/intentcall_codegen/test/agent_catalog_generator_test.dart create mode 100644 packages/intentcall_codegen/test/agent_tool_codegen_test.dart create mode 100644 packages/intentcall_codegen/test/build.yaml create mode 100644 packages/intentcall_codegen/test/fixtures/host_instance_tool.dart create mode 100644 packages/intentcall_codegen/test/fixtures/instance_without_host_tool.dart create mode 100644 packages/intentcall_codegen/test/fixtures/static_method_tool.dart diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 7d53fdb..012e40f 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -389,6 +389,35 @@ catalog rows). `intentcall.yaml` carries host wiring and global `defaults` only. No. You can register `AgentCallEntry` objects manually. Codegen is a convenience layer. +**Q: How do instance-bound or handwritten tools join the generated catalog and manifest?** + +Put instance methods and `AgentCallEntry` getters on a host class (typically +`static final shared`), list rows in `lib/catalog/handwritten_entries.dart` as +`handwrittenCatalogEntries`, and run `build_runner`. The aggregate catalog at +`lib/generated/agent_catalog.g.dart` spreads those rows next to `@AgentTool` +entries; `intentcall manifest export --check` then merges catalog + projection +into committed `agent_manifest.json`. Per-row projection uses `EntryProjection` +on catalog rows — same policy surface as `@AgentProjection` on annotated tools. +See [ADR 0019](decisions/0019-framework-neutral-intentcall-cli.md) and the +[`intentcall_codegen` example](../packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart). + +**Probe anchor:** `intentcall manifest export` runs a subprocess that evaluates +each row's `entry:` and calls `resolveDescriptor()` — handlers are stripped +before merge. Use `Host.shared.CallEntry` or top-level `*CallEntry` +getters so the catalog compiles at export time. Runtime may register a different +instance when descriptors match. + +**Descriptor-only rows:** When a tool should appear in the manifest but should +not materialize an `AgentCallEntry` at probe time, set `descriptor:` on +`AgentRegistryCatalogEntry` and register the handler in app setup; keep +`qualifiedName` aligned for parity gates. + +**Optional instance `@AgentTool` codegen:** Annotate instance methods on a host +class with a static binding field (default name `shared`). Codegen emits an +extension with `AgentCallEntry` getters whose handlers call instance methods on +`this`; the catalog references `Host.shared.CallEntry`. Handwritten +getters remain the canonical path when you need full control. + --- ## 🧪 Testing diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart index ee80782..00953f9 100644 --- a/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/tools/demo_ping_tool.g.dart @@ -6,7 +6,7 @@ part of 'demo_ping_tool.dart'; // ************************************************************************** -// AgentToolGenerator +// _AgentToolPartGenerator // ************************************************************************** const _demo_pingInputSchema = { diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json index 8109e44..b409fcc 100644 --- a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json @@ -1,22 +1,22 @@ { - "name": "codegen_dart_project", - "start_url": ".", - "shortcuts": [ - { - "name": "Demo Ping", - "short_name": "Demo Ping", - "description": "Returns pong for a message", - "url": "/agent/invoke?name=app_demo_ping" - } - ], - "protocol_handlers": [ - { - "protocol": "web+intentcall", - "url": "/agent/invoke?protocol=%s" - }, - { - "protocol": "web+intentcall", - "url": "/agent/invoke?name=app_demo_ping&payload=%s" - } - ] -} \ No newline at end of file + "name": "codegen_dart_project", + "start_url": ".", + "shortcuts": [ + { + "name": "Demo Ping", + "short_name": "Demo Ping", + "description": "Returns pong for a message", + "url": "/agent/invoke?name=app_demo_ping" + } + ], + "protocol_handlers": [ + { + "protocol": "web+intentcall", + "url": "/agent/invoke?protocol=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_ping&payload=%s" + } + ] +} diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart b/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart index fde2b39..f9c8f19 100644 --- a/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart +++ b/packages/intentcall_cli/test/fixtures/flutter_project/lib/tools/cart_total_tool.g.dart @@ -6,7 +6,7 @@ part of 'cart_total_tool.dart'; // ************************************************************************** -// AgentToolGenerator +// _AgentToolPartGenerator // ************************************************************************** const _cart_totalInputSchema = { diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart index b19ba0c..b212d1c 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/tools/shop_search_tool.g.dart @@ -6,7 +6,7 @@ part of 'shop_search_tool.dart'; // ************************************************************************** -// AgentToolGenerator +// _AgentToolPartGenerator // ************************************************************************** const _searchInputSchema = { diff --git a/packages/intentcall_codegen/README.md b/packages/intentcall_codegen/README.md index e98003b..a5dd1a8 100644 --- a/packages/intentcall_codegen/README.md +++ b/packages/intentcall_codegen/README.md @@ -11,6 +11,83 @@ Optional `@AgentTool` / `@AgentParam` annotations and **build_runner** codegen p Hand-written `AgentCallEntry` remains first-class; codegen is opt-in for stable tools with typed parameters. +## Wiring instance methods + +When a tool handler needs host state (services, config, or UI policy), keep the +business logic on an instance method and expose an `AgentCallEntry` getter whose +handler closes over `this`. This matches the **mcp_flutter harness** pattern: one +shared host object, instance methods for behavior, catalog rows for projection. + +1. **Host class with a shared singleton** + +```dart +final class DemoHostTools { + DemoHostTools(); + + static final DemoHostTools shared = DemoHostTools(); + + Future inbox(final String folder) async { /* … */ } + + AgentCallEntry get inboxCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_inbox', + description: 'Read inbox folder', + inputSchema: const { /* … */ }, + handler: (final args) async => inbox(args['folder'] as String), + ); +} +``` + +2. **Merge handwritten rows into the generated catalog** + +Create `lib/catalog/handwritten_entries.dart` exporting +`handwrittenCatalogEntries` — a `List` that references +`DemoHostTools.shared.CallEntry`. Optional per-row projection uses +`EntryProjection` (same as `@AgentProjection` on annotated tools). + +```dart +final List handwrittenCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + entry: DemoHostTools.shared.inboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), + ), +]; +``` + +After `dart run build_runner build`, `lib/generated/agent_catalog.g.dart` +imports that file and spreads `...handwrittenCatalogEntries` alongside +`@AgentTool` rows. Duplicate `registryKey` values between codegen and +handwritten rows fail the build. + +3. **Export manifest and register at runtime** + +```bash +dart run build_runner build --delete-conflicting-outputs +dart run intentcall_cli:intentcall manifest export --check +``` + +Register from the merged catalog in app setup, or invoke by `registryKey`. + +### Probe anchor for manifest export + +`intentcall manifest export` evaluates each catalog row's `entry:` expression in a +subprocess (`resolveDescriptor()`). Use a **compile-time anchor** such as +`Host.shared.CallEntry` or a top-level `*CallEntry` getter — not a +per-request or widget-scoped instance. The probe needs descriptor metadata only; +your runtime registry may bind a different live instance as long as +`qualifiedName` and schema stay aligned. + +For manifest-only rows without a handler at probe time, use `descriptor:` on +`AgentRegistryCatalogEntry` and register the handler separately at runtime. + +Full runnable example: +[`example/lib/tools/demo_host_tools.dart`](example/lib/tools/demo_host_tools.dart), +[`example/lib/catalog/handwritten_entries.dart`](example/lib/catalog/handwritten_entries.dart). + ## Pilot usage 1. Add dependencies from the current hosted train: @@ -75,18 +152,34 @@ only annotations and builders. ## Generated output -For each `@AgentTool` function, `.g.dart` emits: +For each top-level `@AgentTool` function, `.g.dart` emits: - `_InputSchema` — JSON Schema from parameter types -- `CallEntry` — `AgentCallEntry.tool(...)` factory +- `CallEntry` — top-level `AgentCallEntry.tool(...)` factory - `Registration` — `RegisteredAgentIntent` via `.toRegistration()` +For each instance `@AgentTool` method on a host class with a static binding field +(default `shared`), `.g.dart` emits: + +- `_InputSchema` constants +- `extension AgentCodegen on ` with `CallEntry` getters whose + handlers call instance methods on `this` +- top-level `Registration` aliases via `Host.shared.CallEntry` + +The aggregate catalog references instance rows as +`Host.shared.CallEntry` (same as handwritten getters). + Supported parameter types: `String`, `int`, `bool`, `double`. +Optional `host_binding_field` in `build.yaml` overrides the default `shared` +static field name used for catalog probe anchors. + ## Scope (pilot) -- Top-level functions only +- Top-level `@AgentTool` functions and optional instance-method codegen on host + classes (see **Wiring instance methods**); handwritten `AgentCallEntry` getters + remain the canonical path for host-bound tools - Tool kind only (resources: hand-write `AgentCallEntry.resource`) -- Test fixture: `test/fixtures/demo_ping_tool.dart` +- Test fixtures: `test/fixtures/demo_ping_tool.dart`, `test/fixtures/host_instance_tool.dart` See [DX FAQ](../../docs/DX_FAQ.mdx) for current codegen workflow and [Design FAQ](../../docs/DESIGN_FAQ.mdx) for the IntentPack direction. diff --git a/packages/intentcall_codegen/example/README.md b/packages/intentcall_codegen/example/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart b/packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart new file mode 100644 index 0000000..d4415c8 --- /dev/null +++ b/packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart @@ -0,0 +1,18 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +import '../tools/demo_host_tools.dart'; + +final List handwrittenCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + entry: DemoHostTools.shared.inboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_demo_handwritten', + entry: DemoHostTools.shared.demoHandwrittenCallEntry, + ), +]; diff --git a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart index 1cf098c..a1e6955 100644 --- a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -1,11 +1,21 @@ // GENERATED CODE - DO NOT MODIFY BY HAND import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import '../tools/demo_host_tools.dart'; import '../tools/demo_ping_tool.dart'; +import '../catalog/handwritten_entries.dart'; final List agentCatalogEntries = [ + AgentRegistryCatalogEntry(registryKey: 'app_demo_host_status', entry: DemoHostTools.shared.demoHostStatusCallEntry, projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: {AgentManifestSurface.webMcp: true}, +)), AgentRegistryCatalogEntry(registryKey: 'app_demo_ping', entry: demoPingCallEntry), - AgentRegistryCatalogEntry(registryKey: 'app_demo_cart', entry: demoCartCallEntry), + AgentRegistryCatalogEntry(registryKey: 'app_demo_cart', entry: demoCartCallEntry, projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: {AgentManifestSurface.webMcp: false}, +)), AgentRegistryCatalogEntry(registryKey: 'app_demo_required_named', entry: demoRequiredNamedCallEntry), + ...handwrittenCatalogEntries, ]; diff --git a/packages/intentcall_codegen/example/lib/main.dart b/packages/intentcall_codegen/example/lib/main.dart new file mode 100644 index 0000000..528a807 --- /dev/null +++ b/packages/intentcall_codegen/example/lib/main.dart @@ -0,0 +1,32 @@ +import 'package:intentcall_core/intentcall_core.dart'; + +import 'generated/agent_catalog.g.dart'; + +Future main() async { + final registry = InMemoryAgentRegistry(); + registerAll(registry, agentCatalogEntries.map((final row) => row.entry!)); + + final inbox = await registry.invoke('app_demo_inbox', {'folder': 'inbox'}); + if (!inbox.ok) { + throw StateError('demo_inbox smoke failed: ${inbox.message}'); + } + + final handwritten = await registry.invoke('app_demo_handwritten', { + 'note': 'hello', + }); + if (!handwritten.ok) { + throw StateError('demo_handwritten smoke failed: ${handwritten.message}'); + } + + final hostStatus = await registry.invoke('app_demo_host_status', { + 'label': 'primary', + }); + if (!hostStatus.ok) { + throw StateError('demo_host_status smoke failed: ${hostStatus.message}'); + } + if (hostStatus.data['source'] != 'codegen_instance') { + throw StateError( + 'demo_host_status smoke failed: expected codegen_instance source', + ); + } +} diff --git a/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart new file mode 100644 index 0000000..1bd4ed9 --- /dev/null +++ b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart @@ -0,0 +1,76 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'demo_host_tools.g.dart'; + +final class DemoHostTools { + DemoHostTools(); + + static final DemoHostTools shared = DemoHostTools(); + + Future inbox(final String folder) async { + return AgentResult.success( + data: { + 'folder': folder, + 'messages': ['Welcome to $folder', 'You have 2 unread items'], + }, + ); + } + + @AgentTool( + namespace: 'app', + name: 'demo_host_status', + description: 'Codegen instance-method host tool', + ) + @AgentProjection(surfaces: {'web.webMcp': true}) + Future hostStatus(@AgentParam('Host label') String label) async { + return AgentResult.success( + data: { + 'label': label, + 'source': 'codegen_instance', + 'host': 'DemoHostTools', + }, + ); + } + + Future demoHandwritten(final String note) async { + return AgentResult.success( + data: {'note': note, 'source': 'handwritten', 'host': 'DemoHostTools'}, + ); + } + + AgentCallEntry get inboxCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_inbox', + description: 'Read inbox folder', + inputSchema: const { + 'type': 'object', + 'properties': { + 'folder': { + 'type': 'string', + 'description': 'Inbox folder name', + }, + }, + 'required': ['folder'], + }, + handler: (final args) async => inbox(args['folder'] as String), + ); + + AgentCallEntry get demoHandwrittenCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_handwritten', + description: 'Handwritten instance-bound tool', + inputSchema: const { + 'type': 'object', + 'properties': { + 'note': { + 'type': 'string', + 'description': 'Note to echo', + }, + }, + 'required': ['note'], + }, + handler: (final args) async => demoHandwritten(args['note'] as String), + ); +} diff --git a/packages/intentcall_codegen/example/lib/tools/demo_host_tools.g.dart b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.g.dart new file mode 100644 index 0000000..715e554 --- /dev/null +++ b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ignore_for_file: type=lint + +part of 'demo_host_tools.dart'; + +// ************************************************************************** +// _AgentToolPartGenerator +// ************************************************************************** + +const _demo_host_statusInputSchema = { + 'type': 'object', + 'properties': { + 'label': {'type': 'string', 'description': 'Host label'}, + }, + 'required': ['label'], +}; + +extension DemoHostToolsAgentCodegen on DemoHostTools { + AgentCallEntry get demoHostStatusCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_host_status', + description: 'Codegen instance-method host tool', + inputSchema: _demo_host_statusInputSchema, + handler: (final args) async { + return await hostStatus(args['label'] as String); + }, + ); +} + +RegisteredAgentIntent get demoHostStatusRegistration => + DemoHostTools.shared.demoHostStatusCallEntry.toRegistration(); diff --git a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart index 7a27a11..83d8e9a 100644 --- a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart +++ b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart @@ -20,6 +20,9 @@ Future demoPing( name: 'demo_cart', description: 'Returns a cart total', ) +@AgentProjection( + surfaces: {'web.webMcp': false}, +) Future demoCart( @AgentParam('Currency code') String currency, { @AgentParam('Include tax', required: false) bool includeTax = false, diff --git a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart index 0d03ec8..ffd2378 100644 --- a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart +++ b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart @@ -6,7 +6,7 @@ part of 'demo_ping_tool.dart'; // ************************************************************************** -// AgentToolGenerator +// _AgentToolPartGenerator // ************************************************************************** const _demo_pingInputSchema = { diff --git a/packages/intentcall_codegen/example/pubspec.lock b/packages/intentcall_codegen/example/pubspec.lock index 9e2b9a6..a1e947c 100644 --- a/packages/intentcall_codegen/example/pubspec.lock +++ b/packages/intentcall_codegen/example/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" build: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" collection: dependency: transitive description: @@ -105,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" crypto: dependency: transitive description: @@ -137,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" glob: dependency: transitive description: @@ -229,6 +261,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" meta: dependency: transitive description: @@ -245,6 +285,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -254,7 +302,7 @@ packages: source: hosted version: "2.2.0" path: - dependency: transitive + dependency: "direct dev" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -293,6 +341,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -309,6 +373,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.3" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -357,6 +437,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" typed_data: dependency: transitive description: @@ -365,6 +469,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" watcher: dependency: transitive description: @@ -397,6 +509,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" xsoulspace_lints: dependency: "direct dev" description: diff --git a/packages/intentcall_codegen/example/pubspec.yaml b/packages/intentcall_codegen/example/pubspec.yaml index d55b402..5e2d3dc 100644 --- a/packages/intentcall_codegen/example/pubspec.yaml +++ b/packages/intentcall_codegen/example/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: dev_dependencies: build_runner: ^2.15.0 lints: ^6.1.0 + path: ^1.9.1 + test: ^1.31.1 xsoulspace_lints: ^0.1.2 dependency_overrides: diff --git a/packages/intentcall_codegen/example/test/manifest_projection_test.dart b/packages/intentcall_codegen/example/test/manifest_projection_test.dart new file mode 100644 index 0000000..1d8ab82 --- /dev/null +++ b/packages/intentcall_codegen/example/test/manifest_projection_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import '../lib/generated/agent_catalog.g.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + late String projectRoot; + late ManifestExporter exporter; + late ManifestExportContext context; + + setUpAll(() { + projectRoot = p.normalize(p.join(Platform.script.toFilePath(), '..', '..')); + exporter = const ManifestExporter(); + context = exporter.loadExportContext(projectRoot: projectRoot); + }); + + AgentManifest buildManifest() => + exporter.buildManifest(catalog: agentCatalogEntries, context: context); + + test('demo_cart respects @AgentProjection(web.webMcp: false)', () { + final manifest = buildManifest(); + final cart = manifest.tools.singleWhere( + (final tool) => tool.qualifiedName == 'app_demo_cart', + ); + + expect(cart.dispatchMode, AgentManifestDispatchMode.openApp); + expect( + cart.surfaces.includes(AgentManifestSurface.webMcp, defaultValue: true), + isFalse, + ); + }); + + test('handwritten instance-bound tools appear in manifest export', () { + final manifest = buildManifest(); + final qualifiedNames = manifest.tools + .map((final tool) => tool.qualifiedName) + .toSet(); + + expect(qualifiedNames, contains('app_demo_inbox')); + expect(qualifiedNames, contains('app_demo_handwritten')); + }); + + test('codegen instance tool respects @AgentProjection(web.webMcp: true)', () { + final manifest = buildManifest(); + final hostStatus = manifest.tools.singleWhere( + (final tool) => tool.qualifiedName == 'app_demo_host_status', + ); + + expect(hostStatus.dispatchMode, AgentManifestDispatchMode.openApp); + expect( + hostStatus.surfaces.includes( + AgentManifestSurface.webMcp, + defaultValue: true, + ), + isTrue, + ); + }); +} diff --git a/packages/intentcall_codegen/example/web/agent_manifest.json b/packages/intentcall_codegen/example/web/agent_manifest.json index 7a6560b..00d90f5 100644 --- a/packages/intentcall_codegen/example/web/agent_manifest.json +++ b/packages/intentcall_codegen/example/web/agent_manifest.json @@ -2,6 +2,52 @@ "version": 1, "platform": "unified", "tools": [ + { + "qualifiedName": "app_demo_host_status", + "namespace": "app", + "name": "demo_host_status", + "description": "Codegen instance-method host tool", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Host label" + } + }, + "required": [ + "label" + ] + } + }, { "qualifiedName": "app_demo_ping", "namespace": "app", @@ -69,7 +115,7 @@ "include": true }, "web.webMcp": { - "include": true + "include": false }, "windows.protocolActivation": { "include": true @@ -143,6 +189,98 @@ "mode" ] } + }, + { + "qualifiedName": "app_demo_inbox", + "namespace": "app", + "name": "demo_inbox", + "description": "Read inbox folder", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "folder": { + "type": "string", + "description": "Inbox folder name" + } + }, + "required": [ + "folder" + ] + } + }, + { + "qualifiedName": "app_demo_handwritten", + "namespace": "app", + "name": "demo_handwritten", + "description": "Handwritten instance-bound tool", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appShortcuts": { + "include": false + }, + "android.shortcuts": { + "include": true + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": true + }, + "windows.msixProtocol": { + "include": true + }, + "linux.schemeHandler": { + "include": true + } + }, + "inputSchema": { + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "Note to echo" + } + }, + "required": [ + "note" + ] + } } ], "protocolScheme": "intentcallcodegen" diff --git a/packages/intentcall_codegen/lib/builder.dart b/packages/intentcall_codegen/lib/builder.dart index 49b7ff1..51c8250 100644 --- a/packages/intentcall_codegen/lib/builder.dart +++ b/packages/intentcall_codegen/lib/builder.dart @@ -1,12 +1,28 @@ +import 'dart:async'; + import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'src/generators/agent_catalog_generator.dart'; import 'src/generators/agent_tool_generator.dart'; +/// Wraps [AgentToolGenerator] as a plain [Generator] so [PartBuilder] runs for +/// instance-method `@AgentTool` annotations (not only top-level declarations). +final class _AgentToolPartGenerator extends Generator { + _AgentToolPartGenerator(this._delegate); + + final AgentToolGenerator _delegate; + + @override + FutureOr generate( + final LibraryReader library, + final BuildStep buildStep, + ) => _delegate.generate(library, buildStep); +} + /// build_runner builder for `@AgentTool` → `.g.dart` registration factories. Builder agentToolBuilder(final BuilderOptions options) => PartBuilder( - [AgentToolGenerator()], + [_AgentToolPartGenerator(AgentToolGenerator(options))], '.g.dart', header: ''' // GENERATED CODE - DO NOT MODIFY BY HAND diff --git a/packages/intentcall_codegen/lib/intentcall_codegen.dart b/packages/intentcall_codegen/lib/intentcall_codegen.dart index a104068..5e61ae4 100644 --- a/packages/intentcall_codegen/lib/intentcall_codegen.dart +++ b/packages/intentcall_codegen/lib/intentcall_codegen.dart @@ -1,5 +1,3 @@ -library; - export 'src/agent_param.dart'; export 'src/agent_projection.dart'; export 'src/agent_tool.dart'; diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart index 742abc3..c66da92 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -1,3 +1,5 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; import 'package:build/build.dart'; import 'package:glob/glob.dart'; import 'package:path/path.dart' as p; @@ -14,6 +16,11 @@ class AgentCatalogGenerator implements Builder { static const _toolChecker = TypeChecker.typeNamed(AgentTool); static const _projectionChecker = TypeChecker.typeNamed(AgentProjection); + static const _defaultHostBindingField = 'shared'; + static const _handwrittenCatalogPath = 'lib/catalog/handwritten_entries.dart'; + static const _handwrittenCatalogImport = + '../catalog/handwritten_entries.dart'; + static const _handwrittenCatalogSymbol = 'handwrittenCatalogEntries'; @override Map> get buildExtensions => const { @@ -22,43 +29,42 @@ class AgentCatalogGenerator implements Builder { bool get _scanExample => options.config['scan_example'] as bool? ?? false; + String get _hostBindingField => + options.config['host_binding_field'] as String? ?? + _defaultHostBindingField; + @override Future build(final BuildStep buildStep) async { final outputPath = buildStep.allowedOutputs.single.path; final imports = {}; final entries = []; + final codegenRegistryKeys = {}; await for (final input in _toolSources(buildStep)) { final library = await buildStep.resolver.libraryFor(input); final fileEntries = []; for (final element in library.topLevelFunctions) { - ConstantReader? toolReader; - ConstantReader? projectionReader; - for (final annotation in element.metadata.annotations) { - final reader = ConstantReader(annotation.computeConstantValue()); - if (!reader.isNull && reader.instanceOf(_toolChecker)) { - toolReader = reader; + final entry = _catalogEntryForToolElement(element, codegenRegistryKeys); + if (entry != null) { + fileEntries.add(entry); + } + } + + final libraryReader = LibraryReader(library); + for (final classElement in libraryReader.classes) { + for (final method in classElement.methods) { + if (method.isStatic) { + continue; } - if (!reader.isNull && reader.instanceOf(_projectionChecker)) { - projectionReader = reader; + final entry = _catalogEntryForToolElement( + method, + codegenRegistryKeys, + ); + if (entry != null) { + fileEntries.add(entry); } } - if (toolReader == null) { - continue; - } - final namespace = toolReader.read('namespace').stringValue; - final name = toolReader.read('name').stringValue; - final registryKey = '${namespace}_$name'; - final entryGetter = '${_toCamelCase(name)}CallEntry'; - final projectionLiteral = projectionReader == null - ? null - : _projectionLiteral(projectionReader); - fileEntries.add( - projectionLiteral == null - ? " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryGetter)," - : " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryGetter, projection: $projectionLiteral),", - ); } if (fileEntries.isEmpty) { @@ -72,6 +78,33 @@ class AgentCatalogGenerator implements Builder { entries.addAll(fileEntries); } + final handwrittenAsset = AssetId( + buildStep.inputId.package, + _handwrittenCatalogPath, + ); + final hasHandwritten = await buildStep.canRead(handwrittenAsset); + if (hasHandwritten) { + imports.add("import '$_handwrittenCatalogImport';"); + final handwrittenKeys = await _readHandwrittenRegistryKeys( + buildStep, + handwrittenAsset, + ); + final seenHandwrittenKeys = {}; + for (final key in handwrittenKeys) { + if (codegenRegistryKeys.contains(key)) { + throw InvalidGenerationSourceError( + "Duplicate registryKey '$key' in agent catalog — declared in " + 'both @AgentTool codegen and handwritten catalog entries.', + ); + } + if (!seenHandwrittenKeys.add(key)) { + throw InvalidGenerationSourceError( + "Duplicate registryKey '$key' in handwritten catalog entries.", + ); + } + } + } + final header = ''' // GENERATED CODE - DO NOT MODIFY BY HAND @@ -80,7 +113,7 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; ${imports.join('\n')} '''; - if (entries.isEmpty) { + if (entries.isEmpty && !hasHandwritten) { await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header /// Empty catalog — add @AgentTool functions under lib/. @@ -90,15 +123,182 @@ final List agentCatalogEntries = return; } + final catalogRows = _catalogRows(entries, hasHandwritten: hasHandwritten); await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header final List agentCatalogEntries = [ -${entries.join('\n')} +$catalogRows ]; '''); } + String? _catalogEntryForToolElement( + final Element element, + final Set codegenRegistryKeys, + ) { + ConstantReader? toolReader; + ConstantReader? projectionReader; + for (final annotation in element.metadata.annotations) { + final reader = ConstantReader(annotation.computeConstantValue()); + if (!reader.isNull && reader.instanceOf(_toolChecker)) { + toolReader = reader; + } + if (!reader.isNull && reader.instanceOf(_projectionChecker)) { + projectionReader = reader; + } + } + if (toolReader == null) { + return null; + } + + if (element is MethodElement) { + if (element.isStatic) { + return null; + } + final enclosing = element.enclosingElement; + if (enclosing is! ClassElement) { + return null; + } + } else if (element is! TopLevelFunctionElement) { + return null; + } + + final namespace = toolReader.read('namespace').stringValue; + final name = toolReader.read('name').stringValue; + final registryKey = '${namespace}_$name'; + if (!codegenRegistryKeys.add(registryKey)) { + throw InvalidGenerationSourceError( + "Duplicate registryKey '$registryKey' from @AgentTool declarations.", + element: element, + ); + } + final entryGetter = '${_toCamelCase(name)}CallEntry'; + final entryReference = element is MethodElement + ? '${(element.enclosingElement! as ClassElement).name}.$_hostBindingField.$entryGetter' + : entryGetter; + final projectionLiteral = projectionReader == null + ? null + : _projectionLiteral(projectionReader); + return projectionLiteral == null + ? " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryReference)," + : " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryReference, projection: $projectionLiteral),"; + } + + String _catalogRows( + final List entries, { + required final bool hasHandwritten, + }) { + if (entries.isEmpty) { + return ' ...$_handwrittenCatalogSymbol,'; + } + if (!hasHandwritten) { + return entries.join('\n'); + } + return '${entries.join('\n')}\n ...$_handwrittenCatalogSymbol,'; + } + + Future> _readHandwrittenRegistryKeys( + final BuildStep buildStep, + final AssetId assetId, + ) async { + final library = await buildStep.resolver.libraryFor(assetId); + TopLevelVariableElement? variable; + for (final element in library.topLevelVariables) { + if (element.name == _handwrittenCatalogSymbol) { + variable = element; + break; + } + } + if (variable == null) { + throw InvalidGenerationSourceError( + '$_handwrittenCatalogPath must export $_handwrittenCatalogSymbol ' + 'as List.', + ); + } + + final constant = variable.computeConstantValue(); + if (constant != null) { + final list = constant.toListValue(); + if (list != null) { + final keys = []; + for (final item in list) { + final key = item.getField('registryKey')?.toStringValue(); + if (key != null) { + keys.add(key); + } + } + return keys; + } + } + + final unit = await buildStep.resolver.compilationUnitFor(assetId); + final keys = []; + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + for (final variableDeclaration in declaration.variables.variables) { + if (variableDeclaration.name.lexeme != _handwrittenCatalogSymbol) { + continue; + } + final initializer = variableDeclaration.initializer; + if (initializer != null) { + _collectRegistryKeysFromExpression(initializer, keys); + } + } + } + if (keys.isEmpty) { + throw InvalidGenerationSourceError( + 'Could not read registryKey values from $_handwrittenCatalogSymbol ' + 'in $_handwrittenCatalogPath.', + element: variable, + ); + } + return keys; + } + + void _collectRegistryKeysFromExpression( + final Expression expression, + final List keys, + ) { + if (expression is ListLiteral) { + for (final element in expression.elements) { + if (element is! Expression) { + continue; + } + _collectRegistryKeyFromEntryExpression(element, keys); + } + return; + } + _collectRegistryKeyFromEntryExpression(expression, keys); + } + + void _collectRegistryKeyFromEntryExpression( + final Expression expression, + final List keys, + ) { + final ArgumentList? argumentList = switch (expression) { + InstanceCreationExpression(:final argumentList) => argumentList, + MethodInvocation(:final argumentList) => argumentList, + _ => null, + }; + if (argumentList == null) { + return; + } + for (final argument in argumentList.arguments) { + if (argument is! NamedExpression || + argument.name.label.name != 'registryKey') { + continue; + } + final value = argument.expression; + if (value is! StringLiteral || value.stringValue == null) { + continue; + } + keys.add(value.stringValue!); + } + } + Stream _toolSources(final BuildStep buildStep) async* { final patterns = ['lib/**.dart']; if (_scanExample) { diff --git a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart index 2bf7ef9..e4dc7a7 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; @@ -8,9 +10,74 @@ import '../agent_param.dart'; import '../agent_tool.dart'; /// Generates [RegisteredAgentIntent] / [AgentCallEntry] factories from -/// `@AgentTool` functions (Phase 5-C pilot). +/// `@AgentTool` top-level functions and instance methods on host classes. class AgentToolGenerator extends GeneratorForAnnotation { + AgentToolGenerator([this._options]); + + final BuilderOptions? _options; + static const _agentParamChecker = TypeChecker.typeNamed(AgentParam); + static const _defaultHostBindingField = 'shared'; + + String get _hostBindingField => + _options?.config['host_binding_field'] as String? ?? + _defaultHostBindingField; + + @override + FutureOr generate( + final LibraryReader library, + final BuildStep buildStep, + ) async { + final values = []; + final topLevel = await super.generate(library, buildStep); + if (topLevel.trim().isNotEmpty) { + values.add(topLevel); + } + + for (final classElement in library.classes) { + final instanceMethods = []; + for (final method in classElement.methods) { + final toolAnnotation = typeChecker.firstAnnotationOf( + method, + throwOnUnresolved: throwOnUnresolved, + ); + if (toolAnnotation == null) { + continue; + } + if (method.isStatic) { + throw InvalidGenerationSourceError( + '@AgentTool on static methods is not supported; ' + 'use top-level @AgentTool or handwritten entry.', + element: method, + ); + } + instanceMethods.add(method); + } + if (instanceMethods.isEmpty) { + continue; + } + + final bindingField = _hostBindingField; + if (!_hasBindingField(classElement, bindingField)) { + throw InvalidGenerationSourceError( + '@AgentTool instance methods require a static host binding field ' + "'$bindingField' on ${classElement.name}. " + 'Add `static final ${classElement.name} $bindingField = ...` or ' + 'configure `host_binding_field` in build.yaml.', + element: classElement, + ); + } + + values.add( + _generateInstanceToolBlock(classElement, instanceMethods, bindingField), + ); + } + + if (values.isEmpty) { + return ''; + } + return values.join('\n\n'); + } @override String generateForAnnotatedElement( @@ -18,20 +85,25 @@ class AgentToolGenerator extends GeneratorForAnnotation { final ConstantReader annotation, final BuildStep buildStep, ) { + if (element is MethodElement) { + return ''; + } if (element is! TopLevelFunctionElement) { throw InvalidGenerationSourceError( - '@AgentTool can only annotate top-level functions.', + '@AgentTool can only annotate top-level functions or instance methods ' + 'on host classes.', element: element, ); } - final returnType = element.returnType; - if (!_isAgentResultFuture(returnType)) { - throw InvalidGenerationSourceError( - '@AgentTool functions must return Future.', - element: element, - ); - } + return _generateTopLevelEntry(element, annotation); + } + + String _generateTopLevelEntry( + final TopLevelFunctionElement executable, + final ConstantReader annotation, + ) { + _validateExecutable(executable); final namespace = annotation.read('namespace').stringValue; final name = annotation.read('name').stringValue; @@ -41,9 +113,9 @@ class AgentToolGenerator extends GeneratorForAnnotation { final registrationGetter = '${_toCamelCase(name)}Registration'; final entryGetter = '${_toCamelCase(name)}CallEntry'; - final schemaMap = inputSchemaMapFor(element); + final schemaMap = inputSchemaMapFor(executable); final schema = _formatInputSchema(schemaMap); - final handlerArgs = _buildHandlerArgs(element); + final handlerArgs = _buildTopLevelHandlerArgs(executable); return ''' const $schemaName = $schema; @@ -63,7 +135,108 @@ $handlerArgs '''; } + String _generateInstanceToolBlock( + final ClassElement classElement, + final List methods, + final String bindingField, + ) { + final schemas = []; + final getters = []; + final registrations = []; + + for (final method in methods) { + final toolAnnotation = typeChecker.firstAnnotationOf( + method, + throwOnUnresolved: throwOnUnresolved, + )!; + final reader = ConstantReader(toolAnnotation); + _validateExecutable(method); + + final name = reader.read('name').stringValue; + final schemaName = '_${name}InputSchema'; + final schemaMap = inputSchemaMapFor(method); + schemas.add( + 'const $schemaName = ${_formatInputSchema(schemaMap)};', + ); + getters.add(_generateExtensionGetter(method, reader, schemaName)); + registrations.add( + _generateRegistrationAlias(classElement, method, reader, bindingField), + ); + } + + return ''' +${schemas.join('\n\n')} + +extension ${classElement.name}AgentCodegen on ${classElement.name} { +${getters.join('\n\n')} +} + +${registrations.join('\n\n')}'''; + } + + String _generateExtensionGetter( + final MethodElement method, + final ConstantReader annotation, + final String schemaName, + ) { + final namespace = annotation.read('namespace').stringValue; + final name = annotation.read('name').stringValue; + final description = annotation.read('description').stringValue; + + final entryGetter = '${_toCamelCase(name)}CallEntry'; + final handlerArgs = _buildInstanceHandlerArgs(method); + + return ''' AgentCallEntry get $entryGetter => AgentCallEntry.tool( + namespace: ${_literalString(namespace)}, + name: ${_literalString(name)}, + description: ${_literalString(description)}, + inputSchema: $schemaName, + handler: (final args) async { +$handlerArgs + }, + );'''; + } + + String _generateRegistrationAlias( + final ClassElement classElement, + final MethodElement method, + final ConstantReader annotation, + final String bindingField, + ) { + final name = annotation.read('name').stringValue; + final registrationGetter = '${_toCamelCase(name)}Registration'; + final entryGetter = '${_toCamelCase(name)}CallEntry'; + return ''' +RegisteredAgentIntent get $registrationGetter => + ${classElement.name}.$bindingField.$entryGetter.toRegistration();'''; + } + + void _validateExecutable(final ExecutableElement executable) { + if (!_isAgentResultFuture(executable.returnType)) { + throw InvalidGenerationSourceError( + '@AgentTool handlers must return Future.', + element: executable, + ); + } + } + + bool _hasBindingField( + final ClassElement classElement, + final String bindingField, + ) { + for (final field in classElement.fields) { + if (field.isStatic && field.name == bindingField) { + return true; + } + } + return false; + } + bool _isAgentResultFuture(final DartType type) { + final display = type.getDisplayString(); + if (display.contains('AgentResult')) { + return type.isDartAsyncFuture || display.startsWith('Future<'); + } if (!type.isDartAsyncFuture) { return false; } @@ -72,13 +245,17 @@ $handlerArgs return false; } final inner = futureType.typeArguments.first; - return inner.getDisplayString() == 'AgentResult'; + final element = inner.element; + if (element != null && element.name == 'AgentResult') { + return true; + } + final innerDisplay = inner.getDisplayString(); + return innerDisplay == 'AgentResult' || + innerDisplay.endsWith('.AgentResult'); } /// Builds JSON-schema-shaped input metadata for manifest and registration. - Map inputSchemaMapFor( - final TopLevelFunctionElement element, - ) { + Map inputSchemaMapFor(final ExecutableElement element) { final properties = {}; final required = []; @@ -143,9 +320,9 @@ $propertyLines }'''; } - String _buildHandlerArgs(final TopLevelFunctionElement element) { + String _buildTopLevelHandlerArgs(final TopLevelFunctionElement element) { final positional = []; - final named = []; + final topLevelNamed = []; for (final param in element.formalParameters) { final name = param.name; if (name == null) { @@ -155,27 +332,63 @@ $propertyLines 'args[${_literalString(name)}] as ${_dartTypeName(param.type)}'; if (param.isNamed) { if (_isRequiredParameter(param, _readAgentParam(param))) { - named.add('#$name: $cast,'); + topLevelNamed.add('#$name: $cast,'); } else { - named.add( - 'if (args.containsKey(${_literalString(name)})) #$name: $cast,', - ); + final guard = 'if (args.containsKey(${_literalString(name)}))'; + topLevelNamed.add('$guard #$name: $cast,'); } } else { positional.add(cast); } } + + final namedBlock = topLevelNamed.isEmpty + ? '{}' + : ''' +{ +${topLevelNamed.map((final line) => ' $line').join('\n')} + }'''; return ''' final result = Function.apply( ${element.name}, [${positional.join(', ')}], - { -${named.map((final line) => ' $line').join('\n')} - }, + $namedBlock, ); return await (result as Future);'''; } + String _buildInstanceHandlerArgs(final MethodElement method) { + final positional = []; + final instanceNamed = []; + for (final param in method.formalParameters) { + final name = param.name; + if (name == null) { + continue; + } + final cast = + 'args[${_literalString(name)}] as ${_dartTypeName(param.type)}'; + if (param.isNamed) { + if (_isRequiredParameter(param, _readAgentParam(param))) { + instanceNamed.add('$name: $cast,'); + } else { + final guard = 'if (args.containsKey(${_literalString(name)}))'; + instanceNamed.add('$guard $name: $cast,'); + } + } else { + positional.add(cast); + } + } + + final callArgs = [ + if (positional.isNotEmpty) positional.join(', '), + if (instanceNamed.isNotEmpty) instanceNamed.join('\n '), + ].where((final part) => part.isNotEmpty).join(',\n '); + final invocation = callArgs.isEmpty + ? '${method.name}()' + : '${method.name}(\n $callArgs\n )'; + return ' return await $invocation;'; + } + ConstantReader? _readAgentParam(final FormalParameterElement param) { for (final meta in param.metadata.annotations) { final value = ConstantReader(meta.computeConstantValue()); diff --git a/packages/intentcall_codegen/pubspec.yaml b/packages/intentcall_codegen/pubspec.yaml index 8e63526..df72d94 100644 --- a/packages/intentcall_codegen/pubspec.yaml +++ b/packages/intentcall_codegen/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: dev_dependencies: build_runner: ^2.15.0 + build_test: ^3.0.0 lints: ^6.1.0 + package_config: ^2.2.0 test: ^1.31.1 xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_codegen/test/agent_catalog_generator_test.dart b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart new file mode 100644 index 0000000..67118b3 --- /dev/null +++ b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart @@ -0,0 +1,41 @@ +import 'package:test/test.dart'; + +import '../example/lib/generated/agent_catalog.g.dart'; + +void main() { + test('aggregates top-level @AgentTool into catalog', () { + final rows = agentCatalogEntries + .map((final row) => row.registryKey) + .toSet(); + expect(rows, contains('app_demo_ping')); + expect( + agentCatalogEntries.any( + (final row) => row.registryKey == 'app_demo_ping' && row.entry != null, + ), + isTrue, + ); + }); + + test('instance @AgentTool catalog row uses Host.shared.getter', () { + final hostStatus = agentCatalogEntries.singleWhere( + (final row) => row.registryKey == 'app_demo_host_status', + ); + expect(hostStatus.entry, isNotNull); + expect(hostStatus.qualifiedName, 'app_demo_host_status'); + }); + + test('spreads handwrittenCatalogEntries when present', () { + final keys = agentCatalogEntries + .map((final row) => row.registryKey) + .toSet(); + expect(keys, contains('app_demo_inbox')); + expect(keys, contains('app_demo_handwritten')); + }); + + test('example agent_catalog has no duplicate registry keys', () { + final keys = agentCatalogEntries + .map((final row) => row.registryKey) + .toList(); + expect(keys.toSet().length, keys.length); + }); +} diff --git a/packages/intentcall_codegen/test/agent_tool_codegen_test.dart b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart new file mode 100644 index 0000000..82aa846 --- /dev/null +++ b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:intentcall_codegen/src/generators/agent_tool_generator.dart'; +import 'package:package_config/package_config.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:test/test.dart'; + +const _package = 'intentcall_codegen'; + +class _FakeBuildStep implements BuildStep { + _FakeBuildStep(this.inputId); + + @override + final AssetId inputId; + + @override + dynamic noSuchMethod(final Invocation invocation) => null; +} + +void main() { + late PackageConfig packageConfig; + final generator = AgentToolGenerator(); + + setUpAll(() async { + final config = await findPackageConfig(Directory.current); + if (config == null) { + throw StateError('Could not find package config for tests.'); + } + packageConfig = config; + }); + + Future generateFixture(final String fixturePath) async => + resolveSources( + {'$_package|$fixturePath': useAssetReader}, + (final resolver) async { + final library = await resolver.libraryFor( + AssetId(_package, fixturePath), + ); + return generator.generate( + LibraryReader(library), + _FakeBuildStep(AssetId(_package, fixturePath)), + ); + }, + packageConfig: packageConfig, + readAllSourcesFromFilesystem: true, + ); + + Future generateFixtureExpectingError(final String fixturePath) async { + try { + await generateFixture(fixturePath); + } on InvalidGenerationSourceError catch (error) { + return error.message; + } + return ''; + } + + test( + 'instance @AgentTool emits extension getter with this-bound handler', + () async { + final output = await generateFixture( + 'test/fixtures/host_instance_tool.dart', + ); + + expect( + output, + contains('extension DemoHostToolsAgentCodegen on DemoHostTools'), + ); + expect(output, contains('AgentCallEntry get demoInboxCallEntry')); + expect(output, contains('return await inbox(')); + expect( + output, + contains('DemoHostTools.shared.demoInboxCallEntry.toRegistration()'), + ); + expect(output, isNot(contains('Function.apply'))); + expect(output, isNot(contains('DemoHostTools.shared.inbox('))); + }, + ); + + test('instance @AgentTool without host binding field is rejected', () async { + final message = await generateFixtureExpectingError( + 'test/fixtures/instance_without_host_tool.dart', + ); + + expect(message, contains("require a static host binding field 'shared'")); + }); + + test('static method @AgentTool on class is rejected', () async { + final message = await generateFixtureExpectingError( + 'test/fixtures/static_method_tool.dart', + ); + + expect(message, contains('use top-level @AgentTool or handwritten entry')); + }); +} diff --git a/packages/intentcall_codegen/test/agent_tool_generator_test.dart b/packages/intentcall_codegen/test/agent_tool_generator_test.dart index f718a45..3350d1b 100644 --- a/packages/intentcall_codegen/test/agent_tool_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_tool_generator_test.dart @@ -2,6 +2,7 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:test/test.dart'; +import '../example/lib/tools/demo_host_tools.dart'; import '../example/lib/tools/demo_ping_tool.dart'; void main() { @@ -81,4 +82,17 @@ void main() { expect(result.ok, isTrue); expect(result.data['mode'], 'slow'); }); + + test('generated instance hostStatusCallEntry invokes shared host method', () async { + final result = await demoHostStatusRegistration.execute( + AgentInvocation( + descriptor: demoHostStatusRegistration.descriptor, + arguments: const {'label': 'primary'}, + ), + ); + + expect(result.ok, isTrue); + expect(result.data['label'], 'primary'); + expect(result.data['source'], 'codegen_instance'); + }); } diff --git a/packages/intentcall_codegen/test/build.yaml b/packages/intentcall_codegen/test/build.yaml new file mode 100644 index 0000000..540af28 --- /dev/null +++ b/packages/intentcall_codegen/test/build.yaml @@ -0,0 +1,11 @@ +targets: + $default: + builders: + intentcall_codegen|agent_tool: + generate_for: + - test/fixtures/**.dart + # Optional: host_binding_field: shared + intentcall_codegen|agent_catalog: + generate_for: + - test/fixtures/**.dart + # Optional: host_binding_field: shared diff --git a/packages/intentcall_codegen/test/fixtures/host_instance_tool.dart b/packages/intentcall_codegen/test/fixtures/host_instance_tool.dart new file mode 100644 index 0000000..68d350f --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/host_instance_tool.dart @@ -0,0 +1,21 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'host_instance_tool.g.dart'; + +final class DemoHostTools { + DemoHostTools(); + + static final DemoHostTools shared = DemoHostTools(); + + @AgentTool( + namespace: 'app', + name: 'demo_inbox', + description: 'Read inbox folder', + ) + Future inbox( + @AgentParam('Inbox folder name') String folder, + ) async { + return AgentResult.success(data: {'folder': folder}); + } +} diff --git a/packages/intentcall_codegen/test/fixtures/instance_without_host_tool.dart b/packages/intentcall_codegen/test/fixtures/instance_without_host_tool.dart new file mode 100644 index 0000000..8bfb30e --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/instance_without_host_tool.dart @@ -0,0 +1,19 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'instance_without_host_tool.g.dart'; + +final class DemoHostTools { + DemoHostTools(); + + @AgentTool( + namespace: 'app', + name: 'demo_inbox', + description: 'Read inbox folder', + ) + Future inbox( + @AgentParam('Inbox folder name') String folder, + ) async { + return AgentResult.success(data: {'folder': folder}); + } +} diff --git a/packages/intentcall_codegen/test/fixtures/static_method_tool.dart b/packages/intentcall_codegen/test/fixtures/static_method_tool.dart new file mode 100644 index 0000000..ed6b27f --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/static_method_tool.dart @@ -0,0 +1,17 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'static_method_tool.g.dart'; + +final class DemoHostTools { + @AgentTool( + namespace: 'app', + name: 'demo_static', + description: 'Static host tool', + ) + static Future demoStatic( + @AgentParam('Message') String message, + ) async { + return AgentResult.success(data: {'message': message}); + } +} diff --git a/pubspec.lock b/pubspec.lock index e4d86a7..120f195 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.15.0" + build_test: + dependency: transitive + description: + name: build_test + sha256: e69ade870705d5ad4d8b7e479d0ca71d0a9932ca04be6d278d97de45a32da07e + url: "https://pub.dev" + source: hosted + version: "3.5.15" built_collection: dependency: transitive description: @@ -177,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dart_mcp: dependency: transitive description: @@ -267,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http_multi_server: dependency: transitive description: diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index abbf102..0581e1b 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -46,7 +46,72 @@ void main() { --- -## 2. Code Generation (`@AgentTool`) +## 2. Instance-bound tools and catalog merge + +Use this when a handler needs host state but you are not using `@AgentTool` +codegen for that tool (dynamic hosts, instance services, or tools that must close +over `this`). + +**Pattern (same as the mcp_flutter harness):** + +1. Put behavior on a host class with `static final shared`. +2. Expose each intent as an instance `AgentCallEntry` getter; the handler calls + instance methods on `this`. +3. List catalog rows in `lib/catalog/handwritten_entries.dart` as + `handwrittenCatalogEntries`. +4. Run `build_runner` so `lib/generated/agent_catalog.g.dart` merges annotated + tools and `...handwrittenCatalogEntries`. + +```dart +final class InboxHost { + InboxHost(); + static final InboxHost shared = InboxHost(); + + Future readInbox(final String folder) async { /* … */ } + + AgentCallEntry get readInboxCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'read_inbox', + description: 'Read inbox folder', + inputSchema: const { /* … */ }, + handler: (final args) async => readInbox(args['folder'] as String), + ); +} +``` + +```dart +// lib/catalog/handwritten_entries.dart +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import '../hosts/inbox_host.dart'; + +final List handwrittenCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_read_inbox', + entry: InboxHost.shared.readInboxCallEntry, + ), +]; +``` + +Reference implementation: +[`packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart`](../../packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart). + +Then run `intentcall manifest export --check` so committed +`agent_manifest.json` stays in sync with the merged catalog (see +[ADR 0019](../../docs/decisions/0019-framework-neutral-intentcall-cli.md)). + +**Probe anchor:** Catalog rows must use compile-time expressions such as +`InboxHost.shared.readInboxCallEntry` — manifest export evaluates `entry:` in a +subprocess and strips handlers. Runtime may use a different instance when +descriptors match. + +**Descriptor-only rows:** Use `descriptor:` on `AgentRegistryCatalogEntry` when +manifest metadata is needed without a probe-time handler; register handlers at +runtime and keep `qualifiedName` aligned. + +--- + +## 3. Code Generation (`@AgentTool`) We can automate registration using the code generator package `intentcall_codegen`. @@ -102,9 +167,17 @@ Transport adapters, WebMCP, and native bridge wrappers should execute this Dart registry entry rather than copying the business logic into JS, Swift, Kotlin, or another host language. +### Optional: instance-method codegen + +For host-bound tools you may annotate instance methods instead of writing +getters by hand. The host class needs a static binding field (default name +`shared`). Codegen emits an extension with `AgentCallEntry` getters; catalog rows +reference `Host.shared.CallEntry`. Handwritten getters in section 2 +remain the canonical path when you need full control. + --- -## 3. After Changing Registrations +## 4. After Changing Registrations Run the package tests that cover the registered handler. When changing this repository rather than only a downstream app, also run: From 37575ce0408fa2951275dc0fd74ef6539255e2b6 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 04:23:52 +0300 Subject: [PATCH 04/27] feat: enhance catalog management with @AgentCatalog annotation, streamline instance-bound tool integration, and update projection handling for manifest export --- docs/DX_FAQ.mdx | 51 +- .../0020-platform-scoped-manifest-surfaces.md | 46 ++ .../0021-agent-catalog-annotation.md | 52 ++ docs/decisions/README.md | 4 +- docs/evidence/projection-pipeline-spec.md | 448 ++++++++++++++++++ .../web/agent_manifest.json | 8 +- .../jaspr_web_project/web/agent_manifest.json | 8 +- packages/intentcall_codegen/README.md | 108 ++++- .../intentcall_codegen/analysis_options.yaml | 3 + .../intentcall_codegen/example/build.yaml | 5 + .../lib/catalog/handwritten_entries.dart | 18 - .../lib/generated/agent_catalog.g.dart | 3 +- .../intentcall_codegen/example/lib/main.dart | 14 +- .../example/lib/tools/demo_host_tools.dart | 21 +- .../example/lib/tools/demo_ping_tool.dart | 5 +- .../test/manifest_projection_test.dart | 63 ++- .../example/web/agent_manifest.json | 48 +- .../lib/intentcall_codegen.dart | 1 + .../lib/src/agent_catalog.dart | 10 + .../lib/src/agent_projection.dart | 14 +- .../generators/agent_catalog_generator.dart | 401 +++++++++++++--- .../src/generators/agent_tool_generator.dart | 36 +- .../test/agent_catalog_build_test.dart | 138 ++++++ .../test/agent_catalog_generator_test.dart | 2 +- .../test/agent_tool_codegen_test.dart | 42 +- packages/intentcall_codegen/test/build.yaml | 2 + .../catalog/agent_catalog_annotated.dart | 36 ++ .../test/fixtures/catalog/top_level_tool.dart | 16 + .../fixtures/catalog/top_level_tool.g.dart | 37 ++ .../catalog/unannotated_catalog_list.dart | 34 ++ .../src/catalog/agent_registry_catalog.dart | 5 +- .../lib/src/projection/manifest_exporter.dart | 1 + .../lib/src/projection/manifest_merger.dart | 33 +- .../lib/src/projection/projection_policy.dart | 42 +- .../test/manifest_merger_test.dart | 37 ++ skills/register-intents/SKILL.md | 67 ++- 36 files changed, 1621 insertions(+), 238 deletions(-) create mode 100644 docs/decisions/0020-platform-scoped-manifest-surfaces.md create mode 100644 docs/decisions/0021-agent-catalog-annotation.md create mode 100644 docs/evidence/projection-pipeline-spec.md delete mode 100644 packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart create mode 100644 packages/intentcall_codegen/lib/src/agent_catalog.dart create mode 100644 packages/intentcall_codegen/test/agent_catalog_build_test.dart create mode 100644 packages/intentcall_codegen/test/fixtures/catalog/agent_catalog_annotated.dart create mode 100644 packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.dart create mode 100644 packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.g.dart create mode 100644 packages/intentcall_codegen/test/fixtures/catalog/unannotated_catalog_list.dart diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 012e40f..03b18a9 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -391,16 +391,36 @@ No. You can register `AgentCallEntry` objects manually. Codegen is a convenience **Q: How do instance-bound or handwritten tools join the generated catalog and manifest?** +Catalog sources: + +```text +@AgentTool → tool implementation + (usually) catalog row +handwritten getter → tool implementation only +catalog row → @AgentCatalog list +agent_catalog.g.dart → merge of all three sources +``` + Put instance methods and `AgentCallEntry` getters on a host class (typically -`static final shared`), list rows in `lib/catalog/handwritten_entries.dart` as -`handwrittenCatalogEntries`, and run `build_runner`. The aggregate catalog at -`lib/generated/agent_catalog.g.dart` spreads those rows next to `@AgentTool` -entries; `intentcall manifest export --check` then merges catalog + projection -into committed `agent_manifest.json`. Per-row projection uses `EntryProjection` -on catalog rows — same policy surface as `@AgentProjection` on annotated tools. -See [ADR 0019](decisions/0019-framework-neutral-intentcall-cli.md) and the +`static final shared`), then co-locate a `List` +annotated with **`@AgentCatalog`** — the catalog builder discovers it via +`tool_globs`. + +Run `build_runner`. `@AgentTool` rows are collected from generated `*.g.dart` +parts (`tool_part_globs`, default `lib/**.g.dart`), including tools under +`lib/src/` when `agent_tool` emits their parts. The aggregate catalog at +`lib/generated/agent_catalog.g.dart` spreads `@AgentCatalog` lists next to +codegen rows; `intentcall manifest export --check` merges catalog + +projection into committed `agent_manifest.json`. Per-row projection uses +`EntryProjection` on catalog rows — same policy surface as `@AgentProjection` +on annotated tools. See [ADR 0019](decisions/0019-framework-neutral-intentcall-cli.md), +[ADR 0021](decisions/0021-agent-catalog-annotation.md), and the [`intentcall_codegen` example](../packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart). +**Catalog builder options** (`intentcall_codegen|agent_catalog` in `build.yaml`): +`tool_part_globs`, `tool_globs` (`@AgentCatalog` scan only), `tool_exclude_globs`, +`host_binding_field`. +Defaults and a commented template: [`example/build.yaml`](../packages/intentcall_codegen/example/build.yaml). + **Probe anchor:** `intentcall manifest export` runs a subprocess that evaluates each row's `entry:` and calls `resolveDescriptor()` — handlers are stripped before merge. Use `Host.shared.CallEntry` or top-level `*CallEntry` @@ -413,10 +433,19 @@ not materialize an `AgentCallEntry` at probe time, set `descriptor:` on `qualifiedName` aligned for parity gates. **Optional instance `@AgentTool` codegen:** Annotate instance methods on a host -class with a static binding field (default name `shared`). Codegen emits an -extension with `AgentCallEntry` getters whose handlers call instance methods on -`this`; the catalog references `Host.shared.CallEntry`. Handwritten -getters remain the canonical path when you need full control. +class. Codegen emits an extension with `AgentCallEntry` getters whose handlers call +instance methods on `this`. Catalog rows use `descriptor:` when no static binding +field exists, or `Host.shared.CallEntry` when a probe anchor is present. +Handwritten getters remain the canonical path when you need full control. + +**Typed projection:** `@AgentProjection` and `EntryProjection` use +`AgentManifestSurface` enum keys. Apple sub-channels (Siri, Spotlight) use +`AgentManifestSurfaceExposure.options` on handwritten rows until emitters define +them — see [ADR 0020](decisions/0020-platform-scoped-manifest-surfaces.md). + +**Platform-scoped surfaces:** `platforms.enabled` in `intentcall.yaml` scopes +default manifest surface families at export time (not just platform sync targets). +Explicit `defaults.surfaces` in yaml overrides platform-scoped defaults. --- diff --git a/docs/decisions/0020-platform-scoped-manifest-surfaces.md b/docs/decisions/0020-platform-scoped-manifest-surfaces.md new file mode 100644 index 0000000..6474328 --- /dev/null +++ b/docs/decisions/0020-platform-scoped-manifest-surfaces.md @@ -0,0 +1,46 @@ +# 0020. Platform-Scoped Manifest Surface Defaults + +Date: 2026-07-07 + +## Status + +Accepted + +## Context + +`intentcall.yaml` `platforms.enabled` gates which targets `intentcall platform sync` +runs, but manifest export ignored it. `ProjectionPolicy.resolvedDefaultSurfaces()` +applied cross-platform `defaultSurfaceInclude()` to every tool, so web-only hosts +committed android/windows/linux surface rows in `agent_manifest.json`. + +Per-entry `@AgentProjection` / `EntryProjection` overlays merge onto defaults — a +partial overlay such as `{webMcp: true}` does not disable other platform families. + +Instance host wiring also treated `static shared` as mandatory for codegen, even +though runtime registration uses live host instances and manifest export only +needs descriptor metadata (`descriptor:` rows or optional probe anchors). + +## Decision + +1. **`platforms.enabled` scopes default manifest surface families** during export. + When the list is non-empty, surfaces map to platform tokens (web, android, ios, + macos, windows, linux) and default to `include: false` outside enabled platforms. +2. **Precedence:** explicit `defaults.surfaces` in yaml wins; then platform-scoped + defaults; then legacy cross-platform defaults when `platforms.enabled` is empty. +3. **`static shared` is optional** for instance `@AgentTool` codegen. Extension + getters remain; catalog emits `descriptor:` rows when no binding static exists; + `entry: Host.shared.*` remains when the probe anchor is present. +4. **`@AgentProjection.surfaces` uses typed `AgentManifestSurface` keys.** Apple + sub-channels (Siri, Spotlight, extensions) use `AgentManifestSurfaceExposure.options` + on handwritten rows until emitters define them. + +## Consequences + +- Web-only example manifests list web surfaces as `include: true` by default. +- Flutter multi-platform hosts with empty `platforms.enabled` keep unified defaults. +- Authors register instance tools from live host objects; catalog `entry:` is optional. + +## Related + +- [0016-dispatch-mode-handoff-contract.md](0016-dispatch-mode-handoff-contract.md) +- [0019-framework-neutral-intentcall-cli.md](0019-framework-neutral-intentcall-cli.md) diff --git a/docs/decisions/0021-agent-catalog-annotation.md b/docs/decisions/0021-agent-catalog-annotation.md new file mode 100644 index 0000000..c38aaf1 --- /dev/null +++ b/docs/decisions/0021-agent-catalog-annotation.md @@ -0,0 +1,52 @@ +# 0021. @AgentCatalog Annotation and Removal of Handwritten Catalog Path + +Date: 2026-07-07 + +## Status + +Accepted + +## Context + +`AgentCatalogGenerator` merged catalog rows from three sources: + +1. `@AgentTool` codegen (via generated `*.g.dart` parts) +2. A **single hardcoded file** at `lib/catalog/handwritten_entries.dart` exporting + `handwrittenCatalogEntries` (overridable via `handwritten_catalog_path` / + `handwritten_catalog_symbol` in `build.yaml`) +3. `@AgentCatalogSupplement` on `List` discovered via + `tool_globs` + +The handwritten path forced instance-bound and descriptor-only rows into one central +file, breaking co-location with host classes. The supplement annotation solved +multi-file discovery but introduced two overlapping mechanisms and confusing naming. + +## Decision + +1. **Rename** `AgentCatalogSupplement` → **`AgentCatalog`** (annotation mirrors + `@AgentTool` naming). +2. **Remove** `handwritten_catalog_path` and `handwritten_catalog_symbol` builder + options. Manual catalog rows merge only through `@AgentCatalog`. +3. **Document** the three-source mental model: + + ```text + @AgentTool → tool implementation + (usually) catalog row + handwritten getter → tool implementation only + catalog row → @AgentCatalog list + agent_catalog.g.dart → merge of all three sources + ``` + +4. **Breaking change** (pre-release): apps using the default handwritten file must + add `@AgentCatalog()` to their list and co-locate it with the owning host. + +## Consequences + +- Catalog rows live next to host classes; no `lib/catalog/handwritten_entries.dart` + convention. +- `tool_globs` scopes `@AgentCatalog` discovery only; unannotated lists are ignored. +- Pre-release consumers must migrate from `handwrittenCatalogEntries` to annotated lists. + +## Related + +- [0019-framework-neutral-intentcall-cli.md](0019-framework-neutral-intentcall-cli.md) +- [0020-platform-scoped-manifest-surfaces.md](0020-platform-scoped-manifest-surfaces.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2661345..b39d8e4 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,7 +3,7 @@ Architecture Decision Records (ADRs) for IntentCall. Format: [MADR](https://adr.github.io/madr/) — see any existing ADR for the template. -Next ADR number: **0020** +Next ADR number: **0021** --- @@ -21,6 +21,8 @@ Next ADR number: **0020** | [0017](0017-apple-inline-runtime-tracks.md) | accepted | Apple Inline Runtime Tracks | 2026-06-28 | | [0018](0018-additive-actions-typed-entities-indexing-lifecycle.md) | accepted | Additive Actions, Typed Entities, and Indexing Lifecycle | 2026-06-29 | | [0019](0019-framework-neutral-intentcall-cli.md) | accepted | Framework-Neutral IntentCall CLI and Registry-Backed Manifest Generation | 2026-07-07 | +| [0020](0020-platform-scoped-manifest-surfaces.md) | accepted | Platform-Scoped Manifest Surface Defaults | 2026-07-07 | +| [0021](0021-agent-catalog-annotation.md) | accepted | @AgentCatalog Annotation and Removal of Handwritten Catalog Path | 2026-07-07 | --- diff --git a/docs/evidence/projection-pipeline-spec.md b/docs/evidence/projection-pipeline-spec.md new file mode 100644 index 0000000..685550b --- /dev/null +++ b/docs/evidence/projection-pipeline-spec.md @@ -0,0 +1,448 @@ +# Platform Projection Completion Spec + +**Status:** Draft for implementation +**Date:** 2026-07-07 +**Goal:** Make manifest export, emitters, runtime bootstrap, Apple sub-channels, and entity lifecycle **one aligned pipeline** — with per-layer subagent lanes and native gates that prove each layer before the next starts. + +**Disposition target:** `promote_to_artifact` → ADR `0021-projection-pipeline-alignment.md` + harness checks after Layer 5. + +**Related:** + +- [ADR 0016 — Dispatch mode handoff](../decisions/0016-dispatch-mode-handoff-contract.md) +- [ADR 0018 — Additive actions, typed entities, indexing](../decisions/0018-additive-actions-typed-entities-indexing-lifecycle.md) +- [ADR 0020 — Platform-scoped manifest surfaces](../decisions/0020-platform-scoped-manifest-surfaces.md) + +--- + +## 1. Acceptance criteria (parent) + +The work is done when **all** of the following hold: + +| # | Criterion | +|---|-----------| +| A1 | Every exported `agent_manifest.json` tool row has **all 8 surfaces** with explicit `include: true/false` | +| A2 | Every emitter treats absent surface as **excluded** (`defaultValue: false`) | +| A3 | `platforms.enabled` scopes defaults correctly even when `defaults.surfaces` is partial | +| A4 | `apple.appShortcuts` stays **opt-in** when `ios`/`macos` enabled (ADR 0016 preserved) | +| A5 | Dart WebMCP bootstrap respects `web.webMcp` from manifest/catalog | +| A6 | `PlatformSync` reads manifest from `layout.manifest` and web dir from `layout.webDir` | +| A7 | Apple sub-channels are independently controllable (new surfaces + emitters) | +| A8 | Entity types export from catalog; single snapshot store; Spotlight indexing wired after upsert | +| A9 | `mcp_flutter` Jaspr + `flutter_test_app` pass three-gate CI | +| A10 | No orphan/broken emitter copies in `intentcall_platform` | + +**Claim ceiling after full completion:** artifact + static CI proof. +**Non-claims:** live Siri ranking, App Store discovery, signed-app Spotlight UX, exactly-once native delivery. + +--- + +## 2. Cross-layer invariants (never break) + +```yaml +invariants: + source_of_truth: AgentRegistry + agent_catalog.g.dart + intentcall.yaml + pipeline_order: + - build_runner # catalog + - manifest export # policy merge → dense manifest + - platform sync # emitters read manifest only + - runtime bootstrap # must not widen beyond manifest + surface_keys_json: dotted # web.webMcp, apple.appShortcuts, … + surface_keys_yaml: camelCase_or_dotted # both accepted in yaml + apple_shortcuts_default: opt_in # ADR 0016 + missing_surface_semantics: exclude # after Layer 1 +``` + +--- + +## 3. ADR prerequisites (before Layer 3) + +Create **`docs/decisions/0021-projection-pipeline-alignment.md`** amending/clarifying 0020: + +1. **Dense export** — export always emits all surfaces explicitly. +2. **Partial yaml merge** — `defaults.surfaces` per-key overrides platform-scoped fill; absent keys use platform scope. +3. **Apple shortcuts** — `apple.appShortcuts` never auto-enables from `platforms.enabled`; only `apple.appIntents` (new) may default on for ios/macos. +4. **New Apple surfaces** — `apple.appIntents`, `apple.spotlight`, `apple.entities` (names TBD, see Layer 3). +5. **Retire** `intentcall_apple` manifest generator from main path (deprecation note). + +Layers 1–2 can start before ADR merge; Layer 3+ requires ADR accepted. + +--- + +## 4. Layer specifications + +### Layer 1 — Policy correctness (foundation) + +**Outcome:** Export and policy are truthful; emitters cannot misread sparse manifests. + +#### 4.1.1 Code changes + +| Area | File(s) | Change | +|------|---------|--------| +| Dense `toJson` | `agent_manifest.dart` → `AgentManifestSurfacePolicy.toJson` | Emit all `AgentManifestSurface.values` with resolved `include` | +| Resolved surfaces helper | `manifest_merger.dart` or new `surface_resolver.dart` | `resolveEntrySurfaces(overlay, defaultSurfaces)` returns full map | +| Partial yaml merge | `projection_policy.dart` → `resolvedDefaultSurfaces` | If `defaultSurfaces` partial: merge each enum — explicit override → else platform-scoped default → else legacy default | +| Apple opt-in fix | `projection_policy.dart` → `defaultSurfaceIncludeForPlatforms` | `appleAppShortcuts` always `false` unless explicit yaml/catalog overlay | +| Emitter defaults | All emitters in `intentcall_platform_sync/lib/src/emitters/` | `defaultValue: false` everywhere | +| Tests | `manifest_merger_test.dart` | + partial defaults + ios enabled + apple shortcuts stay false | +| Fixtures | `intentcall_codegen/example`, `intentcall_cli/test/fixtures/*`, `mcp_flutter/jaspr_web_example` | Regenerate manifests | + +#### 4.1.2 Subagent lane L1 + +| Field | Value | +|-------|-------| +| Role | Policy + export engineer | +| Write set | `intentcall_platform_sync/lib/src/{agent_manifest,projection}/*`, `test/manifest_merger_test.dart`, fixtures | +| Forbidden | Apple emitters, runtime bootstrap, mcp_flutter | +| Native gate | `dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart` | +| Direct fix | yes | + +#### 4.1.3 Layer 1 verification gate + +```bash +# Gate L1-A: unit +dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart + +# Gate L1-B: codegen example (web-only scoping + dense surfaces) +dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart + +# Gate L1-C: every tool row has 8 surface keys (new test) +dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart # add + +# Gate L1-D: emitter unit — opt-out honored +dart test packages/intentcall_platform_sync/test/native_emitters_test.dart +``` + +**Alignment check L1→L2:** Parse exported manifest; for each tool assert `surfaces.keys.length == 8`; run `platform sync --check` on web-only fixture — **zero** android/windows/linux artifact changes. + +--- + +### Layer 2 — Runtime + sync parity + +**Outcome:** Sync reads correct paths; runtime does not widen beyond manifest. + +#### 4.2.1 Code changes + +| Area | File(s) | Change | +|------|---------|--------| +| Layout wiring | `platform_sync.dart` | Load `layout.manifest`, `layout.webDir` from yaml (reuse `ManifestMerger` readers); `_resolveManifestFile` uses both | +| Export context consumption | `manifest_exporter.dart` | Pass `manifestRelativePath` to CLI `resolveManifestOutput` if not already unified | +| WebMCP bootstrap filter | `agent_web_mcp_bootstrap_web.dart`, `intentcall_flutter_host.dart` | Load manifest or accept `Set webMcpQualifiedNames` from catalog projection; skip tools with `web.webMcp: false` | +| Manifest loader for runtime | New `ManifestSurfaceIndex` in `platform_sync` | `includes(qualifiedName, surface)` from parsed manifest | +| Web hardcoding (optional L2.1) | `web_manifest_emitter.dart` | Document `web+intentcall` vs `protocolScheme`; defer config to Layer 5 docs unless trivial | +| Orphan cleanup | `intentcall_platform/lib/src/emitters/*` | Delete or replace with `export` from `intentcall_platform_sync` | +| Stale JS | `intentcall_codegen/example/web/intentcall_webmcp.generated.js` | Regenerate; `app_demo_cart` must be absent | + +#### 4.2.2 Subagent lanes (parallel after L1 gate passes) + +| Lane | Role | Scope | Gate | +|------|------|-------|------| +| **L2a** | Sync/layout engineer | `platform_sync.dart`, `platform_sync_test.dart` | `dart test .../platform_sync_test.dart` | +| **L2b** | WebMCP runtime engineer | bootstrap + flutter host | New `webmcp_bootstrap_surface_test.dart` | +| **L2c** | Package hygiene | `intentcall_platform` orphan removal | `dart analyze packages/intentcall_platform` | + +#### 4.2.3 Layer 2 verification gate + +```bash +# Gate L2-A: custom layout.manifest +# Test fixture: intentcall.yaml layout.manifest: assets/agent_manifest.json +dart test packages/intentcall_platform_sync/test/platform_sync_layout_test.dart # add + +# Gate L2-B: WebMCP bootstrap respects opt-out +dart test packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart # add + +# Gate L2-C: sync --check on web-only project does not touch android/ +cd packages/intentcall_codegen/example && \ + dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check && \ + dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check + +# Gate L2-D: generated JS excludes webMcp:false tools +rg -l 'app_demo_cart' packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js && exit 1 || true +``` + +**Alignment check L2→L3:** Manifest `web.webMcp: false` → absent from JS **and** Dart bootstrap registration in integration test. + +--- + +### Layer 3 — Apple sub-channels + +**Outcome:** Siri, Spotlight, Shortcuts, and base App Intents are independently gated. + +#### 4.3.1 Surface model (proposed) + +```yaml +new_surfaces: + appleAppIntents: # Swift AppIntent struct emission (all tools default on ios/macos) + manifest_key: apple.appIntents + default_ios_macos: true + appleAppShortcuts: # AppShortcutsProvider phrases (opt-in, ADR 0016) + manifest_key: apple.appShortcuts + default: false + appleSpotlight: # CSSearchableIndex / IndexedEntity indexing + manifest_key: apple.spotlight + default: false + appleEntities: # AppEntity + EntityQuery generation + manifest_key: apple.entities + default: false # true when entityTypes present + explicit or default policy +``` + +`AgentManifestSurfaceExposure.options` for future Siri phrase templates, donation hints — parsed in yaml/codegen in this layer minimally (store + round-trip), emitters read in Layer 4. + +#### 4.3.2 Code changes + +| Area | File(s) | Change | +|------|---------|--------| +| Enum + keys | `agent_manifest.dart` | Add 3 surfaces; bump schema doc only (version stays 1 if additive) | +| Policy defaults | `projection_policy.dart` | Map new surfaces to `ios`/`macos` | +| Codegen | `agent_projection.dart`, `agent_catalog_generator.dart` | Support new `@AgentProjection` keys | +| Apple emitter | `apple_swift_app_intents_emitter.dart` | Gate: struct emission ← `appleAppIntents`; shortcuts ← `appleAppShortcuts`; entity block ← `appleEntities`; indexing ← `appleSpotlight` | +| Testing emitter | `apple_app_intents_testing_emitter.dart` | Filter tools by relevant surfaces | +| Tests | `native_emitters_test.dart` | Matrix: each surface independently on/off | +| Docs/skills | `register-intents/SKILL.md`, `DX_FAQ.mdx` | Update surface table | +| Deprecate | `intentcall_apple` | README deprecation banner; no new features | + +#### 4.3.3 Subagent lanes + +| Lane | Role | Scope | Gate | +|------|------|-------|------| +| **L3a** | Schema + policy | manifest enum, projection_policy, merger tests | L1 gates + new surface tests | +| **L3b** | Apple emitter split | `apple_swift_app_intents_emitter.dart` | `native_emitters_test.dart` | +| **L3c** | Codegen + annotations | `intentcall_codegen` | `agent_catalog_generator_test.dart`, example export | +| **L3d** | ADR author | `docs/decisions/0021-*.md` | Review only | + +**Merge order:** L3d (ADR) → L3a → L3b ∥ L3c → parent synthesis. + +#### 4.3.4 Layer 3 verification gate + +```bash +dart test packages/intentcall_platform_sync/test/native_emitters_test.dart + +# New: apple surface independence matrix +dart test packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart # add + +# ios sync generates Swift only for appleAppIntents:true tools +# shortcuts provider empty when all appleAppShortcuts:false +``` + +**Alignment check L3→L4:** Tool with `apple.appIntents: true`, `apple.appShortcuts: false` → Swift struct exists, no `AppShortcut` row. Entity types only when `apple.entities: true` or global entityTypes + policy. + +--- + +### Layer 4 — Entity lifecycle (ADR 0018 completion) + +**Outcome:** Entity types flow catalog → manifest → native cache → Spotlight; one store. + +#### 4.4.1 Code changes + +| Area | File(s) | Change | +|------|---------|--------| +| Catalog entity rows | `agent_catalog_generator.dart` | Emit `@AgentEntity` / descriptor rows into catalog | +| CLI export | `command_runner.dart`, `manifest_exporter.dart` | Pass `entityTypeDescriptors` from catalog loader | +| Catalog loader | `catalog_loader.dart` | Load entity descriptors from generated catalog | +| Unify snapshot store | `IntentCallPlatformPlugin.swift` (all copies) | Delegate to generated `IntentCallNativeEntitySnapshotStore` or delete hand-written store | +| Dart API | `intentcall_entity_index.dart` | Deprecate legacy `upsertAgentSnapshots`; canonical: `upsertAgentSnapshotsForType` | +| Indexing hook | Plugin method channel | After upsert → call generated `IntentCallAppEntityIndexer.indexAppEntities()` | +| Emitter | `apple_swift_app_intents_emitter.dart` | Indexing gated by `apple.spotlight` | + +#### 4.4.2 Subagent lanes + +| Lane | Role | Scope | Gate | +|------|------|-------|------| +| **L4a** | Export + catalog | CLI, catalog loader, codegen | `manifest export` includes `entityTypes` | +| **L4b** | Native store unification | iOS/macOS plugin Swift | `intentcall_entity_index_test.dart` | +| **L4c** | Indexing lifecycle | emitter + plugin bridge | AppIntentsTesting scaffold compile | + +#### 4.4.3 Layer 4 verification gate + +```bash +dart test packages/intentcall_core/test/agent_entity_snapshot_projection_test.dart +dart test packages/intentcall_platform/test/intentcall_entity_index_test.dart + +# New: export includes entityTypes from fixture catalog +dart test packages/intentcall_cli/test/manifest_entity_export_test.dart # add + +# AppIntentsTesting compile (if SDK available) +dart run packages/intentcall_cli/bin/intentcall.dart apple-appintents-testing generate --check # if fixture exists +``` + +**Alignment check L4→L5:** `entityTypes` in manifest match catalog; upsert uses descriptor keys; generated entity query reads same keys cold. + +--- + +### Layer 5 — Consumer migration + harness proof + +**Outcome:** mcp_flutter and agentkit examples dogfood the full pipeline. + +#### 4.5.1 Code changes + +| Repo | Change | +|------|--------| +| **agentkit** | Steward scenario `intentcall.projection-pipeline` (new yaml) | +| **mcp_flutter** | `flutter_test_app`: add `intentcall.yaml`, catalog codegen, drop hand manifest | +| **mcp_flutter** | Extend `check_intentcall_hosted_consumer.sh` with `manifest export --check` | +| **mcp_flutter** | Regenerate `jaspr_web_example/web/agent_manifest.json` (web-only surfaces) | +| **agentkit** | `register-intents/SKILL.md` surface table + three-gate workflow | +| **optional** | `WebMcpPublishAdapter` in dogfood app | + +#### 4.5.2 Subagent lanes + +| Lane | Role | Scope | Gate | +|------|------|-------|------| +| **L5a** | agentkit harness | steward scenario, docs | `steward benchmark --scenario intentcall.projection-pipeline --json` | +| **L5b** | mcp_flutter Jaspr | jaspr_web_example fixtures | `tool/contracts/check_intentcall_jaspr_three_gate.sh` | +| **L5c** | mcp_flutter Flutter dogfood | flutter_test_app migration | `tool/contracts/check_intentcall_hosted_consumer.sh` | +| **L5d** | Docs/skills | DX_FAQ, platform_support, register-intents | Docs review only | + +#### 4.5.3 Layer 5 verification gate (aggregate) + +```bash +# agentkit root +steward doctor --json +steward probe --json --profile quick +dart test packages/intentcall_platform_sync +dart test packages/intentcall_codegen +dart test packages/intentcall_cli + +# sibling integration (when ../agentkit exists) +cd ~/mcp/mcp_flutter && make check-contracts +``` + +--- + +## 5. Master subagent batch contract + +```markdown +## Parallel Batch +Original goal: Align platform projection across export → manifest → emitters → runtime → consumers +Acceptance check: Criteria A1–A10 above +Product impact check: agent_manifest.json shape, generated artifacts, WebMCP registration, Apple Swift output, entity cache +Default native gate: dart test packages/intentcall_platform_sync +Aggregate gate: steward benchmark --scenario intentcall.projection-pipeline + mcp_flutter make check-contracts +Detour budget: No new surfaces beyond 0021; no runtime Siri proof rabbit holes +Claim ceiling: Static CI + artifact proof; no live OS discovery claims +Non-claims: Siri ranking, App Store approval, signed-app Spotlight UX, exactly-once delivery + +| Lane | Agent/role | Scope | Write set | Forbidden | Native gate | Layer | Terminal | +|------|------------|-------|-----------|-----------|-------------|-------|----------| +| L1 | policy-export | L1 | platform_sync projection/* | emitters, mcp_flutter | manifest_merger_test | 1 | pending | +| L2a | sync-layout | L2 | platform_sync.dart | apple, mcp_flutter | platform_sync_test | 2 | pending | +| L2b | webmcp-runtime | L2 | bootstrap, flutter_host | apple, mcp_flutter | webmcp_bootstrap_test | 2 | pending | +| L2c | pkg-hygiene | L2 | intentcall_platform emitters | codegen | dart analyze | 2 | pending | +| L3a | schema-policy | L3 | agent_manifest, projection_policy | emitters | merger + dense tests | 3 | pending | +| L3b | apple-emitter | L3 | apple_swift_* | web, mcp_flutter | native_emitters_test | 3 | pending | +| L3c | codegen-surfaces | L3 | intentcall_codegen | platform_sync emitters | catalog_generator_test | 3 | pending | +| L3d | adr-author | L3 | docs/decisions/0021-* | code | review | 3 | pending | +| L4a | entity-export | L4 | CLI, catalog_loader | plugin swift | manifest_entity_export_test | 4 | pending | +| L4b | native-store | L4 | plugin Swift, entity_index | emitters | entity_index_test | 4 | pending | +| L4c | indexing-bridge | L4 | plugin + emitter hook | mcp_flutter | platform tests | 4 | pending | +| L5a | harness | L5 | steward scenarios | app code | steward benchmark | 5 | pending | +| L5b | mcp-jaspr | L5 | jaspr_web_example | agentkit core | jaspr three-gate | 5 | pending | +| L5c | mcp-flutter | L5 | flutter_test_app | agentkit core | hosted consumer | 5 | pending | +| L5d | docs | L5 | skills, FAQ | code | doc review | 5 | pending | +``` + +**Execution order (strict):** + +``` +L1 → [L2a ∥ L2b ∥ L2c] → ADR 0021 → [L3a → L3b ∥ L3c] → [L4a → L4b ∥ L4c] → [L5a ∥ L5b ∥ L5c ∥ L5d] +``` + +Parent **blocks** layer N+1 until layer N aggregate gate passes. + +--- + +## 6. Cross-layer alignment matrix + +After each layer, parent runs this matrix (automate as `projection_alignment_test.dart` in Layer 5): + +| Check | L1 | L2 | L3 | L4 | L5 | +|-------|----|----|----|----|-----| +| 8 surfaces per tool in JSON | ✓ | ✓ | ✓ (+3 Apple) | ✓ | ✓ | +| web-only yaml → no non-web `include:true` | ✓ | ✓ | ✓ | ✓ | ✓ | +| emitter `defaultValue:false` | ✓ | ✓ | ✓ | ✓ | ✓ | +| sync uses layout.manifest | — | ✓ | ✓ | ✓ | ✓ | +| Dart WebMCP ⊆ manifest webMcp | — | ✓ | ✓ | ✓ | ✓ | +| apple shortcuts opt-in on ios | ✓ | ✓ | ✓ | ✓ | ✓ | +| apple struct gated separately | — | — | ✓ | ✓ | ✓ | +| entityTypes in export | — | — | — | ✓ | ✓ | +| single snapshot store | — | — | — | ✓ | ✓ | +| mcp_flutter three-gate | — | — | — | — | ✓ | + +--- + +## 7. New tests to add (checklist) + +| Test file | Layer | Asserts | +|-----------|-------|---------| +| `dense_manifest_test.dart` | 1 | 8 keys per tool; all `include` bool | +| `partial_defaults_platform_scope_test.dart` | 1 | yaml `{webMcp:true}` + `platforms:[web]` → android false | +| `ios_shortcuts_opt_in_test.dart` | 1 | `enabledPlatforms:[ios]` → `apple.appShortcuts` false | +| `platform_sync_layout_test.dart` | 2 | custom `layout.manifest` path | +| `webmcp_bootstrap_surface_test.dart` | 2 | opt-out tool not registered | +| `apple_surface_matrix_test.dart` | 3 | independent surface gating | +| `manifest_entity_export_test.dart` | 4 | `entityTypes` from catalog | +| `projection_alignment_test.dart` | 5 | full matrix above | + +--- + +## 8. Steward harness scenario (Layer 5) + +Add `steward/scenarios/intentcall.projection-pipeline.yaml`: + +```yaml +name: intentcall.projection-pipeline +steps: + - run: dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart + - run: dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart + - run: dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart + - run: dart test packages/intentcall_platform_sync/test/native_emitters_test.dart + - working_directory: packages/intentcall_codegen/example + run: dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check + - working_directory: packages/intentcall_codegen/example + run: dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check +``` + +--- + +## 9. Parent synthesis template (after each layer) + +```markdown +## Layer N synthesis +- Gates run: {list} +- Gates skipped: {reason} +- Lanes integrated: {ids} +- Lanes partial/timed_out: {ids} +- Alignment matrix row: {pass/fail per check} +- Claim ceiling: {what we can claim now} +- Blockers for layer N+1: {none | list} +``` + +--- + +## 10. Risk register + +| Risk | Mitigation | +|------|------------| +| Schema bump breaks consumers | Additive surfaces only; dense export backward-compatible | +| Breaking sparse manifest consumers | Layer 1 regenerates all fixtures in same PR | +| Apple SDK CI variance | AppIntentsTesting optional; compile proof labeled | +| mcp_flutter path deps | Land agentkit first; then bump hosted versions | +| Dual store migration breaks apps | Deprecation period; legacy API calls new store | + +--- + +## 11. Suggested first execution prompt + +> Execute Layer 1 per projection completion spec: dense manifest export, partial yaml + platform merge, appleAppShortcuts opt-in on ios, emitter defaultValue:false. Add dense_manifest_test + ios_shortcuts_opt_in_test. Regenerate codegen example and jaspr_web_example manifests. Run L1 gates only; do not start Layer 2. + +--- + +## 12. MoE audit origin + +This spec synthesizes findings from a Mixture-of-Experts audit (2026-07-07) covering: + +- Apple projection wiring (Siri, Spotlight, Shortcuts, entity pipeline) +- Web and multi-platform emitter/runtime gaps +- mcp_flutter consumer delegation and three-gate CI +- ADR 0020 partial implementation state + +Lens status at audit time: Apple and web wiring integrated; harness QA partial; disposition `promote_to_artifact`. diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json index b8d67d4..23c6af2 100644 --- a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json @@ -14,7 +14,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -26,13 +26,13 @@ "include": true }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json index 1d3edf5..ebe68fd 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json @@ -14,7 +14,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -26,13 +26,13 @@ "include": true }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { diff --git a/packages/intentcall_codegen/README.md b/packages/intentcall_codegen/README.md index a5dd1a8..754be14 100644 --- a/packages/intentcall_codegen/README.md +++ b/packages/intentcall_codegen/README.md @@ -11,6 +11,17 @@ Optional `@AgentTool` / `@AgentParam` annotations and **build_runner** codegen p Hand-written `AgentCallEntry` remains first-class; codegen is opt-in for stable tools with typed parameters. +## Catalog mental model + +```text +@AgentTool → tool implementation + (usually) catalog row +handwritten getter → tool implementation only +catalog row → @AgentCatalog list +agent_catalog.g.dart → merge of all three sources +``` + +See [ADR 0021](../../docs/decisions/0021-agent-catalog-annotation.md). + ## Wiring instance methods When a tool handler needs host state (services, config, or UI policy), keep the @@ -38,30 +49,30 @@ final class DemoHostTools { } ``` -2. **Merge handwritten rows into the generated catalog** +2. **Merge catalog rows with `@AgentCatalog`** -Create `lib/catalog/handwritten_entries.dart` exporting -`handwrittenCatalogEntries` — a `List` that references -`DemoHostTools.shared.CallEntry`. Optional per-row projection uses -`EntryProjection` (same as `@AgentProjection` on annotated tools). +Co-locate a `List` with the host class (or module) and +annotate it with `@AgentCatalog`. The catalog builder discovers annotated lists +under `lib/` and spreads them into `lib/generated/agent_catalog.g.dart` alongside +`@AgentTool` rows. Optional per-row projection uses `EntryProjection` (same as +`@AgentProjection` on annotated tools). ```dart -final List handwrittenCatalogEntries = +@AgentCatalog() +final List demoHostCatalogEntries = [ AgentRegistryCatalogEntry( registryKey: 'app_demo_inbox', entry: DemoHostTools.shared.inboxCallEntry, - projection: const EntryProjection( - surfaces: {AgentManifestSurface.webMcp: true}, - ), + projection: DemoHostTools.inboxProjection, ), ]; ``` After `dart run build_runner build`, `lib/generated/agent_catalog.g.dart` -imports that file and spreads `...handwrittenCatalogEntries` alongside -`@AgentTool` rows. Duplicate `registryKey` values between codegen and -handwritten rows fail the build. +spreads `@AgentCatalog` lists next to `@AgentTool` rows discovered from generated +`*.g.dart` parts. Duplicate `registryKey` values between codegen and +`@AgentCatalog` rows fail the build. 3. **Export manifest and register at runtime** @@ -81,12 +92,58 @@ per-request or widget-scoped instance. The probe needs descriptor metadata only; your runtime registry may bind a different live instance as long as `qualifiedName` and schema stay aligned. +`static shared` is **optional**. When absent, instance `@AgentTool` catalog rows +use `descriptor:` (manifest metadata only); register extension getters from your +live host at bootstrap. When present, catalog may use `entry: Host.shared.*` for +one-line `registerAll` demos. + +### Handwritten projection + +Co-locate projection policy on the host class and reference it from the catalog: + +```dart +static const inboxProjection = EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, +); + +AgentCallEntry get inboxCallEntry => AgentCallEntry.tool(/* … */); +``` + +```dart +AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + entry: DemoHostTools.shared.inboxCallEntry, + projection: DemoHostTools.inboxProjection, +), +``` + For manifest-only rows without a handler at probe time, use `descriptor:` on `AgentRegistryCatalogEntry` and register the handler separately at runtime. Full runnable example: -[`example/lib/tools/demo_host_tools.dart`](example/lib/tools/demo_host_tools.dart), -[`example/lib/catalog/handwritten_entries.dart`](example/lib/catalog/handwritten_entries.dart). +[`example/lib/tools/demo_host_tools.dart`](example/lib/tools/demo_host_tools.dart). + +### Catalog builder options + +Configure `intentcall_codegen|agent_catalog` in `build.yaml`: + +```yaml +targets: + $default: + builders: + intentcall_codegen|agent_catalog: + options: + tool_part_globs: [lib/**.g.dart] # @AgentTool via agent_tool parts + tool_globs: [lib/**.dart] # @AgentCatalog scan only + tool_exclude_globs: [lib/**.g.dart, lib/generated/**] + host_binding_field: shared # optional static probe anchor name +``` + +- **`tool_part_globs`** — catalog rows come from generated parts, not raw `@AgentTool` sources. +- **`tool_globs` / `tool_exclude_globs`** — scope `@AgentCatalog` discovery only. +- **`lib/src/`** — not hard-excluded; tools under `lib/src/` join the catalog when `agent_tool` emits their `.g.dart`. + +See [`example/build.yaml`](example/build.yaml) for a commented template. ## Pilot usage @@ -158,21 +215,30 @@ For each top-level `@AgentTool` function, `.g.dart` emits: - `CallEntry` — top-level `AgentCallEntry.tool(...)` factory - `Registration` — `RegisteredAgentIntent` via `.toRegistration()` -For each instance `@AgentTool` method on a host class with a static binding field -(default `shared`), `.g.dart` emits: +For each instance `@AgentTool` method on a host class, `.g.dart` emits: - `_InputSchema` constants - `extension AgentCodegen on ` with `CallEntry` getters whose handlers call instance methods on `this` -- top-level `Registration` aliases via `Host.shared.CallEntry` +- optional top-level `Registration` aliases when a static binding field + (default `shared`) exists -The aggregate catalog references instance rows as -`Host.shared.CallEntry` (same as handwritten getters). +The aggregate catalog references instance rows as `Host.shared.CallEntry` +when a binding static exists, otherwise `descriptor:` metadata only. + +`@AgentProjection` uses typed `AgentManifestSurface` keys: + +```dart +@AgentProjection(surfaces: {AgentManifestSurface.webMcp: true}) +``` Supported parameter types: `String`, `int`, `bool`, `double`. -Optional `host_binding_field` in `build.yaml` overrides the default `shared` -static field name used for catalog probe anchors. +`host_binding_field` (see **Catalog builder options**) overrides the default +`shared` static field name used for optional catalog probe anchors. + +`platforms.enabled` in `intentcall.yaml` scopes default manifest surface families +(see [ADR 0020](../../docs/decisions/0020-platform-scoped-manifest-surfaces.md)). ## Scope (pilot) diff --git a/packages/intentcall_codegen/analysis_options.yaml b/packages/intentcall_codegen/analysis_options.yaml index f04c6cf..dd404e0 100644 --- a/packages/intentcall_codegen/analysis_options.yaml +++ b/packages/intentcall_codegen/analysis_options.yaml @@ -1 +1,4 @@ +analyzer: + errors: + leading_newlines_in_multiline_strings: ignore include: ../../analysis_options.yaml diff --git a/packages/intentcall_codegen/example/build.yaml b/packages/intentcall_codegen/example/build.yaml index ae936ad..a21e78a 100644 --- a/packages/intentcall_codegen/example/build.yaml +++ b/packages/intentcall_codegen/example/build.yaml @@ -7,3 +7,8 @@ targets: intentcall_codegen|agent_catalog: generate_for: - lib/** + # options: + # tool_part_globs: [lib/**.g.dart] + # tool_globs: [lib/**.dart] # @AgentCatalog scan only + # tool_exclude_globs: [lib/**.g.dart, lib/generated/**] + # host_binding_field: shared diff --git a/packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart b/packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart deleted file mode 100644 index d4415c8..0000000 --- a/packages/intentcall_codegen/example/lib/catalog/handwritten_entries.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; - -import '../tools/demo_host_tools.dart'; - -final List handwrittenCatalogEntries = - [ - AgentRegistryCatalogEntry( - registryKey: 'app_demo_inbox', - entry: DemoHostTools.shared.inboxCallEntry, - projection: const EntryProjection( - surfaces: {AgentManifestSurface.webMcp: true}, - ), - ), - AgentRegistryCatalogEntry( - registryKey: 'app_demo_handwritten', - entry: DemoHostTools.shared.demoHandwrittenCallEntry, - ), -]; diff --git a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart index a1e6955..90557d4 100644 --- a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -3,7 +3,6 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import '../tools/demo_host_tools.dart'; import '../tools/demo_ping_tool.dart'; -import '../catalog/handwritten_entries.dart'; final List agentCatalogEntries = [ @@ -17,5 +16,5 @@ final List agentCatalogEntries = surfaces: {AgentManifestSurface.webMcp: false}, )), AgentRegistryCatalogEntry(registryKey: 'app_demo_required_named', entry: demoRequiredNamedCallEntry), - ...handwrittenCatalogEntries, + ...demoHostCatalogEntries, ]; diff --git a/packages/intentcall_codegen/example/lib/main.dart b/packages/intentcall_codegen/example/lib/main.dart index 528a807..997d2ad 100644 --- a/packages/intentcall_codegen/example/lib/main.dart +++ b/packages/intentcall_codegen/example/lib/main.dart @@ -1,10 +1,22 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'generated/agent_catalog.g.dart'; +import 'tools/demo_host_tools.dart'; Future main() async { final registry = InMemoryAgentRegistry(); - registerAll(registry, agentCatalogEntries.map((final row) => row.entry!)); + final liveHost = DemoHostTools(); + + for (final row in agentCatalogEntries) { + if (row.registryKey == 'app_demo_host_status') { + continue; + } + final entry = row.entry; + if (entry != null) { + registry.register(entry.toRegistration()); + } + } + registry.register(liveHost.demoHostStatusCallEntry.toRegistration()); final inbox = await registry.invoke('app_demo_inbox', {'folder': 'inbox'}); if (!inbox.ok) { diff --git a/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart index 1bd4ed9..8adfd7b 100644 --- a/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart +++ b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart @@ -1,5 +1,6 @@ import 'package:intentcall_codegen/intentcall_codegen.dart'; import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; part 'demo_host_tools.g.dart'; @@ -9,6 +10,10 @@ final class DemoHostTools { static final DemoHostTools shared = DemoHostTools(); + static const inboxProjection = EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ); + Future inbox(final String folder) async { return AgentResult.success( data: { @@ -23,7 +28,7 @@ final class DemoHostTools { name: 'demo_host_status', description: 'Codegen instance-method host tool', ) - @AgentProjection(surfaces: {'web.webMcp': true}) + @AgentProjection(surfaces: {AgentManifestSurface.webMcp: true}) Future hostStatus(@AgentParam('Host label') String label) async { return AgentResult.success( data: { @@ -74,3 +79,17 @@ final class DemoHostTools { handler: (final args) async => demoHandwritten(args['note'] as String), ); } + +@AgentCatalog() +final List demoHostCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + entry: DemoHostTools.shared.inboxCallEntry, + projection: DemoHostTools.inboxProjection, + ), + AgentRegistryCatalogEntry( + registryKey: 'app_demo_handwritten', + entry: DemoHostTools.shared.demoHandwrittenCallEntry, + ), + ]; diff --git a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart index 83d8e9a..20c27fc 100644 --- a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart +++ b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart @@ -1,5 +1,6 @@ import 'package:intentcall_codegen/intentcall_codegen.dart'; import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; part 'demo_ping_tool.g.dart'; @@ -20,9 +21,7 @@ Future demoPing( name: 'demo_cart', description: 'Returns a cart total', ) -@AgentProjection( - surfaces: {'web.webMcp': false}, -) +@AgentProjection(surfaces: {AgentManifestSurface.webMcp: false}) Future demoCart( @AgentParam('Currency code') String currency, { @AgentParam('Include tax', required: false) bool includeTax = false, diff --git a/packages/intentcall_codegen/example/test/manifest_projection_test.dart b/packages/intentcall_codegen/example/test/manifest_projection_test.dart index 1d8ab82..ee50951 100644 --- a/packages/intentcall_codegen/example/test/manifest_projection_test.dart +++ b/packages/intentcall_codegen/example/test/manifest_projection_test.dart @@ -5,13 +5,27 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +String _exampleProjectRoot() { + final cwd = Directory.current.path; + final candidates = [ + p.normalize(p.join(cwd, 'packages', 'intentcall_codegen', 'example')), + p.normalize(cwd), + ]; + for (final candidate in candidates) { + if (File(p.join(candidate, 'intentcall.yaml')).existsSync()) { + return candidate; + } + } + throw StateError('Could not locate intentcall_codegen example project root'); +} + void main() { late String projectRoot; late ManifestExporter exporter; late ManifestExportContext context; setUpAll(() { - projectRoot = p.normalize(p.join(Platform.script.toFilePath(), '..', '..')); + projectRoot = _exampleProjectRoot(); exporter = const ManifestExporter(); context = exporter.loadExportContext(projectRoot: projectRoot); }); @@ -19,7 +33,7 @@ void main() { AgentManifest buildManifest() => exporter.buildManifest(catalog: agentCatalogEntries, context: context); - test('demo_cart respects @AgentProjection(web.webMcp: false)', () { + test('demo_cart respects @AgentProjection(webMcp: false)', () { final manifest = buildManifest(); final cart = manifest.tools.singleWhere( (final tool) => tool.qualifiedName == 'app_demo_cart', @@ -42,7 +56,7 @@ void main() { expect(qualifiedNames, contains('app_demo_handwritten')); }); - test('codegen instance tool respects @AgentProjection(web.webMcp: true)', () { + test('codegen instance tool respects @AgentProjection(webMcp: true)', () { final manifest = buildManifest(); final hostStatus = manifest.tools.singleWhere( (final tool) => tool.qualifiedName == 'app_demo_host_status', @@ -50,11 +64,48 @@ void main() { expect(hostStatus.dispatchMode, AgentManifestDispatchMode.openApp); expect( - hostStatus.surfaces.includes( - AgentManifestSurface.webMcp, + hostStatus.surfaces.includes(AgentManifestSurface.webMcp, defaultValue: true), + isTrue, + ); + }); + + test('handwritten inbox respects co-located inboxProjection', () { + final manifest = buildManifest(); + final inbox = manifest.tools.singleWhere( + (final tool) => tool.qualifiedName == 'app_demo_inbox', + ); + + expect( + inbox.surfaces.includes(AgentManifestSurface.webMcp, defaultValue: false), + isTrue, + ); + }); + + test('web-only example disables non-web default surfaces', () { + expect(context.enabledPlatforms, contains('web')); + + final manifest = buildManifest(); + final ping = manifest.tools.singleWhere( + (final tool) => tool.qualifiedName == 'app_demo_ping', + ); + + expect( + ping.surfaces.includes(AgentManifestSurface.webMcp, defaultValue: false), + isTrue, + ); + expect( + ping.surfaces.includes( + AgentManifestSurface.androidShortcuts, defaultValue: true, ), - isTrue, + isFalse, + ); + expect( + ping.surfaces.includes( + AgentManifestSurface.windowsProtocolActivation, + defaultValue: true, + ), + isFalse, ); }); } diff --git a/packages/intentcall_codegen/example/web/agent_manifest.json b/packages/intentcall_codegen/example/web/agent_manifest.json index 00d90f5..9d23e90 100644 --- a/packages/intentcall_codegen/example/web/agent_manifest.json +++ b/packages/intentcall_codegen/example/web/agent_manifest.json @@ -14,7 +14,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -26,13 +26,13 @@ "include": true }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { @@ -60,7 +60,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -72,13 +72,13 @@ "include": true }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { @@ -106,7 +106,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -118,13 +118,13 @@ "include": false }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { @@ -156,7 +156,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -168,13 +168,13 @@ "include": true }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { @@ -202,7 +202,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -214,13 +214,13 @@ "include": true }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { @@ -248,7 +248,7 @@ "include": false }, "android.shortcuts": { - "include": true + "include": false }, "web.manifestShortcuts": { "include": true @@ -260,13 +260,13 @@ "include": true }, "windows.protocolActivation": { - "include": true + "include": false }, "windows.msixProtocol": { - "include": true + "include": false }, "linux.schemeHandler": { - "include": true + "include": false } }, "inputSchema": { diff --git a/packages/intentcall_codegen/lib/intentcall_codegen.dart b/packages/intentcall_codegen/lib/intentcall_codegen.dart index 5e61ae4..934cd7f 100644 --- a/packages/intentcall_codegen/lib/intentcall_codegen.dart +++ b/packages/intentcall_codegen/lib/intentcall_codegen.dart @@ -1,3 +1,4 @@ +export 'src/agent_catalog.dart'; export 'src/agent_param.dart'; export 'src/agent_projection.dart'; export 'src/agent_tool.dart'; diff --git a/packages/intentcall_codegen/lib/src/agent_catalog.dart b/packages/intentcall_codegen/lib/src/agent_catalog.dart new file mode 100644 index 0000000..995c30d --- /dev/null +++ b/packages/intentcall_codegen/lib/src/agent_catalog.dart @@ -0,0 +1,10 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +/// Marks a top-level [AgentRegistryCatalogEntry] list for catalog merge. +/// +/// Annotate a `List` anywhere under `lib/`. +/// [AgentCatalogGenerator] discovers annotated lists and spreads them into +/// `lib/generated/agent_catalog.g.dart` alongside `@AgentTool` rows. +class AgentCatalog { + const AgentCatalog(); +} diff --git a/packages/intentcall_codegen/lib/src/agent_projection.dart b/packages/intentcall_codegen/lib/src/agent_projection.dart index 7da50ac..622b702 100644 --- a/packages/intentcall_codegen/lib/src/agent_projection.dart +++ b/packages/intentcall_codegen/lib/src/agent_projection.dart @@ -1,15 +1,19 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + /// Platform projection hints for generated [agent_manifest.json] rows. -/// -/// Types resolve through [intentcall_platform_sync] at build time. class AgentProjection { const AgentProjection({ this.dispatchMode = 'openApp', - this.surfaces = const {}, + this.surfaces = const {}, }); /// `openApp`, `inlineRuntime`, or `queueOnly`. final String dispatchMode; - /// Surface keys such as `web.webMcp`, `apple.appShortcuts`. - final Map surfaces; + /// Per-surface inclusion overrides using typed [AgentManifestSurface] keys. + /// + /// Sub-channel hints (for example Apple Siri vs Spotlight) use + /// [AgentManifestSurfaceExposure.options] on handwritten [EntryProjection] + /// rows until emitters support them. + final Map surfaces; } diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart index c66da92..dc856e3 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -1,12 +1,16 @@ import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; import 'package:build/build.dart'; import 'package:glob/glob.dart'; import 'package:path/path.dart' as p; import 'package:source_gen/source_gen.dart'; +import '../agent_catalog.dart'; import '../agent_projection.dart'; import '../agent_tool.dart'; +import 'agent_tool_generator.dart'; /// Emits `lib/generated/agent_catalog.g.dart` listing all tool call entries. class AgentCatalogGenerator implements Builder { @@ -16,18 +20,25 @@ class AgentCatalogGenerator implements Builder { static const _toolChecker = TypeChecker.typeNamed(AgentTool); static const _projectionChecker = TypeChecker.typeNamed(AgentProjection); + static const _agentCatalogChecker = TypeChecker.typeNamed(AgentCatalog); static const _defaultHostBindingField = 'shared'; - static const _handwrittenCatalogPath = 'lib/catalog/handwritten_entries.dart'; - static const _handwrittenCatalogImport = - '../catalog/handwritten_entries.dart'; - static const _handwrittenCatalogSymbol = 'handwrittenCatalogEntries'; @override Map> get buildExtensions => const { r'$lib$': ['generated/agent_catalog.g.dart'], }; - bool get _scanExample => options.config['scan_example'] as bool? ?? false; + List get _toolGlobs => + (options.config['tool_globs'] as List?)?.cast() ?? + const ['lib/**.dart']; + + List get _toolExcludeGlobs => + (options.config['tool_exclude_globs'] as List?)?.cast() ?? + const ['lib/**.g.dart', 'lib/generated/**']; + + List get _toolPartGlobs => + (options.config['tool_part_globs'] as List?)?.cast() ?? + const ['lib/**.g.dart']; String get _hostBindingField => options.config['host_binding_field'] as String? ?? @@ -71,38 +82,38 @@ class AgentCatalogGenerator implements Builder { continue; } - final relative = p - .relative(input.path, from: p.dirname(outputPath)) - .replaceAll(r'\', '/'); - imports.add("import '$relative';"); + imports.add("import '${_importForAsset(input.path, outputPath)}';"); entries.addAll(fileEntries); } - final handwrittenAsset = AssetId( - buildStep.inputId.package, - _handwrittenCatalogPath, + final knownRegistryKeys = {...codegenRegistryKeys}; + + final agentCatalogLists = await _discoverAgentCatalogLists( + buildStep, + outputPath, ); - final hasHandwritten = await buildStep.canRead(handwrittenAsset); - if (hasHandwritten) { - imports.add("import '$_handwrittenCatalogImport';"); - final handwrittenKeys = await _readHandwrittenRegistryKeys( + final seenAgentCatalogSymbols = {}; + for (final catalogList in agentCatalogLists) { + if (!seenAgentCatalogSymbols.add(catalogList.symbolName)) { + throw InvalidGenerationSourceError( + "Duplicate @AgentCatalog symbol '${catalogList.symbolName}' " + 'in agent catalog — each annotated list must use a unique name.', + ); + } + imports.add("import '${catalogList.importPath}';"); + final catalogKeys = await _readCatalogListRegistryKeys( buildStep, - handwrittenAsset, + catalogList.assetId, + catalogList.symbolName, + ); + _assertUniqueRegistryKeys( + knownKeys: knownRegistryKeys, + newKeys: catalogKeys, + sourceLabel: + '@AgentCatalog ${catalogList.symbolName} ' + 'in ${catalogList.assetId.path}', + conflictWithCodegen: true, ); - final seenHandwrittenKeys = {}; - for (final key in handwrittenKeys) { - if (codegenRegistryKeys.contains(key)) { - throw InvalidGenerationSourceError( - "Duplicate registryKey '$key' in agent catalog — declared in " - 'both @AgentTool codegen and handwritten catalog entries.', - ); - } - if (!seenHandwrittenKeys.add(key)) { - throw InvalidGenerationSourceError( - "Duplicate registryKey '$key' in handwritten catalog entries.", - ); - } - } } final header = @@ -113,7 +124,7 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; ${imports.join('\n')} '''; - if (entries.isEmpty && !hasHandwritten) { + if (entries.isEmpty && agentCatalogLists.isEmpty) { await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header /// Empty catalog — add @AgentTool functions under lib/. @@ -123,7 +134,12 @@ final List agentCatalogEntries = return; } - final catalogRows = _catalogRows(entries, hasHandwritten: hasHandwritten); + final catalogRows = _catalogRows( + entries, + agentCatalogSymbols: agentCatalogLists + .map((final list) => list.symbolName) + .toList(), + ); await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header final List agentCatalogEntries = @@ -133,6 +149,35 @@ $catalogRows '''); } + String _importForAsset(final String assetPath, final String outputPath) => + p.relative(assetPath, from: p.dirname(outputPath)).replaceAll(r'\', '/'); + + void _assertUniqueRegistryKeys({ + required final Set knownKeys, + required final List newKeys, + required final String sourceLabel, + required final bool conflictWithCodegen, + }) { + final seen = {}; + for (final key in newKeys) { + if (knownKeys.contains(key)) { + throw InvalidGenerationSourceError( + conflictWithCodegen + ? "Duplicate registryKey '$key' in agent catalog — declared in " + 'both @AgentTool codegen and $sourceLabel.' + : "Duplicate registryKey '$key' in agent catalog — " + 'declared in $sourceLabel.', + ); + } + if (!seen.add(key)) { + throw InvalidGenerationSourceError( + "Duplicate registryKey '$key' in $sourceLabel.", + ); + } + knownKeys.add(key); + } + } + String? _catalogEntryForToolElement( final Element element, final Set codegenRegistryKeys, @@ -174,45 +219,206 @@ $catalogRows ); } final entryGetter = '${_toCamelCase(name)}CallEntry'; - final entryReference = element is MethodElement - ? '${(element.enclosingElement! as ClassElement).name}.$_hostBindingField.$entryGetter' - : entryGetter; + final description = toolReader.read('description').stringValue; final projectionLiteral = projectionReader == null ? null : _projectionLiteral(projectionReader); - return projectionLiteral == null - ? " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryReference)," - : " AgentRegistryCatalogEntry(registryKey: '$registryKey', entry: $entryReference, projection: $projectionLiteral),"; + + if (element is MethodElement) { + final classElement = element.enclosingElement! as ClassElement; + if (_hasBindingField(classElement, _hostBindingField)) { + final entryReference = + '${classElement.name}.$_hostBindingField.$entryGetter'; + return _formatCatalogRow( + registryKey: registryKey, + entryReference: entryReference, + projectionLiteral: projectionLiteral, + ); + } + final descriptorLiteral = _descriptorLiteralForExecutable( + element, + namespace: namespace, + name: name, + description: description, + ); + return _formatCatalogRow( + registryKey: registryKey, + descriptorLiteral: descriptorLiteral, + projectionLiteral: projectionLiteral, + ); + } + + return _formatCatalogRow( + registryKey: registryKey, + entryReference: entryGetter, + projectionLiteral: projectionLiteral, + ); + } + + String _formatCatalogRow({ + required final String registryKey, + final String? entryReference, + final String? descriptorLiteral, + final String? projectionLiteral, + }) { + assert(entryReference != null || descriptorLiteral != null); + final target = entryReference != null + ? 'entry: $entryReference' + : 'descriptor: $descriptorLiteral'; + if (projectionLiteral == null) { + return " AgentRegistryCatalogEntry(registryKey: '$registryKey', $target),"; + } + return " AgentRegistryCatalogEntry(registryKey: '$registryKey', $target, projection: $projectionLiteral),"; + } + + bool _hasBindingField( + final ClassElement classElement, + final String bindingField, + ) { + for (final field in classElement.fields) { + if (field.isStatic && field.name == bindingField) { + return true; + } + } + return false; + } + + String _descriptorLiteralForExecutable( + final ExecutableElement executable, { + required final String namespace, + required final String name, + required final String description, + }) { + final schemaGenerator = AgentToolGenerator(options); + final schemaMap = schemaGenerator.inputSchemaMapFor(executable); + final schemaLiteral = _formatInputSchemaConst(schemaMap); + return '''AgentIntentDescriptor( + namespace: ${_literalString(namespace)}, + name: ${_literalString(name)}, + description: ${_literalString(description)}, + kind: AgentIntentKind.tool, + inputSchema: const $schemaLiteral, + )'''; + } + + String _formatInputSchemaConst(final Map schema) { + final properties = schema['properties']! as Map; + final required = (schema['required']! as List).cast(); + final propertyLines = properties.entries + .map( + (final entry) => + " ${_literalString(entry.key)}: {'type': ${_literalString((entry.value! as Map)['type'] as String)}, 'description': ${_literalString((entry.value! as Map)['description'] as String)}},", + ) + .join('\n'); + final requiredLines = required.map(_literalString).join(', '); + return '''{ + 'type': 'object', + 'properties': { +$propertyLines + }, + 'required': [$requiredLines], + }'''; } + String _literalString(final String value) => + "'${value.replaceAll("'", r"\'")}'"; + String _catalogRows( final List entries, { - required final bool hasHandwritten, + required final List agentCatalogSymbols, }) { - if (entries.isEmpty) { - return ' ...$_handwrittenCatalogSymbol,'; + final lines = []; + if (entries.isNotEmpty) { + lines.add(entries.join('\n')); + } + for (final symbol in agentCatalogSymbols) { + lines.add(' ...$symbol,'); + } + return lines.join('\n'); + } + + Future> _discoverAgentCatalogLists( + final BuildStep buildStep, + final String outputPath, + ) async { + final supplements = <_CatalogSpread>[]; + for (final pattern in _toolGlobs) { + await for (final input in buildStep.findAssets(Glob(pattern))) { + if (input.path.endsWith('.g.dart')) { + continue; + } + if (_isExcluded(input.path) || _isInternalSource(input.path)) { + continue; + } + + final library = await buildStep.resolver.libraryFor(input); + for (final variable in library.topLevelVariables) { + if (!_hasAgentCatalogAnnotation(variable)) { + continue; + } + if (!_isCatalogEntryListType(variable.type)) { + throw InvalidGenerationSourceError( + '@AgentCatalog on ${variable.name} must be ' + 'List.', + element: variable, + ); + } + supplements.add( + _CatalogSpread( + assetId: input, + symbolName: variable.name!, + importPath: _importForAsset(input.path, outputPath), + ), + ); + } + } + } + supplements.sort( + (final a, final b) => a.assetId.path.compareTo(b.assetId.path), + ); + return supplements; + } + + bool _hasAgentCatalogAnnotation(final TopLevelVariableElement variable) { + for (final annotation in variable.metadata.annotations) { + final reader = ConstantReader(annotation.computeConstantValue()); + if (!reader.isNull && reader.instanceOf(_agentCatalogChecker)) { + return true; + } + } + return false; + } + + bool _isCatalogEntryListType(final DartType type) { + if (type is! InterfaceType) { + return false; + } + if (type.element.name != 'List' || type.typeArguments.length != 1) { + return false; } - if (!hasHandwritten) { - return entries.join('\n'); + final argument = type.typeArguments.single; + if (argument is! InterfaceType) { + return false; } - return '${entries.join('\n')}\n ...$_handwrittenCatalogSymbol,'; + return argument.element.name == 'AgentRegistryCatalogEntry'; } - Future> _readHandwrittenRegistryKeys( + Future> _readCatalogListRegistryKeys( final BuildStep buildStep, final AssetId assetId, + final String symbolName, ) async { final library = await buildStep.resolver.libraryFor(assetId); TopLevelVariableElement? variable; for (final element in library.topLevelVariables) { - if (element.name == _handwrittenCatalogSymbol) { + if (element.name == symbolName) { variable = element; break; } } if (variable == null) { throw InvalidGenerationSourceError( - '$_handwrittenCatalogPath must export $_handwrittenCatalogSymbol ' + '${assetId.path} must export $symbolName ' 'as List.', ); } @@ -239,7 +445,7 @@ $catalogRows continue; } for (final variableDeclaration in declaration.variables.variables) { - if (variableDeclaration.name.lexeme != _handwrittenCatalogSymbol) { + if (variableDeclaration.name.lexeme != symbolName) { continue; } final initializer = variableDeclaration.initializer; @@ -250,8 +456,8 @@ $catalogRows } if (keys.isEmpty) { throw InvalidGenerationSourceError( - 'Could not read registryKey values from $_handwrittenCatalogSymbol ' - 'in $_handwrittenCatalogPath.', + 'Could not read registryKey values from $symbolName ' + 'in ${assetId.path}.', element: variable, ); } @@ -300,24 +506,44 @@ $catalogRows } Stream _toolSources(final BuildStep buildStep) async* { - final patterns = ['lib/**.dart']; - if (_scanExample) { - patterns.add('example/**.dart'); - } - for (final pattern in patterns) { - await for (final input in buildStep.findAssets(Glob(pattern))) { - if (_isInternalSource(input.path)) { + final seen = {}; + for (final pattern in _toolPartGlobs) { + await for (final gDart in buildStep.findAssets(Glob(pattern))) { + if (gDart.path.contains('generated/agent_catalog.g.dart')) { + continue; + } + final parentPath = gDart.path.replaceFirst( + RegExp(r'\.g\.dart$'), + '.dart', + ); + if (_isExcluded(parentPath)) { + continue; + } + if (_isInternalSource(gDart.path) || _isInternalSource(parentPath)) { + continue; + } + final contents = await buildStep.readAsString(gDart); + if (!contents.contains('_AgentToolPartGenerator')) { + continue; + } + if (!seen.add(parentPath)) { continue; } - yield input; + yield AssetId(gDart.package, parentPath); + } + } + } + + bool _isExcluded(final String path) { + for (final pattern in _toolExcludeGlobs) { + if (Glob(pattern).matches(path)) { + return true; } } + return false; } bool _isInternalSource(final String path) => - path.contains('.g.dart') || - path.contains('generated/') || - path.contains('lib/src/') || path.endsWith('lib/builder.dart') || path.endsWith('lib/intentcall_codegen.dart'); @@ -326,14 +552,12 @@ $catalogRows final surfacesRaw = reader.read('surfaces').mapValue; final surfaceEntries = []; for (final entry in surfacesRaw.entries) { - final key = entry.key?.toStringValue(); + final surfaceName = _surfaceEnumNameFromConstant(entry.key); final value = entry.value?.toBoolValue(); - if (key == null || value == null) { + if (surfaceName == null || value == null) { continue; } - surfaceEntries.add( - 'AgentManifestSurface.${_surfaceEnumName(key)}: $value', - ); + surfaceEntries.add('AgentManifestSurface.$surfaceName: $value'); } final surfacesBlock = surfaceEntries.isEmpty ? 'const {}' @@ -345,8 +569,33 @@ EntryProjection( )'''; } - String _surfaceEnumName(final String key) { - const dottedToEnum = { + String? _surfaceEnumNameFromConstant(final DartObject? key) { + if (key == null) { + return null; + } + final typeElement = key.type?.element; + if (typeElement is EnumElement) { + for (final field in typeElement.fields) { + if (field.isEnumConstant && field.computeConstantValue() == key) { + return field.name; + } + } + } + if (typeElement is FieldElement && + typeElement.enclosingElement is EnumElement) { + return typeElement.name; + } + try { + final stringKey = ConstantReader(key).stringValue; + return _surfaceEnumNameFromManifestKey(stringKey); + } on FormatException { + // Legacy string-key maps only. + } + return null; + } + + String? _surfaceEnumNameFromManifestKey(final String key) { + const manifestKeyToEnum = { 'web.webMcp': 'webMcp', 'web.manifestShortcuts': 'webManifestShortcuts', 'web.protocolHandlers': 'webProtocolHandlers', @@ -356,7 +605,7 @@ EntryProjection( 'windows.msixProtocol': 'windowsMsixProtocol', 'linux.schemeHandler': 'linuxSchemeHandler', }; - return dottedToEnum[key] ?? key; + return manifestKeyToEnum[key]; } String _toCamelCase(final String value) { @@ -374,3 +623,15 @@ EntryProjection( return first + rest.join(); } } + +final class _CatalogSpread { + const _CatalogSpread({ + required this.assetId, + required this.symbolName, + required this.importPath, + }); + + final AssetId assetId; + final String symbolName; + final String importPath; +} diff --git a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart index e4dc7a7..8a9f07b 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart @@ -57,16 +57,9 @@ class AgentToolGenerator extends GeneratorForAnnotation { continue; } - final bindingField = _hostBindingField; - if (!_hasBindingField(classElement, bindingField)) { - throw InvalidGenerationSourceError( - '@AgentTool instance methods require a static host binding field ' - "'$bindingField' on ${classElement.name}. " - 'Add `static final ${classElement.name} $bindingField = ...` or ' - 'configure `host_binding_field` in build.yaml.', - element: classElement, - ); - } + final bindingField = _hasBindingField(classElement, _hostBindingField) + ? _hostBindingField + : null; values.add( _generateInstanceToolBlock(classElement, instanceMethods, bindingField), @@ -138,7 +131,7 @@ $handlerArgs String _generateInstanceToolBlock( final ClassElement classElement, final List methods, - final String bindingField, + final String? bindingField, ) { final schemas = []; final getters = []; @@ -159,19 +152,28 @@ $handlerArgs 'const $schemaName = ${_formatInputSchema(schemaMap)};', ); getters.add(_generateExtensionGetter(method, reader, schemaName)); - registrations.add( - _generateRegistrationAlias(classElement, method, reader, bindingField), - ); + if (bindingField != null) { + registrations.add( + _generateRegistrationAlias( + classElement, + method, + reader, + bindingField, + ), + ); + } } + final registrationBlock = registrations.isEmpty + ? '' + : '\n\n${registrations.join('\n\n')}'; + return ''' ${schemas.join('\n\n')} extension ${classElement.name}AgentCodegen on ${classElement.name} { ${getters.join('\n\n')} -} - -${registrations.join('\n\n')}'''; +}$registrationBlock'''; } String _generateExtensionGetter( diff --git a/packages/intentcall_codegen/test/agent_catalog_build_test.dart b/packages/intentcall_codegen/test/agent_catalog_build_test.dart new file mode 100644 index 0000000..ca19c04 --- /dev/null +++ b/packages/intentcall_codegen/test/agent_catalog_build_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:glob/glob.dart'; +import 'package:intentcall_codegen/src/generators/agent_catalog_generator.dart'; +import 'package:package_config/package_config.dart'; +import 'package:test/test.dart'; + +const _package = 'intentcall_codegen'; +const _trigger = '$_package|lib/\$lib\$'; +const _output = '$_package|lib/generated/agent_catalog.g.dart'; + +String _fixture(final String path) => File(path).readAsStringSync(); + +BuilderOptions _options(final Map config) => + BuilderOptions(config); + +bool _isCatalogBuildInput(final String id) => + id == _trigger || id.startsWith('$_package|test/fixtures/catalog/'); + +Map _catalogAssets(final Map fixtures) => { + for (final entry in fixtures.entries) '$_package|${entry.key}': entry.value, +}; + +Future _readerWriterWithAllSources( + final PackageConfig packageConfig, +) async { + final assetReader = PackageAssetReader(packageConfig, _package); + final readerWriter = TestReaderWriter(rootPackage: _package); + for (final package in packageConfig.packages) { + await for (final id in assetReader.findAssets( + Glob('**'), + package: package.name, + )) { + if (id.path.startsWith('.dart_tool/build/asset_graph.json')) { + continue; + } + readerWriter.testing.writeBytes(id, await assetReader.readAsBytes(id)); + } + } + return readerWriter; +} + +void main() { + late PackageConfig packageConfig; + late TestReaderWriter readerWriter; + + setUpAll(() async { + final config = await findPackageConfig(Directory.current); + if (config == null) { + throw StateError('Could not find package config for tests.'); + } + packageConfig = config; + readerWriter = await _readerWriterWithAllSources(packageConfig); + }); + + Future runCatalogBuilder({ + required final AgentCatalogGenerator generator, + required final Map fixtures, + required final Object outputMatcher, + }) async { + await testBuilder( + generator, + _catalogAssets(fixtures), + isInput: _isCatalogBuildInput, + packageConfig: packageConfig, + readerWriter: readerWriter, + outputs: {_output: decodedMatches(outputMatcher)}, + ); + } + + group('AgentCatalogGenerator', () { + test('aggregates @AgentTool from .g.dart parent', () async { + await runCatalogBuilder( + generator: AgentCatalogGenerator( + _options({ + 'tool_part_globs': ['test/fixtures/catalog/**.g.dart'], + 'tool_exclude_globs': [], + }), + ), + fixtures: { + 'test/fixtures/catalog/top_level_tool.dart': _fixture( + 'test/fixtures/catalog/top_level_tool.dart', + ), + 'test/fixtures/catalog/top_level_tool.g.dart': _fixture( + 'test/fixtures/catalog/top_level_tool.g.dart', + ), + }, + outputMatcher: allOf( + contains('app_catalog_ping'), + contains('catalogPingCallEntry'), + ), + ); + }); + + test('discovers @AgentCatalog', () async { + expect( + _fixture('test/fixtures/catalog/agent_catalog_annotated.dart'), + contains('app_sup_a'), + ); + await runCatalogBuilder( + generator: AgentCatalogGenerator( + _options({ + 'tool_globs': ['test/fixtures/catalog/**.dart'], + 'tool_exclude_globs': [], + }), + ), + fixtures: { + 'test/fixtures/catalog/agent_catalog_annotated.dart': _fixture( + 'test/fixtures/catalog/agent_catalog_annotated.dart', + ), + }, + outputMatcher: allOf( + contains('...supplementCatalogEntries,'), + contains( + "import '../../test/fixtures/catalog/agent_catalog_annotated.dart';", + ), + ), + ); + }); + + test('unannotated catalog list omitted', () async { + await runCatalogBuilder( + generator: AgentCatalogGenerator(_options({})), + fixtures: { + 'test/fixtures/catalog/unannotated_catalog_list.dart': _fixture( + 'test/fixtures/catalog/unannotated_catalog_list.dart', + ), + }, + outputMatcher: allOf( + isNot(contains('app_wrong_a')), + isNot(contains('...unannotatedCatalogEntries,')), + ), + ); + }); + }); +} diff --git a/packages/intentcall_codegen/test/agent_catalog_generator_test.dart b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart index 67118b3..661adc9 100644 --- a/packages/intentcall_codegen/test/agent_catalog_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart @@ -24,7 +24,7 @@ void main() { expect(hostStatus.qualifiedName, 'app_demo_host_status'); }); - test('spreads handwrittenCatalogEntries when present', () { + test('merges @AgentCatalog rows from example', () { final keys = agentCatalogEntries .map((final row) => row.registryKey) .toSet(); diff --git a/packages/intentcall_codegen/test/agent_tool_codegen_test.dart b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart index 82aa846..cf36a86 100644 --- a/packages/intentcall_codegen/test/agent_tool_codegen_test.dart +++ b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart @@ -47,15 +47,6 @@ void main() { readAllSourcesFromFilesystem: true, ); - Future generateFixtureExpectingError(final String fixturePath) async { - try { - await generateFixture(fixturePath); - } on InvalidGenerationSourceError catch (error) { - return error.message; - } - return ''; - } - test( 'instance @AgentTool emits extension getter with this-bound handler', () async { @@ -78,19 +69,30 @@ void main() { }, ); - test('instance @AgentTool without host binding field is rejected', () async { - final message = await generateFixtureExpectingError( - 'test/fixtures/instance_without_host_tool.dart', - ); + test( + 'instance @AgentTool without binding static omits registration alias', + () async { + final output = await generateFixture( + 'test/fixtures/instance_without_host_tool.dart', + ); - expect(message, contains("require a static host binding field 'shared'")); - }); + expect( + output, + contains('extension DemoHostToolsAgentCodegen on DemoHostTools'), + ); + expect(output, isNot(contains('Registration'))); + }, + ); test('static method @AgentTool on class is rejected', () async { - final message = await generateFixtureExpectingError( - 'test/fixtures/static_method_tool.dart', - ); - - expect(message, contains('use top-level @AgentTool or handwritten entry')); + try { + await generateFixture('test/fixtures/static_method_tool.dart'); + fail('expected InvalidGenerationSourceError'); + } on InvalidGenerationSourceError catch (error) { + expect( + error.message, + contains('use top-level @AgentTool or handwritten entry'), + ); + } }); } diff --git a/packages/intentcall_codegen/test/build.yaml b/packages/intentcall_codegen/test/build.yaml index 540af28..9e15b59 100644 --- a/packages/intentcall_codegen/test/build.yaml +++ b/packages/intentcall_codegen/test/build.yaml @@ -4,8 +4,10 @@ targets: intentcall_codegen|agent_tool: generate_for: - test/fixtures/**.dart + - test/fixtures/catalog/**.dart # Optional: host_binding_field: shared intentcall_codegen|agent_catalog: generate_for: - test/fixtures/**.dart + - test/fixtures/catalog/**.dart # Optional: host_binding_field: shared diff --git a/packages/intentcall_codegen/test/fixtures/catalog/agent_catalog_annotated.dart b/packages/intentcall_codegen/test/fixtures/catalog/agent_catalog_annotated.dart new file mode 100644 index 0000000..9f8ef76 --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/catalog/agent_catalog_annotated.dart @@ -0,0 +1,36 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +@AgentCatalog() +final List supplementCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_sup_a', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'sup_a', + description: 'Supplement catalog row A', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_sup_b', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'sup_b', + description: 'Supplement catalog row B', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + ), + ), + ]; diff --git a/packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.dart b/packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.dart new file mode 100644 index 0000000..c85cb0e --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.dart @@ -0,0 +1,16 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +part 'top_level_tool.g.dart'; + +@AgentTool( + namespace: 'app', + name: 'catalog_ping', + description: 'Returns pong for a message', +) +Future catalogPing( + @AgentParam('Message to echo') String message, +) async { + return AgentResult.success(data: {'pong': message}); +} diff --git a/packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.g.dart b/packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.g.dart new file mode 100644 index 0000000..5621af4 --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/catalog/top_level_tool.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ignore_for_file: type=lint + +part of 'top_level_tool.dart'; + +// ************************************************************************** +// _AgentToolPartGenerator +// ************************************************************************** + +const _catalog_pingInputSchema = { + 'type': 'object', + 'properties': { + 'message': { + 'type': 'string', + 'description': 'Message to echo', + }, + }, + 'required': ['message'], +}; + +RegisteredAgentIntent get catalogPingRegistration => + catalogPingCallEntry.toRegistration(); + +AgentCallEntry get catalogPingCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'catalog_ping', + description: 'Returns pong for a message', + inputSchema: _catalog_pingInputSchema, + handler: (final args) async { + final result = Function.apply(catalogPing, [ + args['message'] as String, + ], {}); + return await (result as Future); + }, +); diff --git a/packages/intentcall_codegen/test/fixtures/catalog/unannotated_catalog_list.dart b/packages/intentcall_codegen/test/fixtures/catalog/unannotated_catalog_list.dart new file mode 100644 index 0000000..1f49b39 --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/catalog/unannotated_catalog_list.dart @@ -0,0 +1,34 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +final List unannotatedCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_wrong_a', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'wrong_a', + description: 'Unannotated catalog row A', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_wrong_b', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'wrong_b', + description: 'Unannotated catalog row B', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + ), + ), + ]; diff --git a/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart index 8ce1f06..b972abe 100644 --- a/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart +++ b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart @@ -9,7 +9,10 @@ final class AgentRegistryCatalogEntry { this.descriptor, this.entry, this.projection, - }) : assert(descriptor != null || entry != null); + }) : assert( + descriptor != null || entry != null, + 'Either descriptor or entry must be provided', + ); final String registryKey; final AgentIntentDescriptor? descriptor; diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart index 2e69374..e16b38b 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart @@ -22,6 +22,7 @@ final class ManifestExporter { policy: context.policy, protocolScheme: context.protocolScheme, platform: context.platform, + enabledPlatforms: context.enabledPlatforms, ); String encodeManifest(final AgentManifest manifest) => diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart index 56a349f..5ddf5f6 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart @@ -16,12 +16,16 @@ final class ManifestExportContext { required this.platform, required this.manifestRelativePath, this.protocolScheme, + this.enabledPlatforms = const [], }); final ProjectionPolicy policy; final String? protocolScheme; final String platform; final String manifestRelativePath; + + /// From `intentcall.yaml` `platforms.enabled` — scopes default surface families. + final List enabledPlatforms; } /// Merges registry catalog rows and projection policy into [agent_manifest.json]. @@ -35,8 +39,11 @@ final class ManifestMerger { final Iterable entityTypeDescriptors = const [], final Iterable> entityTypes = const [], final String platform = 'unified', + final Iterable enabledPlatforms = const [], }) { - final defaultSurfaces = policy.resolvedDefaultSurfaces(); + final defaultSurfaces = policy.resolvedDefaultSurfaces( + enabledPlatforms: enabledPlatforms, + ); final entries = []; for (final row in catalog) { final descriptor = row.resolveDescriptor(); @@ -140,8 +147,32 @@ final class ManifestMerger { protocolScheme: readProtocolScheme(projectRoot), platform: readPlatformLabel(projectRoot), manifestRelativePath: readManifestRelativePath(projectRoot), + enabledPlatforms: readEnabledPlatforms(projectRoot), ); + List readEnabledPlatforms(final String projectRoot) { + final configFile = File(p.join(projectRoot, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return const []; + } + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return const []; + } + final platforms = doc['platforms']; + if (platforms is! YamlMap) { + return const []; + } + final enabled = platforms['enabled']; + if (enabled is! YamlList) { + return const []; + } + return enabled + .map((final value) => value?.toString().trim().toLowerCase() ?? '') + .where((final value) => value.isNotEmpty) + .toList(); + } + String? readProtocolScheme(final String projectRoot) { final configFile = File(p.join(projectRoot, 'intentcall.yaml')); if (!configFile.existsSync()) { diff --git a/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart index 2d7b8bd..2289ee6 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart @@ -13,6 +13,30 @@ bool defaultSurfaceInclude(final AgentManifestSurface surface) => AgentManifestSurface.linuxSchemeHandler => true, }; +/// Platform tokens required for a manifest surface family to default on. +Set platformsForManifestSurface(final AgentManifestSurface surface) => + switch (surface) { + AgentManifestSurface.webMcp || + AgentManifestSurface.webManifestShortcuts || + AgentManifestSurface.webProtocolHandlers => {'web'}, + AgentManifestSurface.androidShortcuts => {'android'}, + AgentManifestSurface.appleAppShortcuts => {'ios', 'macos'}, + AgentManifestSurface.windowsProtocolActivation || + AgentManifestSurface.windowsMsixProtocol => {'windows'}, + AgentManifestSurface.linuxSchemeHandler => {'linux'}, + }; + +bool defaultSurfaceIncludeForPlatforms( + final AgentManifestSurface surface, + final Set enabledPlatforms, +) { + if (enabledPlatforms.isEmpty) { + return defaultSurfaceInclude(surface); + } + final required = platformsForManifestSurface(surface); + return required.any(enabledPlatforms.contains); +} + /// Per-entry projection overlay (dispatch + surfaces only). final class EntryProjection { const EntryProjection({ @@ -69,18 +93,14 @@ final class EntryProjection { final class ProjectionPolicy { const ProjectionPolicy({ this.defaultDispatchMode = AgentManifestDispatchMode.openApp, - this.defaultSurfaces = const AgentManifestSurfacePolicy( - {}, - ), + this.defaultSurfaces = AgentManifestSurfacePolicy.empty, this.overlays = const {}, }); factory ProjectionPolicy.fromYamlMap(final Map yaml) { final defaultsRaw = yaml['defaults']; var dispatchMode = AgentManifestDispatchMode.openApp; - var defaultSurfaces = const AgentManifestSurfacePolicy( - {}, - ); + var defaultSurfaces = AgentManifestSurfacePolicy.empty; if (defaultsRaw is Map) { final dispatchName = defaultsRaw['dispatchMode']?.toString(); if (dispatchName != null) { @@ -121,14 +141,20 @@ final class ProjectionPolicy { EntryProjection? overlayFor(final String qualifiedName) => overlays[qualifiedName]; - AgentManifestSurfacePolicy resolvedDefaultSurfaces() { + AgentManifestSurfacePolicy resolvedDefaultSurfaces({ + final Iterable enabledPlatforms = const [], + }) { if (!defaultSurfaces.isEmpty) { return defaultSurfaces; } + final normalized = enabledPlatforms + .map((final platform) => platform.trim().toLowerCase()) + .where((final platform) => platform.isNotEmpty) + .toSet(); final overrides = {}; for (final surface in AgentManifestSurface.values) { overrides[surface] = AgentManifestSurfaceExposure( - include: defaultSurfaceInclude(surface), + include: defaultSurfaceIncludeForPlatforms(surface, normalized), ); } return AgentManifestSurfacePolicy(overrides); diff --git a/packages/intentcall_platform_sync/test/manifest_merger_test.dart b/packages/intentcall_platform_sync/test/manifest_merger_test.dart index ec2d4a6..fb2ea23 100644 --- a/packages/intentcall_platform_sync/test/manifest_merger_test.dart +++ b/packages/intentcall_platform_sync/test/manifest_merger_test.dart @@ -39,4 +39,41 @@ void main() { isFalse, ); }); + + test('web-only enabledPlatforms scopes default surfaces', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['web'], + ); + final surfaces = manifest.entries.single.surfaces; + expect( + surfaces.includes(AgentManifestSurface.webMcp, defaultValue: false), + isTrue, + ); + expect( + surfaces.includes(AgentManifestSurface.androidShortcuts, defaultValue: true), + isFalse, + ); + expect( + surfaces.includes( + AgentManifestSurface.windowsProtocolActivation, + defaultValue: true, + ), + isFalse, + ); + }); } diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index 0581e1b..d68d603 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -54,13 +54,14 @@ over `this`). **Pattern (same as the mcp_flutter harness):** -1. Put behavior on a host class with `static final shared`. +1. Put behavior on a host class (optional `static final shared` for probe anchors). 2. Expose each intent as an instance `AgentCallEntry` getter; the handler calls instance methods on `this`. -3. List catalog rows in `lib/catalog/handwritten_entries.dart` as - `handwrittenCatalogEntries`. -4. Run `build_runner` so `lib/generated/agent_catalog.g.dart` merges annotated - tools and `...handwrittenCatalogEntries`. +3. Optionally co-locate `EntryProjection` consts on the host (see **Handwritten projection** below). +4. Co-locate a `List` annotated with **`@AgentCatalog`** + (discovered via `tool_globs`). +5. Run `build_runner` so `lib/generated/agent_catalog.g.dart` merges `@AgentTool` + rows (from `tool_part_globs` / generated `.g.dart` parts) and `@AgentCatalog` spreads. ```dart final class InboxHost { @@ -80,11 +81,9 @@ final class InboxHost { ``` ```dart -// lib/catalog/handwritten_entries.dart -import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; -import '../hosts/inbox_host.dart'; - -final List handwrittenCatalogEntries = +// Co-located with InboxHost (same file or sibling module file) +@AgentCatalog() +final List inboxHostCatalogEntries = [ AgentRegistryCatalogEntry( registryKey: 'app_read_inbox', @@ -100,14 +99,33 @@ Then run `intentcall manifest export --check` so committed `agent_manifest.json` stays in sync with the merged catalog (see [ADR 0019](../../docs/decisions/0019-framework-neutral-intentcall-cli.md)). -**Probe anchor:** Catalog rows must use compile-time expressions such as +**Probe anchor (optional):** When `static shared` exists, catalog rows may use `InboxHost.shared.readInboxCallEntry` — manifest export evaluates `entry:` in a -subprocess and strips handlers. Runtime may use a different instance when -descriptors match. +subprocess and strips handlers. **Live instance registration is canonical** for +stateful hosts: construct the host and register `liveHost.readInboxCallEntry` +at bootstrap even when catalog uses `shared` for probe convenience. **Descriptor-only rows:** Use `descriptor:` on `AgentRegistryCatalogEntry` when -manifest metadata is needed without a probe-time handler; register handlers at -runtime and keep `qualifiedName` aligned. +manifest metadata is needed without a probe-time handler; register handlers from +a live host at runtime and keep `qualifiedName` aligned. + +**Handwritten projection:** Co-locate surface hints on the host class and +reference them from catalog rows: + +```dart +static const inboxProjection = EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, +); + +AgentRegistryCatalogEntry( + registryKey: 'app_read_inbox', + entry: InboxHost.shared.readInboxCallEntry, + projection: InboxHost.inboxProjection, +), +``` + +`platforms.enabled` in `intentcall.yaml` scopes default manifest surface families +(web-only hosts omit android/windows/linux defaults unless overridden). --- @@ -170,10 +188,21 @@ another host language. ### Optional: instance-method codegen For host-bound tools you may annotate instance methods instead of writing -getters by hand. The host class needs a static binding field (default name -`shared`). Codegen emits an extension with `AgentCallEntry` getters; catalog rows -reference `Host.shared.CallEntry`. Handwritten getters in section 2 -remain the canonical path when you need full control. +getters by hand. Codegen emits an extension with `this`-bound `AgentCallEntry` +getters. When a static binding field exists (default name `shared`), catalog +rows use `entry: Host.shared.CallEntry` for probe convenience; otherwise +codegen emits `descriptor:`-only rows and you register from a live host instance. +Handwritten getters in section 2 remain the canonical path when you need full +control. + +`@AgentProjection` uses typed `AgentManifestSurface` keys: + +```dart +@AgentProjection(surfaces: {AgentManifestSurface.webMcp: true}) +``` + +Apple sub-channels (Siri, Spotlight, etc.) use `AgentManifestSurfaceExposure.options` +on handwritten `EntryProjection` rows until emitters support them. --- From d373c744150cdde56e2d4844211e25362f3d6723 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 04:27:05 +0300 Subject: [PATCH 05/27] feat: refine @AgentCatalog usage with static field support, enhance documentation on catalog placement, and improve projection handling in examples --- docs/DX_FAQ.mdx | 32 +++ .../0019-framework-neutral-intentcall-cli.md | 3 +- .../0021-agent-catalog-annotation.md | 12 +- packages/intentcall_codegen/README.md | 64 +++-- .../lib/generated/agent_catalog.g.dart | 2 +- .../example/lib/tools/demo_host_tools.dart | 34 ++- .../test/manifest_projection_test.dart | 7 +- packages/intentcall_codegen/lib/builder.dart | 4 +- .../lib/src/agent_catalog.dart | 58 ++++- .../generators/agent_catalog_generator.dart | 239 ++++++++++++++---- .../test/agent_catalog_build_test.dart | 26 ++ .../fixtures/catalog/host_static_catalog.dart | 38 +++ skills/register-intents/SKILL.md | 53 ++-- 13 files changed, 443 insertions(+), 129 deletions(-) create mode 100644 packages/intentcall_codegen/test/fixtures/catalog/host_static_catalog.dart diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 03b18a9..f94d707 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -405,6 +405,38 @@ Put instance methods and `AgentCallEntry` getters on a host class (typically annotated with **`@AgentCatalog`** — the catalog builder discovers it via `tool_globs`. +**Placement:** + +| Where | Generated spread | Supported | +|-------|------------------|-----------| +| **Static field on host class** (recommended) | `...HostClass.catalogEntries` | Yes | +| Top-level variable | `...catalogEntries` | Yes | +| Instance field on host | — | No | + +```dart +final class DemoHostTools { + static final DemoHostTools shared = DemoHostTools(); + + AgentCallEntry get inboxCallEntry => AgentCallEntry.tool(/* … */); + + @AgentCatalog() + static final List demoHostCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + entry: shared.inboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), + ), + ]; +} +``` + +**Do not duplicate `@AgentTool` rows** in `@AgentCatalog` — e.g. `app_demo_host_status` +is merged from `@AgentTool` codegen on the instance method, not from the host catalog +list. Duplicate `registryKey` values fail the build. + Run `build_runner`. `@AgentTool` rows are collected from generated `*.g.dart` parts (`tool_part_globs`, default `lib/**.g.dart`), including tools under `lib/src/` when `agent_tool` emits their parts. The aggregate catalog at diff --git a/docs/decisions/0019-framework-neutral-intentcall-cli.md b/docs/decisions/0019-framework-neutral-intentcall-cli.md index 5e7c5a9..872243f 100644 --- a/docs/decisions/0019-framework-neutral-intentcall-cli.md +++ b/docs/decisions/0019-framework-neutral-intentcall-cli.md @@ -59,7 +59,8 @@ contracts. Global defaults in `intentcall.yaml` `defaults` only — **no per-tool YAML**. 3. **`intentcall_codegen` gains one aggregate builder:** - `AgentCatalogBuilder` → `lib/generated/agent_catalog.g.dart` (aggregates - registrations and optional per-row projection from `@AgentProjection`). + `@AgentTool` registrations, `@AgentCatalog` lists — top-level or static host + fields — and optional per-row projection from `@AgentProjection`). - **`agent_manifest.json` is written by `intentcall manifest export`** (shared `ManifestExporter` in `intentcall_platform_sync`) — not a second build_runner builder. diff --git a/docs/decisions/0021-agent-catalog-annotation.md b/docs/decisions/0021-agent-catalog-annotation.md index c38aaf1..77a8388 100644 --- a/docs/decisions/0021-agent-catalog-annotation.md +++ b/docs/decisions/0021-agent-catalog-annotation.md @@ -38,15 +38,25 @@ multi-file discovery but introduced two overlapping mechanisms and confusing nam 4. **Breaking change** (pre-release): apps using the default handwritten file must add `@AgentCatalog()` to their list and co-locate it with the owning host. +5. **Discover `@AgentCatalog` on static host fields** (2026-07-07 amendment): + annotated `List` may live as a **static** field on a + host class (recommended) or as a top-level variable. The generator spreads + static lists as `HostClass.catalogSymbol` (e.g. + `...DemoHostTools.demoHostCatalogEntries`). **Instance fields are not + supported** — no compile-time symbol exists for build-time spread. ## Consequences -- Catalog rows live next to host classes; no `lib/catalog/handwritten_entries.dart` +- Catalog rows live on or next to host classes; no `lib/catalog/handwritten_entries.dart` convention. - `tool_globs` scopes `@AgentCatalog` discovery only; unannotated lists are ignored. +- Static host catalogs are the preferred co-location pattern; top-level lists remain valid. - Pre-release consumers must migrate from `handwrittenCatalogEntries` to annotated lists. +- Tools already covered by `@AgentTool` codegen must not be duplicated in `@AgentCatalog` + lists (`registryKey` collision fails the build). ## Related - [0019-framework-neutral-intentcall-cli.md](0019-framework-neutral-intentcall-cli.md) - [0020-platform-scoped-manifest-surfaces.md](0020-platform-scoped-manifest-surfaces.md) +- Reference: [`demo_host_tools.dart`](../../packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart) diff --git a/packages/intentcall_codegen/README.md b/packages/intentcall_codegen/README.md index 754be14..7a04d28 100644 --- a/packages/intentcall_codegen/README.md +++ b/packages/intentcall_codegen/README.md @@ -51,22 +51,31 @@ final class DemoHostTools { 2. **Merge catalog rows with `@AgentCatalog`** -Co-locate a `List` with the host class (or module) and -annotate it with `@AgentCatalog`. The catalog builder discovers annotated lists -under `lib/` and spreads them into `lib/generated/agent_catalog.g.dart` alongside -`@AgentTool` rows. Optional per-row projection uses `EntryProjection` (same as -`@AgentProjection` on annotated tools). +Co-locate a top-level or **static** `List` with the host +class and annotate it with `@AgentCatalog`. The catalog builder discovers annotated +lists under `lib/` and spreads them into `lib/generated/agent_catalog.g.dart` +alongside `@AgentTool` rows. Static host lists merge as +`HostClass.catalogEntries`. Optional per-row projection uses `EntryProjection` +(same as `@AgentProjection` on annotated tools). ```dart -@AgentCatalog() -final List demoHostCatalogEntries = - [ - AgentRegistryCatalogEntry( - registryKey: 'app_demo_inbox', - entry: DemoHostTools.shared.inboxCallEntry, - projection: DemoHostTools.inboxProjection, - ), -]; +final class DemoHostTools { + static final DemoHostTools shared = DemoHostTools(); + + AgentCallEntry get inboxCallEntry => AgentCallEntry.tool(/* … */); + + @AgentCatalog() + static final List demoHostCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + entry: shared.inboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), + ), + ]; +} ``` After `dart run build_runner build`, `lib/generated/agent_catalog.g.dart` @@ -99,27 +108,32 @@ one-line `registerAll` demos. ### Handwritten projection -Co-locate projection policy on the host class and reference it from the catalog: - -```dart -static const inboxProjection = EntryProjection( - surfaces: {AgentManifestSurface.webMcp: true}, -); - -AgentCallEntry get inboxCallEntry => AgentCallEntry.tool(/* … */); -``` +Use inline `EntryProjection` on `@AgentCatalog` rows (recommended for small hosts): ```dart AgentRegistryCatalogEntry( registryKey: 'app_demo_inbox', - entry: DemoHostTools.shared.inboxCallEntry, - projection: DemoHostTools.inboxProjection, + entry: shared.inboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), ), ``` For manifest-only rows without a handler at probe time, use `descriptor:` on `AgentRegistryCatalogEntry` and register the handler separately at runtime. +### `@AgentCatalog` placement + +| Placement | Spread in `agent_catalog.g.dart` | +|-----------|----------------------------------| +| Static field on host (recommended) | `...HostClass.catalogEntries` | +| Top-level list | `...catalogEntries` | +| Instance field | Not supported | + +Do not list tools in `@AgentCatalog` when `@AgentTool` already emits their catalog +row (duplicate `registryKey` fails the build). + Full runnable example: [`example/lib/tools/demo_host_tools.dart`](example/lib/tools/demo_host_tools.dart). diff --git a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart index 90557d4..cb53c24 100644 --- a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -16,5 +16,5 @@ final List agentCatalogEntries = surfaces: {AgentManifestSurface.webMcp: false}, )), AgentRegistryCatalogEntry(registryKey: 'app_demo_required_named', entry: demoRequiredNamedCallEntry), - ...demoHostCatalogEntries, + ...DemoHostTools.demoHostCatalogEntries, ]; diff --git a/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart index 8adfd7b..2f7a00e 100644 --- a/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart +++ b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart @@ -10,10 +10,6 @@ final class DemoHostTools { static final DemoHostTools shared = DemoHostTools(); - static const inboxProjection = EntryProjection( - surfaces: {AgentManifestSurface.webMcp: true}, - ); - Future inbox(final String folder) async { return AgentResult.success( data: { @@ -78,18 +74,20 @@ final class DemoHostTools { }, handler: (final args) async => demoHandwritten(args['note'] as String), ); -} -@AgentCatalog() -final List demoHostCatalogEntries = - [ - AgentRegistryCatalogEntry( - registryKey: 'app_demo_inbox', - entry: DemoHostTools.shared.inboxCallEntry, - projection: DemoHostTools.inboxProjection, - ), - AgentRegistryCatalogEntry( - registryKey: 'app_demo_handwritten', - entry: DemoHostTools.shared.demoHandwrittenCallEntry, - ), - ]; + @AgentCatalog() + static final List demoHostCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + entry: shared.inboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_demo_handwritten', + entry: shared.demoHandwrittenCallEntry, + ), + ]; +} diff --git a/packages/intentcall_codegen/example/test/manifest_projection_test.dart b/packages/intentcall_codegen/example/test/manifest_projection_test.dart index ee50951..3a8bd42 100644 --- a/packages/intentcall_codegen/example/test/manifest_projection_test.dart +++ b/packages/intentcall_codegen/example/test/manifest_projection_test.dart @@ -64,12 +64,15 @@ void main() { expect(hostStatus.dispatchMode, AgentManifestDispatchMode.openApp); expect( - hostStatus.surfaces.includes(AgentManifestSurface.webMcp, defaultValue: true), + hostStatus.surfaces.includes( + AgentManifestSurface.webMcp, + defaultValue: true, + ), isTrue, ); }); - test('handwritten inbox respects co-located inboxProjection', () { + test('handwritten inbox respects inline EntryProjection', () { final manifest = buildManifest(); final inbox = manifest.tools.singleWhere( (final tool) => tool.qualifiedName == 'app_demo_inbox', diff --git a/packages/intentcall_codegen/lib/builder.dart b/packages/intentcall_codegen/lib/builder.dart index 51c8250..7d33f39 100644 --- a/packages/intentcall_codegen/lib/builder.dart +++ b/packages/intentcall_codegen/lib/builder.dart @@ -30,6 +30,8 @@ Builder agentToolBuilder(final BuilderOptions options) => PartBuilder( ''', ); -/// Aggregates all `@AgentTool` registrations into `lib/generated/agent_catalog.g.dart`. +/// Aggregates `@AgentTool` and `@AgentCatalog` rows into `lib/generated/agent_catalog.g.dart`. +/// +/// See [AgentCatalogGenerator] and [AgentCatalog]. Builder agentCatalogBuilder(final BuilderOptions options) => AgentCatalogGenerator(options); diff --git a/packages/intentcall_codegen/lib/src/agent_catalog.dart b/packages/intentcall_codegen/lib/src/agent_catalog.dart index 995c30d..e04f1d7 100644 --- a/packages/intentcall_codegen/lib/src/agent_catalog.dart +++ b/packages/intentcall_codegen/lib/src/agent_catalog.dart @@ -1,10 +1,60 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; -/// Marks a top-level [AgentRegistryCatalogEntry] list for catalog merge. +/// Marks a [AgentRegistryCatalogEntry] list for merge into the generated catalog. /// -/// Annotate a `List` anywhere under `lib/`. -/// [AgentCatalogGenerator] discovers annotated lists and spreads them into -/// `lib/generated/agent_catalog.g.dart` alongside `@AgentTool` rows. +/// Annotate a **top-level** or **static** `List` under +/// `lib/`. [AgentCatalogGenerator] discovers annotated lists via `tool_globs` and +/// spreads them into `lib/generated/agent_catalog.g.dart` alongside `@AgentTool` +/// rows from generated `*.g.dart` parts. +/// +/// ## Catalog mental model +/// +/// ```text +/// @AgentTool → tool implementation + (usually) catalog row +/// handwritten getter → tool implementation only +/// catalog row → @AgentCatalog list +/// agent_catalog.g.dart → merge of all three sources +/// ``` +/// +/// ## Placement +/// +/// | Placement | Generated spread | Notes | +/// |-----------|------------------|-------| +/// | Top-level list | `...myCatalogEntries,` | Valid; prefer static host field for co-location | +/// | **Static** field on host class | `...HostClass.myCatalogEntries,` | **Recommended** — catalog lives with getters | +/// | Instance field | — | **Not supported** — no compile-time symbol for spread | +/// +/// Unannotated `List` values are ignored (explicit +/// opt-in). Duplicate `registryKey` values across `@AgentTool` codegen and +/// `@AgentCatalog` lists fail the build. +/// +/// ## Example (static host catalog — recommended) +/// +/// ```dart +/// final class DemoHostTools { +/// static final DemoHostTools shared = DemoHostTools(); +/// +/// AgentCallEntry get inboxCallEntry => AgentCallEntry.tool(/* … */); +/// +/// @AgentCatalog() +/// static final List demoHostCatalogEntries = +/// [ +/// AgentRegistryCatalogEntry( +/// registryKey: 'app_demo_inbox', +/// entry: shared.inboxCallEntry, +/// projection: const EntryProjection( +/// surfaces: {AgentManifestSurface.webMcp: true}, +/// ), +/// ), +/// ]; +/// } +/// ``` +/// +/// After `dart run build_runner build`, the aggregate catalog contains +/// `...DemoHostTools.demoHostCatalogEntries`. +/// +/// See [ADR 0021](https://github.com/Arenukvern/intentcall/blob/main/docs/decisions/0021-agent-catalog-annotation.md). class AgentCatalog { + /// Marks a catalog list for discovery by [AgentCatalogGenerator]. const AgentCatalog(); } diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart index dc856e3..a25f4c6 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -12,7 +12,18 @@ import '../agent_projection.dart'; import '../agent_tool.dart'; import 'agent_tool_generator.dart'; -/// Emits `lib/generated/agent_catalog.g.dart` listing all tool call entries. +/// Emits `lib/generated/agent_catalog.g.dart` by merging three catalog sources: +/// +/// 1. **`@AgentTool`** — rows from generated `*.g.dart` parts (`tool_part_globs`). +/// 2. **`@AgentCatalog`** — top-level or static `List` +/// discovered via `tool_globs` (default `lib/**.dart`). Static host lists +/// spread as `HostClass.catalogSymbol`. +/// 3. Empty catalog stub when neither source is present. +/// +/// Configure via `intentcall_codegen|agent_catalog` in `build.yaml`: +/// `tool_part_globs`, `tool_globs`, `tool_exclude_globs`, `host_binding_field`. +/// +/// See [AgentCatalog] and [ADR 0021](https://github.com/Arenukvern/intentcall/blob/main/docs/decisions/0021-agent-catalog-annotation.md). class AgentCatalogGenerator implements Builder { AgentCatalogGenerator(this.options); @@ -352,35 +363,75 @@ $propertyLines } final library = await buildStep.resolver.libraryFor(input); + final libraryReader = LibraryReader(library); for (final variable in library.topLevelVariables) { - if (!_hasAgentCatalogAnnotation(variable)) { - continue; - } - if (!_isCatalogEntryListType(variable.type)) { - throw InvalidGenerationSourceError( - '@AgentCatalog on ${variable.name} must be ' - 'List.', - element: variable, + _addAgentCatalogSpread( + spreads: supplements, + element: variable, + assetId: input, + outputPath: outputPath, + symbolName: variable.name!, + ); + } + for (final classElement in libraryReader.classes) { + for (final field in classElement.fields) { + if (!field.isStatic) { + continue; + } + _addAgentCatalogSpread( + spreads: supplements, + element: field, + assetId: input, + outputPath: outputPath, + symbolName: '${classElement.name}.${field.name}', ); } - supplements.add( - _CatalogSpread( - assetId: input, - symbolName: variable.name!, - importPath: _importForAsset(input.path, outputPath), - ), - ); } } } - supplements.sort( - (final a, final b) => a.assetId.path.compareTo(b.assetId.path), - ); + supplements.sort((final a, final b) { + final pathCompare = a.assetId.path.compareTo(b.assetId.path); + if (pathCompare != 0) { + return pathCompare; + } + return a.symbolName.compareTo(b.symbolName); + }); return supplements; } - bool _hasAgentCatalogAnnotation(final TopLevelVariableElement variable) { - for (final annotation in variable.metadata.annotations) { + void _addAgentCatalogSpread({ + required final List<_CatalogSpread> spreads, + required final Element element, + required final AssetId assetId, + required final String outputPath, + required final String symbolName, + }) { + if (!_hasAgentCatalogAnnotation(element)) { + return; + } + final type = switch (element) { + TopLevelVariableElement(:final type) => type, + FieldElement(:final type) => type, + _ => null, + }; + if (type == null || !_isCatalogEntryListType(type)) { + throw InvalidGenerationSourceError( + '@AgentCatalog on $symbolName must be ' + 'List.', + element: element, + ); + } + spreads.add( + _CatalogSpread( + assetId: assetId, + symbolName: symbolName, + importPath: _importForAsset(assetId.path, outputPath), + ), + ); + } + + bool _hasAgentCatalogAnnotation(final Element element) { + for (final annotation in element.metadata.annotations) { final reader = ConstantReader(annotation.computeConstantValue()); if (!reader.isNull && reader.instanceOf(_agentCatalogChecker)) { return true; @@ -409,61 +460,130 @@ $propertyLines final String symbolName, ) async { final library = await buildStep.resolver.libraryFor(assetId); - TopLevelVariableElement? variable; - for (final element in library.topLevelVariables) { - if (element.name == symbolName) { - variable = element; - break; - } - } - if (variable == null) { + final qualified = _parseCatalogSymbolName(symbolName); + final Element? element = switch (qualified) { + _TopLevelCatalogSymbol(:final name) => _findTopLevelCatalogVariable( + library, + name, + ), + _StaticCatalogSymbol(:final className, :final fieldName) => + _findStaticCatalogField(library, className, fieldName), + }; + if (element == null) { throw InvalidGenerationSourceError( '${assetId.path} must export $symbolName ' 'as List.', ); } - final constant = variable.computeConstantValue(); - if (constant != null) { - final list = constant.toListValue(); - if (list != null) { - final keys = []; - for (final item in list) { - final key = item.getField('registryKey')?.toStringValue(); - if (key != null) { - keys.add(key); + if (element is VariableElement) { + final constant = element.computeConstantValue(); + if (constant != null) { + final list = constant.toListValue(); + if (list != null) { + final keys = []; + for (final item in list) { + final key = item.getField('registryKey')?.toStringValue(); + if (key != null) { + keys.add(key); + } } + return keys; } - return keys; } } final unit = await buildStep.resolver.compilationUnitFor(assetId); final keys = []; - for (final declaration in unit.declarations) { - if (declaration is! TopLevelVariableDeclaration) { - continue; - } - for (final variableDeclaration in declaration.variables.variables) { - if (variableDeclaration.name.lexeme != symbolName) { - continue; + switch (qualified) { + case _TopLevelCatalogSymbol(:final name): + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + for (final variableDeclaration in declaration.variables.variables) { + if (variableDeclaration.name.lexeme != name) { + continue; + } + final initializer = variableDeclaration.initializer; + if (initializer != null) { + _collectRegistryKeysFromExpression(initializer, keys); + } + } } - final initializer = variableDeclaration.initializer; - if (initializer != null) { - _collectRegistryKeysFromExpression(initializer, keys); + case _StaticCatalogSymbol(:final className, :final fieldName): + for (final declaration in unit.declarations) { + if (declaration is! ClassDeclaration || + declaration.name.lexeme != className) { + continue; + } + for (final member in declaration.members) { + if (member is! FieldDeclaration) { + continue; + } + for (final variableDeclaration in member.fields.variables) { + if (variableDeclaration.name.lexeme != fieldName) { + continue; + } + final initializer = variableDeclaration.initializer; + if (initializer != null) { + _collectRegistryKeysFromExpression(initializer, keys); + } + } + } } - } } if (keys.isEmpty) { throw InvalidGenerationSourceError( 'Could not read registryKey values from $symbolName ' 'in ${assetId.path}.', - element: variable, + element: element, ); } return keys; } + _CatalogSymbolName _parseCatalogSymbolName(final String symbolName) { + final separator = symbolName.lastIndexOf('.'); + if (separator == -1) { + return _TopLevelCatalogSymbol(symbolName); + } + return _StaticCatalogSymbol( + symbolName.substring(0, separator), + symbolName.substring(separator + 1), + ); + } + + TopLevelVariableElement? _findTopLevelCatalogVariable( + final LibraryElement library, + final String name, + ) { + for (final element in library.topLevelVariables) { + if (element.name == name) { + return element; + } + } + return null; + } + + FieldElement? _findStaticCatalogField( + final LibraryElement library, + final String className, + final String fieldName, + ) { + for (final type in library.classes) { + if (type.name != className) { + continue; + } + for (final field in type.fields) { + if (field.isStatic && field.name == fieldName) { + return field; + } + } + } + return null; + } + void _collectRegistryKeysFromExpression( final Expression expression, final List keys, @@ -635,3 +755,20 @@ final class _CatalogSpread { final String symbolName; final String importPath; } + +sealed class _CatalogSymbolName { + const _CatalogSymbolName(); +} + +final class _TopLevelCatalogSymbol extends _CatalogSymbolName { + const _TopLevelCatalogSymbol(this.name); + + final String name; +} + +final class _StaticCatalogSymbol extends _CatalogSymbolName { + const _StaticCatalogSymbol(this.className, this.fieldName); + + final String className; + final String fieldName; +} diff --git a/packages/intentcall_codegen/test/agent_catalog_build_test.dart b/packages/intentcall_codegen/test/agent_catalog_build_test.dart index ca19c04..4435d64 100644 --- a/packages/intentcall_codegen/test/agent_catalog_build_test.dart +++ b/packages/intentcall_codegen/test/agent_catalog_build_test.dart @@ -120,6 +120,32 @@ void main() { ); }); + test('discovers @AgentCatalog on static class field', () async { + expect( + _fixture('test/fixtures/catalog/host_static_catalog.dart'), + contains('app_host_static_a'), + ); + await runCatalogBuilder( + generator: AgentCatalogGenerator( + _options({ + 'tool_globs': ['test/fixtures/catalog/**.dart'], + 'tool_exclude_globs': [], + }), + ), + fixtures: { + 'test/fixtures/catalog/host_static_catalog.dart': _fixture( + 'test/fixtures/catalog/host_static_catalog.dart', + ), + }, + outputMatcher: allOf( + contains('...CatalogHost.hostCatalogEntries,'), + contains( + "import '../../test/fixtures/catalog/host_static_catalog.dart';", + ), + ), + ); + }); + test('unannotated catalog list omitted', () async { await runCatalogBuilder( generator: AgentCatalogGenerator(_options({})), diff --git a/packages/intentcall_codegen/test/fixtures/catalog/host_static_catalog.dart b/packages/intentcall_codegen/test/fixtures/catalog/host_static_catalog.dart new file mode 100644 index 0000000..8e03841 --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/catalog/host_static_catalog.dart @@ -0,0 +1,38 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +final class CatalogHost { + @AgentCatalog() + static final List hostCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_host_static_a', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'host_static_a', + description: 'Static @AgentCatalog row A', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_host_static_b', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'host_static_b', + description: 'Static @AgentCatalog row B', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + ), + ), + ]; +} diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index d68d603..d5a4137 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -57,11 +57,13 @@ over `this`). 1. Put behavior on a host class (optional `static final shared` for probe anchors). 2. Expose each intent as an instance `AgentCallEntry` getter; the handler calls instance methods on `this`. -3. Optionally co-locate `EntryProjection` consts on the host (see **Handwritten projection** below). -4. Co-locate a `List` annotated with **`@AgentCatalog`** - (discovered via `tool_globs`). +3. Optionally add inline `EntryProjection` on catalog rows (see **Handwritten projection** below). +4. Co-locate a **static** `List` on the host class, + annotated with **`@AgentCatalog`** (discovered via `tool_globs`). Top-level + lists are also valid; instance fields are not supported. 5. Run `build_runner` so `lib/generated/agent_catalog.g.dart` merges `@AgentTool` - rows (from `tool_part_globs` / generated `.g.dart` parts) and `@AgentCatalog` spreads. + rows (from `tool_part_globs` / generated `.g.dart` parts) and `@AgentCatalog` + spreads (e.g. `...InboxHost.inboxHostCatalogEntries`). ```dart final class InboxHost { @@ -77,19 +79,19 @@ final class InboxHost { inputSchema: const { /* … */ }, handler: (final args) async => readInbox(args['folder'] as String), ); -} -``` -```dart -// Co-located with InboxHost (same file or sibling module file) -@AgentCatalog() -final List inboxHostCatalogEntries = - [ - AgentRegistryCatalogEntry( - registryKey: 'app_read_inbox', - entry: InboxHost.shared.readInboxCallEntry, - ), -]; + @AgentCatalog() + static final List inboxHostCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_read_inbox', + entry: shared.readInboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), + ), + ]; +} ``` Reference implementation: @@ -97,7 +99,8 @@ Reference implementation: Then run `intentcall manifest export --check` so committed `agent_manifest.json` stays in sync with the merged catalog (see -[ADR 0019](../../docs/decisions/0019-framework-neutral-intentcall-cli.md)). +[ADR 0019](../../docs/decisions/0019-framework-neutral-intentcall-cli.md) and +[ADR 0021](../../docs/decisions/0021-agent-catalog-annotation.md)). **Probe anchor (optional):** When `static shared` exists, catalog rows may use `InboxHost.shared.readInboxCallEntry` — manifest export evaluates `entry:` in a @@ -109,21 +112,21 @@ at bootstrap even when catalog uses `shared` for probe convenience. manifest metadata is needed without a probe-time handler; register handlers from a live host at runtime and keep `qualifiedName` aligned. -**Handwritten projection:** Co-locate surface hints on the host class and -reference them from catalog rows: +**Handwritten projection:** Use inline `EntryProjection` on catalog rows: ```dart -static const inboxProjection = EntryProjection( - surfaces: {AgentManifestSurface.webMcp: true}, -); - AgentRegistryCatalogEntry( registryKey: 'app_read_inbox', - entry: InboxHost.shared.readInboxCallEntry, - projection: InboxHost.inboxProjection, + entry: shared.readInboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), ), ``` +Alternatively, co-locate a `static const` projection on the host and reference it +from the row. Do not duplicate tools already merged by `@AgentTool` codegen. + `platforms.enabled` in `intentcall.yaml` scopes default manifest surface families (web-only hosts omit android/windows/linux defaults unless overridden). From 2e4b137b8ef0b13a38f43be91d8a831d1a3191ab Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 04:33:26 +0300 Subject: [PATCH 06/27] feat: enhance example app with detailed documentation on catalog and runtime registration, improve instance-bound tool integration --- .../intentcall_codegen/example/lib/main.dart | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/intentcall_codegen/example/lib/main.dart b/packages/intentcall_codegen/example/lib/main.dart index 997d2ad..f881730 100644 --- a/packages/intentcall_codegen/example/lib/main.dart +++ b/packages/intentcall_codegen/example/lib/main.dart @@ -3,11 +3,40 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'generated/agent_catalog.g.dart'; import 'tools/demo_host_tools.dart'; +/// Smoke harness for catalog → registry wiring in the example app. +/// +/// Catalog sources (see `@AgentCatalog` in `demo_host_tools.dart` and `docs/DX_FAQ.mdx`): +/// +/// ```text +/// @AgentTool → tool implementation + (usually) catalog row +/// handwritten getter → tool implementation only +/// catalog row → @AgentCatalog list +/// agent_catalog.g.dart → merge of all three sources +/// ``` +/// +/// Build time and runtime serve different jobs: +/// +/// - **Build / manifest export** — `AgentCatalogGenerator` merges rows into +/// [agentCatalogEntries]. For `@AgentTool` on instance methods it needs a +/// compile-time probe anchor, defaulting to `Host.shared.CallEntry` +/// (here `DemoHostTools.shared.demoHostStatusCallEntry`). +/// - **Runtime** — the app may register a different live host instance when +/// descriptors match (DI container, per-session state, tests). Future main() async { final registry = InMemoryAgentRegistry(); + + // Live host for runtime registration. Not the same object as + // [DemoHostTools.shared], which exists only as the catalog probe anchor. final liveHost = DemoHostTools(); + // Bulk-register every row from the generated catalog except one override + // (see below). Rows come from @AgentTool codegen, @AgentCatalog spreads, + // and their merged projection metadata. for (final row in agentCatalogEntries) { + // Skip `app_demo_host_status`: the generated catalog binds that row to + // [DemoHostTools.shared] so manifest export can resolve descriptors at + // build time. We re-register the same qualified name from [liveHost] + // immediately after this loop to demonstrate instance-bound runtime wiring. if (row.registryKey == 'app_demo_host_status') { continue; } @@ -16,8 +45,13 @@ Future main() async { registry.register(entry.toRegistration()); } } + + // Runtime override: same registry key and descriptor as the catalog row, but + // handler closes over [liveHost] so invocation runs on this instance. registry.register(liveHost.demoHostStatusCallEntry.toRegistration()); + // Handwritten instance-bound tool registered via @AgentCatalog on + // [DemoHostTools.demoHostCatalogEntries] (no skip/re-register needed). final inbox = await registry.invoke('app_demo_inbox', {'folder': 'inbox'}); if (!inbox.ok) { throw StateError('demo_inbox smoke failed: ${inbox.message}'); @@ -30,6 +64,8 @@ Future main() async { throw StateError('demo_handwritten smoke failed: ${handwritten.message}'); } + // Codegen @AgentTool on an instance method — proves [liveHost], not + // [DemoHostTools.shared], handled the call after the runtime override above. final hostStatus = await registry.invoke('app_demo_host_status', { 'label': 'primary', }); From ec1fbb07775c3125e7fc5e5446995b5ef6042ac4 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 05:18:13 +0300 Subject: [PATCH 07/27] feat: add projection pipeline check command and enhance entity type descriptor handling in manifest export --- docs/DX_FAQ.mdx | 25 +- .../0022-projection-pipeline-alignment.md | 71 +++ .../0023-entity-three-slot-projection.md | 151 ++++++ docs/decisions/README.md | 4 +- docs/evidence/projection-pipeline-spec.md | 2 +- justfile | 10 + .../lib/intentcall_android.dart | 2 - .../lib/src/agent_manifest_generator.dart | 73 +-- .../lib/src/catalog/catalog_loader.dart | 186 ++++++-- .../lib/src/command_runner.dart | 12 +- .../lib/generated/agent_catalog.g.dart | 3 + .../web/agent_manifest.json | 9 + .../lib/generated/agent_catalog.g.dart | 40 ++ .../entity_catalog_project/pubspec.lock | 90 ++++ .../entity_catalog_project/pubspec.yaml | 20 + .../lib/generated/agent_catalog.g.dart | 3 + .../flutter_project/web/agent_manifest.json | 27 ++ .../lib/generated/agent_catalog.g.dart | 3 + .../jaspr_web_project/web/agent_manifest.json | 9 + .../test/manifest_entity_export_test.dart | 106 +++++ .../lib/generated/agent_catalog.g.dart | 45 +- .../example/web/agent_manifest.json | 54 +++ .../web/intentcall_webmcp.generated.js | 56 ++- .../example/web/manifest.json | 112 +++-- .../lib/intentcall_codegen.dart | 2 + .../lib/src/agent_entity.dart | 64 +++ .../src/agent_entity_snapshot_builder.dart | 33 ++ .../lib/src/agent_projection.dart | 7 +- .../generators/agent_catalog_generator.dart | 370 ++++++++++++++- .../test/agent_catalog_build_test.dart | 40 +- .../agent_entity_snapshot_builder_test.dart | 41 ++ .../catalog/agent_entity_annotated.dart | 25 + .../intentcall_core/lib/intentcall_core.dart | 3 + .../agent_entity_property_descriptor.dart | 3 + .../entity/agent_entity_property_role.dart | 1 + .../entity/agent_entity_snapshot_keys.dart | 141 ++++++ .../agent_entity_snapshot_projection.dart | 53 +-- .../entity/agent_entity_snapshot_schema.dart | 39 ++ .../entity/agent_entity_type_descriptor.dart | 15 +- .../test/agent_entity_snapshot_keys_test.dart | 127 +++++ .../lib/intentcall_gemma.dart | 2 - .../IntentCallNativeEntitySnapshotStore.swift | 201 ++++++++ .../Classes/IntentCallPlatformPlugin.swift | 152 ++---- .../IntentCallNativeEntitySnapshotStore.swift | 201 ++++++++ .../IntentCallPlatformPlugin.swift | 152 ++---- .../src/flutter/intentcall_entity_index.dart | 43 -- .../src/flutter/intentcall_flutter_host.dart | 23 +- .../IntentCallNativeEntitySnapshotStore.swift | 201 ++++++++ .../Classes/IntentCallPlatformPlugin.swift | 152 ++---- .../IntentCallNativeEntitySnapshotStore.swift | 201 ++++++++ .../IntentCallPlatformPlugin.swift | 152 ++---- .../test/intentcall_entity_index_test.dart | 19 +- .../lib/intentcall_platform_sync.dart | 1 + .../lib/src/agent_manifest.dart | 16 +- .../bootstrap/agent_web_mcp_bootstrap.dart | 15 +- .../agent_web_mcp_bootstrap_stub.dart | 3 + .../agent_web_mcp_bootstrap_web.dart | 19 + .../android_shortcuts_xml_emitter.dart | 2 +- .../apple_swift_app_intents_emitter.dart | 447 +++++++----------- .../emitters/linux_desktop_entry_emitter.dart | 2 +- .../src/emitters/web_manifest_emitter.dart | 4 +- .../lib/src/emitters/web_mcp_js_emitter.dart | 2 +- .../emitters/windows_protocol_emitter.dart | 4 +- .../lib/src/projection/manifest_exporter.dart | 11 +- .../lib/src/projection/manifest_merger.dart | 58 ++- .../projection/manifest_surface_index.dart | 29 ++ .../lib/src/projection/projection_policy.dart | 34 +- .../lib/src/projection/surface_resolver.dart | 32 ++ .../lib/src/sync/platform_sync.dart | 17 +- .../test/apple_surface_matrix_test.dart | 159 +++++++ .../test/dense_manifest_test.dart | 33 ++ .../test/ios_shortcuts_opt_in_test.dart | 72 +++ .../test/manifest_merger_test.dart | 6 +- .../test/native_emitters_test.dart | 107 ++--- .../partial_defaults_platform_scope_test.dart | 49 ++ .../test/platform_sync_layout_test.dart | 68 +++ .../test/projection_alignment_test.dart | 363 ++++++++++++++ .../test/web_emitters_test.dart | 9 + .../test/webmcp_bootstrap_surface_test.dart | 120 +++++ packages/intentcall_schema/README.md | 191 +++++++- .../lib/intentcall_schema.dart | 23 + .../lib/src/agent_entity_model.dart | 77 +++ .../lib/src/agent_result.dart | 74 +++ .../lib/src/agent_result_envelope.dart | 30 +- .../lib/src/agent_validation_exception.dart | 8 + .../lib/src/agent_wire_args.dart | 22 +- .../src/client_resource_input_schemas.dart | 20 +- .../lib/src/schema_coercion.dart | 18 +- .../lib/src/schema_validator.dart | 32 +- packages/intentcall_schema/pubspec.yaml | 4 +- skills/register-intents/SKILL.md | 171 ++++++- steward.yaml | 76 +++ .../intentcall.projection-pipeline.yaml | 64 +++ 93 files changed, 4900 insertions(+), 1168 deletions(-) create mode 100644 docs/decisions/0022-projection-pipeline-alignment.md create mode 100644 docs/decisions/0023-entity-three-slot-projection.md create mode 100644 packages/intentcall_cli/test/fixtures/entity_catalog_project/lib/generated/agent_catalog.g.dart create mode 100644 packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.lock create mode 100644 packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.yaml create mode 100644 packages/intentcall_cli/test/manifest_entity_export_test.dart create mode 100644 packages/intentcall_codegen/lib/src/agent_entity.dart create mode 100644 packages/intentcall_codegen/lib/src/agent_entity_snapshot_builder.dart create mode 100644 packages/intentcall_codegen/test/agent_entity_snapshot_builder_test.dart create mode 100644 packages/intentcall_codegen/test/fixtures/catalog/agent_entity_annotated.dart create mode 100644 packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart create mode 100644 packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart create mode 100644 packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart create mode 100644 packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart create mode 100644 packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift create mode 100644 packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift create mode 100644 packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift create mode 100644 packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift create mode 100644 packages/intentcall_platform_sync/lib/src/projection/manifest_surface_index.dart create mode 100644 packages/intentcall_platform_sync/lib/src/projection/surface_resolver.dart create mode 100644 packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart create mode 100644 packages/intentcall_platform_sync/test/dense_manifest_test.dart create mode 100644 packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart create mode 100644 packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart create mode 100644 packages/intentcall_platform_sync/test/platform_sync_layout_test.dart create mode 100644 packages/intentcall_platform_sync/test/projection_alignment_test.dart create mode 100644 packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart create mode 100644 steward/scenarios/intentcall.projection-pipeline.yaml diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index f94d707..0edfbed 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -52,6 +52,12 @@ For adapter, platform bridge, or registry contract work, also run: steward benchmark --scenario intentcall.adapter-contract --json ``` +For manifest export, emitter, and platform projection alignment, run: + +```bash +steward benchmark --scenario intentcall.projection-pipeline --json +``` + The quick probe validates package synchronization, internal dependency floors, path dependency hygiene, active-plan hygiene, and the adapter/platform contract lane selected by `steward.yaml`. @@ -471,7 +477,24 @@ field exists, or `Host.shared.CallEntry` when a probe anchor is present. Handwritten getters remain the canonical path when you need full control. **Typed projection:** `@AgentProjection` and `EntryProjection` use -`AgentManifestSurface` enum keys. Apple sub-channels (Siri, Spotlight) use +`AgentManifestSurface` enum keys. See [ADR 0022](decisions/0022-projection-pipeline-alignment.md) +for the full surface table and Apple sub-channel defaults. + +| Enum | Manifest key | Default when platform enabled | +|------|--------------|------------------------------| +| `appleAppIntents` | `apple.appIntents` | `true` on `ios`/`macos` | +| `appleAppShortcuts` | `apple.appShortcuts` | `false` (opt-in) | +| `appleSpotlight` | `apple.spotlight` | `false` | +| `appleEntities` | `apple.entities` | `false` | +| `androidShortcuts` | `android.shortcuts` | `true` on `android` | +| `webManifestShortcuts` | `web.manifestShortcuts` | `true` on `web` | +| `webProtocolHandlers` | `web.protocolHandlers` | `true` on `web` | +| `webMcp` | `web.webMcp` | `true` on `web` | +| `windowsProtocolActivation` | `windows.protocolActivation` | `true` on `windows` | +| `windowsMsixProtocol` | `windows.msixProtocol` | `true` on `windows` | +| `linuxSchemeHandler` | `linux.schemeHandler` | `true` on `linux` | + +Apple sub-channels (Siri phrases, Spotlight donation hints) use `AgentManifestSurfaceExposure.options` on handwritten rows until emitters define them — see [ADR 0020](decisions/0020-platform-scoped-manifest-surfaces.md). diff --git a/docs/decisions/0022-projection-pipeline-alignment.md b/docs/decisions/0022-projection-pipeline-alignment.md new file mode 100644 index 0000000..e6c2f7e --- /dev/null +++ b/docs/decisions/0022-projection-pipeline-alignment.md @@ -0,0 +1,71 @@ +# 0022. Projection Pipeline Alignment — Dense Export and Apple Sub-Channels + +Date: 2026-07-07 + +## Status + +Accepted + +## Context + +[ADR 0020](0020-platform-scoped-manifest-surfaces.md) scoped default manifest +surfaces to `platforms.enabled`, but export still emitted sparse surface maps, +emitters disagreed on absent-key semantics, and Apple projection treated App +Intent struct emission, Shortcuts phrases, entity queries, and Spotlight indexing +as one bundle. + +The [projection pipeline spec](../evidence/projection-pipeline-spec.md) requires +Layer 3 alignment before entity lifecycle and consumer harness work proceed. + +## Decision + +1. **Dense export** — `AgentManifestSurfacePolicy.toJson()` and + `resolveEntrySurfaces()` always emit **all** `AgentManifestSurface` values with + explicit `include: true | false`. Absent keys in handwritten yaml are not + exported; merge resolves them before serialization. + +2. **Partial yaml merge** — `ProjectionPolicy.resolvedDefaultSurfaces()` applies + precedence: explicit `defaults.surfaces` key → platform-scoped default for that + surface family → legacy `defaultSurfaceInclude()` when `platforms.enabled` is + empty. + +3. **Apple shortcuts opt-in (ADR 0016 preserved)** — `apple.appShortcuts` never + auto-enables from `platforms.enabled` alone. Authors must set it explicitly in + yaml, catalog `@AgentProjection`, or per-entry overlay. + +4. **New Apple surfaces** (additive; manifest schema version stays `1`): + + | Enum | Manifest key | Default when `ios`/`macos` enabled | + |------|--------------|-------------------------------------| + | `appleAppIntents` | `apple.appIntents` | `true` | + | `appleAppShortcuts` | `apple.appShortcuts` | `false` (opt-in) | + | `appleSpotlight` | `apple.spotlight` | `false` | + | `appleEntities` | `apple.entities` | `false` | + +5. **Emitter gating** — `AppleSwiftAppIntentsEmitter` reads dense manifest rows: + - Swift `AppIntent` structs ← `apple.appIntents` + - `AppShortcutsProvider` rows ← `apple.appShortcuts` + - `AppEntity` / `EntityQuery` / snapshot store ← `apple.entities` + - `CoreSpotlight` / `IndexedEntity` / indexer helpers ← `apple.spotlight` + +6. **`intentcall_apple` manifest generator** — deprecated on the main path; + `intentcall_platform_sync` emitters are canonical. No new features land in + `intentcall_apple`. + +## Consequences + +- Tool rows grow from 8 to 11 surface keys; regenerating fixtures is required in + the same change set as enum additions. +- iOS/macOS hosts get App Intent structs by default; Shortcuts, entities, and + Spotlight remain explicit opt-in surfaces. +- Emitters treat missing surface keys as excluded (`defaultValue: false`). +- Codegen `@AgentProjection` and yaml may address each Apple sub-channel + independently. + +## Related + +- [0016-dispatch-mode-handoff-contract.md](0016-dispatch-mode-handoff-contract.md) +- [0018-additive-actions-typed-entities-indexing-lifecycle.md](0018-additive-actions-typed-entities-indexing-lifecycle.md) +- [0020-platform-scoped-manifest-surfaces.md](0020-platform-scoped-manifest-surfaces.md) +- [0021-agent-catalog-annotation.md](0021-agent-catalog-annotation.md) +- [projection-pipeline-spec.md](../evidence/projection-pipeline-spec.md) diff --git a/docs/decisions/0023-entity-three-slot-projection.md b/docs/decisions/0023-entity-three-slot-projection.md new file mode 100644 index 0000000..3e3718c --- /dev/null +++ b/docs/decisions/0023-entity-three-slot-projection.md @@ -0,0 +1,151 @@ +# 0023. Entity Three-Slot Projection and Property Roles + +Date: 2026-07-07 + +## Status + +Accepted + +## Context + +[ADR 0018](0018-additive-actions-typed-entities-indexing-lifecycle.md) defines +typed app entities as additive projection metadata: Dart owns snapshots; native +platforms cache JSON-safe rows for cold-start query and indexing. + +Entity descriptors declare many properties (`name`, `summary`, `tags`, …), but +native discovery UIs expose a **fixed display surface**: + +| Platform | Primary line | Secondary line | Search tokens | +|----------|--------------|----------------|---------------| +| Apple `AppEntity` | `title` | `subtitle` | `keywords: [String]` | +| Apple `DisplayRepresentation` | title | subtitle | — | +| IntentCall native cache search | `titleKey` field | `subtitleKey` field | `keywordsKey` array | + +Apple is the first concrete emitter (`apple.entities`, `apple.spotlight`). Android +and Windows entity lanes are not implemented yet, but they will map to the same +neutral three-slot vocabulary rather than per-platform property lists. + +Before this ADR, manifest export duplicated key-derivation heuristics in +`manifest_merger.dart` and `intentcall_apple`, sometimes hardcoding +`subtitleKey: 'subtitle'` while descriptors used domain field names like +`summary`. Authors had only `isDisplay` / `isSearchable` booleans with implicit +ordering (first display property → title), which is fragile when multiple +properties share a flag. + +## Decision + +### 1. Three-slot manifest vocabulary + +Each `entityTypes[]` row in `agent_manifest.json` carries exactly three +snapshot key slots plus the identifier: + +| Manifest key | Semantic role | Default when unset | +|--------------|---------------|--------------------| +| `idKey` | Stable entity identifier in cache rows | `id` | +| `titleKey` | Primary display string | `title` | +| `subtitleKey` | Secondary display string | `subtitle` | +| `keywordsKey` | Search token array | `keywords` | + +Emitters and native stores read these keys from the manifest. They do **not** +project arbitrary `displayProperties` lists — only one property name per slot. + +### 2. `AgentEntityPropertyRole` + +Core vocabulary in `intentcall_core`: + +```dart +enum AgentEntityPropertyRole { none, title, subtitle, keywords } +``` + +`AgentEntityPropertyDescriptor.role` defaults to `none`. Codegen +`@AgentEntityProperty(role: 'title' | 'subtitle' | 'keywords')` maps to this +enum. Entity-level overrides on `@AgentEntity` (`titleProperty`, +`subtitleProperty`, `keywordsProperty`) assign roles by property name at codegen +time. + +Validation (codegen and `AgentEntitySnapshotKeys.fromDescriptor`): + +- At most one property per role (`title`, `subtitle`, `keywords`). +- `keywords` role requires `valueType: array`. +- Entity-level override names must match a declared property. + +### 3. Canonical key resolution + +`AgentEntitySnapshotKeys.fromDescriptor(AgentEntityTypeDescriptor)` in +`intentcall_core` is the single source of truth for slot assignment. Precedence: + +1. Explicit `role` on a property (or entity-level override resolved at codegen). +2. Heuristic fallback for backward compatibility: + - `titleKey` ← first `isDisplay` property, else `'title'` + - `subtitleKey` ← second `isDisplay`, else first `isSearchable` ≠ title, else `'subtitle'` + - `keywordsKey` ← first `isSearchable` array property, else `'keywords'` + +Consumers must call this API — not reimplement heuristics: + +- `projectAgentEntitySnapshot()` (runtime cache rows) +- `generateEntityManifestJson()` in `intentcall_platform_sync` +- Deprecated `intentcall_apple` manifest generator (delegates to core) + +### 4. Snapshot schema extensions + +`agentEntitySnapshotSchema(descriptor)` emits JSON Schema for each entity type. +Property rows include additive extensions: + +- `x-intentcall-display`, `x-intentcall-searchable`, `x-intentcall-indexed` +- `x-intentcall-role` when `role != none` + +Manifest schema version stays `1`; extensions are additive. + +### 5. Codegen typed field constants + +For each `@AgentEntity`, codegen emits `{Namespace}{Name}EntityFields` with +`static const String` per property name (for example `AppProjectEntityFields.name`). +Authors use these constants with `AgentEntitySnapshotBuilder` to avoid string +typos when building cache rows. This is optional sugar; descriptors and manifest +keys remain the contract. + +### 6. Platform mapping (current and future) + +| Layer | Responsibility | +|-------|----------------| +| Dart `AgentEntitySnapshot` + `projectAgentEntitySnapshot` | Source rows keyed by descriptor fields | +| `agent_manifest.json` `entityTypes[]` | Declares slot → property name mapping | +| Apple `AppEntity` codegen | Fixed `title`/`subtitle`/`keywords` struct; reads snapshot via manifest keys | +| `IntentCallNativeEntitySnapshotStore` | Cold-start search over `titleKey`/`subtitleKey`/`keywordsKey` | +| Android / Windows (future) | Reuse same three slots; map to platform-specific labels when emitters land | + +## Consequences + +Good: + +- One derivation path; manifest export and runtime projection stay aligned. +- Explicit roles remove guesswork when multiple properties are `isDisplay`. +- Neutral vocabulary is ready for non-Apple entity emitters without schema churn. +- `x-intentcall-role` in `snapshotSchema` documents intent for agents and tooling. + +Tradeoffs: + +- `IntentCallPlatformEntityIndex.upsertAgentSnapshots` was removed; use + `upsertAgentSnapshotsForType` with an `AgentEntityTypeDescriptor` so cache + rows align with manifest slot keys via `projectAgentEntitySnapshot()`. +- Only three discovery slots; extra display fields stay in `properties` but are + not used for native string search unless a future ADR adds slots. +- Heuristic fallback remains for older catalogs; authors should migrate to + explicit `role` or entity-level overrides. +- `isDisplay` / `isSearchable` booleans still exist for schema extensions and + backward compatibility; `role` wins when both are set. + +Neutral: + +- This ADR does not require every app to declare entities. +- Indexed / Spotlight-specific behavior remains gated by manifest surfaces + ([ADR 0022](0022-projection-pipeline-alignment.md)), not by property roles alone. + +## Related + +- [0018-additive-actions-typed-entities-indexing-lifecycle.md](0018-additive-actions-typed-entities-indexing-lifecycle.md) +- [0021-agent-catalog-annotation.md](0021-agent-catalog-annotation.md) +- [0022-projection-pipeline-alignment.md](0022-projection-pipeline-alignment.md) +- [projection-pipeline-spec.md](../evidence/projection-pipeline-spec.md) +- `packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart` +- `packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart` diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b39d8e4..0caa6ae 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,7 +3,7 @@ Architecture Decision Records (ADRs) for IntentCall. Format: [MADR](https://adr.github.io/madr/) — see any existing ADR for the template. -Next ADR number: **0021** +Next ADR number: **0024** --- @@ -23,6 +23,8 @@ Next ADR number: **0021** | [0019](0019-framework-neutral-intentcall-cli.md) | accepted | Framework-Neutral IntentCall CLI and Registry-Backed Manifest Generation | 2026-07-07 | | [0020](0020-platform-scoped-manifest-surfaces.md) | accepted | Platform-Scoped Manifest Surface Defaults | 2026-07-07 | | [0021](0021-agent-catalog-annotation.md) | accepted | @AgentCatalog Annotation and Removal of Handwritten Catalog Path | 2026-07-07 | +| [0022](0022-projection-pipeline-alignment.md) | accepted | Projection Pipeline Alignment — Dense Export and Apple Sub-Channels | 2026-07-07 | +| [0023](0023-entity-three-slot-projection.md) | accepted | Entity Three-Slot Projection and Property Roles | 2026-07-07 | --- diff --git a/docs/evidence/projection-pipeline-spec.md b/docs/evidence/projection-pipeline-spec.md index 685550b..d563097 100644 --- a/docs/evidence/projection-pipeline-spec.md +++ b/docs/evidence/projection-pipeline-spec.md @@ -239,7 +239,7 @@ dart test packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart | CLI export | `command_runner.dart`, `manifest_exporter.dart` | Pass `entityTypeDescriptors` from catalog loader | | Catalog loader | `catalog_loader.dart` | Load entity descriptors from generated catalog | | Unify snapshot store | `IntentCallPlatformPlugin.swift` (all copies) | Delegate to generated `IntentCallNativeEntitySnapshotStore` or delete hand-written store | -| Dart API | `intentcall_entity_index.dart` | Deprecate legacy `upsertAgentSnapshots`; canonical: `upsertAgentSnapshotsForType` | +| Dart API | `intentcall_entity_index.dart` | Remove legacy `upsertAgentSnapshots`; canonical API is `upsertAgentSnapshotsForType` (descriptor-aligned projection) | | Indexing hook | Plugin method channel | After upsert → call generated `IntentCallAppEntityIndexer.indexAppEntities()` | | Emitter | `apple_swift_app_intents_emitter.dart` | Indexing gated by `apple.spotlight` | diff --git a/justfile b/justfile index 49d4755..1f21c88 100644 --- a/justfile +++ b/justfile @@ -25,6 +25,16 @@ adr-gates: just manifest-parity just platform-sync-check +# Layer 5 projection pipeline gate (spec section 8) +projection-pipeline-check: + dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart + dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart + dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart + dart test packages/intentcall_platform_sync/test/native_emitters_test.dart + cd packages/intentcall_codegen/example && dart pub get && dart run build_runner build + cd packages/intentcall_codegen/example && dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check + cd packages/intentcall_codegen/example && dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check + # Verify platform artifact sync on fixture projects platform-sync-check: dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/flutter_project --platform web --check diff --git a/packages/intentcall_android/lib/intentcall_android.dart b/packages/intentcall_android/lib/intentcall_android.dart index a369c1a..9ab470b 100644 --- a/packages/intentcall_android/lib/intentcall_android.dart +++ b/packages/intentcall_android/lib/intentcall_android.dart @@ -1,3 +1 @@ -library; - export 'src/agent_manifest_generator.dart'; diff --git a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart b/packages/intentcall_apple/lib/src/agent_manifest_generator.dart index 22db6bc..9fe2376 100644 --- a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart +++ b/packages/intentcall_apple/lib/src/agent_manifest_generator.dart @@ -37,81 +37,20 @@ String generateAppleAgentManifest( Map _entityTypeDescriptorManifest( final AgentEntityTypeDescriptor descriptor, ) { - final displayProperties = descriptor.displayProperties.toList(); - final searchableProperties = descriptor.searchableProperties.toList(); - final titleKey = displayProperties.isNotEmpty - ? displayProperties.first.name - : 'title'; - final subtitleKey = displayProperties.length > 1 - ? displayProperties[1].name - : _firstOrNull( - searchableProperties - .where((final property) => property.name != titleKey) - .map((final property) => property.name), - ) ?? - 'subtitle'; - final keywordsKey = - _firstOrNull( - searchableProperties - .where( - (final property) => - property.valueType == AgentEntityPropertyValueType.array, - ) - .map((final property) => property.name), - ) ?? - 'keywords'; + final keys = AgentEntitySnapshotKeys.fromDescriptor(descriptor); return { 'qualifiedName': descriptor.qualifiedName, 'namespace': descriptor.namespace, 'name': descriptor.name, 'displayName': descriptor.displayName ?? _humanizeName(descriptor.name), - 'idKey': descriptor.identifierName, - 'titleKey': titleKey, - 'subtitleKey': subtitleKey, - 'keywordsKey': keywordsKey, - 'snapshotSchema': _snapshotSchema(descriptor), + 'idKey': keys.idKey, + 'titleKey': keys.titleKey, + 'subtitleKey': keys.subtitleKey, + 'keywordsKey': keys.keywordsKey, + 'snapshotSchema': agentEntitySnapshotSchema(descriptor), }; } -Map _snapshotSchema( - final AgentEntityTypeDescriptor descriptor, -) { - final properties = { - descriptor.identifierName: const {'type': 'string'}, - }; - for (final property in descriptor.properties) { - properties[property.name] = { - 'type': _jsonSchemaType(property.valueType), - if (property.description.isNotEmpty) 'description': property.description, - if (property.isDisplay) 'x-intentcall-display': true, - if (property.isSearchable) 'x-intentcall-searchable': true, - if (property.isIndexed) 'x-intentcall-indexed': true, - if (property.privacy != null) - 'x-intentcall-privacy': property.privacy!.name, - }; - } - return { - 'type': 'object', - 'required': [descriptor.identifierName], - 'properties': properties, - }; -} - -String? _firstOrNull(final Iterable values) { - final iterator = values.iterator; - return iterator.moveNext() ? iterator.current : null; -} - -String _jsonSchemaType(final AgentEntityPropertyValueType type) => - switch (type) { - AgentEntityPropertyValueType.string => 'string', - AgentEntityPropertyValueType.integer => 'integer', - AgentEntityPropertyValueType.number => 'number', - AgentEntityPropertyValueType.boolean => 'boolean', - AgentEntityPropertyValueType.object => 'object', - AgentEntityPropertyValueType.array => 'array', - }; - String _humanizeName(final String name) { final parts = name .split(RegExp(r'[_\s-]+')) diff --git a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart index 4364d5f..ab49fcb 100644 --- a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart +++ b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart @@ -15,6 +15,20 @@ final class CatalogLoader { Future> load({ required final String projectRoot, + }) async { + final bundle = await _loadBundle(projectRoot: projectRoot); + return bundle.entries; + } + + Future> loadEntityTypeDescriptors({ + required final String projectRoot, + }) async { + final bundle = await _loadBundle(projectRoot: projectRoot); + return bundle.entityTypeDescriptors; + } + + Future<_CatalogBundle> _loadBundle({ + required final String projectRoot, }) async { final root = p.normalize(p.absolute(projectRoot)); final catalogFile = File(p.join(root, catalogRelativePath)); @@ -35,7 +49,7 @@ final class CatalogLoader { ); } - Future?> _loadFromGeneratedCatalog( + Future<_CatalogBundle?> _loadFromGeneratedCatalog( final String projectRoot, ) async { final packageName = readPackageName(projectRoot); @@ -45,11 +59,14 @@ final class CatalogLoader { final probeDir = Directory(p.join(projectRoot, '.dart_tool')); probeDir.createSync(recursive: true); - final probeFile = File(p.join(probeDir.path, 'intentcall_catalog_probe.dart')); + final probeFile = File( + p.join(probeDir.path, 'intentcall_catalog_probe.dart'), + ); probeFile.writeAsStringSync(''' // ignore_for_file: avoid_print import 'dart:convert'; import 'package:$packageName/generated/agent_catalog.g.dart'; +import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; void main() { @@ -68,7 +85,40 @@ void main() { if (row.projection != null) 'projection': _projectionJson(row.projection!), }); } - print(jsonEncode(rows)); + final entities = agentEntityTypeDescriptors + .map(_entityDescriptorJson) + .toList(growable: false); + print(jsonEncode({ + 'entries': rows, + 'entityTypeDescriptors': entities, + })); +} + +Map _entityDescriptorJson(final AgentEntityTypeDescriptor descriptor) { + return { + 'namespace': descriptor.namespace, + 'name': descriptor.name, + 'identifierName': descriptor.identifierName, + if (descriptor.displayName != null) 'displayName': descriptor.displayName, + 'privacy': descriptor.privacy.name, + 'deepLinkBehavior': descriptor.deepLinkBehavior.name, + 'openBehavior': descriptor.openBehavior.name, + 'properties': descriptor.properties + .map( + (final property) => { + 'name': property.name, + 'valueType': property.valueType.name, + 'description': property.description, + 'isDisplay': property.isDisplay, + 'isSearchable': property.isSearchable, + 'isIndexed': property.isIndexed, + if (property.privacy != null) 'privacy': property.privacy!.name, + if (property.role != AgentEntityPropertyRole.none) + 'role': property.role.name, + }, + ) + .toList(growable: false), + }; } Map _projectionJson(final EntryProjection projection) { @@ -94,39 +144,105 @@ Map _projectionJson(final EntryProjection projection) { } final stdoutText = '${result.stdout}'.trim(); if (stdoutText.isEmpty) { - return const []; + return const _CatalogBundle( + entries: [], + entityTypeDescriptors: [], + ); } final decoded = jsonDecode(stdoutText); - if (decoded is! List) { + if (decoded is List) { + return _CatalogBundle( + entries: _parseCatalogEntries(decoded), + entityTypeDescriptors: const [], + ); + } + if (decoded is! Map) { return null; } - return decoded.map((final row) { - final map = (row as Map).cast(); - EntryProjection? projection; - final projectionRaw = map['projection']; - if (projectionRaw is Map) { - projection = _projectionFromJson( - projectionRaw.cast(), - ); - } - return AgentRegistryCatalogEntry( - registryKey: '${map['registryKey']}', - descriptor: AgentIntentDescriptor( - namespace: '${map['namespace']}', - name: '${map['name']}', - description: '${map['description']}', - kind: AgentIntentKind.values.byName('${map['kind']}'), - inputSchema: Map.from( - (map['inputSchema'] as Map?)?.cast() ?? - const {'type': 'object'}, - ), - resourceUri: map['resourceUri']?.toString(), - ), - projection: projection, - ); - }).toList(); + final map = decoded.cast(); + final entriesRaw = map['entries']; + final entitiesRaw = map['entityTypeDescriptors']; + return _CatalogBundle( + entries: entriesRaw is List + ? _parseCatalogEntries(entriesRaw) + : const [], + entityTypeDescriptors: entitiesRaw is List + ? _parseEntityTypeDescriptors(entitiesRaw) + : const [], + ); } + List _parseCatalogEntries(final List decoded) => + decoded.map((final row) { + final map = (row as Map).cast(); + EntryProjection? projection; + final projectionRaw = map['projection']; + if (projectionRaw is Map) { + projection = _projectionFromJson( + projectionRaw.cast(), + ); + } + return AgentRegistryCatalogEntry( + registryKey: '${map['registryKey']}', + descriptor: AgentIntentDescriptor( + namespace: '${map['namespace']}', + name: '${map['name']}', + description: '${map['description']}', + kind: AgentIntentKind.values.byName('${map['kind']}'), + inputSchema: Map.from( + (map['inputSchema'] as Map?)?.cast() ?? + const {'type': 'object'}, + ), + resourceUri: map['resourceUri']?.toString(), + ), + projection: projection, + ); + }).toList(); + + List _parseEntityTypeDescriptors( + final List decoded, + ) => decoded.map((final row) { + final map = (row as Map).cast(); + final propertiesRaw = map['properties']; + final properties = propertiesRaw is List + ? propertiesRaw.map((final property) { + final propertyMap = (property as Map).cast(); + final privacyName = propertyMap['privacy']?.toString(); + final roleName = propertyMap['role']?.toString(); + return AgentEntityPropertyDescriptor( + name: '${propertyMap['name']}', + valueType: AgentEntityPropertyValueType.values.byName( + '${propertyMap['valueType']}', + ), + description: '${propertyMap['description'] ?? ''}', + isDisplay: propertyMap['isDisplay'] as bool? ?? false, + isSearchable: propertyMap['isSearchable'] as bool? ?? false, + isIndexed: propertyMap['isIndexed'] as bool? ?? false, + privacy: privacyName == null + ? null + : AgentEntityPrivacy.values.byName(privacyName), + role: roleName == null + ? AgentEntityPropertyRole.none + : AgentEntityPropertyRole.values.byName(roleName), + ); + }).toList() + : const []; + return AgentEntityTypeDescriptor( + namespace: '${map['namespace']}', + name: '${map['name']}', + identifierName: '${map['identifierName']}', + displayName: map['displayName']?.toString(), + properties: properties, + privacy: AgentEntityPrivacy.values.byName('${map['privacy']}'), + deepLinkBehavior: AgentEntityDeepLinkBehavior.values.byName( + '${map['deepLinkBehavior']}', + ), + openBehavior: AgentEntityOpenBehavior.values.byName( + '${map['openBehavior']}', + ), + ); + }).toList(); + EntryProjection _projectionFromJson(final Map json) { final dispatchName = json['dispatchMode']?.toString(); final dispatchMode = dispatchName == null @@ -144,6 +260,16 @@ Map _projectionJson(final EntryProjection projection) { } } +final class _CatalogBundle { + const _CatalogBundle({ + required this.entries, + required this.entityTypeDescriptors, + }); + + final List entries; + final List entityTypeDescriptors; +} + /// Thrown when the generated catalog cannot be loaded. final class CatalogLoadException implements Exception { CatalogLoadException(this.message); diff --git a/packages/intentcall_cli/lib/src/command_runner.dart b/packages/intentcall_cli/lib/src/command_runner.dart index 0e206b8..5743ecd 100644 --- a/packages/intentcall_cli/lib/src/command_runner.dart +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -263,10 +263,18 @@ final class _ManifestExportCommand extends Command { const exporter = ManifestExporter(); final context = exporter.loadExportContext(projectRoot: projectRoot); - final catalog = await const CatalogLoader().load(projectRoot: projectRoot); + final catalogLoader = const CatalogLoader(); + final catalog = await catalogLoader.load(projectRoot: projectRoot); + final entityTypeDescriptors = await catalogLoader.loadEntityTypeDescriptors( + projectRoot: projectRoot, + ); final encoded = exporter.encodeManifest( - exporter.buildManifest(catalog: catalog, context: context), + exporter.buildManifest( + catalog: catalog, + context: context, + entityTypeDescriptors: entityTypeDescriptors, + ), ); if (checkOnly) { diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart index fa7df58..8b881a9 100644 --- a/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart @@ -7,3 +7,6 @@ final List agentCatalogEntries = [ AgentRegistryCatalogEntry(registryKey: 'app_demo_ping', entry: demoPingCallEntry), ]; + +final List agentEntityTypeDescriptors = + []; diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json index 23c6af2..603fdc4 100644 --- a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json @@ -10,9 +10,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, diff --git a/packages/intentcall_cli/test/fixtures/entity_catalog_project/lib/generated/agent_catalog.g.dart b/packages/intentcall_cli/test/fixtures/entity_catalog_project/lib/generated/agent_catalog.g.dart new file mode 100644 index 0000000..eea1bc8 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/entity_catalog_project/lib/generated/agent_catalog.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +abstract final class AppProjectEntityFields { + static const String name = 'name'; + static const String summary = 'summary'; +} + +final List agentCatalogEntries = + []; + +final List agentEntityTypeDescriptors = + [ + AgentEntityTypeDescriptor( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + displayName: 'Project', + properties: [ + AgentEntityPropertyDescriptor( + name: 'name', + valueType: AgentEntityPropertyValueType.string, + description: 'Display name', + isDisplay: true, + role: AgentEntityPropertyRole.title, + ), + AgentEntityPropertyDescriptor( + name: 'summary', + valueType: AgentEntityPropertyValueType.string, + description: 'Searchable summary', + isSearchable: true, + role: AgentEntityPropertyRole.subtitle, + ), + ], + privacy: AgentEntityPrivacy.private, + deepLinkBehavior: AgentEntityDeepLinkBehavior.unsupported, + openBehavior: AgentEntityOpenBehavior.unsupported, + ), + ]; diff --git a/packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.lock b/packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.lock new file mode 100644 index 0000000..2bc4e80 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.lock @@ -0,0 +1,90 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + intentcall_core: + dependency: "direct main" + description: + path: "../../../../intentcall_core" + relative: true + source: path + version: "0.6.0" + intentcall_platform_sync: + dependency: "direct main" + description: + path: "../../../../intentcall_platform_sync" + relative: true + source: path + version: "0.6.0" + intentcall_schema: + dependency: "direct overridden" + description: + path: "../../../../intentcall_schema" + relative: true + source: path + version: "0.6.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" diff --git a/packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.yaml b/packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.yaml new file mode 100644 index 0000000..af4774a --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/entity_catalog_project/pubspec.yaml @@ -0,0 +1,20 @@ +name: entity_catalog_project +description: Layer 4 fixture — catalog entity descriptor export. +publish_to: none + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync + +dependency_overrides: + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync + intentcall_schema: + path: ../../../../intentcall_schema diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart b/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart index 99126de..eafdf70 100644 --- a/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart @@ -7,3 +7,6 @@ final List agentCatalogEntries = [ AgentRegistryCatalogEntry(registryKey: 'app_cart_total', entry: cartTotalCallEntry), ]; + +final List agentEntityTypeDescriptors = + []; diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json index 1fad67c..cfefe1a 100644 --- a/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json @@ -10,11 +10,38 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, + "apple.appShortcuts": { + "include": false + }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, + "android.shortcuts": { + "include": false + }, "web.manifestShortcuts": { "include": true }, + "web.protocolHandlers": { + "include": true + }, "web.webMcp": { "include": true + }, + "windows.protocolActivation": { + "include": false + }, + "windows.msixProtocol": { + "include": false + }, + "linux.schemeHandler": { + "include": false } }, "inputSchema": { diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart index f9a2c3b..2c0d202 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart @@ -7,3 +7,6 @@ final List agentCatalogEntries = [ AgentRegistryCatalogEntry(registryKey: 'shop_search', entry: searchCallEntry), ]; + +final List agentEntityTypeDescriptors = + []; diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json index ebe68fd..3fac999 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json @@ -10,9 +10,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, diff --git a/packages/intentcall_cli/test/manifest_entity_export_test.dart b/packages/intentcall_cli/test/manifest_entity_export_test.dart new file mode 100644 index 0000000..c760a6d --- /dev/null +++ b/packages/intentcall_cli/test/manifest_entity_export_test.dart @@ -0,0 +1,106 @@ +import 'package:intentcall_cli/src/catalog/catalog_loader.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:test/test.dart'; + +import 'manifest_registry_parity_test.dart'; + +void main() { + test('mergeManifest includes entityTypes from catalog descriptors', () { + const merger = ManifestMerger(); + final manifest = merger.mergeManifest( + catalog: const [], + entityTypeDescriptors: [ + AgentEntityTypeDescriptor( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + displayName: 'Project', + properties: [ + AgentEntityPropertyDescriptor( + name: 'name', + valueType: AgentEntityPropertyValueType.string, + isDisplay: true, + ), + AgentEntityPropertyDescriptor( + name: 'summary', + valueType: AgentEntityPropertyValueType.string, + isSearchable: true, + ), + ], + ), + ], + policy: const ProjectionPolicy(), + ); + + expect(manifest.entityTypes, hasLength(1)); + expect(manifest.entityTypes.single.qualifiedName, 'app_project'); + expect(manifest.entityTypes.single.titleKey, 'name'); + expect(manifest.entityTypes.single.subtitleKey, 'summary'); + expect(manifest.entityTypes.single.keywordsKey, 'keywords'); + expect(manifest.entityTypes.single.snapshotSchema, { + 'type': 'object', + 'required': ['projectId'], + 'properties': { + 'projectId': {'type': 'string'}, + 'name': { + 'type': 'string', + 'x-intentcall-display': true, + }, + 'summary': { + 'type': 'string', + 'x-intentcall-searchable': true, + }, + }, + }); + }); + + test( + 'catalog loader reads entity descriptors from fixture catalog', + () async { + final projectRoot = fixtureRoot('entity_catalog_project'); + final descriptors = await const CatalogLoader().loadEntityTypeDescriptors( + projectRoot: projectRoot, + ); + + expect(descriptors, hasLength(1)); + expect(descriptors.single.qualifiedName, 'app_project'); + expect(descriptors.single.identifierName, 'projectId'); + expect(descriptors.single.displayProperties.map((final p) => p.name), [ + 'name', + 'summary', + ]); + expect( + descriptors.single.properties + .singleWhere((final p) => p.role == AgentEntityPropertyRole.title) + .name, + 'name', + ); + }, + ); + + test('manifest export includes entityTypes from fixture catalog', () async { + final projectRoot = fixtureRoot('entity_catalog_project'); + const exporter = ManifestExporter(); + final context = exporter.loadExportContext(projectRoot: projectRoot); + final catalog = await const CatalogLoader().load(projectRoot: projectRoot); + final entityTypeDescriptors = await const CatalogLoader() + .loadEntityTypeDescriptors(projectRoot: projectRoot); + + final manifest = exporter.buildManifest( + catalog: catalog, + context: context, + entityTypeDescriptors: entityTypeDescriptors, + ); + + expect(manifest.entityTypes, hasLength(1)); + expect(manifest.entityTypes.single.qualifiedName, 'app_project'); + expect(manifest.entityTypes.single.titleKey, 'name'); + expect(manifest.entityTypes.single.subtitleKey, 'summary'); + expect( + (manifest.entityTypes.single.snapshotSchema['properties']! + as Map)['name']['x-intentcall-role'], + 'title', + ); + }); +} diff --git a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart index cb53c24..1f525bd 100644 --- a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -6,15 +6,36 @@ import '../tools/demo_ping_tool.dart'; final List agentCatalogEntries = [ - AgentRegistryCatalogEntry(registryKey: 'app_demo_host_status', entry: DemoHostTools.shared.demoHostStatusCallEntry, projection: EntryProjection( - dispatchMode: AgentManifestDispatchMode.openApp, - surfaces: {AgentManifestSurface.webMcp: true}, -)), - AgentRegistryCatalogEntry(registryKey: 'app_demo_ping', entry: demoPingCallEntry), - AgentRegistryCatalogEntry(registryKey: 'app_demo_cart', entry: demoCartCallEntry, projection: EntryProjection( - dispatchMode: AgentManifestDispatchMode.openApp, - surfaces: {AgentManifestSurface.webMcp: false}, -)), - AgentRegistryCatalogEntry(registryKey: 'app_demo_required_named', entry: demoRequiredNamedCallEntry), - ...DemoHostTools.demoHostCatalogEntries, -]; + AgentRegistryCatalogEntry( + registryKey: 'app_demo_host_status', + entry: DemoHostTools.shared.demoHostStatusCallEntry, + projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: { + AgentManifestSurface.webMcp: true, + }, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_demo_ping', + entry: demoPingCallEntry, + ), + AgentRegistryCatalogEntry( + registryKey: 'app_demo_cart', + entry: demoCartCallEntry, + projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: { + AgentManifestSurface.webMcp: false, + }, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_demo_required_named', + entry: demoRequiredNamedCallEntry, + ), + ...DemoHostTools.demoHostCatalogEntries, + ]; + +final List agentEntityTypeDescriptors = + []; diff --git a/packages/intentcall_codegen/example/web/agent_manifest.json b/packages/intentcall_codegen/example/web/agent_manifest.json index 9d23e90..ae9c707 100644 --- a/packages/intentcall_codegen/example/web/agent_manifest.json +++ b/packages/intentcall_codegen/example/web/agent_manifest.json @@ -10,9 +10,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, @@ -56,9 +65,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, @@ -102,9 +120,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, @@ -152,9 +179,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, @@ -198,9 +234,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, @@ -244,9 +289,18 @@ "kind": "tool", "dispatchMode": "openApp", "surfaces": { + "apple.appIntents": { + "include": false + }, "apple.appShortcuts": { "include": false }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, "android.shortcuts": { "include": false }, diff --git a/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js index c8d74a7..cd9b0f7 100644 --- a/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js @@ -16,38 +16,34 @@ var invokePath = "/agent/invoke"; var tools = [ { - "name": "app_demo_ping", - "description": "Returns pong for a message", + "name": "app_demo_host_status", + "description": "Codegen instance-method host tool", "inputSchema": { "type": "object", "properties": { - "message": { + "label": { "type": "string", - "description": "Message to echo" + "description": "Host label" } }, "required": [ - "message" + "label" ] } }, { - "name": "app_demo_cart", - "description": "Returns a cart total", + "name": "app_demo_ping", + "description": "Returns pong for a message", "inputSchema": { "type": "object", "properties": { - "currency": { + "message": { "type": "string", - "description": "Currency code" - }, - "includeTax": { - "type": "boolean", - "description": "Include tax" + "description": "Message to echo" } }, "required": [ - "currency" + "message" ] } }, @@ -66,6 +62,38 @@ "mode" ] } + }, + { + "name": "app_demo_inbox", + "description": "Read inbox folder", + "inputSchema": { + "type": "object", + "properties": { + "folder": { + "type": "string", + "description": "Inbox folder name" + } + }, + "required": [ + "folder" + ] + } + }, + { + "name": "app_demo_handwritten", + "description": "Handwritten instance-bound tool", + "inputSchema": { + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "Note to echo" + } + }, + "required": [ + "note" + ] + } } ]; diff --git a/packages/intentcall_codegen/example/web/manifest.json b/packages/intentcall_codegen/example/web/manifest.json index 9f16cc4..b737a8c 100644 --- a/packages/intentcall_codegen/example/web/manifest.json +++ b/packages/intentcall_codegen/example/web/manifest.json @@ -1,42 +1,72 @@ { - "name": "intentcall_codegen_example", - "start_url": ".", - "shortcuts": [ - { - "name": "Demo Ping", - "short_name": "Demo Ping", - "description": "Returns pong for a message", - "url": "/agent/invoke?name=app_demo_ping" - }, - { - "name": "Demo Cart", - "short_name": "Demo Cart", - "description": "Returns a cart total", - "url": "/agent/invoke?name=app_demo_cart" - }, - { - "name": "Demo Required Named", - "short_name": "Demo Required Named", - "description": "Returns a required named parameter", - "url": "/agent/invoke?name=app_demo_required_named" - } - ], - "protocol_handlers": [ - { - "protocol": "web+intentcall", - "url": "/agent/invoke?protocol=%s" - }, - { - "protocol": "web+intentcall", - "url": "/agent/invoke?name=app_demo_ping&payload=%s" - }, - { - "protocol": "web+intentcall", - "url": "/agent/invoke?name=app_demo_cart&payload=%s" - }, - { - "protocol": "web+intentcall", - "url": "/agent/invoke?name=app_demo_required_named&payload=%s" - } - ] -} \ No newline at end of file + "name": "intentcall_codegen_example", + "start_url": ".", + "shortcuts": [ + { + "name": "Demo Host Status", + "short_name": "Demo Host Status", + "description": "Codegen instance-method host tool", + "url": "/agent/invoke?name=app_demo_host_status" + }, + { + "name": "Demo Ping", + "short_name": "Demo Ping", + "description": "Returns pong for a message", + "url": "/agent/invoke?name=app_demo_ping" + }, + { + "name": "Demo Cart", + "short_name": "Demo Cart", + "description": "Returns a cart total", + "url": "/agent/invoke?name=app_demo_cart" + }, + { + "name": "Demo Required Named", + "short_name": "Demo Required Named", + "description": "Returns a required named parameter", + "url": "/agent/invoke?name=app_demo_required_named" + }, + { + "name": "Demo Inbox", + "short_name": "Demo Inbox", + "description": "Read inbox folder", + "url": "/agent/invoke?name=app_demo_inbox" + }, + { + "name": "Demo Handwritten", + "short_name": "Demo Handwritten", + "description": "Handwritten instance-bound tool", + "url": "/agent/invoke?name=app_demo_handwritten" + } + ], + "protocol_handlers": [ + { + "protocol": "web+intentcall", + "url": "/agent/invoke?protocol=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_host_status&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_ping&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_cart&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_required_named&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_inbox&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_handwritten&payload=%s" + } + ] +} diff --git a/packages/intentcall_codegen/lib/intentcall_codegen.dart b/packages/intentcall_codegen/lib/intentcall_codegen.dart index 934cd7f..9134d0f 100644 --- a/packages/intentcall_codegen/lib/intentcall_codegen.dart +++ b/packages/intentcall_codegen/lib/intentcall_codegen.dart @@ -1,4 +1,6 @@ export 'src/agent_catalog.dart'; +export 'src/agent_entity.dart'; +export 'src/agent_entity_snapshot_builder.dart'; export 'src/agent_param.dart'; export 'src/agent_projection.dart'; export 'src/agent_tool.dart'; diff --git a/packages/intentcall_codegen/lib/src/agent_entity.dart b/packages/intentcall_codegen/lib/src/agent_entity.dart new file mode 100644 index 0000000..a6403f4 --- /dev/null +++ b/packages/intentcall_codegen/lib/src/agent_entity.dart @@ -0,0 +1,64 @@ +/// Declares one property on an [@AgentEntity] type for catalog projection. +class AgentEntityProperty { + const AgentEntityProperty({ + required this.name, + this.valueType = 'string', + this.description = '', + this.isDisplay = false, + this.isSearchable = false, + this.isIndexed = false, + this.role = 'none', + }); + + final String name; + final String valueType; + final String description; + final bool isDisplay; + final bool isSearchable; + final bool isIndexed; + + /// Semantic snapshot role: `none`, `title`, `subtitle`, or `keywords`. + /// + /// Entity-level [AgentEntity.titleProperty] / [subtitleProperty] / + /// [keywordsProperty] overrides win when they name this property. + final String role; +} + +/// Marks a class declaring an app entity type for catalog + manifest export. +/// +/// [AgentCatalogGenerator] discovers annotated classes under `lib/` and emits +/// descriptor rows into `lib/generated/agent_catalog.g.dart`. +class AgentEntity { + const AgentEntity({ + required this.namespace, + required this.name, + required this.identifierName, + this.displayName, + this.properties = const [], + this.titleProperty, + this.subtitleProperty, + this.keywordsProperty, + this.privacy = 'private', + this.deepLinkBehavior = 'unsupported', + this.openBehavior = 'unsupported', + }); + + final String namespace; + final String name; + final String identifierName; + final String? displayName; + final List properties; + + /// Assigns [AgentEntityPropertyRole.title] to the named property in codegen. + final String? titleProperty; + + /// Assigns [AgentEntityPropertyRole.subtitle] to the named property in codegen. + final String? subtitleProperty; + + /// Assigns [AgentEntityPropertyRole.keywords] to the named property in codegen. + final String? keywordsProperty; + + final String privacy; + final String deepLinkBehavior; + final String openBehavior; +} diff --git a/packages/intentcall_codegen/lib/src/agent_entity_snapshot_builder.dart b/packages/intentcall_codegen/lib/src/agent_entity_snapshot_builder.dart new file mode 100644 index 0000000..e9f1a80 --- /dev/null +++ b/packages/intentcall_codegen/lib/src/agent_entity_snapshot_builder.dart @@ -0,0 +1,33 @@ +import 'package:intentcall_core/intentcall_core.dart'; + +/// Builds entity snapshot property maps using [AgentEntitySnapshotKeys]. +/// +/// Prefer generated `{Namespace}{Name}EntityFields` constants for property +/// names; this helper maps values onto the resolved snapshot keys from the +/// catalog descriptor. +final class AgentEntitySnapshotBuilder { + AgentEntitySnapshotBuilder(this.descriptor) + : keys = AgentEntitySnapshotKeys.fromDescriptor(descriptor); + + final AgentEntityTypeDescriptor descriptor; + final AgentEntitySnapshotKeys keys; + + /// Returns a property map keyed by descriptor field names. + /// + /// Pass [values] keyed by property name (for example + /// `AppProjectEntityFields.name`). The identifier is written under + /// [AgentEntitySnapshotKeys.idKey]. + Map buildProperties({ + required final String identifier, + required final Map values, + }) { + final row = {keys.idKey: identifier}; + for (final property in descriptor.properties) { + if (!values.containsKey(property.name)) { + continue; + } + row[property.name] = values[property.name]; + } + return row; + } +} diff --git a/packages/intentcall_codegen/lib/src/agent_projection.dart b/packages/intentcall_codegen/lib/src/agent_projection.dart index 622b702..5275596 100644 --- a/packages/intentcall_codegen/lib/src/agent_projection.dart +++ b/packages/intentcall_codegen/lib/src/agent_projection.dart @@ -12,8 +12,9 @@ class AgentProjection { /// Per-surface inclusion overrides using typed [AgentManifestSurface] keys. /// - /// Sub-channel hints (for example Apple Siri vs Spotlight) use - /// [AgentManifestSurfaceExposure.options] on handwritten [EntryProjection] - /// rows until emitters support them. + /// Apple sub-channels: [AgentManifestSurface.appleAppIntents], + /// [AgentManifestSurface.appleAppShortcuts], + /// [AgentManifestSurface.appleSpotlight], and + /// [AgentManifestSurface.appleEntities]. final Map surfaces; } diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart index a25f4c6..d974b14 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -4,10 +4,12 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:build/build.dart'; import 'package:glob/glob.dart'; +import 'package:intentcall_core/intentcall_core.dart'; import 'package:path/path.dart' as p; import 'package:source_gen/source_gen.dart'; import '../agent_catalog.dart'; +import '../agent_entity.dart'; import '../agent_projection.dart'; import '../agent_tool.dart'; import 'agent_tool_generator.dart'; @@ -30,6 +32,7 @@ class AgentCatalogGenerator implements Builder { final BuilderOptions options; static const _toolChecker = TypeChecker.typeNamed(AgentTool); + static const _entityChecker = TypeChecker.typeNamed(AgentEntity); static const _projectionChecker = TypeChecker.typeNamed(AgentProjection); static const _agentCatalogChecker = TypeChecker.typeNamed(AgentCatalog); static const _defaultHostBindingField = 'shared'; @@ -103,6 +106,10 @@ class AgentCatalogGenerator implements Builder { buildStep, outputPath, ); + final entityCodegenRows = await _discoverAgentEntityDescriptors( + buildStep, + outputPath, + ); final seenAgentCatalogSymbols = {}; for (final catalogList in agentCatalogLists) { if (!seenAgentCatalogSymbols.add(catalogList.symbolName)) { @@ -135,12 +142,17 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; ${imports.join('\n')} '''; - if (entries.isEmpty && agentCatalogLists.isEmpty) { + if (entries.isEmpty && + agentCatalogLists.isEmpty && + entityCodegenRows.isEmpty) { await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header /// Empty catalog — add @AgentTool functions under lib/. final List agentCatalogEntries = []; + +final List agentEntityTypeDescriptors = + []; '''); return; } @@ -151,12 +163,29 @@ final List agentCatalogEntries = .map((final list) => list.symbolName) .toList(), ); + final entityFieldClasses = entityCodegenRows + .map((final row) => row.fieldsClass) + .join('\n\n'); + final entityDescriptorRows = entityCodegenRows + .map((final row) => row.descriptorLiteral) + .join('\n'); + final entityBlock = entityCodegenRows.isEmpty + ? 'final List agentEntityTypeDescriptors =\n [];' + : ''' +$entityFieldClasses + +final List agentEntityTypeDescriptors = + [ +$entityDescriptorRows +];'''; await buildStep.writeAsString(buildStep.allowedOutputs.single, ''' $header final List agentCatalogEntries = [ $catalogRows ]; + +$entityBlock '''); } @@ -719,7 +748,10 @@ EntryProjection( 'web.webMcp': 'webMcp', 'web.manifestShortcuts': 'webManifestShortcuts', 'web.protocolHandlers': 'webProtocolHandlers', + 'apple.appIntents': 'appleAppIntents', 'apple.appShortcuts': 'appleAppShortcuts', + 'apple.spotlight': 'appleSpotlight', + 'apple.entities': 'appleEntities', 'android.shortcuts': 'androidShortcuts', 'windows.protocolActivation': 'windowsProtocolActivation', 'windows.msixProtocol': 'windowsMsixProtocol', @@ -742,6 +774,325 @@ EntryProjection( }); return first + rest.join(); } + + Future> _discoverAgentEntityDescriptors( + final BuildStep buildStep, + final String outputPath, + ) async { + final descriptors = <_EntityCodegenRow>[]; + final seenQualifiedNames = {}; + for (final pattern in _toolGlobs) { + await for (final input in buildStep.findAssets(Glob(pattern))) { + if (input.path.endsWith('.g.dart')) { + continue; + } + if (_isExcluded(input.path) || _isInternalSource(input.path)) { + continue; + } + final library = await buildStep.resolver.libraryFor(input); + for (final type in library.classes) { + final entityReader = _entityAnnotationReader(type); + if (entityReader == null) { + continue; + } + final qualifiedName = + '${entityReader.read('namespace').stringValue}_' + '${entityReader.read('name').stringValue}'; + if (!seenQualifiedNames.add(qualifiedName)) { + throw InvalidGenerationSourceError( + "Duplicate entity qualifiedName '$qualifiedName' in agent catalog.", + element: type, + ); + } + final resolvedProperties = _resolveEntityProperties( + entityReader: entityReader, + element: type, + ); + descriptors.add( + _EntityCodegenRow( + fieldsClass: _entityFieldsClassLiteral( + namespace: entityReader.read('namespace').stringValue, + name: entityReader.read('name').stringValue, + properties: resolvedProperties, + ), + descriptorLiteral: + ' ${_entityDescriptorLiteral(entityReader, resolvedProperties)},', + ), + ); + } + } + } + return descriptors; + } + + ConstantReader? _entityAnnotationReader(final ClassElement type) { + for (final annotation in type.metadata.annotations) { + final reader = ConstantReader(annotation.computeConstantValue()); + if (!reader.isNull && reader.instanceOf(_entityChecker)) { + return reader; + } + } + return null; + } + + String _entityDescriptorLiteral( + final ConstantReader reader, + final List<_ResolvedEntityProperty> properties, + ) { + final propertyLiterals = properties + .map(_entityPropertyLiteral) + .join(',\n '); + final displayName = reader.read('displayName'); + final displayNameLiteral = displayName.isNull + ? null + : _literalString(displayName.stringValue); + return '''AgentEntityTypeDescriptor( + namespace: ${_literalString(reader.read('namespace').stringValue)}, + name: ${_literalString(reader.read('name').stringValue)}, + identifierName: ${_literalString(reader.read('identifierName').stringValue)}, + ${displayNameLiteral == null ? '' : 'displayName: $displayNameLiteral,\n '}properties: [ + $propertyLiterals + ], + privacy: AgentEntityPrivacy.${reader.read('privacy').stringValue}, + deepLinkBehavior: AgentEntityDeepLinkBehavior.${reader.read('deepLinkBehavior').stringValue}, + openBehavior: AgentEntityOpenBehavior.${reader.read('openBehavior').stringValue}, + )'''; + } + + String _entityFieldsClassLiteral({ + required final String namespace, + required final String name, + required final List<_ResolvedEntityProperty> properties, + }) { + final className = _entityFieldsClassName(namespace: namespace, name: name); + final fields = properties + .map( + (final property) => + " static const String ${property.reader.read('name').stringValue} = " + "${_literalString(property.reader.read('name').stringValue)};", + ) + .join('\n'); + return '''abstract final class $className { +$fields +}'''; + } + + String _entityPropertyLiteral(final _ResolvedEntityProperty property) { + final reader = property.reader; + return '''AgentEntityPropertyDescriptor( + name: ${_literalString(reader.read('name').stringValue)}, + valueType: AgentEntityPropertyValueType.${reader.read('valueType').stringValue}, + description: ${_literalString(reader.read('description').stringValue)}, + isDisplay: ${reader.read('isDisplay').boolValue}, + isSearchable: ${reader.read('isSearchable').boolValue}, + isIndexed: ${reader.read('isIndexed').boolValue}, + role: AgentEntityPropertyRole.${property.role.name}, + )'''; + } + + List<_ResolvedEntityProperty> _resolveEntityProperties({ + required final ConstantReader entityReader, + required final Element element, + }) { + final titleProperty = _optionalAnnotationString( + entityReader, + 'titleProperty', + ); + final subtitleProperty = _optionalAnnotationString( + entityReader, + 'subtitleProperty', + ); + final keywordsProperty = _optionalAnnotationString( + entityReader, + 'keywordsProperty', + ); + final propertyReaders = entityReader + .read('properties') + .listValue + .map(ConstantReader.new) + .toList(); + final propertyNames = propertyReaders + .map((final reader) => reader.read('name').stringValue) + .toSet(); + + for (final entry in <(String?, String)>[ + (titleProperty, 'titleProperty'), + (subtitleProperty, 'subtitleProperty'), + (keywordsProperty, 'keywordsProperty'), + ]) { + final overrideName = entry.$1; + final label = entry.$2; + if (overrideName != null && !propertyNames.contains(overrideName)) { + throw InvalidGenerationSourceError( + '@AgentEntity $label must match a declared property name, ' + "got '$overrideName'.", + element: element, + ); + } + } + + final resolved = <_ResolvedEntityProperty>[]; + final roleCounts = {}; + + for (final reader in propertyReaders) { + final propertyName = reader.read('name').stringValue; + final explicitRole = _parseEntityPropertyRole( + reader.read('role').stringValue, + element: element, + ); + final role = _resolveEntityPropertyRole( + propertyName: propertyName, + explicitRole: explicitRole, + titleProperty: titleProperty, + subtitleProperty: subtitleProperty, + keywordsProperty: keywordsProperty, + ); + final isDisplay = reader.read('isDisplay').boolValue; + final isSearchable = reader.read('isSearchable').boolValue; + final valueType = reader.read('valueType').stringValue; + + _warnRoleDisplayConflicts( + propertyName: propertyName, + role: role, + isDisplay: isDisplay, + isSearchable: isSearchable, + ); + + if (role == AgentEntityPropertyRole.keywords && valueType != 'array') { + throw InvalidGenerationSourceError( + "@AgentEntity property '$propertyName' has role 'keywords' but " + "valueType '$valueType'; keywords requires valueType 'array'.", + element: element, + ); + } + + if (role != AgentEntityPropertyRole.none) { + roleCounts[role] = (roleCounts[role] ?? 0) + 1; + } + + resolved.add(_ResolvedEntityProperty(reader: reader, role: role)); + } + + for (final role in [ + AgentEntityPropertyRole.title, + AgentEntityPropertyRole.subtitle, + AgentEntityPropertyRole.keywords, + ]) { + final count = roleCounts[role] ?? 0; + if (count > 1) { + throw InvalidGenerationSourceError( + '@AgentEntity declares more than one property with role ' + "'${role.name}'.", + element: element, + ); + } + } + + return resolved; + } + + AgentEntityPropertyRole _resolveEntityPropertyRole({ + required final String propertyName, + required final AgentEntityPropertyRole explicitRole, + required final String? titleProperty, + required final String? subtitleProperty, + required final String? keywordsProperty, + }) { + if (titleProperty == propertyName) { + return AgentEntityPropertyRole.title; + } + if (subtitleProperty == propertyName) { + return AgentEntityPropertyRole.subtitle; + } + if (keywordsProperty == propertyName) { + return AgentEntityPropertyRole.keywords; + } + return explicitRole; + } + + void _warnRoleDisplayConflicts({ + required final String propertyName, + required final AgentEntityPropertyRole role, + required final bool isDisplay, + required final bool isSearchable, + }) { + if (role == AgentEntityPropertyRole.title && !isDisplay) { + log.warning( + "@AgentEntity property '$propertyName' has role 'title' but " + 'isDisplay is false; role wins in generated descriptor.', + ); + } + if (role == AgentEntityPropertyRole.subtitle && !isSearchable) { + log.warning( + "@AgentEntity property '$propertyName' has role 'subtitle' but " + 'isSearchable is false; role wins in generated descriptor.', + ); + } + if (role == AgentEntityPropertyRole.keywords && !isSearchable) { + log.warning( + "@AgentEntity property '$propertyName' has role 'keywords' but " + 'isSearchable is false; role wins in generated descriptor.', + ); + } + if (isDisplay && + role != AgentEntityPropertyRole.none && + role != AgentEntityPropertyRole.title) { + log.warning( + "@AgentEntity property '$propertyName' sets isDisplay but role is " + "'${role.name}'; role wins in generated descriptor.", + ); + } + if (isSearchable && + role != AgentEntityPropertyRole.none && + role != AgentEntityPropertyRole.subtitle && + role != AgentEntityPropertyRole.keywords) { + log.warning( + "@AgentEntity property '$propertyName' sets isSearchable but role is " + "'${role.name}'; role wins in generated descriptor.", + ); + } + } + + AgentEntityPropertyRole _parseEntityPropertyRole( + final String rawRole, { + required final Element element, + }) => switch (rawRole) { + 'none' => AgentEntityPropertyRole.none, + 'title' => AgentEntityPropertyRole.title, + 'subtitle' => AgentEntityPropertyRole.subtitle, + 'keywords' => AgentEntityPropertyRole.keywords, + _ => throw InvalidGenerationSourceError( + '@AgentEntityProperty role must be one of none, title, subtitle, ' + "keywords; got '$rawRole'.", + element: element, + ), + }; + + String? _optionalAnnotationString( + final ConstantReader reader, + final String fieldName, + ) { + final value = reader.read(fieldName); + return value.isNull ? null : value.stringValue; + } + + String _entityFieldsClassName({ + required final String namespace, + required final String name, + }) => '${_toPascalCase(namespace)}${_toPascalCase(name)}EntityFields'; + + String _toPascalCase(final String value) { + if (value.isEmpty) { + return value; + } + final parts = value.split(RegExp('[._]')); + return parts.map((final part) { + if (part.isEmpty) { + return part; + } + return part[0].toUpperCase() + part.substring(1); + }).join(); + } } final class _CatalogSpread { @@ -772,3 +1123,20 @@ final class _StaticCatalogSymbol extends _CatalogSymbolName { final String className; final String fieldName; } + +final class _EntityCodegenRow { + const _EntityCodegenRow({ + required this.fieldsClass, + required this.descriptorLiteral, + }); + + final String fieldsClass; + final String descriptorLiteral; +} + +final class _ResolvedEntityProperty { + const _ResolvedEntityProperty({required this.reader, required this.role}); + + final ConstantReader reader; + final AgentEntityPropertyRole role; +} diff --git a/packages/intentcall_codegen/test/agent_catalog_build_test.dart b/packages/intentcall_codegen/test/agent_catalog_build_test.dart index 4435d64..aa613be 100644 --- a/packages/intentcall_codegen/test/agent_catalog_build_test.dart +++ b/packages/intentcall_codegen/test/agent_catalog_build_test.dart @@ -5,13 +5,26 @@ import 'package:build_test/build_test.dart'; import 'package:glob/glob.dart'; import 'package:intentcall_codegen/src/generators/agent_catalog_generator.dart'; import 'package:package_config/package_config.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; const _package = 'intentcall_codegen'; const _trigger = '$_package|lib/\$lib\$'; const _output = '$_package|lib/generated/agent_catalog.g.dart'; -String _fixture(final String path) => File(path).readAsStringSync(); +String _fixture(final String path) { + final candidates = [ + path, + p.join('packages', 'intentcall_codegen', path), + ]; + for (final candidate in candidates) { + final file = File(candidate); + if (file.existsSync()) { + return file.readAsStringSync(); + } + } + throw FileSystemException('Fixture not found', path); +} BuilderOptions _options(final Map config) => BuilderOptions(config); @@ -146,6 +159,31 @@ void main() { ); }); + test('discovers @AgentEntity descriptor rows', () async { + await runCatalogBuilder( + generator: AgentCatalogGenerator( + _options({ + 'tool_globs': ['test/fixtures/catalog/**.dart'], + 'tool_exclude_globs': [], + }), + ), + fixtures: { + 'test/fixtures/catalog/agent_entity_annotated.dart': _fixture( + 'test/fixtures/catalog/agent_entity_annotated.dart', + ), + }, + outputMatcher: allOf( + contains('agentEntityTypeDescriptors'), + contains('abstract final class AppProjectEntityFields'), + contains("name: 'project'"), + contains('projectId'), + contains('AgentEntityPropertyValueType.string'), + contains('AgentEntityPropertyRole.title'), + contains('AgentEntityPropertyRole.subtitle'), + ), + ); + }); + test('unannotated catalog list omitted', () async { await runCatalogBuilder( generator: AgentCatalogGenerator(_options({})), diff --git a/packages/intentcall_codegen/test/agent_entity_snapshot_builder_test.dart b/packages/intentcall_codegen/test/agent_entity_snapshot_builder_test.dart new file mode 100644 index 0000000..2cc8ff7 --- /dev/null +++ b/packages/intentcall_codegen/test/agent_entity_snapshot_builder_test.dart @@ -0,0 +1,41 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('buildProperties maps values onto descriptor field names', () { + final descriptor = AgentEntityTypeDescriptor( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + properties: [ + AgentEntityPropertyDescriptor( + name: 'name', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.title, + ), + AgentEntityPropertyDescriptor( + name: 'summary', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.subtitle, + ), + ], + ); + final builder = AgentEntitySnapshotBuilder(descriptor); + final row = builder.buildProperties( + identifier: 'p-1', + values: { + 'name': 'Launch', + 'summary': 'Q3 launch', + 'ignored': 'not in descriptor', + }, + ); + + expect(row['projectId'], 'p-1'); + expect(row['name'], 'Launch'); + expect(row['summary'], 'Q3 launch'); + expect(row.containsKey('ignored'), isFalse); + expect(builder.keys.titleKey, 'name'); + expect(builder.keys.subtitleKey, 'summary'); + }); +} diff --git a/packages/intentcall_codegen/test/fixtures/catalog/agent_entity_annotated.dart b/packages/intentcall_codegen/test/fixtures/catalog/agent_entity_annotated.dart new file mode 100644 index 0000000..9c17cd5 --- /dev/null +++ b/packages/intentcall_codegen/test/fixtures/catalog/agent_entity_annotated.dart @@ -0,0 +1,25 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; + +@AgentEntity( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + displayName: 'Project', + properties: [ + AgentEntityProperty( + name: 'name', + valueType: 'string', + description: 'Display name', + isDisplay: true, + role: 'title', + ), + AgentEntityProperty( + name: 'summary', + valueType: 'string', + description: 'Searchable summary', + isSearchable: true, + role: 'subtitle', + ), + ], +) +final class AppProjectEntityDescriptor {} diff --git a/packages/intentcall_core/lib/intentcall_core.dart b/packages/intentcall_core/lib/intentcall_core.dart index 2ff7dfe..c5b4e5e 100644 --- a/packages/intentcall_core/lib/intentcall_core.dart +++ b/packages/intentcall_core/lib/intentcall_core.dart @@ -4,8 +4,11 @@ export 'src/adapter/agent_adapter.dart'; export 'src/authoring/agent_call_entry.dart'; export 'src/entity/agent_entity_index.dart'; export 'src/entity/agent_entity_property_descriptor.dart'; +export 'src/entity/agent_entity_property_role.dart'; export 'src/entity/agent_entity_provider.dart'; +export 'src/entity/agent_entity_snapshot_keys.dart'; export 'src/entity/agent_entity_snapshot_projection.dart'; +export 'src/entity/agent_entity_snapshot_schema.dart'; export 'src/entity/agent_entity_type_descriptor.dart'; export 'src/intent/agent_intent_descriptor.dart'; export 'src/intent/agent_intent_kind.dart'; diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart b/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart index 140d3a2..ab831ba 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import '../naming/qualified_name.dart'; +import 'agent_entity_property_role.dart'; enum AgentEntityPropertyValueType { string, @@ -23,6 +24,7 @@ final class AgentEntityPropertyDescriptor { this.isSearchable = false, this.isIndexed = false, this.privacy, + this.role = AgentEntityPropertyRole.none, }) { validateBareName(name); } @@ -34,4 +36,5 @@ final class AgentEntityPropertyDescriptor { final bool isSearchable; final bool isIndexed; final AgentEntityPrivacy? privacy; + final AgentEntityPropertyRole role; } diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart b/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart new file mode 100644 index 0000000..aeaef9e --- /dev/null +++ b/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart @@ -0,0 +1 @@ +enum AgentEntityPropertyRole { none, title, subtitle, keywords } diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart new file mode 100644 index 0000000..eb1f300 --- /dev/null +++ b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart @@ -0,0 +1,141 @@ +import 'agent_entity_property_descriptor.dart'; +import 'agent_entity_property_role.dart'; +import 'agent_entity_type_descriptor.dart'; + +final class AgentEntitySnapshotKeys { + const AgentEntitySnapshotKeys({ + required this.idKey, + required this.titleKey, + required this.subtitleKey, + required this.keywordsKey, + }); + + factory AgentEntitySnapshotKeys.fromDescriptor( + final AgentEntityTypeDescriptor descriptor, + ) { + _validateUniqueRoles(descriptor.properties); + + final titleKey = _propertyNameWithRole( + descriptor.properties, + AgentEntityPropertyRole.title, + ) ?? + _firstPropertyNameWhere( + descriptor.properties, + (final property) => property.isDisplay, + ) ?? + 'title'; + + final subtitleKey = _propertyNameWithRole( + descriptor.properties, + AgentEntityPropertyRole.subtitle, + ) ?? + _nthPropertyNameWhere( + descriptor.properties, + (final property) => property.isDisplay, + 1, + ) ?? + _firstOrNull( + descriptor.properties + .where( + (final property) => + property.isSearchable && property.name != titleKey, + ) + .map((final property) => property.name), + ) ?? + 'subtitle'; + + final keywordsKey = _propertyNameWithRole( + descriptor.properties, + AgentEntityPropertyRole.keywords, + ) ?? + _firstOrNull( + descriptor.properties + .where( + (final property) => + property.isSearchable && + property.valueType == AgentEntityPropertyValueType.array, + ) + .map((final property) => property.name), + ) ?? + 'keywords'; + + return AgentEntitySnapshotKeys( + idKey: descriptor.identifierName, + titleKey: titleKey, + subtitleKey: subtitleKey, + keywordsKey: keywordsKey, + ); + } + + final String idKey; + final String titleKey; + final String subtitleKey; + final String keywordsKey; +} + +void _validateUniqueRoles( + final Iterable properties, +) { + for (final role in [ + AgentEntityPropertyRole.title, + AgentEntityPropertyRole.subtitle, + AgentEntityPropertyRole.keywords, + ]) { + final matches = properties + .where((final property) => property.role == role) + .toList(); + if (matches.length > 1) { + throw ArgumentError( + 'Duplicate entity property role ${role.name}: ' + '${matches.map((final property) => property.name).join(', ')}', + ); + } + } +} + +String? _propertyNameWithRole( + final Iterable properties, + final AgentEntityPropertyRole role, +) { + for (final property in properties) { + if (property.role == role) { + return property.name; + } + } + return null; +} + +String? _firstPropertyNameWhere( + final Iterable properties, + final bool Function(AgentEntityPropertyDescriptor property) test, +) { + for (final property in properties) { + if (test(property)) { + return property.name; + } + } + return null; +} + +String? _nthPropertyNameWhere( + final Iterable properties, + final bool Function(AgentEntityPropertyDescriptor property) test, + final int index, +) { + var seen = 0; + for (final property in properties) { + if (!test(property)) { + continue; + } + if (seen == index) { + return property.name; + } + seen++; + } + return null; +} + +String? _firstOrNull(final Iterable values) { + final iterator = values.iterator; + return iterator.moveNext() ? iterator.current : null; +} diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart index 40e0093..c6a7f41 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart @@ -1,6 +1,6 @@ import 'package:intentcall_schema/intentcall_schema.dart'; -import 'agent_entity_property_descriptor.dart'; +import 'agent_entity_snapshot_keys.dart'; import 'agent_entity_type_descriptor.dart'; /// Projects an app-owned entity snapshot into a platform-neutral cache row. @@ -12,45 +12,23 @@ Map projectAgentEntitySnapshot( final AgentEntitySnapshot snapshot, final AgentEntityTypeDescriptor descriptor, ) { - final displayProperties = descriptor.displayProperties.toList(); - final searchableProperties = descriptor.searchableProperties.toList(); - final titleKey = displayProperties.isNotEmpty - ? displayProperties.first.name - : 'title'; - final subtitleKey = displayProperties.length > 1 - ? displayProperties[1].name - : _firstOrNull( - searchableProperties - .where((final property) => property.name != titleKey) - .map((final property) => property.name), - ) ?? - 'subtitle'; - final keywordsKey = - _firstOrNull( - searchableProperties - .where( - (final property) => - property.valueType == AgentEntityPropertyValueType.array, - ) - .map((final property) => property.name), - ) ?? - 'keywords'; - final title = _fieldValue(snapshot, titleKey) ?? snapshot.effectiveTitle; - final subtitle = _fieldValue(snapshot, subtitleKey) ?? snapshot.subtitle; + final keys = AgentEntitySnapshotKeys.fromDescriptor(descriptor); + final title = _fieldValue(snapshot, keys.titleKey) ?? snapshot.effectiveTitle; + final subtitle = + _fieldValue(snapshot, keys.subtitleKey) ?? snapshot.subtitle; final keywords = - _fieldValue(snapshot, keywordsKey) ?? + _fieldValue(snapshot, keys.keywordsKey) ?? (snapshot.keywords.isNotEmpty ? snapshot.keywords : null); final row = { ...snapshot.properties, 'id': snapshot.ref.identifier, - if (descriptor.identifierName != 'id') - descriptor.identifierName: snapshot.ref.identifier, - if (titleKey != 'title' && snapshot.effectiveTitle != null) + if (keys.idKey != 'id') keys.idKey: snapshot.ref.identifier, + if (keys.titleKey != 'title' && snapshot.effectiveTitle != null) 'title': snapshot.effectiveTitle, - if (subtitleKey != 'subtitle' && snapshot.subtitle != null) + if (keys.subtitleKey != 'subtitle' && snapshot.subtitle != null) 'subtitle': snapshot.subtitle, - if (keywordsKey != 'keywords' && snapshot.keywords.isNotEmpty) + if (keys.keywordsKey != 'keywords' && snapshot.keywords.isNotEmpty) 'keywords': snapshot.keywords, if (snapshot.thumbnailUrl != null) 'thumbnailUrl': snapshot.thumbnailUrl, if (snapshot.url != null) 'url': snapshot.url, @@ -63,13 +41,13 @@ Map projectAgentEntitySnapshot( if (snapshot.properties.isNotEmpty) 'properties': snapshot.properties, }; if (title != null) { - row[titleKey] = title; + row[keys.titleKey] = title; } if (subtitle != null) { - row[subtitleKey] = subtitle; + row[keys.subtitleKey] = subtitle; } if (keywords != null) { - row[keywordsKey] = keywords; + row[keys.keywordsKey] = keywords; } return row; } @@ -78,8 +56,3 @@ Object? _fieldValue(final AgentEntitySnapshot snapshot, final String key) { final value = snapshot.properties[key]; return value; } - -String? _firstOrNull(final Iterable values) { - final iterator = values.iterator; - return iterator.moveNext() ? iterator.current : null; -} diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart new file mode 100644 index 0000000..e2c90bd --- /dev/null +++ b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart @@ -0,0 +1,39 @@ +import 'agent_entity_property_descriptor.dart'; +import 'agent_entity_property_role.dart'; +import 'agent_entity_type_descriptor.dart'; + +Map agentEntitySnapshotSchema( + final AgentEntityTypeDescriptor descriptor, +) { + final properties = { + descriptor.identifierName: const {'type': 'string'}, + }; + for (final property in descriptor.properties) { + properties[property.name] = { + 'type': _jsonSchemaType(property.valueType), + if (property.description.isNotEmpty) 'description': property.description, + if (property.isDisplay) 'x-intentcall-display': true, + if (property.isSearchable) 'x-intentcall-searchable': true, + if (property.isIndexed) 'x-intentcall-indexed': true, + if (property.privacy != null) + 'x-intentcall-privacy': property.privacy!.name, + if (property.role != AgentEntityPropertyRole.none) + 'x-intentcall-role': property.role.name, + }; + } + return { + 'type': 'object', + 'required': [descriptor.identifierName], + 'properties': properties, + }; +} + +String _jsonSchemaType(final AgentEntityPropertyValueType type) => + switch (type) { + AgentEntityPropertyValueType.string => 'string', + AgentEntityPropertyValueType.integer => 'integer', + AgentEntityPropertyValueType.number => 'number', + AgentEntityPropertyValueType.boolean => 'boolean', + AgentEntityPropertyValueType.object => 'object', + AgentEntityPropertyValueType.array => 'array', + }; diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_type_descriptor.dart b/packages/intentcall_core/lib/src/entity/agent_entity_type_descriptor.dart index 7446c88..94c971c 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_type_descriptor.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_type_descriptor.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import '../naming/qualified_name.dart'; import 'agent_entity_property_descriptor.dart'; +import 'agent_entity_property_role.dart'; enum AgentEntityDeepLinkBehavior { unsupported, optional, required } @@ -44,10 +45,20 @@ final class AgentEntityTypeDescriptor { String get qualifiedName => qualifyName(namespace: namespace, name: name); Iterable get displayProperties => - properties.where((final property) => property.isDisplay); + properties.where( + (final property) => + property.isDisplay || + property.role == AgentEntityPropertyRole.title || + property.role == AgentEntityPropertyRole.subtitle, + ); Iterable get searchableProperties => - properties.where((final property) => property.isSearchable); + properties.where( + (final property) => + property.isSearchable || + property.role == AgentEntityPropertyRole.subtitle || + property.role == AgentEntityPropertyRole.keywords, + ); Iterable get indexedProperties => properties.where((final property) => property.isIndexed); diff --git a/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart b/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart new file mode 100644 index 0000000..d2b09c1 --- /dev/null +++ b/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart @@ -0,0 +1,127 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:test/test.dart'; + +AgentEntityTypeDescriptor _projectFixtureDescriptor({ + final Iterable properties = + const [], +}) { + return AgentEntityTypeDescriptor( + namespace: 'app', + name: 'project', + identifierName: 'project_id', + displayName: 'Project', + properties: properties, + ); +} + +void main() { + group('AgentEntitySnapshotKeys.fromDescriptor', () { + test('derives keys from display/searchable heuristics', () { + final keys = AgentEntitySnapshotKeys.fromDescriptor( + _projectFixtureDescriptor( + properties: [ + AgentEntityPropertyDescriptor( + name: 'name', + valueType: AgentEntityPropertyValueType.string, + isDisplay: true, + isSearchable: true, + isIndexed: true, + ), + AgentEntityPropertyDescriptor( + name: 'summary', + valueType: AgentEntityPropertyValueType.string, + isSearchable: true, + ), + AgentEntityPropertyDescriptor( + name: 'tags', + valueType: AgentEntityPropertyValueType.array, + isSearchable: true, + ), + ], + ), + ); + + expect(keys.idKey, 'project_id'); + expect(keys.titleKey, 'name'); + expect(keys.subtitleKey, 'summary'); + expect(keys.keywordsKey, 'tags'); + }); + + test('prefers explicit role over heuristics', () { + final keys = AgentEntitySnapshotKeys.fromDescriptor( + _projectFixtureDescriptor( + properties: [ + AgentEntityPropertyDescriptor( + name: 'name', + valueType: AgentEntityPropertyValueType.string, + isDisplay: true, + isSearchable: true, + ), + AgentEntityPropertyDescriptor( + name: 'headline', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.title, + ), + AgentEntityPropertyDescriptor( + name: 'blurb', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.subtitle, + ), + AgentEntityPropertyDescriptor( + name: 'labels', + valueType: AgentEntityPropertyValueType.array, + role: AgentEntityPropertyRole.keywords, + ), + ], + ), + ); + + expect(keys.titleKey, 'headline'); + expect(keys.subtitleKey, 'blurb'); + expect(keys.keywordsKey, 'labels'); + }); + + test('throws when duplicate explicit roles are declared', () { + expect( + () => AgentEntitySnapshotKeys.fromDescriptor( + _projectFixtureDescriptor( + properties: [ + AgentEntityPropertyDescriptor( + name: 'title_a', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.title, + ), + AgentEntityPropertyDescriptor( + name: 'title_b', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.title, + ), + ], + ), + ), + throwsA( + isA().having( + (final error) => error.message, + 'message', + contains('Duplicate entity property role title'), + ), + ), + ); + }); + + test('uses legacy default key names when properties are absent', () { + final keys = AgentEntitySnapshotKeys.fromDescriptor( + AgentEntityTypeDescriptor( + namespace: 'notes', + name: 'note', + identifierName: 'id', + ), + ); + + expect(keys.idKey, 'id'); + expect(keys.titleKey, 'title'); + expect(keys.subtitleKey, 'subtitle'); + expect(keys.keywordsKey, 'keywords'); + }); + }); +} diff --git a/packages/intentcall_gemma/lib/intentcall_gemma.dart b/packages/intentcall_gemma/lib/intentcall_gemma.dart index a963c82..1f2b1f3 100644 --- a/packages/intentcall_gemma/lib/intentcall_gemma.dart +++ b/packages/intentcall_gemma/lib/intentcall_gemma.dart @@ -1,3 +1 @@ -library; - export 'src/gemma_publish_adapter.dart'; diff --git a/packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift new file mode 100644 index 0000000..712732e --- /dev/null +++ b/packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift @@ -0,0 +1,201 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Shared native cache for Dart-projected entity snapshots. +/// +/// Generated App Intents entity/query code and the Flutter plugin bridge both +/// read and write through this store. +public enum IntentCallNativeEntitySnapshotStore { + public static let snapshotsDidChangeNotification = Notification.Name( + "intentcall.entitySnapshotsDidChange" + ) + + public static var fallbackScheme: String? + + private static let snapshotsKeyPrefix = "intentcall.entity_snapshots." + private static let pendingOpenKey = "intentcall.pending_entity_opens" + + @discardableResult + public static func upsertSnapshots( + entityType: String, + snapshots: [[String: Any]], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + var byId = Dictionary( + uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in + guard let id = string(snapshot[idKey]) else { return nil } + return (id, snapshot) + } + ) + for snapshot in snapshots { + guard let id = string(snapshot[idKey]) else { continue } + byId[id] = snapshot + } + existing = Array(byId.values) + UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: type)) + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + return snapshots.count + } + + @discardableResult + public static func deleteSnapshots( + entityType: String, + ids: [String], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let deleted = Set(ids) + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + let kept = existing.filter { snapshot in + guard let id = string(snapshot[idKey]) else { return true } + return !deleted.contains(id) + } + UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: type)) + let removed = existing.count - kept.count + if removed > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return removed + } + + @discardableResult + public static func clearSnapshots(entityType: String) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let existing = snapshots(entityType: type).count + UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: type)) + if existing > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return existing + } + + public static func snapshots(entityType: String) -> [[String: Any]] { + guard let type = validatedEntityType(entityType) else { return [] } + return UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + } + + public static func entities( + entityType: String, + identifiers: [String], + idKey: String, + limit: Int? + ) -> [[String: Any]] { + let wanted = Set(identifiers) + let matches = snapshots(entityType: entityType).filter { snapshot in + guard let id = string(snapshot[idKey]) else { return false } + return wanted.contains(id) + } + return applyingLimit(matches, limit: limit) + } + + public static func suggested(entityType: String, limit: Int) -> [[String: Any]] { + applyingLimit(snapshots(entityType: entityType), limit: limit) + } + + public static func search( + entityType: String, + query: String, + titleKey: String, + subtitleKey: String, + keywordsKey: String, + limit: Int + ) -> [[String: Any]] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let all = snapshots(entityType: entityType) + guard !needle.isEmpty else { + return suggested(entityType: entityType, limit: limit) + } + let matches = all.filter { snapshot in + let fields = + [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } + + strings(snapshot[keywordsKey]) + return fields.contains { $0.lowercased().contains(needle) } + } + return applyingLimit(matches, limit: limit) + } + + public static func recordOpen(entityType: String, id: String) async -> String { + let type = validatedEntityType(entityType) ?? entityType + let openId = UUID().uuidString + let item: [String: Any] = [ + "id": openId, + "entityType": type, + "entityId": id, + "source": "native.entity.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = + UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingOpenKey) + guard let scheme = fallbackScheme else { return openId } + let encodedEntityType = encodedPathComponent(type) + let encodedId = encodedPathComponent(id) + guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { + return openId + } + #if canImport(UIKit) + await UIApplication.shared.open(url) + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + return openId + } + + public static func string(_ value: Any?) -> String? { + if let value = value as? String { return value } + if let value = value as? CustomStringConvertible { return value.description } + return nil + } + + public static func strings(_ value: Any?) -> [String] { + if let values = value as? [String] { return values } + if let values = value as? [Any] { return values.compactMap { string($0) } } + if let value = string(value) { return [value] } + return [] + } + + private static func snapshotsKey(entityType: String) -> String { + snapshotsKeyPrefix + entityType + } + + private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] { + guard let limit else { return rows } + return Array(rows.prefix(max(0, limit))) + } + + private static func encodedPathComponent(_ value: String) -> String { + var allowedPath = CharacterSet.alphanumerics + allowedPath.insert(charactersIn: "_-.~") + return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value + } + + private static func validatedEntityType(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let range = NSRange(location: 0, length: trimmed.utf16.count) + guard + let regex = try? NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$"), + regex.firstMatch(in: trimmed, options: [], range: range) != nil + else { + return nil + } + return trimmed + } +} diff --git a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift index a0c25c9..3b58f5a 100644 --- a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift @@ -15,112 +15,6 @@ private enum IntentCallHandoffStore { } } -private enum IntentCallEntitySnapshotStore { - private static let prefix = "intentcall.entity_snapshots." - - static func upsert(entityType: String, snapshots: [[String: Any]]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var byId = Dictionary(uniqueKeysWithValues: rows(entityType: type).compactMap { row -> (String, [String: Any])? in - guard let id = row["id"] as? String, !id.isEmpty else { return nil } - return (id, row) - }) - for snapshot in snapshots { - guard let id = snapshot["id"] as? String, !id.isEmpty else { continue } - byId[id] = snapshot - } - write(Array(byId.values), entityType: type) - return snapshots.count - } - - static func delete(entityType: String, ids: [String]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let remove = Set(ids) - let next = rows(entityType: type).filter { row in - guard let id = row["id"] as? String else { return true } - return !remove.contains(id) - } - let removed = rows(entityType: type).count - next.count - write(next, entityType: type) - return removed - } - - static func clear(entityType: String) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let count = rows(entityType: type).count - UserDefaults.standard.removeObject(forKey: key(entityType: type)) - return count - } - - static func list(entityType: String) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - return rows(entityType: type) - } - - static func search(entityType: String, query: String, limit: Int?) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let all = try list(entityType: type) - let matched = needle.isEmpty ? all : all.filter { searchableText($0).contains(needle) } - guard let limit, limit >= 0 else { return matched } - return Array(matched.prefix(limit)) - } - - private static func rows(entityType: String) -> [[String: Any]] { - let raw = UserDefaults.standard.array(forKey: key(entityType: entityType)) as? [[String: Any]] ?? [] - return raw.sorted { left, right in - let leftId = left["id"] as? String ?? "" - let rightId = right["id"] as? String ?? "" - return leftId < rightId - } - } - - private static func write(_ rows: [[String: Any]], entityType: String) { - UserDefaults.standard.set(rows, forKey: key(entityType: entityType)) - } - - private static func key(entityType: String) -> String { - "\(prefix)\(entityType)" - } - - private static func validateEntityType(_ value: String) throws -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let range = NSRange(location: 0, length: trimmed.utf16.count) - let regex = try NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$") - guard regex.firstMatch(in: trimmed, options: [], range: range) != nil else { - throw NSError( - domain: "IntentCallEntitySnapshotStore", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid entityType \(value)."] - ) - } - return trimmed - } - - private static func searchableText(_ row: [String: Any]) -> String { - var parts: [String] = [] - for key in ["id", "title", "subtitle", "deepLink", "url"] { - if let value = row[key] as? String { - parts.append(value) - } - } - if let keywords = row["keywords"] as? [Any] { - parts.append(contentsOf: keywords.map { "\($0)" }) - } - if let properties = row["properties"] as? [String: Any] { - parts.append(contentsOf: properties.values.map { "\($0)" }) - } - return parts.joined(separator: " ").lowercased() - } -} - /// Plugin bridge for pending native intent dispatch into Dart. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { @@ -146,26 +40,52 @@ extension IntentCallPlatformPlugin { case "upsertEntitySnapshots": withEntityArgs(call, result) { args, entityType in let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.upsertSnapshots( + entityType: entityType, + snapshots: snapshots, + idKey: idKey + ) + ) } case "deleteEntitySnapshots": withEntityArgs(call, result) { args, entityType in let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.deleteSnapshots( + entityType: entityType, + ids: ids, + idKey: idKey + ) + ) } case "clearEntityTypeSnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) } case "listEntitySnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) } case "searchEntitySnapshots": withEntityArgs(call, result) { args, entityType in let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int - result(try IntentCallEntitySnapshotStore.search(entityType: entityType, query: query, limit: limit)) + let limit = args["limit"] as? Int ?? 20 + let titleKey = args["titleKey"] as? String ?? "title" + let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" + let keywordsKey = args["keywordsKey"] as? String ?? "keywords" + result( + IntentCallNativeEntitySnapshotStore.search( + entityType: entityType, + query: query, + titleKey: titleKey, + subtitleKey: subtitleKey, + keywordsKey: keywordsKey, + limit: limit + ) + ) } default: result(FlutterMethodNotImplemented) @@ -175,17 +95,13 @@ extension IntentCallPlatformPlugin { private func withEntityArgs( _ call: FlutterMethodCall, _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> Void + _ body: ([String: Any], String) -> Void ) { guard let args = call.arguments as? [String: Any], let entityType = args["entityType"] as? String else { result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) return } - do { - try body(args, entityType) - } catch { - result(FlutterError(code: "entity_index_error", message: error.localizedDescription, details: nil)) - } + body(args, entityType) } } diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift new file mode 100644 index 0000000..712732e --- /dev/null +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift @@ -0,0 +1,201 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Shared native cache for Dart-projected entity snapshots. +/// +/// Generated App Intents entity/query code and the Flutter plugin bridge both +/// read and write through this store. +public enum IntentCallNativeEntitySnapshotStore { + public static let snapshotsDidChangeNotification = Notification.Name( + "intentcall.entitySnapshotsDidChange" + ) + + public static var fallbackScheme: String? + + private static let snapshotsKeyPrefix = "intentcall.entity_snapshots." + private static let pendingOpenKey = "intentcall.pending_entity_opens" + + @discardableResult + public static func upsertSnapshots( + entityType: String, + snapshots: [[String: Any]], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + var byId = Dictionary( + uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in + guard let id = string(snapshot[idKey]) else { return nil } + return (id, snapshot) + } + ) + for snapshot in snapshots { + guard let id = string(snapshot[idKey]) else { continue } + byId[id] = snapshot + } + existing = Array(byId.values) + UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: type)) + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + return snapshots.count + } + + @discardableResult + public static func deleteSnapshots( + entityType: String, + ids: [String], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let deleted = Set(ids) + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + let kept = existing.filter { snapshot in + guard let id = string(snapshot[idKey]) else { return true } + return !deleted.contains(id) + } + UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: type)) + let removed = existing.count - kept.count + if removed > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return removed + } + + @discardableResult + public static func clearSnapshots(entityType: String) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let existing = snapshots(entityType: type).count + UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: type)) + if existing > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return existing + } + + public static func snapshots(entityType: String) -> [[String: Any]] { + guard let type = validatedEntityType(entityType) else { return [] } + return UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + } + + public static func entities( + entityType: String, + identifiers: [String], + idKey: String, + limit: Int? + ) -> [[String: Any]] { + let wanted = Set(identifiers) + let matches = snapshots(entityType: entityType).filter { snapshot in + guard let id = string(snapshot[idKey]) else { return false } + return wanted.contains(id) + } + return applyingLimit(matches, limit: limit) + } + + public static func suggested(entityType: String, limit: Int) -> [[String: Any]] { + applyingLimit(snapshots(entityType: entityType), limit: limit) + } + + public static func search( + entityType: String, + query: String, + titleKey: String, + subtitleKey: String, + keywordsKey: String, + limit: Int + ) -> [[String: Any]] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let all = snapshots(entityType: entityType) + guard !needle.isEmpty else { + return suggested(entityType: entityType, limit: limit) + } + let matches = all.filter { snapshot in + let fields = + [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } + + strings(snapshot[keywordsKey]) + return fields.contains { $0.lowercased().contains(needle) } + } + return applyingLimit(matches, limit: limit) + } + + public static func recordOpen(entityType: String, id: String) async -> String { + let type = validatedEntityType(entityType) ?? entityType + let openId = UUID().uuidString + let item: [String: Any] = [ + "id": openId, + "entityType": type, + "entityId": id, + "source": "native.entity.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = + UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingOpenKey) + guard let scheme = fallbackScheme else { return openId } + let encodedEntityType = encodedPathComponent(type) + let encodedId = encodedPathComponent(id) + guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { + return openId + } + #if canImport(UIKit) + await UIApplication.shared.open(url) + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + return openId + } + + public static func string(_ value: Any?) -> String? { + if let value = value as? String { return value } + if let value = value as? CustomStringConvertible { return value.description } + return nil + } + + public static func strings(_ value: Any?) -> [String] { + if let values = value as? [String] { return values } + if let values = value as? [Any] { return values.compactMap { string($0) } } + if let value = string(value) { return [value] } + return [] + } + + private static func snapshotsKey(entityType: String) -> String { + snapshotsKeyPrefix + entityType + } + + private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] { + guard let limit else { return rows } + return Array(rows.prefix(max(0, limit))) + } + + private static func encodedPathComponent(_ value: String) -> String { + var allowedPath = CharacterSet.alphanumerics + allowedPath.insert(charactersIn: "_-.~") + return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value + } + + private static func validatedEntityType(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let range = NSRange(location: 0, length: trimmed.utf16.count) + guard + let regex = try? NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$"), + regex.firstMatch(in: trimmed, options: [], range: range) != nil + else { + return nil + } + return trimmed + } +} diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift index 4a4224d..3b58f5a 100644 --- a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift @@ -15,112 +15,6 @@ private enum IntentCallHandoffStore { } } -private enum IntentCallEntitySnapshotStore { - private static let prefix = "intentcall.entity_snapshots." - - static func upsert(entityType: String, snapshots: [[String: Any]]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var byId = Dictionary(uniqueKeysWithValues: rows(entityType: type).compactMap { row -> (String, [String: Any])? in - guard let id = row["id"] as? String, !id.isEmpty else { return nil } - return (id, row) - }) - for snapshot in snapshots { - guard let id = snapshot["id"] as? String, !id.isEmpty else { continue } - byId[id] = snapshot - } - write(Array(byId.values), entityType: type) - return snapshots.count - } - - static func delete(entityType: String, ids: [String]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let remove = Set(ids) - let current = rows(entityType: type) - let next = current.filter { row in - guard let id = row["id"] as? String else { return true } - return !remove.contains(id) - } - write(next, entityType: type) - return current.count - next.count - } - - static func clear(entityType: String) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let count = rows(entityType: type).count - UserDefaults.standard.removeObject(forKey: key(entityType: type)) - return count - } - - static func list(entityType: String) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - return rows(entityType: type) - } - - static func search(entityType: String, query: String, limit: Int?) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let all = try list(entityType: type) - let matched = needle.isEmpty ? all : all.filter { searchableText($0).contains(needle) } - guard let limit, limit >= 0 else { return matched } - return Array(matched.prefix(limit)) - } - - private static func rows(entityType: String) -> [[String: Any]] { - let raw = UserDefaults.standard.array(forKey: key(entityType: entityType)) as? [[String: Any]] ?? [] - return raw.sorted { left, right in - let leftId = left["id"] as? String ?? "" - let rightId = right["id"] as? String ?? "" - return leftId < rightId - } - } - - private static func write(_ rows: [[String: Any]], entityType: String) { - UserDefaults.standard.set(rows, forKey: key(entityType: entityType)) - } - - private static func key(entityType: String) -> String { - "\(prefix)\(entityType)" - } - - private static func validateEntityType(_ value: String) throws -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let range = NSRange(location: 0, length: trimmed.utf16.count) - let regex = try NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$") - guard regex.firstMatch(in: trimmed, options: [], range: range) != nil else { - throw NSError( - domain: "IntentCallEntitySnapshotStore", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid entityType \(value)."] - ) - } - return trimmed - } - - private static func searchableText(_ row: [String: Any]) -> String { - var parts: [String] = [] - for key in ["id", "title", "subtitle", "deepLink", "url"] { - if let value = row[key] as? String { - parts.append(value) - } - } - if let keywords = row["keywords"] as? [Any] { - parts.append(contentsOf: keywords.map { "\($0)" }) - } - if let properties = row["properties"] as? [String: Any] { - parts.append(contentsOf: properties.values.map { "\($0)" }) - } - return parts.joined(separator: " ").lowercased() - } -} - /// Plugin bridge for pending native intent dispatch into Dart. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { @@ -146,26 +40,52 @@ extension IntentCallPlatformPlugin { case "upsertEntitySnapshots": withEntityArgs(call, result) { args, entityType in let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.upsertSnapshots( + entityType: entityType, + snapshots: snapshots, + idKey: idKey + ) + ) } case "deleteEntitySnapshots": withEntityArgs(call, result) { args, entityType in let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.deleteSnapshots( + entityType: entityType, + ids: ids, + idKey: idKey + ) + ) } case "clearEntityTypeSnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) } case "listEntitySnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) } case "searchEntitySnapshots": withEntityArgs(call, result) { args, entityType in let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int - result(try IntentCallEntitySnapshotStore.search(entityType: entityType, query: query, limit: limit)) + let limit = args["limit"] as? Int ?? 20 + let titleKey = args["titleKey"] as? String ?? "title" + let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" + let keywordsKey = args["keywordsKey"] as? String ?? "keywords" + result( + IntentCallNativeEntitySnapshotStore.search( + entityType: entityType, + query: query, + titleKey: titleKey, + subtitleKey: subtitleKey, + keywordsKey: keywordsKey, + limit: limit + ) + ) } default: result(FlutterMethodNotImplemented) @@ -175,17 +95,13 @@ extension IntentCallPlatformPlugin { private func withEntityArgs( _ call: FlutterMethodCall, _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> Void + _ body: ([String: Any], String) -> Void ) { guard let args = call.arguments as? [String: Any], let entityType = args["entityType"] as? String else { result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) return } - do { - try body(args, entityType) - } catch { - result(FlutterError(code: "entity_index_error", message: error.localizedDescription, details: nil)) - } + body(args, entityType) } } diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart index 571875e..2d9c77f 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart @@ -18,19 +18,6 @@ final class IntentCallPlatformEntityIndex { final IntentCallPlatformInvoke _invoke; - Future upsertAgentSnapshots({ - required final Iterable snapshots, - }) async { - var count = 0; - for (final entry in _snapshotsByEntityType(snapshots).entries) { - count += await upsertSnapshots( - entityType: entry.key, - snapshots: entry.value.map(_snapshotFromModel), - ); - } - return count; - } - Future upsertAgentSnapshotsForType({ required final AgentEntityTypeDescriptor descriptor, required final Iterable snapshots, @@ -108,19 +95,6 @@ final class IntentCallPlatformEntityIndex { } } -Map> _snapshotsByEntityType( - final Iterable snapshots, -) { - final groups = >{}; - for (final snapshot in snapshots) { - final entityType = _entityType( - '${snapshot.ref.namespace}_${snapshot.ref.typeName}', - ); - groups.putIfAbsent(entityType, () => []).add(snapshot); - } - return groups; -} - Map> _refsByEntityType( final Iterable refs, ) { @@ -132,23 +106,6 @@ Map> _refsByEntityType( return groups; } -Map _snapshotFromModel(final AgentEntitySnapshot snapshot) => - { - 'id': snapshot.ref.identifier, - if (snapshot.effectiveTitle != null) 'title': snapshot.effectiveTitle, - if (snapshot.subtitle != null) 'subtitle': snapshot.subtitle, - if (snapshot.keywords.isNotEmpty) 'keywords': snapshot.keywords, - if (snapshot.thumbnailUrl != null) 'thumbnailUrl': snapshot.thumbnailUrl, - if (snapshot.url != null) 'url': snapshot.url, - if (snapshot.deepLink != null) 'deepLink': snapshot.deepLink, - if (snapshot.updatedAt != null) - 'updatedAt': snapshot.updatedAt!.toUtc().toIso8601String(), - if (snapshot.deleted) 'deleted': true, - if (snapshot.version != null) 'version': snapshot.version, - if (snapshot.freshness != null) 'freshness': snapshot.freshness, - if (snapshot.properties.isNotEmpty) 'properties': snapshot.properties, - }; - String _entityType(final String value) { final trimmed = value.trim(); if (!RegExp(r'^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$').hasMatch(trimmed)) { diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart index c8e94c2..f08aa72 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart @@ -1,9 +1,8 @@ import 'dart:async'; import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_schema/intentcall_schema.dart'; - import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; import 'intentcall_host_events.dart'; import 'intentcall_invoke_link_stub.dart' @@ -31,23 +30,23 @@ final class IntentCallFlutterHost { required this.bridge, required this.takePendingInvocations, required this.registerWebMcp, + required this.webMcpSurfaceIndex, required this.onEnvelope, required this.onResult, required this.onDenied, required this.onError, required this.drainOnStart, - final Stream? wakeSignals, - final IntentCallLifecycleWakeSignals? lifecycleWakeSignals, - final IntentCallInvokeLinkListener? deepLinkListener, - }) : _wakeSignals = wakeSignals, - _lifecycleWakeSignals = lifecycleWakeSignals, - _deepLinkListener = deepLinkListener; + this._wakeSignals, + this._lifecycleWakeSignals, + this._deepLinkListener, + }); factory IntentCallFlutterHost.bindRegistry({ required final AgentRegistry registry, final IntentCallAuthorizationPolicy policy = const IntentCallAuthorizationPolicy.denyAll(), final bool registerWebMcp = false, + final ManifestSurfaceIndex? webMcpSurfaceIndex, final bool drainOnStart = true, final bool drainOnResume = true, final bool listenForDeepLinks = false, @@ -86,6 +85,7 @@ final class IntentCallFlutterHost { takePendingInvocations ?? const IntentCallPendingInvocations().takePending, registerWebMcp: registerWebMcp, + webMcpSurfaceIndex: webMcpSurfaceIndex, onEnvelope: onEnvelope, onResult: onResult, onDenied: onDenied, @@ -101,6 +101,7 @@ final class IntentCallFlutterHost { final IntentCallNativeBridge bridge; final IntentCallPendingReader takePendingInvocations; final bool registerWebMcp; + final ManifestSurfaceIndex? webMcpSurfaceIndex; final IntentCallEnvelopeCallback? onEnvelope; final IntentCallResultCallback? onResult; final IntentCallResultCallback? onDenied; @@ -122,7 +123,11 @@ final class IntentCallFlutterHost { Future> start() async { if (registerWebMcp) { - registerAgentWebMcpFromRegistry(bridge.registry, policy: bridge.policy); + registerAgentWebMcpFromRegistry( + bridge.registry, + policy: bridge.policy, + surfaceIndex: webMcpSurfaceIndex, + ); } await _deepLinkListener?.start(); _wakeSubscription ??= _wakeSignals?.listen((final trigger) { diff --git a/packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift new file mode 100644 index 0000000..712732e --- /dev/null +++ b/packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift @@ -0,0 +1,201 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Shared native cache for Dart-projected entity snapshots. +/// +/// Generated App Intents entity/query code and the Flutter plugin bridge both +/// read and write through this store. +public enum IntentCallNativeEntitySnapshotStore { + public static let snapshotsDidChangeNotification = Notification.Name( + "intentcall.entitySnapshotsDidChange" + ) + + public static var fallbackScheme: String? + + private static let snapshotsKeyPrefix = "intentcall.entity_snapshots." + private static let pendingOpenKey = "intentcall.pending_entity_opens" + + @discardableResult + public static func upsertSnapshots( + entityType: String, + snapshots: [[String: Any]], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + var byId = Dictionary( + uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in + guard let id = string(snapshot[idKey]) else { return nil } + return (id, snapshot) + } + ) + for snapshot in snapshots { + guard let id = string(snapshot[idKey]) else { continue } + byId[id] = snapshot + } + existing = Array(byId.values) + UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: type)) + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + return snapshots.count + } + + @discardableResult + public static func deleteSnapshots( + entityType: String, + ids: [String], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let deleted = Set(ids) + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + let kept = existing.filter { snapshot in + guard let id = string(snapshot[idKey]) else { return true } + return !deleted.contains(id) + } + UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: type)) + let removed = existing.count - kept.count + if removed > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return removed + } + + @discardableResult + public static func clearSnapshots(entityType: String) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let existing = snapshots(entityType: type).count + UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: type)) + if existing > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return existing + } + + public static func snapshots(entityType: String) -> [[String: Any]] { + guard let type = validatedEntityType(entityType) else { return [] } + return UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + } + + public static func entities( + entityType: String, + identifiers: [String], + idKey: String, + limit: Int? + ) -> [[String: Any]] { + let wanted = Set(identifiers) + let matches = snapshots(entityType: entityType).filter { snapshot in + guard let id = string(snapshot[idKey]) else { return false } + return wanted.contains(id) + } + return applyingLimit(matches, limit: limit) + } + + public static func suggested(entityType: String, limit: Int) -> [[String: Any]] { + applyingLimit(snapshots(entityType: entityType), limit: limit) + } + + public static func search( + entityType: String, + query: String, + titleKey: String, + subtitleKey: String, + keywordsKey: String, + limit: Int + ) -> [[String: Any]] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let all = snapshots(entityType: entityType) + guard !needle.isEmpty else { + return suggested(entityType: entityType, limit: limit) + } + let matches = all.filter { snapshot in + let fields = + [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } + + strings(snapshot[keywordsKey]) + return fields.contains { $0.lowercased().contains(needle) } + } + return applyingLimit(matches, limit: limit) + } + + public static func recordOpen(entityType: String, id: String) async -> String { + let type = validatedEntityType(entityType) ?? entityType + let openId = UUID().uuidString + let item: [String: Any] = [ + "id": openId, + "entityType": type, + "entityId": id, + "source": "native.entity.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = + UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingOpenKey) + guard let scheme = fallbackScheme else { return openId } + let encodedEntityType = encodedPathComponent(type) + let encodedId = encodedPathComponent(id) + guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { + return openId + } + #if canImport(UIKit) + await UIApplication.shared.open(url) + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + return openId + } + + public static func string(_ value: Any?) -> String? { + if let value = value as? String { return value } + if let value = value as? CustomStringConvertible { return value.description } + return nil + } + + public static func strings(_ value: Any?) -> [String] { + if let values = value as? [String] { return values } + if let values = value as? [Any] { return values.compactMap { string($0) } } + if let value = string(value) { return [value] } + return [] + } + + private static func snapshotsKey(entityType: String) -> String { + snapshotsKeyPrefix + entityType + } + + private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] { + guard let limit else { return rows } + return Array(rows.prefix(max(0, limit))) + } + + private static func encodedPathComponent(_ value: String) -> String { + var allowedPath = CharacterSet.alphanumerics + allowedPath.insert(charactersIn: "_-.~") + return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value + } + + private static func validatedEntityType(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let range = NSRange(location: 0, length: trimmed.utf16.count) + guard + let regex = try? NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$"), + regex.firstMatch(in: trimmed, options: [], range: range) != nil + else { + return nil + } + return trimmed + } +} diff --git a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift index 7f78401..a95f037 100644 --- a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift @@ -15,112 +15,6 @@ private enum IntentCallHandoffStore { } } -private enum IntentCallEntitySnapshotStore { - private static let prefix = "intentcall.entity_snapshots." - - static func upsert(entityType: String, snapshots: [[String: Any]]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var byId = Dictionary(uniqueKeysWithValues: rows(entityType: type).compactMap { row -> (String, [String: Any])? in - guard let id = row["id"] as? String, !id.isEmpty else { return nil } - return (id, row) - }) - for snapshot in snapshots { - guard let id = snapshot["id"] as? String, !id.isEmpty else { continue } - byId[id] = snapshot - } - write(Array(byId.values), entityType: type) - return snapshots.count - } - - static func delete(entityType: String, ids: [String]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let remove = Set(ids) - let current = rows(entityType: type) - let next = current.filter { row in - guard let id = row["id"] as? String else { return true } - return !remove.contains(id) - } - write(next, entityType: type) - return current.count - next.count - } - - static func clear(entityType: String) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let count = rows(entityType: type).count - UserDefaults.standard.removeObject(forKey: key(entityType: type)) - return count - } - - static func list(entityType: String) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - return rows(entityType: type) - } - - static func search(entityType: String, query: String, limit: Int?) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let all = try list(entityType: type) - let matched = needle.isEmpty ? all : all.filter { searchableText($0).contains(needle) } - guard let limit, limit >= 0 else { return matched } - return Array(matched.prefix(limit)) - } - - private static func rows(entityType: String) -> [[String: Any]] { - let raw = UserDefaults.standard.array(forKey: key(entityType: entityType)) as? [[String: Any]] ?? [] - return raw.sorted { left, right in - let leftId = left["id"] as? String ?? "" - let rightId = right["id"] as? String ?? "" - return leftId < rightId - } - } - - private static func write(_ rows: [[String: Any]], entityType: String) { - UserDefaults.standard.set(rows, forKey: key(entityType: entityType)) - } - - private static func key(entityType: String) -> String { - "\(prefix)\(entityType)" - } - - private static func validateEntityType(_ value: String) throws -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let range = NSRange(location: 0, length: trimmed.utf16.count) - let regex = try NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$") - guard regex.firstMatch(in: trimmed, options: [], range: range) != nil else { - throw NSError( - domain: "IntentCallEntitySnapshotStore", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid entityType \(value)."] - ) - } - return trimmed - } - - private static func searchableText(_ row: [String: Any]) -> String { - var parts: [String] = [] - for key in ["id", "title", "subtitle", "deepLink", "url"] { - if let value = row[key] as? String { - parts.append(value) - } - } - if let keywords = row["keywords"] as? [Any] { - parts.append(contentsOf: keywords.map { "\($0)" }) - } - if let properties = row["properties"] as? [String: Any] { - parts.append(contentsOf: properties.values.map { "\($0)" }) - } - return parts.joined(separator: " ").lowercased() - } -} - /// Plugin bridge for pending native intent dispatch into Dart. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { @@ -146,26 +40,52 @@ extension IntentCallPlatformPlugin { case "upsertEntitySnapshots": withEntityArgs(call, result) { args, entityType in let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.upsertSnapshots( + entityType: entityType, + snapshots: snapshots, + idKey: idKey + ) + ) } case "deleteEntitySnapshots": withEntityArgs(call, result) { args, entityType in let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.deleteSnapshots( + entityType: entityType, + ids: ids, + idKey: idKey + ) + ) } case "clearEntityTypeSnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) } case "listEntitySnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) } case "searchEntitySnapshots": withEntityArgs(call, result) { args, entityType in let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int - result(try IntentCallEntitySnapshotStore.search(entityType: entityType, query: query, limit: limit)) + let limit = args["limit"] as? Int ?? 20 + let titleKey = args["titleKey"] as? String ?? "title" + let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" + let keywordsKey = args["keywordsKey"] as? String ?? "keywords" + result( + IntentCallNativeEntitySnapshotStore.search( + entityType: entityType, + query: query, + titleKey: titleKey, + subtitleKey: subtitleKey, + keywordsKey: keywordsKey, + limit: limit + ) + ) } default: result(FlutterMethodNotImplemented) @@ -175,17 +95,13 @@ extension IntentCallPlatformPlugin { private func withEntityArgs( _ call: FlutterMethodCall, _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> Void + _ body: ([String: Any], String) -> Void ) { guard let args = call.arguments as? [String: Any], let entityType = args["entityType"] as? String else { result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) return } - do { - try body(args, entityType) - } catch { - result(FlutterError(code: "entity_index_error", message: error.localizedDescription, details: nil)) - } + body(args, entityType) } } diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift new file mode 100644 index 0000000..712732e --- /dev/null +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift @@ -0,0 +1,201 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Shared native cache for Dart-projected entity snapshots. +/// +/// Generated App Intents entity/query code and the Flutter plugin bridge both +/// read and write through this store. +public enum IntentCallNativeEntitySnapshotStore { + public static let snapshotsDidChangeNotification = Notification.Name( + "intentcall.entitySnapshotsDidChange" + ) + + public static var fallbackScheme: String? + + private static let snapshotsKeyPrefix = "intentcall.entity_snapshots." + private static let pendingOpenKey = "intentcall.pending_entity_opens" + + @discardableResult + public static func upsertSnapshots( + entityType: String, + snapshots: [[String: Any]], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + var byId = Dictionary( + uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in + guard let id = string(snapshot[idKey]) else { return nil } + return (id, snapshot) + } + ) + for snapshot in snapshots { + guard let id = string(snapshot[idKey]) else { continue } + byId[id] = snapshot + } + existing = Array(byId.values) + UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: type)) + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + return snapshots.count + } + + @discardableResult + public static func deleteSnapshots( + entityType: String, + ids: [String], + idKey: String = "id" + ) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let deleted = Set(ids) + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let existing = + UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + let kept = existing.filter { snapshot in + guard let id = string(snapshot[idKey]) else { return true } + return !deleted.contains(id) + } + UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: type)) + let removed = existing.count - kept.count + if removed > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return removed + } + + @discardableResult + public static func clearSnapshots(entityType: String) -> Int { + guard let type = validatedEntityType(entityType) else { return 0 } + let existing = snapshots(entityType: type).count + UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: type)) + if existing > 0 { + NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) + } + return existing + } + + public static func snapshots(entityType: String) -> [[String: Any]] { + guard let type = validatedEntityType(entityType) else { return [] } + return UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) + as? [[String: Any]] ?? [] + } + + public static func entities( + entityType: String, + identifiers: [String], + idKey: String, + limit: Int? + ) -> [[String: Any]] { + let wanted = Set(identifiers) + let matches = snapshots(entityType: entityType).filter { snapshot in + guard let id = string(snapshot[idKey]) else { return false } + return wanted.contains(id) + } + return applyingLimit(matches, limit: limit) + } + + public static func suggested(entityType: String, limit: Int) -> [[String: Any]] { + applyingLimit(snapshots(entityType: entityType), limit: limit) + } + + public static func search( + entityType: String, + query: String, + titleKey: String, + subtitleKey: String, + keywordsKey: String, + limit: Int + ) -> [[String: Any]] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let all = snapshots(entityType: entityType) + guard !needle.isEmpty else { + return suggested(entityType: entityType, limit: limit) + } + let matches = all.filter { snapshot in + let fields = + [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } + + strings(snapshot[keywordsKey]) + return fields.contains { $0.lowercased().contains(needle) } + } + return applyingLimit(matches, limit: limit) + } + + public static func recordOpen(entityType: String, id: String) async -> String { + let type = validatedEntityType(entityType) ?? entityType + let openId = UUID().uuidString + let item: [String: Any] = [ + "id": openId, + "entityType": type, + "entityId": id, + "source": "native.entity.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = + UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingOpenKey) + guard let scheme = fallbackScheme else { return openId } + let encodedEntityType = encodedPathComponent(type) + let encodedId = encodedPathComponent(id) + guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { + return openId + } + #if canImport(UIKit) + await UIApplication.shared.open(url) + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + return openId + } + + public static func string(_ value: Any?) -> String? { + if let value = value as? String { return value } + if let value = value as? CustomStringConvertible { return value.description } + return nil + } + + public static func strings(_ value: Any?) -> [String] { + if let values = value as? [String] { return values } + if let values = value as? [Any] { return values.compactMap { string($0) } } + if let value = string(value) { return [value] } + return [] + } + + private static func snapshotsKey(entityType: String) -> String { + snapshotsKeyPrefix + entityType + } + + private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] { + guard let limit else { return rows } + return Array(rows.prefix(max(0, limit))) + } + + private static func encodedPathComponent(_ value: String) -> String { + var allowedPath = CharacterSet.alphanumerics + allowedPath.insert(charactersIn: "_-.~") + return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value + } + + private static func validatedEntityType(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let range = NSRange(location: 0, length: trimmed.utf16.count) + guard + let regex = try? NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$"), + regex.firstMatch(in: trimmed, options: [], range: range) != nil + else { + return nil + } + return trimmed + } +} diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift index 7f78401..a95f037 100644 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift @@ -15,112 +15,6 @@ private enum IntentCallHandoffStore { } } -private enum IntentCallEntitySnapshotStore { - private static let prefix = "intentcall.entity_snapshots." - - static func upsert(entityType: String, snapshots: [[String: Any]]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var byId = Dictionary(uniqueKeysWithValues: rows(entityType: type).compactMap { row -> (String, [String: Any])? in - guard let id = row["id"] as? String, !id.isEmpty else { return nil } - return (id, row) - }) - for snapshot in snapshots { - guard let id = snapshot["id"] as? String, !id.isEmpty else { continue } - byId[id] = snapshot - } - write(Array(byId.values), entityType: type) - return snapshots.count - } - - static func delete(entityType: String, ids: [String]) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let remove = Set(ids) - let current = rows(entityType: type) - let next = current.filter { row in - guard let id = row["id"] as? String else { return true } - return !remove.contains(id) - } - write(next, entityType: type) - return current.count - next.count - } - - static func clear(entityType: String) throws -> Int { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let count = rows(entityType: type).count - UserDefaults.standard.removeObject(forKey: key(entityType: type)) - return count - } - - static func list(entityType: String) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - return rows(entityType: type) - } - - static func search(entityType: String, query: String, limit: Int?) throws -> [[String: Any]] { - let type = try validateEntityType(entityType) - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let all = try list(entityType: type) - let matched = needle.isEmpty ? all : all.filter { searchableText($0).contains(needle) } - guard let limit, limit >= 0 else { return matched } - return Array(matched.prefix(limit)) - } - - private static func rows(entityType: String) -> [[String: Any]] { - let raw = UserDefaults.standard.array(forKey: key(entityType: entityType)) as? [[String: Any]] ?? [] - return raw.sorted { left, right in - let leftId = left["id"] as? String ?? "" - let rightId = right["id"] as? String ?? "" - return leftId < rightId - } - } - - private static func write(_ rows: [[String: Any]], entityType: String) { - UserDefaults.standard.set(rows, forKey: key(entityType: entityType)) - } - - private static func key(entityType: String) -> String { - "\(prefix)\(entityType)" - } - - private static func validateEntityType(_ value: String) throws -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let range = NSRange(location: 0, length: trimmed.utf16.count) - let regex = try NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$") - guard regex.firstMatch(in: trimmed, options: [], range: range) != nil else { - throw NSError( - domain: "IntentCallEntitySnapshotStore", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid entityType \(value)."] - ) - } - return trimmed - } - - private static func searchableText(_ row: [String: Any]) -> String { - var parts: [String] = [] - for key in ["id", "title", "subtitle", "deepLink", "url"] { - if let value = row[key] as? String { - parts.append(value) - } - } - if let keywords = row["keywords"] as? [Any] { - parts.append(contentsOf: keywords.map { "\($0)" }) - } - if let properties = row["properties"] as? [String: Any] { - parts.append(contentsOf: properties.values.map { "\($0)" }) - } - return parts.joined(separator: " ").lowercased() - } -} - /// Plugin bridge for pending native intent dispatch into Dart. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { @@ -146,26 +40,52 @@ extension IntentCallPlatformPlugin { case "upsertEntitySnapshots": withEntityArgs(call, result) { args, entityType in let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.upsertSnapshots( + entityType: entityType, + snapshots: snapshots, + idKey: idKey + ) + ) } case "deleteEntitySnapshots": withEntityArgs(call, result) { args, entityType in let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) + let idKey = args["idKey"] as? String ?? "id" + result( + IntentCallNativeEntitySnapshotStore.deleteSnapshots( + entityType: entityType, + ids: ids, + idKey: idKey + ) + ) } case "clearEntityTypeSnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) } case "listEntitySnapshots": withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(entityType: entityType)) + result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) } case "searchEntitySnapshots": withEntityArgs(call, result) { args, entityType in let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int - result(try IntentCallEntitySnapshotStore.search(entityType: entityType, query: query, limit: limit)) + let limit = args["limit"] as? Int ?? 20 + let titleKey = args["titleKey"] as? String ?? "title" + let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" + let keywordsKey = args["keywordsKey"] as? String ?? "keywords" + result( + IntentCallNativeEntitySnapshotStore.search( + entityType: entityType, + query: query, + titleKey: titleKey, + subtitleKey: subtitleKey, + keywordsKey: keywordsKey, + limit: limit + ) + ) } default: result(FlutterMethodNotImplemented) @@ -175,17 +95,13 @@ extension IntentCallPlatformPlugin { private func withEntityArgs( _ call: FlutterMethodCall, _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> Void + _ body: ([String: Any], String) -> Void ) { guard let args = call.arguments as? [String: Any], let entityType = args["entityType"] as? String else { result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) return } - do { - try body(args, entityType) - } catch { - result(FlutterError(code: "entity_index_error", message: error.localizedDescription, details: nil)) - } + body(args, entityType) } } diff --git a/packages/intentcall_platform/test/intentcall_entity_index_test.dart b/packages/intentcall_platform/test/intentcall_entity_index_test.dart index c423c31..2f9952e 100644 --- a/packages/intentcall_platform/test/intentcall_entity_index_test.dart +++ b/packages/intentcall_platform/test/intentcall_entity_index_test.dart @@ -33,7 +33,7 @@ void main() { }); test( - 'IntentCallPlatformEntityIndex writes schema snapshots by ref', + 'IntentCallPlatformEntityIndex writes notes snapshots with legacy keys', () async { final calls = {}; final index = IntentCallPlatformEntityIndex( @@ -42,8 +42,22 @@ void main() { return 1; }, ); + final descriptor = AgentEntityTypeDescriptor( + namespace: 'notes', + name: 'note', + identifierName: 'id', + properties: [ + AgentEntityPropertyDescriptor( + name: 'title', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.title, + isDisplay: true, + ), + ], + ); - final count = await index.upsertAgentSnapshots( + final count = await index.upsertAgentSnapshotsForType( + descriptor: descriptor, snapshots: [ AgentEntitySnapshot( ref: const AgentEntityRef( @@ -70,6 +84,7 @@ void main() { expect(row['keywords'], ['launch']); expect(row['deepLink'], 'demo://entity/notes_note/note-1'); expect(row['updatedAt'], '2026-06-29T00:00:00.000Z'); + expect(row['category'], 'work'); }, ); diff --git a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart index 1767568..ff0e305 100644 --- a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart @@ -15,6 +15,7 @@ export 'src/init/platform_hooks_init.dart'; export 'src/invocation/intentcall_invocation.dart'; export 'src/projection/manifest_exporter.dart'; export 'src/projection/manifest_merger.dart'; +export 'src/projection/manifest_surface_index.dart'; export 'src/projection/projection_policy.dart'; export 'src/sync/platform_sync.dart'; export 'src/templates/platform_hook_templates.dart'; diff --git a/packages/intentcall_platform_sync/lib/src/agent_manifest.dart b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart index 10cf168..f3b54ab 100644 --- a/packages/intentcall_platform_sync/lib/src/agent_manifest.dart +++ b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart @@ -81,7 +81,10 @@ final class AgentManifestInlineRuntime { /// Platform projection surfaces that can expose a manifest entry. enum AgentManifestSurface { + appleAppIntents, appleAppShortcuts, + appleSpotlight, + appleEntities, androidShortcuts, webManifestShortcuts, webProtocolHandlers, @@ -125,10 +128,10 @@ final class AgentManifestSurfacePolicy { final out = {}; for (final surface in AgentManifestSurface.values) { final exposure = overrides[surface]; - if (exposure == null) { - continue; - } - out[surface.manifestKey] = exposure.toJson(); + out[surface.manifestKey] = AgentManifestSurfaceExposure( + include: exposure?.include ?? false, + options: exposure?.options ?? const {}, + ).toJson(); } return out; } @@ -209,7 +212,7 @@ final class AgentManifestEntry { 'kind': kind.name, 'dispatchMode': dispatchMode.name, if (inlineRuntime != null) 'inlineRuntime': inlineRuntime!.toJson(), - if (!surfaces.isEmpty) 'surfaces': surfaces.toJson(), + 'surfaces': surfaces.toJson(), if (resourceUri != null) 'resourceUri': resourceUri, 'inputSchema': inputSchema, }; @@ -727,7 +730,10 @@ String? _readOptionalProtocolScheme(final Object? value) { extension AgentManifestSurfaceKey on AgentManifestSurface { String get manifestKey => switch (this) { + AgentManifestSurface.appleAppIntents => 'apple.appIntents', AgentManifestSurface.appleAppShortcuts => 'apple.appShortcuts', + AgentManifestSurface.appleSpotlight => 'apple.spotlight', + AgentManifestSurface.appleEntities => 'apple.entities', AgentManifestSurface.androidShortcuts => 'android.shortcuts', AgentManifestSurface.webManifestShortcuts => 'web.manifestShortcuts', AgentManifestSurface.webProtocolHandlers => 'web.protocolHandlers', diff --git a/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap.dart b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap.dart index 1be81e3..b600ab4 100644 --- a/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap.dart +++ b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap.dart @@ -1,6 +1,7 @@ import 'package:intentcall_core/intentcall_core.dart'; import '../invocation/intentcall_invocation.dart'; +import '../projection/manifest_surface_index.dart'; import 'agent_web_mcp_bootstrap_stub.dart' if (dart.library.js_interop) 'agent_web_mcp_bootstrap_web.dart' as impl; @@ -14,7 +15,12 @@ void registerAgentWebMcpFromEntries( final Set entries, { final IntentCallAuthorizationPolicy policy = const IntentCallAuthorizationPolicy.debugAllowAll(), -}) => impl.registerFromEntries(entries, policy: policy); + final ManifestSurfaceIndex? surfaceIndex, +}) => impl.registerFromEntries( + entries, + policy: policy, + surfaceIndex: surfaceIndex, +); /// Registers WebMCP tools directly from [registry] and executes them in Dart. /// @@ -25,7 +31,12 @@ void registerAgentWebMcpFromRegistry( final AgentRegistry registry, { final IntentCallAuthorizationPolicy policy = const IntentCallAuthorizationPolicy.debugAllowAll(), -}) => impl.registerFromRegistry(registry, policy: policy); + final ManifestSurfaceIndex? surfaceIndex, +}) => impl.registerFromRegistry( + registry, + policy: policy, + surfaceIndex: surfaceIndex, +); /// Whether a tool was already registered on WebMCP (web only; stub returns false). bool isAgentWebMcpToolRegistered(final String qualifiedName) => diff --git a/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart index 4e11f1b..9b4c7fa 100644 --- a/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart +++ b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart @@ -1,15 +1,18 @@ import 'package:intentcall_core/intentcall_core.dart'; import '../invocation/intentcall_invocation.dart'; +import '../projection/manifest_surface_index.dart'; void registerFromEntries( final Set entries, { required final IntentCallAuthorizationPolicy policy, + final ManifestSurfaceIndex? surfaceIndex, }) {} void registerFromRegistry( final AgentRegistry registry, { required final IntentCallAuthorizationPolicy policy, + final ManifestSurfaceIndex? surfaceIndex, }) {} bool isAgentWebMcpToolRegistered(final String qualifiedName) => false; diff --git a/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart index b0b6e97..861c3e8 100644 --- a/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart +++ b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart @@ -6,6 +6,7 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; import '../invocation/intentcall_invocation.dart'; +import '../projection/manifest_surface_index.dart'; @JS('JSON.parse') external JSAny? _jsonParse(final JSString source); @@ -54,6 +55,7 @@ extension type _WebMcpToolDefinition._(JSObject _) implements JSObject { void registerFromEntries( final Set entries, { required final IntentCallAuthorizationPolicy policy, + final ManifestSurfaceIndex? surfaceIndex, }) { final modelContext = _readModelContext(); if (modelContext == null) { @@ -69,6 +71,9 @@ void registerFromEntries( } final qualifiedName = descriptor.qualifiedName; + if (!_includesWebMcp(qualifiedName, surfaceIndex)) { + continue; + } _entriesByQualifiedName[qualifiedName] = entry; _entryPoliciesByQualifiedName[qualifiedName] = policy; @@ -97,6 +102,7 @@ void registerFromEntries( void registerFromRegistry( final AgentRegistry registry, { required final IntentCallAuthorizationPolicy policy, + final ManifestSurfaceIndex? surfaceIndex, }) { final modelContext = _readModelContext(); if (modelContext == null) { @@ -115,6 +121,9 @@ void registerFromRegistry( continue; } final qualifiedName = entry.key; + if (!_includesWebMcp(qualifiedName, surfaceIndex)) { + continue; + } _bridgesByQualifiedName[qualifiedName] = bridge; if (_webMcpRegisteredToolNames.contains(qualifiedName)) { @@ -259,3 +268,13 @@ Map _encodeResult(final AgentResult result) { } return {'ok': true, ...result.data}; } + +bool _includesWebMcp( + final String qualifiedName, + final ManifestSurfaceIndex? surfaceIndex, +) { + if (surfaceIndex == null) { + return true; + } + return surfaceIndex.includesWebMcp(qualifiedName); +} diff --git a/packages/intentcall_platform_sync/lib/src/emitters/android_shortcuts_xml_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/android_shortcuts_xml_emitter.dart index eb6e0e1..f659d5b 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/android_shortcuts_xml_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/android_shortcuts_xml_emitter.dart @@ -27,7 +27,7 @@ final class AndroidShortcutsXmlEmitter { final tools = manifest.tools.where( (final tool) => tool.surfaces.includes( AgentManifestSurface.androidShortcuts, - defaultValue: true, + defaultValue: false, ), ); diff --git a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart index 418c10d..42b9f4b 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -12,8 +12,18 @@ final class AppleSwiftAppIntentsEmitter { String emit(final AgentManifest manifest) { final protocolScheme = fallbackProtocolScheme ?? manifest.protocolScheme; + final intentTools = manifest.tools + .where( + (final tool) => tool.surfaces.includes( + AgentManifestSurface.appleAppIntents, + defaultValue: false, + ), + ) + .toList(); + final emitEntities = _manifestEmitsAppleEntities(manifest); + final emitSpotlight = _manifestEmitsAppleSpotlight(manifest); final bridgeProtocolScheme = - manifest.tools.any( + intentTools.any( (final tool) => tool.dispatchMode == AgentManifestDispatchMode.openApp, ) @@ -23,7 +33,7 @@ final class AppleSwiftAppIntentsEmitter { ..writeln('// Generated by intentcall_platform — do not edit by hand.') ..writeln('import AppIntents') ..writeln('import Foundation'); - if (manifest.entityTypes.isNotEmpty) { + if (emitSpotlight) { buffer.writeln('import CoreSpotlight'); } buffer @@ -35,7 +45,7 @@ final class AppleSwiftAppIntentsEmitter { ..writeln(); final shortcutLines = []; - for (final tool in manifest.tools) { + for (final tool in intentTools) { final inlineRuntime = _appleInlineRuntime(tool); final typeName = swiftIntentTypeName(tool.qualifiedName); final title = escapeSwiftString( @@ -132,7 +142,9 @@ final class AppleSwiftAppIntentsEmitter { } for (final entityType in manifest.entityTypes) { - buffer.write(_swiftEntityType(entityType)); + if (emitEntities) { + buffer.write(_swiftEntityType(entityType, includeSpotlight: emitSpotlight)); + } } buffer @@ -147,17 +159,26 @@ final class AppleSwiftAppIntentsEmitter { buffer ..writeln(' }') ..writeln('}') - ..writeln() - ..write(_swiftInlineRuntimeSupport()); - if (manifest.entityTypes.isNotEmpty) { + ..writeln(); + if (intentTools.any( + (final tool) => + tool.dispatchMode == AgentManifestDispatchMode.inlineRuntime, + )) { + buffer.write(_swiftInlineRuntimeSupport()); + } + if (emitEntities) { buffer ..writeln() ..write( - _swiftEntitySnapshotSupport(manifest.entityTypes, protocolScheme), + _swiftEntitySnapshotSupport( + manifest.entityTypes, + protocolScheme, + includeSpotlight: emitSpotlight, + ), ); } - buffer - ..writeln() + if (intentTools.isNotEmpty) { + buffer ..writeln('enum IntentCallNativeHandoffStore {') ..writeln( ' private static let pendingKey = "intentcall.pending_invocations"', @@ -209,11 +230,44 @@ final class AppleSwiftAppIntentsEmitter { ..writeln(' return invocationId') ..writeln(' }') ..writeln('}'); + } return buffer.toString(); } } +bool _manifestEmitsAppleEntities(final AgentManifest manifest) { + if (manifest.entityTypes.isEmpty) { + return false; + } + final tools = manifest.tools.toList(); + if (tools.isEmpty) { + return true; + } + return tools.any( + (final tool) => tool.surfaces.includes( + AgentManifestSurface.appleEntities, + defaultValue: false, + ), + ); +} + +bool _manifestEmitsAppleSpotlight(final AgentManifest manifest) { + if (!_manifestEmitsAppleEntities(manifest)) { + return false; + } + final tools = manifest.tools.toList(); + if (tools.isEmpty) { + return true; + } + return tools.any( + (final tool) => tool.surfaces.includes( + AgentManifestSurface.appleSpotlight, + defaultValue: false, + ), + ); +} + AgentManifestAppleInlineRuntime? _appleInlineRuntime( final AgentManifestEntry tool, ) { @@ -260,7 +314,10 @@ AgentManifestAppleInlineRuntime? _appleInlineRuntime( return apple; } -String _swiftEntityType(final AgentManifestEntityType entityType) { +String _swiftEntityType( + final AgentManifestEntityType entityType, { + required final bool includeSpotlight, +}) { final entityTypeName = _swiftEntityTypeName(entityType); final queryTypeName = '${entityTypeName}Query'; final openIntentTypeName = _swiftOpenEntityIntentTypeName(entityType); @@ -276,9 +333,15 @@ String _swiftEntityType(final AgentManifestEntityType entityType) { final subtitleKey = escapeSwiftString(entityType.subtitleKey); final keywordsKey = escapeSwiftString(entityType.keywordsKey); final defaultQueryLimit = entityType.defaultQueryLimit; + final entityProtocols = includeSpotlight + ? 'AppEntity, IndexedEntity' + : 'AppEntity'; + final queryProtocols = includeSpotlight + ? 'EntityStringQuery, IndexedEntityQuery' + : 'EntityStringQuery'; final buffer = StringBuffer() ..writeln('@available(iOS 18.0, macOS 15.0, *)') - ..writeln('struct $entityTypeName: AppEntity, IndexedEntity {') + ..writeln('struct $entityTypeName: $entityProtocols {') ..writeln( ' static var typeDisplayRepresentation: TypeDisplayRepresentation = "$displayName"', ) @@ -324,7 +387,7 @@ String _swiftEntityType(final AgentManifestEntityType entityType) { ..writeln('}') ..writeln() ..writeln('@available(iOS 18.0, macOS 15.0, *)') - ..writeln('struct $queryTypeName: EntityStringQuery, IndexedEntityQuery {') + ..writeln('struct $queryTypeName: $queryProtocols {') ..writeln( ' func entities(for identifiers: [String]) async throws -> [$entityTypeName] {', ) @@ -345,33 +408,37 @@ String _swiftEntityType(final AgentManifestEntityType entityType) { ..writeln( ' IntentCallNativeEntitySnapshotStore.search(entityType: "$qualifiedName", query: string, titleKey: "$titleKey", subtitleKey: "$subtitleKey", keywordsKey: "$keywordsKey", limit: $defaultQueryLimit).map($entityTypeName.init(snapshot:))', ) - ..writeln(' }') - ..writeln() - ..writeln(' @available(iOS 27.0, macOS 27.0, *)') - ..writeln( - ' func reindexEntities(for identifiers: [String], indexDescription: CSSearchableIndexDescription) async throws {', - ) - ..writeln( - ' let entities = IntentCallNativeEntitySnapshotStore.entities(entityType: "$qualifiedName", identifiers: identifiers, idKey: "$idKey", limit: nil).map($entityTypeName.init(snapshot:))', - ) - ..writeln(' guard !entities.isEmpty else { return }') - ..writeln( - ' try await CSSearchableIndex.default().indexAppEntities(entities)', - ) - ..writeln(' }') - ..writeln() - ..writeln(' @available(iOS 27.0, macOS 27.0, *)') - ..writeln( - ' func reindexAllEntities(indexDescription: CSSearchableIndexDescription) async throws {', - ) - ..writeln( - ' let entities = IntentCallNativeEntitySnapshotStore.snapshots(entityType: "$qualifiedName").map($entityTypeName.init(snapshot:))', - ) - ..writeln(' guard !entities.isEmpty else { return }') - ..writeln( - ' try await CSSearchableIndex.default().indexAppEntities(entities)', - ) - ..writeln(' }') + ..writeln(' }'); + if (includeSpotlight) { + buffer + ..writeln() + ..writeln(' @available(iOS 27.0, macOS 27.0, *)') + ..writeln( + ' func reindexEntities(for identifiers: [String], indexDescription: CSSearchableIndexDescription) async throws {', + ) + ..writeln( + ' let entities = IntentCallNativeEntitySnapshotStore.entities(entityType: "$qualifiedName", identifiers: identifiers, idKey: "$idKey", limit: nil).map($entityTypeName.init(snapshot:))', + ) + ..writeln(' guard !entities.isEmpty else { return }') + ..writeln( + ' try await CSSearchableIndex.default().indexAppEntities(entities)', + ) + ..writeln(' }') + ..writeln() + ..writeln(' @available(iOS 27.0, macOS 27.0, *)') + ..writeln( + ' func reindexAllEntities(indexDescription: CSSearchableIndexDescription) async throws {', + ) + ..writeln( + ' let entities = IntentCallNativeEntitySnapshotStore.snapshots(entityType: "$qualifiedName").map($entityTypeName.init(snapshot:))', + ) + ..writeln(' guard !entities.isEmpty else { return }') + ..writeln( + ' try await CSSearchableIndex.default().indexAppEntities(entities)', + ) + ..writeln(' }'); + } + buffer ..writeln('}') ..writeln() ..writeln('@available(iOS 18.0, macOS 15.0, *)') @@ -398,245 +465,91 @@ String _swiftEntityType(final AgentManifestEntityType entityType) { String _swiftEntitySnapshotSupport( final Iterable entityTypes, - final String? protocolScheme, -) { - final buffer = StringBuffer() - ..writeln('@available(iOS 18.0, macOS 15.0, *)') - ..writeln('enum IntentCallAppEntityIndexer {') - ..writeln(' static func indexAppEntities() async throws {'); - for (final entityType in entityTypes) { - final entityTypeName = _swiftEntityTypeName(entityType); - final variableName = _swiftLocalEntityVariableName(entityTypeName); + final String? protocolScheme, { + required final bool includeSpotlight, +}) { + final buffer = StringBuffer(); + if (includeSpotlight) { + buffer + ..writeln('@available(iOS 18.0, macOS 15.0, *)') + ..writeln('enum IntentCallAppEntityIndexer {') + ..writeln(' static func indexAppEntities() async throws {'); + for (final entityType in entityTypes) { + final entityTypeName = _swiftEntityTypeName(entityType); + final variableName = _swiftLocalEntityVariableName(entityTypeName); + buffer + ..writeln( + ' let $variableName = IntentCallNativeEntitySnapshotStore.snapshots(entityType: "${escapeSwiftString(entityType.qualifiedName)}").map($entityTypeName.init(snapshot:))', + ) + ..writeln(' if !$variableName.isEmpty {') + ..writeln( + ' try await CSSearchableIndex.default().indexAppEntities($variableName)', + ) + ..writeln(' }'); + } buffer + ..writeln(' }') + ..writeln() ..writeln( - ' let $variableName = IntentCallNativeEntitySnapshotStore.snapshots(entityType: "${escapeSwiftString(entityType.qualifiedName)}").map($entityTypeName.init(snapshot:))', + ' static func deleteAppEntities(entityType: String, ids: [String]) async throws {', ) - ..writeln(' if !$variableName.isEmpty {') + ..writeln(r' let identifiers = ids.map { "\(entityType):\($0)" }') + ..writeln(' guard !identifiers.isEmpty else { return }') ..writeln( - ' try await CSSearchableIndex.default().indexAppEntities($variableName)', + ' try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in', ) - ..writeln(' }'); + ..writeln( + ' CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: identifiers) { error in', + ) + ..writeln(' if let error {') + ..writeln(' continuation.resume(throwing: error)') + ..writeln(' } else {') + ..writeln(' continuation.resume()') + ..writeln(' }') + ..writeln(' }') + ..writeln(' }') + ..writeln(' }') + ..writeln() + ..writeln(' static func reindexAllEntities() async throws {') + ..writeln(' try await indexAppEntities()') + ..writeln(' }') + ..writeln('}') + ..writeln(); } buffer - ..writeln(' }') - ..writeln() - ..writeln( - ' static func deleteAppEntities(entityType: String, ids: [String]) async throws {', - ) - ..writeln(r' let identifiers = ids.map { "\(entityType):\($0)" }') - ..writeln(' guard !identifiers.isEmpty else { return }') - ..writeln( - ' try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in', - ) + ..writeln('enum IntentCallGeneratedEntityConfig {') + ..writeln(' static func configure() {') ..writeln( - ' CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: identifiers) { error in', + ' IntentCallNativeEntitySnapshotStore.fallbackScheme = ${_swiftOptionalString(protocolScheme)}', ) - ..writeln(' if let error {') - ..writeln(' continuation.resume(throwing: error)') - ..writeln(' } else {') - ..writeln(' continuation.resume()') - ..writeln(' }') - ..writeln(' }') - ..writeln(' }') - ..writeln(' }') - ..writeln() - ..writeln(' static func reindexAllEntities() async throws {') - ..writeln(' try await indexAppEntities()') ..writeln(' }') ..writeln('}') - ..writeln() - ..writeln('enum IntentCallNativeEntitySnapshotStore {') - ..writeln( - ' private static let snapshotsKeyPrefix = "intentcall.entity_snapshots."', - ) - ..writeln( - ' private static let pendingOpenKey = "intentcall.pending_entity_opens"', - ) - ..writeln( - ' private static let fallbackScheme: String? = ${_swiftOptionalString(protocolScheme)}', - ) - ..writeln() - ..writeln( - ' static func upsertSnapshots(entityType: String, snapshots: [[String: Any]], idKey: String = "id") -> Int {', - ) - ..writeln(' objc_sync_enter(UserDefaults.standard)') - ..writeln(' defer { objc_sync_exit(UserDefaults.standard) }') - ..writeln( - ' var existing = UserDefaults.standard.array(forKey: snapshotsKey(entityType: entityType)) as? [[String: Any]] ?? []', - ) - ..writeln( - ' var byId = Dictionary(uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in', - ) - ..writeln( - ' guard let id = string(snapshot[idKey]) else { return nil }', - ) - ..writeln(' return (id, snapshot)') - ..writeln(' })') - ..writeln(' for snapshot in snapshots {') - ..writeln(' guard let id = string(snapshot[idKey]) else { continue }') - ..writeln(' byId[id] = snapshot') - ..writeln(' }') - ..writeln(' existing = Array(byId.values)') - ..writeln( - ' UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: entityType))', - ) - ..writeln(' return snapshots.count') - ..writeln(' }') - ..writeln() - ..writeln( - ' static func deleteSnapshots(entityType: String, ids: [String], idKey: String = "id") -> Int {', - ) - ..writeln(' let deleted = Set(ids)') - ..writeln(' objc_sync_enter(UserDefaults.standard)') - ..writeln(' defer { objc_sync_exit(UserDefaults.standard) }') - ..writeln( - ' let existing = UserDefaults.standard.array(forKey: snapshotsKey(entityType: entityType)) as? [[String: Any]] ?? []', - ) - ..writeln(' let kept = existing.filter { snapshot in') - ..writeln( - ' guard let id = string(snapshot[idKey]) else { return true }', - ) - ..writeln(' return !deleted.contains(id)') - ..writeln(' }') - ..writeln( - ' UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: entityType))', - ) - ..writeln(' return existing.count - kept.count') - ..writeln(' }') - ..writeln() - ..writeln(' static func clearSnapshots(entityType: String) -> Int {') - ..writeln(' let existing = snapshots(entityType: entityType).count') - ..writeln( - ' UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: entityType))', - ) - ..writeln(' return existing') - ..writeln(' }') - ..writeln() - ..writeln( - ' static func snapshots(entityType: String) -> [[String: Any]] {', - ) - ..writeln( - ' UserDefaults.standard.array(forKey: snapshotsKey(entityType: entityType)) as? [[String: Any]] ?? []', - ) - ..writeln(' }') - ..writeln() - ..writeln( - ' static func entities(entityType: String, identifiers: [String], idKey: String, limit: Int?) -> [[String: Any]] {', - ) - ..writeln(' let wanted = Set(identifiers)') - ..writeln( - ' let matches = snapshots(entityType: entityType).filter { snapshot in', - ) - ..writeln( - ' guard let id = string(snapshot[idKey]) else { return false }', - ) - ..writeln(' return wanted.contains(id)') - ..writeln(' }') - ..writeln(' return applyingLimit(matches, limit: limit)') - ..writeln(' }') - ..writeln() - ..writeln( - ' static func suggested(entityType: String, limit: Int) -> [[String: Any]] {', - ) - ..writeln( - ' applyingLimit(snapshots(entityType: entityType), limit: limit)', - ) - ..writeln(' }') - ..writeln() - ..writeln( - ' static func search(entityType: String, query: String, titleKey: String, subtitleKey: String, keywordsKey: String, limit: Int) -> [[String: Any]] {', - ) - ..writeln( - ' let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()', - ) ..writeln( - ' guard !needle.isEmpty else { return suggested(entityType: entityType, limit: limit) }', - ) - ..writeln( - ' let matches = snapshots(entityType: entityType).filter { snapshot in', - ) - ..writeln( - r' let fields = [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } + strings(snapshot[keywordsKey])', - ) - ..writeln( - r' return fields.contains { $0.lowercased().contains(needle) }', - ) - ..writeln(' }') - ..writeln(' return applyingLimit(matches, limit: limit)') - ..writeln(' }') - ..writeln() - ..writeln( - ' static func recordOpen(entityType: String, id: String) async -> String {', - ) - ..writeln(' let openId = UUID().uuidString') - ..writeln(' let item: [String: Any] = [') - ..writeln(' "id": openId,') - ..writeln(' "entityType": entityType,') - ..writeln(' "entityId": id,') - ..writeln(' "source": "native.entity.generated",') - ..writeln(' "createdAt": ISO8601DateFormatter().string(from: Date())') - ..writeln(' ]') - ..writeln(' objc_sync_enter(UserDefaults.standard)') - ..writeln(' defer { objc_sync_exit(UserDefaults.standard) }') - ..writeln( - ' var pending = UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? []', - ) - ..writeln(' pending.append(item)') - ..writeln(' UserDefaults.standard.set(pending, forKey: pendingOpenKey)') - ..writeln(' guard let scheme = fallbackScheme else { return openId }') - ..writeln(' let encodedEntityType = encodedPathComponent(entityType)') - ..writeln(' let encodedId = encodedPathComponent(id)') - ..writeln( - r' guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { return openId }', - ) - ..writeln(' #if canImport(UIKit)') - ..writeln(' await UIApplication.shared.open(url)') - ..writeln(' #elseif canImport(AppKit)') - ..writeln(' NSWorkspace.shared.open(url)') - ..writeln(' #endif') - ..writeln(' return openId') - ..writeln(' }') - ..writeln() - ..writeln(' static func string(_ value: Any?) -> String? {') - ..writeln(' if let value = value as? String { return value }') - ..writeln(' if let value = value as? CustomStringConvertible {') - ..writeln(' return value.description') - ..writeln(' }') - ..writeln(' return nil') - ..writeln(' }') - ..writeln() - ..writeln(' static func strings(_ value: Any?) -> [String] {') - ..writeln(' if let values = value as? [String] { return values }') - ..writeln(' if let values = value as? [Any] {') - ..writeln(r' return values.compactMap { string($0) }') - ..writeln(' }') - ..writeln(' if let value = string(value) { return [value] }') - ..writeln(' return []') - ..writeln(' }') - ..writeln() - ..writeln( - ' private static func snapshotsKey(entityType: String) -> String {', - ) - ..writeln(' snapshotsKeyPrefix + entityType') - ..writeln(' }') - ..writeln() - ..writeln( - ' private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] {', - ) - ..writeln(' guard let limit else { return rows }') - ..writeln(' return Array(rows.prefix(max(0, limit)))') - ..writeln(' }') - ..writeln() - ..writeln( - ' private static func encodedPathComponent(_ value: String) -> String {', - ) - ..writeln(' var allowedPath = CharacterSet.alphanumerics') - ..writeln(' allowedPath.insert(charactersIn: "_-.~")') - ..writeln( - ' return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value', - ) - ..writeln(' }') - ..writeln('}'); + 'private let _intentCallGeneratedEntityConfig = IntentCallGeneratedEntityConfig.configure()', + ); + if (includeSpotlight) { + buffer + ..writeln() + ..writeln('@available(iOS 18.0, macOS 15.0, *)') + ..writeln('private final class IntentCallEntitySnapshotReindexObserver {') + ..writeln(' init() {') + ..writeln(' NotificationCenter.default.addObserver(') + ..writeln( + ' forName: IntentCallNativeEntitySnapshotStore.snapshotsDidChangeNotification,', + ) + ..writeln(' object: nil,') + ..writeln(' queue: nil') + ..writeln(' ) { _ in') + ..writeln(' Task {') + ..writeln(' try? await IntentCallAppEntityIndexer.indexAppEntities()') + ..writeln(' }') + ..writeln(' }') + ..writeln(' }') + ..writeln('}') + ..writeln( + 'private let _intentCallEntitySnapshotReindexObserver = IntentCallEntitySnapshotReindexObserver()', + ); + } return buffer.toString(); } diff --git a/packages/intentcall_platform_sync/lib/src/emitters/linux_desktop_entry_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/linux_desktop_entry_emitter.dart index b582cc1..0522709 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/linux_desktop_entry_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/linux_desktop_entry_emitter.dart @@ -22,7 +22,7 @@ final class LinuxDesktopEntryEmitter { .where( (final tool) => tool.surfaces.includes( AgentManifestSurface.linuxSchemeHandler, - defaultValue: true, + defaultValue: false, ), ) .map((final t) => '# tool: ${t.qualifiedName}') diff --git a/packages/intentcall_platform_sync/lib/src/emitters/web_manifest_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/web_manifest_emitter.dart index 8ed6303..9574043 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/web_manifest_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/web_manifest_emitter.dart @@ -25,13 +25,13 @@ final class WebManifestEmitter { final shortcutTools = manifest.tools.where( (final tool) => tool.surfaces.includes( AgentManifestSurface.webManifestShortcuts, - defaultValue: true, + defaultValue: false, ), ); final protocolTools = manifest.tools.where( (final tool) => tool.surfaces.includes( AgentManifestSurface.webProtocolHandlers, - defaultValue: true, + defaultValue: false, ), ); diff --git a/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart index 0acc4ee..ddca29b 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart @@ -27,7 +27,7 @@ final class WebMcpJsEmitter { .where( (final tool) => tool.surfaces.includes( AgentManifestSurface.webMcp, - defaultValue: true, + defaultValue: false, ), ) .map( diff --git a/packages/intentcall_platform_sync/lib/src/emitters/windows_protocol_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/windows_protocol_emitter.dart index 090384e..d3cbc00 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/windows_protocol_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/windows_protocol_emitter.dart @@ -20,7 +20,7 @@ final class WindowsProtocolEmitter { .where( (final tool) => tool.surfaces.includes( AgentManifestSurface.windowsProtocolActivation, - defaultValue: true, + defaultValue: false, ), ) .map((final t) => '; tool: ${t.qualifiedName}') @@ -49,7 +49,7 @@ Windows Registry Editor Version 5.00 .where( (final tool) => tool.surfaces.includes( AgentManifestSurface.windowsMsixProtocol, - defaultValue: true, + defaultValue: false, ), ) .map((final t) => t.qualifiedName) diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart index e16b38b..2cb8643 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:intentcall_core/intentcall_core.dart'; + import '../agent_manifest.dart'; import '../catalog/agent_registry_catalog.dart'; import 'manifest_merger.dart'; @@ -17,12 +19,14 @@ final class ManifestExporter { AgentManifest buildManifest({ required final Iterable catalog, required final ManifestExportContext context, + final Iterable entityTypeDescriptors = const [], }) => _merger.mergeManifest( catalog: catalog, policy: context.policy, protocolScheme: context.protocolScheme, platform: context.platform, enabledPlatforms: context.enabledPlatforms, + entityTypeDescriptors: entityTypeDescriptors, ); String encodeManifest(final AgentManifest manifest) => @@ -35,10 +39,15 @@ final class ManifestExporter { required final Iterable catalog, required final ManifestExportContext context, required final File outPath, + final Iterable entityTypeDescriptors = const [], final bool checkOnly = false, }) { final encoded = encodeManifest( - buildManifest(catalog: catalog, context: context), + buildManifest( + catalog: catalog, + context: context, + entityTypeDescriptors: entityTypeDescriptors, + ), ); if (checkOnly) { diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart index 5ddf5f6..097c620 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart @@ -8,6 +8,7 @@ import 'package:yaml/yaml.dart'; import '../agent_manifest.dart'; import '../catalog/agent_registry_catalog.dart'; import 'projection_policy.dart'; +import 'surface_resolver.dart'; /// Inputs for [ManifestMerger.mergeManifest] loaded from host config. final class ManifestExportContext { @@ -57,9 +58,10 @@ final class ManifestMerger { '"${row.qualifiedName}".', ); } - final surfaces = - overlay?.resolveSurfaces(defaults: defaultSurfaces) ?? - defaultSurfaces; + final surfaces = resolveEntrySurfaces( + defaultSurfaces: defaultSurfaces, + overlay: overlay, + ); entries.add( AgentManifestEntry( qualifiedName: row.qualifiedName, @@ -122,22 +124,38 @@ final class ManifestMerger { } String readManifestRelativePath(final String projectRoot) { + final layout = _readLayoutYamlMap(projectRoot); + if (layout != null) { + final manifest = layout['manifest']?.toString().trim(); + if (manifest != null && manifest.isNotEmpty) { + return manifest; + } + } + return 'web/agent_manifest.json'; + } + + String readWebDirRelativePath(final String projectRoot) { + final layout = _readLayoutYamlMap(projectRoot); + if (layout != null) { + final webDir = layout['webDir']?.toString().trim(); + if (webDir != null && webDir.isNotEmpty) { + return webDir; + } + } + return 'web'; + } + + YamlMap? _readLayoutYamlMap(final String projectRoot) { final configFile = File(p.join(projectRoot, 'intentcall.yaml')); if (!configFile.existsSync()) { - return 'web/agent_manifest.json'; + return null; } final doc = loadYaml(configFile.readAsStringSync()); if (doc is! YamlMap) { - return 'web/agent_manifest.json'; + return null; } final layout = doc['layout']; - if (layout is YamlMap) { - final manifest = layout['manifest']?.toString().trim(); - if (manifest != null && manifest.isNotEmpty) { - return manifest; - } - } - return 'web/agent_manifest.json'; + return layout is YamlMap ? layout : null; } ManifestExportContext loadExportContext({ @@ -198,20 +216,16 @@ AgentManifestEntityType _entityFromDescriptor( /// Shared entity projection JSON for one descriptor. String generateEntityManifestJson(final AgentEntityTypeDescriptor descriptor) { - final displayProperties = descriptor.displayProperties.toList(); - final searchableProperties = descriptor.searchableProperties.toList(); - final titleKey = displayProperties.isNotEmpty - ? displayProperties.first.name - : 'title'; + final keys = AgentEntitySnapshotKeys.fromDescriptor(descriptor); return const JsonEncoder.withIndent(' ').convert({ 'qualifiedName': descriptor.qualifiedName, 'namespace': descriptor.namespace, 'name': descriptor.name, 'displayName': descriptor.displayName ?? descriptor.name, - 'idKey': descriptor.identifierName, - 'titleKey': titleKey, - 'subtitleKey': 'subtitle', - 'keywordsKey': 'keywords', - 'snapshotSchema': const {'type': 'object'}, + 'idKey': keys.idKey, + 'titleKey': keys.titleKey, + 'subtitleKey': keys.subtitleKey, + 'keywordsKey': keys.keywordsKey, + 'snapshotSchema': agentEntitySnapshotSchema(descriptor), }); } diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_surface_index.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_surface_index.dart new file mode 100644 index 0000000..e4a576b --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_surface_index.dart @@ -0,0 +1,29 @@ +import '../agent_manifest.dart'; + +/// Fast lookup of per-tool surface policy from a parsed [AgentManifest]. +final class ManifestSurfaceIndex { + ManifestSurfaceIndex.fromManifest(final AgentManifest manifest) + : _byQualifiedName = { + for (final entry in manifest.entries) entry.qualifiedName: entry.surfaces, + }; + + final Map _byQualifiedName; + + bool includes( + final String qualifiedName, + final AgentManifestSurface surface, { + required final bool defaultValue, + }) { + final policy = _byQualifiedName[qualifiedName]; + if (policy == null) { + return defaultValue; + } + return policy.includes(surface, defaultValue: defaultValue); + } + + bool includesWebMcp(final String qualifiedName) => includes( + qualifiedName, + AgentManifestSurface.webMcp, + defaultValue: false, + ); +} diff --git a/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart index 2289ee6..9e46b7b 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart @@ -3,7 +3,10 @@ import '../agent_manifest.dart'; /// Default surface inclusion when no per-entry override exists. bool defaultSurfaceInclude(final AgentManifestSurface surface) => switch (surface) { - AgentManifestSurface.appleAppShortcuts => false, + AgentManifestSurface.appleAppIntents || + AgentManifestSurface.appleAppShortcuts || + AgentManifestSurface.appleSpotlight || + AgentManifestSurface.appleEntities => false, AgentManifestSurface.androidShortcuts || AgentManifestSurface.webManifestShortcuts || AgentManifestSurface.webProtocolHandlers || @@ -20,7 +23,10 @@ Set platformsForManifestSurface(final AgentManifestSurface surface) => AgentManifestSurface.webManifestShortcuts || AgentManifestSurface.webProtocolHandlers => {'web'}, AgentManifestSurface.androidShortcuts => {'android'}, - AgentManifestSurface.appleAppShortcuts => {'ios', 'macos'}, + AgentManifestSurface.appleAppIntents || + AgentManifestSurface.appleAppShortcuts || + AgentManifestSurface.appleSpotlight || + AgentManifestSurface.appleEntities => {'ios', 'macos'}, AgentManifestSurface.windowsProtocolActivation || AgentManifestSurface.windowsMsixProtocol => {'windows'}, AgentManifestSurface.linuxSchemeHandler => {'linux'}, @@ -30,6 +36,20 @@ bool defaultSurfaceIncludeForPlatforms( final AgentManifestSurface surface, final Set enabledPlatforms, ) { + // ADR 0016 / 0022: shortcuts, entities, spotlight never auto-enable. + if (surface == AgentManifestSurface.appleAppShortcuts || + surface == AgentManifestSurface.appleSpotlight || + surface == AgentManifestSurface.appleEntities) { + return false; + } + // ADR 0022: App Intent structs default on for ios/macos when platforms scoped. + if (surface == AgentManifestSurface.appleAppIntents) { + if (enabledPlatforms.isEmpty) { + return defaultSurfaceInclude(surface); + } + return enabledPlatforms.contains('ios') || + enabledPlatforms.contains('macos'); + } if (enabledPlatforms.isEmpty) { return defaultSurfaceInclude(surface); } @@ -144,18 +164,16 @@ final class ProjectionPolicy { AgentManifestSurfacePolicy resolvedDefaultSurfaces({ final Iterable enabledPlatforms = const [], }) { - if (!defaultSurfaces.isEmpty) { - return defaultSurfaces; - } final normalized = enabledPlatforms .map((final platform) => platform.trim().toLowerCase()) .where((final platform) => platform.isNotEmpty) .toSet(); final overrides = {}; for (final surface in AgentManifestSurface.values) { - overrides[surface] = AgentManifestSurfaceExposure( - include: defaultSurfaceIncludeForPlatforms(surface, normalized), - ); + final explicit = defaultSurfaces.overrides[surface]?.include; + final include = + explicit ?? defaultSurfaceIncludeForPlatforms(surface, normalized); + overrides[surface] = AgentManifestSurfaceExposure(include: include); } return AgentManifestSurfacePolicy(overrides); } diff --git a/packages/intentcall_platform_sync/lib/src/projection/surface_resolver.dart b/packages/intentcall_platform_sync/lib/src/projection/surface_resolver.dart new file mode 100644 index 0000000..cee4f92 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/surface_resolver.dart @@ -0,0 +1,32 @@ +import '../agent_manifest.dart'; +import 'projection_policy.dart'; + +/// Returns a dense [AgentManifestSurfacePolicy] with all surfaces resolved. +AgentManifestSurfacePolicy resolveEntrySurfaces({ + required final AgentManifestSurfacePolicy defaultSurfaces, + final EntryProjection? overlay, +}) { + final overrides = + Map.from( + defaultSurfaces.overrides, + ); + if (overlay != null) { + for (final entry in overlay.surfaces.entries) { + overrides[entry.key] = AgentManifestSurfaceExposure(include: entry.value); + } + } + return AgentManifestSurfacePolicy(_denseOverrides(overrides)); +} + +Map _denseOverrides( + final Map sparse, +) { + final out = {}; + for (final surface in AgentManifestSurface.values) { + final exposure = sparse[surface]; + out[surface] = AgentManifestSurfaceExposure( + include: exposure?.include ?? false, + ); + } + return Map.unmodifiable(out); +} diff --git a/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart b/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart index f0579ab..2dcec41 100644 --- a/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import '../agent_manifest.dart'; +import '../projection/manifest_merger.dart'; import '../emitters/android_shortcuts_xml_emitter.dart'; import '../emitters/apple_swift_app_intents_emitter.dart'; import '../emitters/linux_desktop_entry_emitter.dart'; @@ -127,6 +128,7 @@ final class PlatformSync { this.appleSwiftEmitter = const AppleSwiftAppIntentsEmitter(), this.linuxDesktopEmitter = const LinuxDesktopEntryEmitter(), this.windowsProtocolEmitter = const WindowsProtocolEmitter(), + this.manifestMerger = const ManifestMerger(), }); final String manifestFileName; @@ -149,6 +151,7 @@ final class PlatformSync { final AppleSwiftAppIntentsEmitter appleSwiftEmitter; final LinuxDesktopEntryEmitter linuxDesktopEmitter; final WindowsProtocolEmitter windowsProtocolEmitter; + final ManifestMerger manifestMerger; AgentManifest readManifest(final String projectRoot) { final manifestFile = _resolveManifestFile(projectRoot); @@ -200,7 +203,7 @@ final class PlatformSync { final bool dryRun = false, }) { final manifest = readManifest(projectRoot); - final webDir = Directory(p.join(projectRoot, webDirName)); + final webDir = Directory(p.join(projectRoot, _resolveWebDirName(projectRoot))); if (!webDir.existsSync()) { throw StateError('Missing web/ directory under $projectRoot'); } @@ -422,7 +425,7 @@ final class PlatformSync { /// Returns `true` when generated web outputs already match emitters. bool checkWeb(final String projectRoot) { final manifest = readManifest(projectRoot); - final webDir = p.join(projectRoot, webDirName); + final webDir = p.join(projectRoot, _resolveWebDirName(projectRoot)); final webManifestFile = File(p.join(webDir, webManifestFileName)); final jsFile = File(p.join(webDir, webMcpJsFileName)); if (!webManifestFile.existsSync() || !jsFile.existsSync()) { @@ -682,12 +685,20 @@ final class PlatformSync { ); File _resolveManifestFile(final String projectRoot) { + final relativePath = manifestMerger.readManifestRelativePath(projectRoot); + final configured = File(p.join(projectRoot, relativePath)); + if (configured.existsSync()) { + return configured; + } final rootCandidate = File(p.join(projectRoot, manifestFileName)); if (rootCandidate.existsSync()) { return rootCandidate; } - return File(p.join(projectRoot, webDirName, manifestFileName)); + return configured; } + + String _resolveWebDirName(final String projectRoot) => + manifestMerger.readWebDirRelativePath(projectRoot); } /// Snippet to inject into `web/index.html` once. diff --git a/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart new file mode 100644 index 0000000..6b272a7 --- /dev/null +++ b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart @@ -0,0 +1,159 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:test/test.dart'; + +const _appleAppIntentsOnly = { + 'apple.appIntents': {'include': true}, +}; + +const _appleShortcutsOnly = { + 'apple.appIntents': {'include': false}, + 'apple.appShortcuts': {'include': true}, +}; + +const _appleEntitiesAndSpotlight = { + 'apple.entities': {'include': true}, + 'apple.spotlight': {'include': true}, +}; + +AgentManifest _appleManifest({ + required final Map toolSurfaces, + final List entityTypes = const [], + final List tools = const [], +}) => AgentManifest.fromJson({ + 'version': 1, + 'platform': 'apple', + 'protocolScheme': 'demoapp', + if (entityTypes.isNotEmpty) 'entityTypes': entityTypes, + 'tools': tools.isNotEmpty + ? tools + : [ + { + 'qualifiedName': 'app_ping', + 'namespace': 'app', + 'name': 'ping', + 'description': 'Ping', + 'kind': 'tool', + 'surfaces': toolSurfaces, + 'inputSchema': {'type': 'object'}, + }, + ], +}); + +void main() { + group('AppleSwiftAppIntentsEmitter surface matrix', () { + test('appleAppIntents emits struct without shortcuts', () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + _appleManifest(toolSurfaces: _appleAppIntentsOnly), + ); + + expect(swift, contains('struct AppPingIntent: AppIntent')); + expect(swift, isNot(contains('AppShortcut(intent: AppPingIntent()'))); + expect(swift, contains('static var appShortcuts: [AppShortcut] {')); + expect(swift, contains('return []')); + }); + + test('appleAppShortcuts without appleAppIntents emits no struct', () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + _appleManifest(toolSurfaces: _appleShortcutsOnly), + ); + + expect(swift, isNot(contains('struct AppPingIntent: AppIntent'))); + expect(swift, isNot(contains('AppShortcut(intent: AppPingIntent()'))); + expect(swift, contains('return []')); + }); + + test('appleAppShortcuts with appleAppIntents emits shortcut row only', () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + _appleManifest( + toolSurfaces: { + 'apple.appIntents': {'include': true}, + 'apple.appShortcuts': {'include': true}, + }, + ), + ); + + expect(swift, contains('struct AppPingIntent: AppIntent')); + expect(swift, contains('AppShortcut(intent: AppPingIntent(), phrases:')); + }); + + test('appleEntities without spotlight omits CoreSpotlight and indexer', () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + _appleManifest( + toolSurfaces: { + 'apple.entities': {'include': true}, + }, + entityTypes: [ + { + 'qualifiedName': 'app_project', + 'namespace': 'app', + 'name': 'project', + 'displayName': 'Project', + 'description': 'Open project', + }, + ], + ), + ); + + expect(swift, contains('struct AppProjectEntity: AppEntity {')); + expect(swift, isNot(contains('IndexedEntity'))); + expect(swift, isNot(contains('import CoreSpotlight'))); + expect(swift, isNot(contains('IntentCallAppEntityIndexer'))); + expect(swift, contains('enum IntentCallGeneratedEntityConfig')); + expect(swift, isNot(contains('enum IntentCallNativeEntitySnapshotStore {'))); + }); + + test('appleSpotlight adds indexing helpers', () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + _appleManifest( + toolSurfaces: _appleEntitiesAndSpotlight, + entityTypes: [ + { + 'qualifiedName': 'app_project', + 'namespace': 'app', + 'name': 'project', + 'displayName': 'Project', + 'description': 'Open project', + }, + ], + ), + ); + + expect( + swift, + contains('struct AppProjectEntity: AppEntity, IndexedEntity'), + ); + expect(swift, contains('import CoreSpotlight')); + expect(swift, contains('enum IntentCallAppEntityIndexer')); + expect(swift, contains('func reindexAllEntities(')); + expect( + swift, + contains('CSSearchableIndex.default().indexAppEntities'), + ); + }); + }); + + group('ProjectionPolicy apple defaults', () { + test('ios enables appleAppIntents but not shortcuts or spotlight', () { + const policy = ProjectionPolicy(); + final surfaces = policy.resolvedDefaultSurfaces( + enabledPlatforms: ['ios'], + ); + expect( + surfaces.includes(AgentManifestSurface.appleAppIntents, defaultValue: false), + isTrue, + ); + expect( + surfaces.includes(AgentManifestSurface.appleAppShortcuts, defaultValue: true), + isFalse, + ); + expect( + surfaces.includes(AgentManifestSurface.appleSpotlight, defaultValue: true), + isFalse, + ); + expect( + surfaces.includes(AgentManifestSurface.appleEntities, defaultValue: true), + isFalse, + ); + }); + }); +} diff --git a/packages/intentcall_platform_sync/test/dense_manifest_test.dart b/packages/intentcall_platform_sync/test/dense_manifest_test.dart new file mode 100644 index 0000000..96973a2 --- /dev/null +++ b/packages/intentcall_platform_sync/test/dense_manifest_test.dart @@ -0,0 +1,33 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:test/test.dart'; + +void main() { + test('every exported tool row has all surface keys with bool include', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['web'], + ); + final json = manifest.entries.single.toJson(); + final surfaces = json['surfaces']! as Map; + expect(surfaces.length, AgentManifestSurface.values.length); + for (final entry in surfaces.entries) { + final exposure = entry.value! as Map; + expect(exposure['include'], isA()); + } + }); +} diff --git a/packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart b/packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart new file mode 100644 index 0000000..e92fff4 --- /dev/null +++ b/packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart @@ -0,0 +1,72 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:test/test.dart'; + +void main() { + test('ios enabled keeps apple.appShortcuts opt-in false', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['ios'], + ); + final surfaces = manifest.entries.single.surfaces; + expect( + surfaces.includes(AgentManifestSurface.appleAppShortcuts, defaultValue: true), + isFalse, + ); + expect( + surfaces.includes(AgentManifestSurface.appleAppIntents, defaultValue: false), + isTrue, + ); + expect( + surfaces.includes(AgentManifestSurface.webMcp, defaultValue: true), + isFalse, + ); + }); + + test('ios enabled with explicit apple.appShortcuts true honors overlay', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy( + defaultSurfaces: AgentManifestSurfacePolicy({ + AgentManifestSurface.appleAppShortcuts: + AgentManifestSurfaceExposure(include: true), + }), + ); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['ios'], + ); + expect( + manifest.entries.single.surfaces.includes( + AgentManifestSurface.appleAppShortcuts, + defaultValue: false, + ), + isTrue, + ); + }); +} diff --git a/packages/intentcall_platform_sync/test/manifest_merger_test.dart b/packages/intentcall_platform_sync/test/manifest_merger_test.dart index fb2ea23..728a72f 100644 --- a/packages/intentcall_platform_sync/test/manifest_merger_test.dart +++ b/packages/intentcall_platform_sync/test/manifest_merger_test.dart @@ -34,7 +34,7 @@ void main() { expect( manifest.entries.single.surfaces.includes( AgentManifestSurface.webMcp, - defaultValue: true, + defaultValue: false, ), isFalse, ); @@ -65,13 +65,13 @@ void main() { isTrue, ); expect( - surfaces.includes(AgentManifestSurface.androidShortcuts, defaultValue: true), + surfaces.includes(AgentManifestSurface.androidShortcuts, defaultValue: false), isFalse, ); expect( surfaces.includes( AgentManifestSurface.windowsProtocolActivation, - defaultValue: true, + defaultValue: false, ), isFalse, ); diff --git a/packages/intentcall_platform_sync/test/native_emitters_test.dart b/packages/intentcall_platform_sync/test/native_emitters_test.dart index 60a5e82..bb14926 100644 --- a/packages/intentcall_platform_sync/test/native_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/native_emitters_test.dart @@ -1,6 +1,10 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:test/test.dart'; +const _appleAppIntentsSurface = { + 'apple.appIntents': {'include': true}, +}; + void main() { final manifest = AgentManifest.fromJson({ 'version': 1, @@ -14,7 +18,12 @@ void main() { 'description': 'Return cart total', 'kind': 'tool', 'surfaces': { + 'apple.appIntents': {'include': true}, 'apple.appShortcuts': {'include': true}, + 'android.shortcuts': {'include': true}, + 'linux.schemeHandler': {'include': true}, + 'windows.protocolActivation': {'include': true}, + 'windows.msixProtocol': {'include': true}, }, 'inputSchema': { 'type': 'object', @@ -177,12 +186,10 @@ void main() { 'IntentCallNativeEntitySnapshotStore.recordOpen(entityType: "app_project", id: target.id)', ), ); - expect(swift, contains('enum IntentCallNativeEntitySnapshotStore')); + expect(swift, contains('enum IntentCallGeneratedEntityConfig')); expect( swift, - contains( - 'private static let snapshotsKeyPrefix = "intentcall.entity_snapshots."', - ), + isNot(contains('enum IntentCallNativeEntitySnapshotStore {')), ); expect(swift, contains('static func indexAppEntities() async throws')); expect(swift, contains('CSSearchableIndex.default().indexAppEntities')); @@ -195,7 +202,9 @@ void main() { ); expect( swift, - contains('private static let fallbackScheme: String? = "demoapp"'), + contains( + 'IntentCallNativeEntitySnapshotStore.fallbackScheme = "demoapp"', + ), ); expect(swift, isNot(contains('FlutterEngine'))); expect(swift, isNot(contains('FlutterMethodChannel'))); @@ -213,6 +222,7 @@ void main() { 'name': 'ping', 'description': 'Ping', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': {'type': 'object'}, }, ], @@ -247,6 +257,7 @@ void main() { 'description': 'Ping', 'kind': 'tool', 'dispatchMode': 'queueOnly', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': {'type': 'object'}, }, ], @@ -286,6 +297,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'platforms': { @@ -347,6 +359,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'result': {'type': 'string'}, @@ -426,6 +439,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'dartExtensionInline', 'platforms': { @@ -463,6 +477,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'platforms': { @@ -499,6 +514,7 @@ void main() { 'description': 'Publish', 'kind': 'tool', 'surfaces': { + 'apple.appIntents': {'include': true}, 'apple.appShortcuts': {'include': true}, }, 'inputSchema': {'type': 'object'}, @@ -509,6 +525,7 @@ void main() { 'name': 'private', 'description': 'Private', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': {'type': 'object'}, }, ], @@ -582,6 +599,7 @@ void main() { 'name': 'reserved', 'description': 'Reserved', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': { 'type': 'object', 'required': ['class'], @@ -609,6 +627,7 @@ void main() { 'name': 'object', 'description': 'Object', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': { 'type': 'object', 'required': ['payload'], @@ -707,6 +726,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'dartExtensionInline', 'result': { @@ -785,6 +805,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'dartExtensionInline', 'platforms': { @@ -864,6 +885,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'result': {'type': 'number'}, @@ -1147,81 +1169,6 @@ struct IntentCallShortcutsProvider: AppShortcutsProvider { } } -struct IntentCallInlineRuntimeResult { - let dialog: String - let value: Any? - - init(dialog: String = "Completed inline runtime invocation.", value: Any? = nil) { - self.dialog = dialog - self.value = value - } -} - -enum IntentCallInlineRuntimeError: Error, CustomStringConvertible { - case missingHandler(String) - case handlerFailed(String) - case invalidTypedResult(String, String) - - var description: String { - switch self { - case .missingHandler(let qualifiedName): - return "No native inline handler registered for \(qualifiedName)." - case .handlerFailed(let message): - return "Inline runtime failed: \(message)" - case .invalidTypedResult(let qualifiedName, let typeName): - return "Inline runtime for \(qualifiedName) did not return \(typeName)." - } - } -} - -typealias IntentCallAppleInlineRuntimeHandler = @Sendable ([String: Any]) async throws -> IntentCallInlineRuntimeResult - -enum IntentCallAppleInlineRuntime { - private static let lock = NSObject() - private nonisolated(unsafe) static var handlers: [String: IntentCallAppleInlineRuntimeHandler] = [:] - - static func register(qualifiedName: String, handler: @escaping IntentCallAppleInlineRuntimeHandler) { - objc_sync_enter(lock) - defer { objc_sync_exit(lock) } - handlers[qualifiedName] = handler - } - - static func perform(qualifiedName: String, arguments: [String: Any]) async throws -> IntentCallInlineRuntimeResult { - objc_sync_enter(lock) - let handler = handlers[qualifiedName] - objc_sync_exit(lock) - guard let handler else { - throw IntentCallInlineRuntimeError.missingHandler(qualifiedName) - } - do { - return try await handler(arguments) - } catch let error as IntentCallInlineRuntimeError { - throw error - } catch { - throw IntentCallInlineRuntimeError.handlerFailed(error.localizedDescription) - } - } - - static func typedValue(_ result: IntentCallInlineRuntimeResult, as type: T.Type, qualifiedName: String) throws -> T { - guard let raw = result.value else { - throw IntentCallInlineRuntimeError.invalidTypedResult(qualifiedName, String(describing: T.self)) - } - if let value = raw as? T { - return value - } - if T.self == Int.self, let value = raw as? NSNumber { - return value.intValue as! T - } - if T.self == Double.self, let value = raw as? NSNumber { - return value.doubleValue as! T - } - if T.self == Bool.self, let value = raw as? NSNumber { - return value.boolValue as! T - } - throw IntentCallInlineRuntimeError.invalidTypedResult(qualifiedName, String(describing: T.self)) - } -} - enum IntentCallNativeHandoffStore { private static let pendingKey = "intentcall.pending_invocations" diff --git a/packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart b/packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart new file mode 100644 index 0000000..097503b --- /dev/null +++ b/packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart @@ -0,0 +1,49 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:test/test.dart'; + +void main() { + test('partial yaml defaults merge with platform scope', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy( + defaultSurfaces: AgentManifestSurfacePolicy({ + AgentManifestSurface.webMcp: AgentManifestSurfaceExposure(include: true), + }), + ); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['web'], + ); + final surfaces = manifest.entries.single.surfaces; + expect( + surfaces.includes(AgentManifestSurface.webMcp, defaultValue: false), + isTrue, + ); + expect( + surfaces.includes( + AgentManifestSurface.androidShortcuts, + defaultValue: true, + ), + isFalse, + ); + expect( + surfaces.includes( + AgentManifestSurface.windowsProtocolActivation, + defaultValue: true, + ), + isFalse, + ); + }); +} diff --git a/packages/intentcall_platform_sync/test/platform_sync_layout_test.dart b/packages/intentcall_platform_sync/test/platform_sync_layout_test.dart new file mode 100644 index 0000000..ec80e61 --- /dev/null +++ b/packages/intentcall_platform_sync/test/platform_sync_layout_test.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('PlatformSync reads custom layout.manifest and layout.webDir', () { + final temp = Directory.systemTemp.createTempSync( + 'intentcall_platform_sync_layout_', + ); + addTearDown(() => temp.deleteSync(recursive: true)); + + final assetsDir = Directory(p.join(temp.path, 'assets'))..createSync(); + final webDir = Directory(p.join(assetsDir.path, 'web'))..createSync(); + File(p.join(temp.path, 'intentcall.yaml')).writeAsStringSync(''' +host: dart +layout: + manifest: assets/agent_manifest.json + webDir: assets/web +'''); + File(p.join(assetsDir.path, 'agent_manifest.json')).writeAsStringSync(''' +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "app_cart_total", + "namespace": "app", + "name": "cart_total", + "description": "Return cart total", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": true}, + "web.manifestShortcuts": {"include": false}, + "web.protocolHandlers": {"include": false}, + "android.shortcuts": {"include": false}, + "apple.appShortcuts": {"include": false}, + "windows.protocolActivation": {"include": false}, + "windows.msixProtocol": {"include": false}, + "linux.schemeHandler": {"include": false} + } + } + ] +} +'''); + File(p.join(webDir.path, 'manifest.json')).writeAsStringSync(''' +{ + "name": "demo", + "start_url": "." +} +'''); + + const sync = PlatformSync(); + expect( + sync.readManifest(temp.path).tools.single.qualifiedName, + 'app_cart_total', + ); + final result = sync.syncWeb(projectRoot: temp.path); + expect(result.wroteWebMcpJs, isTrue); + expect( + p.dirname(result.webMcpJsPath!), + p.join(temp.path, 'assets', 'web'), + ); + expect(sync.checkWeb(temp.path), isTrue); + }); +} diff --git a/packages/intentcall_platform_sync/test/projection_alignment_test.dart b/packages/intentcall_platform_sync/test/projection_alignment_test.dart new file mode 100644 index 0000000..2ae3a01 --- /dev/null +++ b/packages/intentcall_platform_sync/test/projection_alignment_test.dart @@ -0,0 +1,363 @@ +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// Cross-layer alignment matrix from projection-pipeline-spec section 6. +void main() { + group('projection pipeline alignment matrix', () { + group('dense surfaces per tool in JSON', () { + test('export emits all AgentManifestSurface keys with bool include', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['web'], + ); + final json = manifest.entries.single.toJson(); + final surfaces = json['surfaces']! as Map; + expect(surfaces.length, AgentManifestSurface.values.length); + for (final entry in surfaces.entries) { + final exposure = entry.value! as Map; + expect(exposure['include'], isA()); + } + }); + }); + + group('web-only yaml scopes non-web surfaces off', () { + test('partial defaults with platforms:[web] omit android/windows', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy( + defaultSurfaces: AgentManifestSurfacePolicy({ + AgentManifestSurface.webMcp: AgentManifestSurfaceExposure( + include: true, + ), + }), + ); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['web'], + ); + final surfaces = manifest.entries.single.surfaces; + expect( + surfaces.includes(AgentManifestSurface.webMcp, defaultValue: false), + isTrue, + ); + expect( + surfaces.includes( + AgentManifestSurface.androidShortcuts, + defaultValue: true, + ), + isFalse, + ); + expect( + surfaces.includes( + AgentManifestSurface.windowsProtocolActivation, + defaultValue: true, + ), + isFalse, + ); + }); + }); + + group('emitter defaultValue:false excludes absent surfaces', () { + test('WebMcpJsEmitter skips tools without web.webMcp include', () { + final manifest = AgentManifest.fromJson({ + 'version': 1, + 'platform': 'web', + 'tools': [ + { + 'qualifiedName': 'app_visible', + 'namespace': 'app', + 'name': 'visible', + 'description': 'visible', + 'kind': 'tool', + 'inputSchema': {'type': 'object'}, + 'surfaces': { + 'web.webMcp': {'include': true}, + }, + }, + { + 'qualifiedName': 'app_hidden', + 'namespace': 'app', + 'name': 'hidden', + 'description': 'hidden', + 'kind': 'tool', + 'inputSchema': {'type': 'object'}, + }, + ], + }); + + final js = const WebMcpJsEmitter().emit(manifest); + expect(js, contains('app_visible')); + expect(js, isNot(contains('app_hidden'))); + }); + }); + + group('sync uses layout.manifest and layout.webDir', () { + test('PlatformSync resolves custom layout paths', () { + final temp = Directory.systemTemp.createTempSync( + 'intentcall_projection_alignment_layout_', + ); + addTearDown(() => temp.deleteSync(recursive: true)); + + final assetsDir = Directory(p.join(temp.path, 'assets'))..createSync(); + final webDir = Directory(p.join(assetsDir.path, 'web'))..createSync(); + File(p.join(temp.path, 'intentcall.yaml')).writeAsStringSync(''' +host: dart +layout: + manifest: assets/agent_manifest.json + webDir: assets/web +'''); + File(p.join(assetsDir.path, 'agent_manifest.json')).writeAsStringSync( + ''' +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "app_ping", + "namespace": "app", + "name": "ping", + "description": "Ping", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": true} + } + } + ] +} +''', + ); + File(p.join(webDir.path, 'manifest.json')).writeAsStringSync(''' +{ + "name": "demo", + "start_url": "." +} +'''); + + const merger = ManifestMerger(); + expect( + merger.readManifestRelativePath(temp.path), + 'assets/agent_manifest.json', + ); + expect(merger.readWebDirRelativePath(temp.path), 'assets/web'); + + const sync = PlatformSync(); + expect( + sync.readManifest(temp.path).tools.single.qualifiedName, + 'app_ping', + ); + }); + }); + + group('Dart WebMCP subset of manifest web.webMcp', () { + test('ManifestSurfaceIndex excludes web.webMcp:false tools', () { + final manifest = AgentManifest.parse(''' +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "app_demo_ping", + "namespace": "app", + "name": "demo_ping", + "description": "ping", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": true} + } + }, + { + "qualifiedName": "app_demo_cart", + "namespace": "app", + "name": "demo_cart", + "description": "cart", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": false} + } + } + ] +} +'''); + final index = ManifestSurfaceIndex.fromManifest(manifest); + expect(index.includesWebMcp('app_demo_ping'), isTrue); + expect(index.includesWebMcp('app_demo_cart'), isFalse); + }); + }); + + group('apple shortcuts opt-in on ios', () { + test('ios enabled keeps apple.appShortcuts false by default', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + enabledPlatforms: ['ios'], + ); + final surfaces = manifest.entries.single.surfaces; + expect( + surfaces.includes( + AgentManifestSurface.appleAppShortcuts, + defaultValue: true, + ), + isFalse, + ); + expect( + surfaces.includes( + AgentManifestSurface.appleAppIntents, + defaultValue: false, + ), + isTrue, + ); + }); + }); + + group('apple struct gated separately from shortcuts', () { + test('appleAppIntents emits struct without shortcuts row', () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + AgentManifest.fromJson({ + 'version': 1, + 'platform': 'apple', + 'protocolScheme': 'demoapp', + 'tools': [ + { + 'qualifiedName': 'app_ping', + 'namespace': 'app', + 'name': 'ping', + 'description': 'Ping', + 'kind': 'tool', + 'inputSchema': {'type': 'object'}, + 'surfaces': { + 'apple.appIntents': {'include': true}, + 'apple.appShortcuts': {'include': false}, + }, + }, + ], + }), + ); + + expect(swift, contains('struct AppPingIntent: AppIntent')); + expect(swift, isNot(contains('AppShortcut(intent: AppPingIntent()'))); + }); + }); + + group('entityTypes in export', () { + test('mergeManifest passes entityTypes through to manifest', () { + final manifest = const ManifestMerger().mergeManifest( + catalog: const [], + policy: const ProjectionPolicy(), + entityTypes: [ + { + 'qualifiedName': 'app_project', + 'namespace': 'app', + 'name': 'project', + 'displayName': 'Project', + }, + ], + platform: 'web', + ); + + expect(manifest.entityTypes, hasLength(1)); + expect(manifest.entityTypes.single.qualifiedName, 'app_project'); + }); + }); + + group('single native entity snapshot store', () { + test( + 'Apple emitter configures shared IntentCallNativeEntitySnapshotStore', + () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + AgentManifest.fromJson({ + 'version': 1, + 'platform': 'apple', + 'protocolScheme': 'demoapp', + 'entityTypes': [ + { + 'qualifiedName': 'app_project', + 'namespace': 'app', + 'name': 'project', + 'displayName': 'Project', + 'description': 'Open project', + }, + ], + 'tools': [ + { + 'qualifiedName': 'app_ping', + 'namespace': 'app', + 'name': 'ping', + 'description': 'Ping', + 'kind': 'tool', + 'inputSchema': {'type': 'object'}, + 'surfaces': { + 'apple.entities': {'include': true}, + }, + }, + ], + }), + ); + + expect(swift, contains('enum IntentCallGeneratedEntityConfig')); + expect( + swift, + contains('IntentCallNativeEntitySnapshotStore.fallbackScheme'), + ); + expect( + swift, + isNot(contains('enum IntentCallNativeEntitySnapshotStore {')), + ); + }, + ); + }); + + group('mcp_flutter three-gate (sibling consumer)', () { + test( + 'skipped in agentkit — run make check-contracts in mcp_flutter', + () {}, + skip: + 'L5b/L5c: mcp_flutter Jaspr + flutter_test_app gates run in sibling repo', + ); + }); + }); +} diff --git a/packages/intentcall_platform_sync/test/web_emitters_test.dart b/packages/intentcall_platform_sync/test/web_emitters_test.dart index 5e64ec3..2893baa 100644 --- a/packages/intentcall_platform_sync/test/web_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/web_emitters_test.dart @@ -24,6 +24,10 @@ void main() { 'name': 'cart_total', 'description': 'Return cart total', 'kind': 'tool', + 'surfaces': { + 'web.manifestShortcuts': {'include': true}, + 'web.protocolHandlers': {'include': true}, + }, 'inputSchema': {'type': 'object'}, }, ], @@ -716,6 +720,11 @@ final _fixtureAgentManifest = AgentManifest.fromJson({ 'name': 'cart_total', 'description': 'Return cart total', 'kind': 'tool', + 'surfaces': { + 'web.manifestShortcuts': {'include': true}, + 'web.protocolHandlers': {'include': true}, + 'web.webMcp': {'include': true}, + }, 'inputSchema': {'type': 'object'}, }, ], diff --git a/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart b/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart new file mode 100644 index 0000000..5a6b80c --- /dev/null +++ b/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart @@ -0,0 +1,120 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:test/test.dart'; + +void main() { + test('ManifestSurfaceIndex excludes web.webMcp:false tools', () { + final manifest = AgentManifest.parse(''' +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "app_demo_ping", + "namespace": "app", + "name": "demo_ping", + "description": "ping", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": true}, + "web.manifestShortcuts": {"include": false}, + "web.protocolHandlers": {"include": false}, + "android.shortcuts": {"include": false}, + "apple.appShortcuts": {"include": false}, + "windows.protocolActivation": {"include": false}, + "windows.msixProtocol": {"include": false}, + "linux.schemeHandler": {"include": false} + } + }, + { + "qualifiedName": "app_demo_cart", + "namespace": "app", + "name": "demo_cart", + "description": "cart", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": false}, + "web.manifestShortcuts": {"include": false}, + "web.protocolHandlers": {"include": false}, + "android.shortcuts": {"include": false}, + "apple.appShortcuts": {"include": false}, + "windows.protocolActivation": {"include": false}, + "windows.msixProtocol": {"include": false}, + "linux.schemeHandler": {"include": false} + } + } + ] +} +'''); + final index = ManifestSurfaceIndex.fromManifest(manifest); + + expect(index.includesWebMcp('app_demo_ping'), isTrue); + expect(index.includesWebMcp('app_demo_cart'), isFalse); + expect( + index.includes( + 'app_unknown', + AgentManifestSurface.webMcp, + defaultValue: false, + ), + isFalse, + ); + }); + + test('registerAgentWebMcpFromRegistry accepts surface index on VM', () { + final registry = InMemoryAgentRegistry() + ..register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'echo', + description: 'echo', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': {}, + }, + ), + execute: (_) async => AgentResult.success(), + ), + ); + final index = ManifestSurfaceIndex.fromManifest( + AgentManifest.parse(''' +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "app_echo", + "namespace": "app", + "name": "echo", + "description": "echo", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": false}, + "web.manifestShortcuts": {"include": false}, + "web.protocolHandlers": {"include": false}, + "android.shortcuts": {"include": false}, + "apple.appShortcuts": {"include": false}, + "windows.protocolActivation": {"include": false}, + "windows.msixProtocol": {"include": false}, + "linux.schemeHandler": {"include": false} + } + } + ] +} +'''), + ); + + expect( + () => registerAgentWebMcpFromRegistry( + registry, + surfaceIndex: index, + ), + returnsNormally, + ); + }); +} diff --git a/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index ab6ca95..ba37be5 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -7,8 +7,197 @@ [![pub points](https://img.shields.io/pub/points/intentcall_schema.svg)](https://pub.dev/packages/intentcall_schema/score) [![repository](https://img.shields.io/badge/repo-intentcall-blue)](https://github.com/Arenukvern/intentcall) -Wire types, `AgentResult`, validation, and `AgentArguments` for the intentcall stack. +Transport-agnostic **wire contract** for IntentCall: result envelopes, argument validation, entity snapshots, and VM-service wire parsing. Pure Dart — no Flutter dependency. + +Registry and invocation live in [`intentcall_core`](../intentcall_core). Adapters (`intentcall_mcp`, `intentcall_webmcp`, platform sync) translate between transports and these types. ```bash dart pub add intentcall_schema ``` + +## Who this is for + +| Audience | Use `intentcall_schema` when you need… | +|----------|--------------------------------------| +| **DX** (app authors, adapter authors) | Typed `AgentResult`, JSON Schema validation before `registry.invoke`, coercion from string-key wire maps | +| **AX** (agents, MCP clients, codegen) | Stable JSON shapes for tool outcomes, entity snapshots, and resource read arguments | + +## Package map + +| Module | Primary types | Role | +|--------|---------------|------| +| Results | `AgentResult`, `AgentArtifact` | Success/failure outcomes from any handler | +| Envelopes | `AgentResultEnvelope` | Versioned snapshot payloads for tools and resources | +| Arguments | `AgentArguments`, `InputSchema`, `AgentWireArgs` | Tool input maps and VM extension parsing | +| Validation | `validateAgainstSchema`, `coerceArgumentsForSchema` | JSON Schema subset check + wire coercion | +| Entities | `AgentEntityRef`, `AgentEntitySnapshot` | Indexable app objects for shortcuts, deep links, and agent context | +| Resources | `clientResourceReadInputSchema`, … | Default MCP dynamic-resource input schemas | + +## Quick start + +### Return a tool result + +```dart +import 'package:intentcall_schema/intentcall_schema.dart'; + +AgentResult success() => AgentResult.success( + message: 'Saved', + data: {'id': 'note-42'}, +); + +AgentResult failure() => AgentResult.failure( + code: 'not_found', + message: 'Note does not exist.', + details: {'id': 'note-42'}, +); +``` + +### Validate and coerce arguments + +VM service extensions and some transports deliver `Map`. Coerce to schema types, then validate: + +```dart +const schema = { + 'type': 'object', + 'additionalProperties': false, + 'required': ['count'], + 'properties': { + 'count': {'type': 'integer', 'minimum': 0}, + 'label': {'type': 'string'}, + }, +}; + +final wire = AgentWireArgs({'count': '3', 'label': 'demo'}); +final args = coerceArgumentsForSchema(schema, wire.toAgentArguments()); +validateAgainstSchema(schema, args); +// args == {'count': 3, 'label': 'demo'} +``` + +On failure, `validateAgainstSchema` throws [`AgentValidationException`](lib/src/agent_validation_exception.dart) with a human-readable `message` (safe to surface to agents). + +### Snapshot envelope (tools and resources) + +Use envelopes when the consumer needs a versioned JSON snapshot (MCP resources, inspector tools, codegen fixtures): + +```dart +final result = AgentResultEnvelope.resourceEnvelope( + resourceName: 'spark_runtime_snapshot', + snapshot: {'phase': 'playing'}, +); +// result.data['resource_uri'] == 'intentcall://resource/spark/runtime/snapshot' +``` + +### Entity snapshots (agent-visible app state) + +Entities are stable, JSON-safe records agents can search, open, or reference: + +```dart +final snapshot = AgentEntitySnapshot( + ref: const AgentEntityRef( + namespace: 'notes', + typeName: 'note', + identifier: 'note-1', + ), + title: 'Inbox note', + keywords: const ['work', 'today'], + deepLink: 'intentcall://notes/note-1', + properties: const { + 'pinned': true, + 'rank': 3, + 'tags': ['work', 'today'], + }, +); + +final json = snapshot.toJson(); // round-trips via AgentEntitySnapshot.fromJson +``` + +`effectiveTitle` resolves `title ?? displayName` for display surfaces. + +## JSON Schema subset + +`validateAgainstSchema` implements a **deliberately small** JSON Schema subset aligned with MCP tool `inputSchema` usage: + +| Feature | Supported | +|---------|-----------| +| Root `type: object` | Yes | +| `required`, `properties` | Yes | +| `additionalProperties: false` | Yes | +| Property types: `string`, `integer`, `number`, `boolean`, `object`, `array` | Yes | +| `enum` on strings | Yes | +| `minimum` / `maximum` on numbers | Yes | +| Array `items` when each item is `type: object` with `required` / `properties` | Yes | +| `pattern`, `format`, `oneOf`, nested object property validation (except array items) | No | +| Type coercion | Use `coerceArgumentsForSchema` first | + +Properties without a `type` are skipped. Unknown keys are allowed unless `additionalProperties` is `false`. + +## Wire types + +```dart +typedef AgentArguments = Map; +typedef InputSchema = Map; +typedef AgentWireMap = Map; +``` + +- **`AgentArguments`** — normalized tool invocation payload after coercion. +- **`InputSchema`** — JSON Schema–shaped map attached to tool/resource registrations. +- **`AgentWireArgs`** — extension type over `AgentWireMap` with `string`, `bool_`, `int_`, `double_`, `jsonObject`, and `toAgentArguments()`. + +## Entity JSON shape (AX) + +Agents and platform projection share this wire shape: + +```yaml +ref: + namespace: notes # app domain, e.g. notes, music + type_name: note # entity kind within namespace + identifier: note-1 # stable id within type +properties: # JSON-safe scalars, lists, nested maps only + pinned: true + rank: 3 +title: Inbox note # optional display fields +keywords: [work, today] +deep_link: intentcall://notes/note-1 +updated_at: 2026-06-29T12:00:00.000Z +deleted: false +version: rev-7 +freshness: fresh +``` + +`DateTime`, custom classes, and non-finite doubles are rejected at construction time so snapshots stay JSON-encodable. + +## Resource input schemas + +For MCP dynamic client resources: + +```dart +final schema = clientResourceReadInputSchema(); +// { type: object, required: [uri], properties: { uri: { type: string } } } + +final fromRegistration = inputSchemaFromDynamicRegistrationMap(registration); +``` + +Templates with variables (for example `count`) use `clientResourceTemplateReadInputSchema`. + +## Where this sits in the stack + +``` +Author handler → AgentResult + ↑ +AgentRegistry.invoke ← validateAgainstSchema(coerceArgumentsForSchema(...)) + ↑ +Adapter (MCP / WebMCP / platform) ← wire maps, entity snapshots +``` + +## Related packages + +- [`intentcall_core`](../intentcall_core) — registry, `AgentCallEntry`, adapters composition +- [`intentcall_mcp`](../intentcall_mcp) — MCP `CallToolResult` mapping from `AgentResult` +- [`intentcall_platform`](../intentcall_platform) — Flutter entity index and native snapshot store +- [`intentcall_platform_sync`](../intentcall_platform_sync) — manifest projection and entity export + +Canonical design docs: [North Star](../../docs/NORTH_STAR.mdx), [Design FAQ](../../docs/DESIGN_FAQ.mdx), and [DX FAQ](../../docs/DX_FAQ.mdx). + +## API reference + +Run `dart doc` in this package, or browse [pub.dev documentation](https://pub.dev/documentation/intentcall_schema/latest/) after publish. diff --git a/packages/intentcall_schema/lib/intentcall_schema.dart b/packages/intentcall_schema/lib/intentcall_schema.dart index 239b074..84ca8b2 100644 --- a/packages/intentcall_schema/lib/intentcall_schema.dart +++ b/packages/intentcall_schema/lib/intentcall_schema.dart @@ -1,3 +1,26 @@ +/// Wire types, validation, and result envelopes for IntentCall. +/// +/// This library is the **transport-neutral contract** between app handlers, +/// the `intentcall_core` registry, and adapters (MCP, WebMCP, platform sync). +/// +/// It has no Flutter dependency. +/// +/// ## For app and adapter authors (DX) +/// +/// - Return [AgentResult] from tool and resource handlers. +/// - Attach an [InputSchema] to registrations in `intentcall_core`. +/// - Parse VM service extension maps with [AgentWireArgs], then run +/// [coerceArgumentsForSchema] and [validateAgainstSchema] before invoke. +/// +/// ## For agents and clients (AX) +/// +/// - Tool outcomes are [AgentResult] values serialized to JSON by adapters. +/// - [AgentResultEnvelope] builds versioned snapshot payloads (`schema_version`, +/// `kind`, `snapshot`, `resource_uri`, …) that MCP and inspector clients consume. +/// - [AgentEntitySnapshot] describes indexable app objects (`ref`, `properties`, +/// display fields) for search, shortcuts, and deep links. +/// +/// See also: [intentcall_core on pub.dev](https://pub.dev/packages/intentcall_core). library; export 'src/agent_entity_model.dart'; diff --git a/packages/intentcall_schema/lib/src/agent_entity_model.dart b/packages/intentcall_schema/lib/src/agent_entity_model.dart index 7f4a017..729a1ee 100644 --- a/packages/intentcall_schema/lib/src/agent_entity_model.dart +++ b/packages/intentcall_schema/lib/src/agent_entity_model.dart @@ -1,13 +1,33 @@ import 'package:meta/meta.dart'; +/// Stable identity for an indexable app object exposed to agents. +/// +/// The triple `(namespace, typeName, identifier)` must be unique within an app. +/// Agents use refs to open, update, or reference entities across transports. +/// +/// ## Wire JSON (AX) +/// +/// ```json +/// { +/// "namespace": "notes", +/// "type_name": "note", +/// "identifier": "note-1" +/// } +/// ``` @immutable final class AgentEntityRef { + /// Creates a reference with the given [namespace], [typeName], and [identifier]. + /// + /// All three strings must be non-empty when parsed from JSON. const AgentEntityRef({ required this.namespace, required this.typeName, required this.identifier, }); + /// Parses [json] produced by [toJson]. + /// + /// Throws [ArgumentError] when required fields are missing or empty. factory AgentEntityRef.fromJson(final Map json) => AgentEntityRef( namespace: _requiredString(json, 'namespace'), @@ -15,10 +35,16 @@ final class AgentEntityRef { identifier: _requiredString(json, 'identifier'), ); + /// App domain grouping entities (for example `notes`, `music`). final String namespace; + + /// Entity kind within [namespace] (for example `note`, `playlist`). final String typeName; + + /// Stable id for this entity within ([namespace], [typeName]). final String identifier; + /// Serializes to JSON using snake_case keys (`type_name`). Map toJson() => { 'namespace': namespace, 'type_name': typeName, @@ -37,8 +63,28 @@ final class AgentEntityRef { int get hashCode => Object.hash(namespace, typeName, identifier); } +/// JSON-safe snapshot of an app entity for search, shortcuts, and agent context. +/// +/// [properties] holds domain fields agents may read or filter on. Display +/// fields (`title`, `subtitle`, `keywords`, …) support human and voice UIs. +/// Only JSON-encodable values are allowed in [properties] (`String`, `int`, +/// `double`, `bool`, `List`, and nested `Map` with string keys). +/// +/// ## Wire JSON (AX) +/// +/// See package README for the full shape. Use [toJson] / [fromJson] for +/// round-tripping across native stores, manifest export, and MCP resources. +/// +/// ## Display +/// +/// Prefer [effectiveTitle] when rendering a single line of text; it falls back +/// from [title] to [displayName]. @immutable final class AgentEntitySnapshot { + /// Creates a snapshot for [ref] with JSON-safe [properties]. + /// + /// [keywords] entries are trimmed; empty strings throw [ArgumentError]. + /// [deleted] marks tombstones so indexes can remove stale entries. AgentEntitySnapshot({ required this.ref, required final Map properties, @@ -64,6 +110,10 @@ final class AgentEntitySnapshot { ), properties = _jsonObject(properties); + /// Parses [json] produced by [toJson]. + /// + /// Throws [ArgumentError] when `ref` or `properties` are not objects, or when + /// nested values are not JSON-safe. factory AgentEntitySnapshot.fromJson(final Map json) { final rawRef = json['ref']; if (rawRef is! Map) { @@ -94,22 +144,49 @@ final class AgentEntitySnapshot { ); } + /// Identity of this entity. final AgentEntityRef ref; + + /// Domain-specific JSON-safe fields (scalars, lists, nested maps). final Map properties; + + /// Primary display title. final String? title; + + /// Secondary display line. final String? subtitle; + + /// Search keywords (non-empty, trimmed). final List keywords; + + /// Thumbnail image URL. final String? thumbnailUrl; + + /// Canonical web or app URL for this entity. final String? url; + + /// Alternate display name when [title] is absent. final String? displayName; + + /// Platform deep link or custom scheme URI to open this entity. final String? deepLink; + + /// Last modification time (serialized as UTC ISO-8601). final DateTime? updatedAt; + + /// When `true`, indexes should treat this snapshot as a tombstone. final bool deleted; + + /// Opaque revision or etag for change detection. final String? version; + + /// Hint for staleness (for example `fresh`, `stale`); adapter-defined. final String? freshness; + /// [title] if set, otherwise [displayName]. String? get effectiveTitle => title ?? displayName; + /// Serializes to JSON using snake_case keys. Map toJson() => { 'ref': ref.toJson(), 'properties': properties, diff --git a/packages/intentcall_schema/lib/src/agent_result.dart b/packages/intentcall_schema/lib/src/agent_result.dart index 452b7b3..768ee4b 100644 --- a/packages/intentcall_schema/lib/src/agent_result.dart +++ b/packages/intentcall_schema/lib/src/agent_result.dart @@ -1,21 +1,74 @@ import 'package:meta/meta.dart'; +/// Normalized tool or resource invocation arguments after wire coercion. +/// +/// Keys are parameter names from the registration [InputSchema]. Values are +/// JSON-compatible Dart types (`String`, `int`, `double`, `bool`, `List`, +/// `Map`) matching schema property types. typedef AgentArguments = Map; + +/// JSON Schema–shaped map describing allowed tool or resource input. +/// +/// Consumed by [validateAgainstSchema] and [coerceArgumentsForSchema]. +/// Typically attached to `ToolRegistration` / `ResourceRegistration` in +/// `intentcall_core`. typedef InputSchema = Map; + +/// String-key map as delivered by VM service extensions and some transports. +/// +/// Parse with [AgentWireArgs] before coercion and validation. typedef AgentWireMap = Map; +/// Binary or text payload returned alongside structured result data on +/// [AgentResult]. +/// +/// Use [AgentArtifact.text] for UTF-8 text (default [mimeType]: +/// `text/plain`) or [AgentArtifact.bytes] for raw bytes with an explicit +/// [mimeType]. @immutable final class AgentArtifact { + /// Creates a text artifact. const AgentArtifact.text(this.text, {this.mimeType = 'text/plain'}) : bytes = null; + /// Creates a binary artifact. const AgentArtifact.bytes(this.bytes, {required this.mimeType}) : text = null; + /// MIME type of the payload (for example `text/plain`, `application/json`). final String mimeType; + + /// UTF-8 text content when this is a text artifact. final String? text; + + /// Raw bytes when this is a binary artifact. final List? bytes; } +/// Outcome of an agent tool or resource handler invocation. +/// +/// Every adapter maps this type to its transport (MCP `CallToolResult`, VM +/// service JSON, and so on). Handlers should not throw for expected failures; +/// return [AgentResult.failure] with a stable `code` instead. +/// +/// ## Success +/// +/// ```dart +/// AgentResult.success( +/// message: 'ok', +/// data: {'count': 3}, +/// artifacts: [AgentArtifact.text('hello')], +/// ); +/// ``` +/// +/// ## Failure +/// +/// ```dart +/// AgentResult.failure( +/// code: 'invalid_state', +/// message: 'Cannot pause while idle.', +/// details: {'phase': 'idle'}, +/// ); +/// ``` @immutable final class AgentResult { const AgentResult._({ @@ -27,6 +80,11 @@ final class AgentResult { this.details = const {}, }); + /// Creates a successful result. + /// + /// [message] is a short human-readable summary (default `'ok'`). + /// [data] holds structured JSON-safe fields for the client or agent. + /// [artifacts] optionally attach text or binary payloads. factory AgentResult.success({ final String message = 'ok', final Map data = const {}, @@ -38,6 +96,11 @@ final class AgentResult { artifacts: artifacts, ); + /// Creates a failed result. + /// + /// [code] should be a stable machine identifier (for example `not_found`). + /// [message] explains the failure to humans and agents. + /// [details] may carry extra JSON-safe context. factory AgentResult.failure({ required final String code, required final String message, @@ -45,10 +108,21 @@ final class AgentResult { }) => AgentResult._(ok: false, code: code, message: message, details: details); + /// Whether the invocation succeeded. final bool ok; + + /// Short human-readable summary of the outcome. final String message; + + /// Structured JSON-safe payload on success (or optional context on failure). final Map data; + + /// Optional text or binary attachments. final List artifacts; + + /// Stable error identifier when [ok] is `false`. final String? code; + + /// Extra JSON-safe context when [ok] is `false`. final Map details; } diff --git a/packages/intentcall_schema/lib/src/agent_result_envelope.dart b/packages/intentcall_schema/lib/src/agent_result_envelope.dart index b9fa4e7..ae79e05 100644 --- a/packages/intentcall_schema/lib/src/agent_result_envelope.dart +++ b/packages/intentcall_schema/lib/src/agent_result_envelope.dart @@ -2,8 +2,24 @@ import 'dart:convert'; import 'agent_result.dart'; -/// Helpers for agent-stable JSON payloads (ecsly-style envelopes). +/// Builders for versioned snapshot payloads inside [AgentResult] data maps. +/// +/// Envelopes follow an ecsly-style shape so MCP resources, inspector tools, and +/// codegen fixtures can share one JSON contract. Agents should read +/// `schema_version`, `kind`, and `snapshot` (or `snapshot_json`) from the +/// result data map. extension AgentResultEnvelope on AgentResult { + /// Creates a success result wrapping a versioned [snapshot]. + /// + /// [kind] identifies the tool or snapshot type (also stored as `tool_name`). + /// [extra] merges additional JSON-safe fields into the result data map. + /// + /// ```dart + /// AgentResultEnvelope.envelope( + /// kind: 'widget_tree', + /// snapshot: {'root': 'MaterialApp'}, + /// ); + /// ``` static AgentResult envelope({ required final String kind, required final Map snapshot, @@ -22,6 +38,11 @@ extension AgentResultEnvelope on AgentResult { }, ); + /// Creates a success result for a named MCP-style resource snapshot. + /// + /// Populates `resource_uri`, `resource`, and `contents` so clients can treat + /// the payload like a resource read response. [resourceName] uses underscore + /// segments that map to path segments in the URI (see [resourceUriForName]). static AgentResult resourceEnvelope({ required final String resourceName, required final Map snapshot, @@ -51,7 +72,12 @@ extension AgentResultEnvelope on AgentResult { ); } - /// `intentcall://resource/a/b` from `a_b` name segments. + /// Builds `intentcall://resource/...` from underscore-separated [name] segments. + /// + /// Example: `spark_runtime_snapshot` → + /// `intentcall://resource/spark/runtime/snapshot`. + /// + /// Returns `intentcall://resource/unknown` when [name] is empty. static String resourceUriForName(final String name) { if (name.isEmpty) { return 'intentcall://resource/unknown'; diff --git a/packages/intentcall_schema/lib/src/agent_validation_exception.dart b/packages/intentcall_schema/lib/src/agent_validation_exception.dart index a664613..a02b811 100644 --- a/packages/intentcall_schema/lib/src/agent_validation_exception.dart +++ b/packages/intentcall_schema/lib/src/agent_validation_exception.dart @@ -1,5 +1,13 @@ +/// Thrown when [validateAgainstSchema] rejects [AgentArguments]. +/// +/// The [message] is intended for humans and agents (for example +/// `Missing required property "uri".` or `"count" must be an integer.`). +/// Adapters may map this to transport-specific validation errors. final class AgentValidationException implements Exception { + /// Creates a validation error with the given [message]. AgentValidationException(this.message); + + /// Human-readable description of the validation failure. final String message; @override diff --git a/packages/intentcall_schema/lib/src/agent_wire_args.dart b/packages/intentcall_schema/lib/src/agent_wire_args.dart index 6ee6657..c67b36e 100644 --- a/packages/intentcall_schema/lib/src/agent_wire_args.dart +++ b/packages/intentcall_schema/lib/src/agent_wire_args.dart @@ -2,8 +2,19 @@ import 'dart:convert'; import 'agent_result.dart'; -/// Parses VM service extension wire maps (`Map`). +/// Typed view over a VM service extension wire map (`Map`). +/// +/// Service extensions and some debug bridges deliver all values as strings. +/// Use the typed accessors to parse common shapes, then +/// [toAgentArguments] before [coerceArgumentsForSchema]. +/// +/// ```dart +/// final wire = AgentWireArgs({'count': '3', 'enabled': 'true'}); +/// wire.int_('count'); // 3 +/// wire.bool_('enabled'); // true +/// ``` extension type const AgentWireArgs(AgentWireMap _raw) { + /// Returns a trimmed non-empty string, or `null` if missing or blank. String? string(final String key) { final value = _raw[key]; if (value == null) { @@ -13,6 +24,9 @@ extension type const AgentWireArgs(AgentWireMap _raw) { return trimmed.isEmpty ? null : trimmed; } + /// Parses common wire boolean literals: `1`/`0`, `true`/`false`, `yes`/`no`. + /// + /// Returns `null` when the key is absent or the value is not recognized. bool? bool_(final String key) { final normalized = string(key)?.toLowerCase(); if (normalized == null) { @@ -27,10 +41,15 @@ extension type const AgentWireArgs(AgentWireMap _raw) { return null; } + /// Parses an integer from the string value, or `null` if missing or invalid. int? int_(final String key) => int.tryParse(_raw[key]?.trim() ?? ''); + /// Parses a double from the string value, or `null` if missing or invalid. double? double_(final String key) => double.tryParse(_raw[key]?.trim() ?? ''); + /// Decodes a JSON object from the string value at [key]. + /// + /// Returns `null` when the key is absent or the decoded value is not a map. Map? jsonObject(final String key) { final raw = string(key); if (raw == null) { @@ -46,5 +65,6 @@ extension type const AgentWireArgs(AgentWireMap _raw) { return null; } + /// Copies the underlying map into [AgentArguments] (values remain strings). AgentArguments toAgentArguments() => Map.from(_raw); } diff --git a/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart b/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart index 0913336..19fc7c8 100644 --- a/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart +++ b/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart @@ -3,8 +3,13 @@ import 'agent_result.dart'; -/// Copies `inputSchema` from a registerDynamics resource payload, or -/// [clientResourceReadInputSchema] when omitted. +/// Extracts an [InputSchema] from a dynamic resource registration map. +/// +/// Reads `inputSchema` from [registration]. When absent, returns +/// [clientResourceReadInputSchema] (URI-only read args for +/// `fmt_client_resource` style resources). +/// +/// Throws [ArgumentError] when `inputSchema` is present but not a `Map`. InputSchema inputSchemaFromDynamicRegistrationMap( final Map registration, ) { @@ -18,7 +23,10 @@ InputSchema inputSchemaFromDynamicRegistrationMap( return _deepCopySchemaMap(Map.from(raw)); } -/// Default read-args schema for dynamic client resources (`fmt_client_resource`). +/// Default JSON Schema for reading a dynamic client resource by URI. +/// +/// Requires a single `uri` string property. Used when a resource registration +/// does not supply a custom `inputSchema`. InputSchema clientResourceReadInputSchema() => { 'type': 'object', 'additionalProperties': false, @@ -31,7 +39,11 @@ InputSchema clientResourceReadInputSchema() => { }, }; -/// Default read-args schema for dynamic client resource templates. +/// Default JSON Schema for reading a dynamic client resource template. +/// +/// Always requires `uri`. Additional [templateVariables] (for example `count`) +/// are added to `properties`; `count` is typed as `integer`, others as +/// `string`. Skips a variable named `uri` if listed twice. InputSchema clientResourceTemplateReadInputSchema({ final Iterable templateVariables = const ['count'], }) { diff --git a/packages/intentcall_schema/lib/src/schema_coercion.dart b/packages/intentcall_schema/lib/src/schema_coercion.dart index 76ef36c..5b5c830 100644 --- a/packages/intentcall_schema/lib/src/schema_coercion.dart +++ b/packages/intentcall_schema/lib/src/schema_coercion.dart @@ -2,8 +2,22 @@ import 'dart:convert'; import 'agent_result.dart'; -/// Coerces VM service-extension wire values (`Map` values often [String]) to -/// types expected by [validateAgainstSchema], using [schema] property types. +/// Coerces wire [arguments] to types expected by [validateAgainstSchema]. +/// +/// VM service extensions and similar transports often deliver every value as a +/// [String]. This function uses [schema] `properties` `type` fields to parse +/// integers, numbers, booleans, JSON objects, and JSON arrays before +/// validation. +/// +/// Typical pipeline: +/// +/// ```dart +/// final args = coerceArgumentsForSchema(schema, wire.toAgentArguments()); +/// validateAgainstSchema(schema, args); +/// ``` +/// +/// Empty strings for non-string types are **omitted** from the result so +/// optional fields can stay unset. Unrecognized types pass through unchanged. AgentArguments coerceArgumentsForSchema( final InputSchema schema, final AgentArguments arguments, diff --git a/packages/intentcall_schema/lib/src/schema_validator.dart b/packages/intentcall_schema/lib/src/schema_validator.dart index d64c8ab..f1a8da5 100644 --- a/packages/intentcall_schema/lib/src/schema_validator.dart +++ b/packages/intentcall_schema/lib/src/schema_validator.dart @@ -3,17 +3,29 @@ import 'agent_validation_exception.dart'; /// Validates [arguments] against a JSON Schema–shaped [schema] subset. /// -/// **Supported:** root `type: object`; top-level `required`; top-level -/// `additionalProperties: false` (unknown keys); per-property `type` of -/// `string`, `integer`, `number`, `boolean`, `object`, or `array`; `enum` on -/// `string` properties (JSON Schema `enum` array of allowed strings); array -/// `items` when each item is `type: object` with `required` / `properties`; -/// `minimum` / `maximum` on numeric types. +/// Throws [AgentValidationException] when validation fails. Call +/// [coerceArgumentsForSchema] first when [arguments] may contain string wire +/// values. /// -/// **Not supported:** `pattern`, `format`, nested object property -/// validation (except array `items` when each item is `type: object` with -/// `required` / `properties`), `oneOf` / `anyOf`, or type coercion. Properties -/// without a `type` are skipped. Unknown keys are allowed when +/// ## Supported +/// +/// - Root `type: object` +/// - Top-level `required` and `properties` +/// - `additionalProperties: false` (rejects unknown keys) +/// - Per-property `type`: `string`, `integer`, `number`, `boolean`, `object`, +/// `array` +/// - `enum` on string properties (allowed string list) +/// - Array `items` when each item is `type: object` with `required` / +/// `properties` +/// - `minimum` / `maximum` on numeric types +/// +/// ## Not supported +/// +/// - `pattern`, `format`, `oneOf`, `anyOf` +/// - Nested object property validation (except array `items` objects) +/// - Type coercion (use [coerceArgumentsForSchema]) +/// +/// Properties without a `type` are skipped. Unknown keys are allowed when /// `additionalProperties` is omitted or not `false`. void validateAgainstSchema( final InputSchema schema, diff --git a/packages/intentcall_schema/pubspec.yaml b/packages/intentcall_schema/pubspec.yaml index d1ca2b4..aa56de8 100644 --- a/packages/intentcall_schema/pubspec.yaml +++ b/packages/intentcall_schema/pubspec.yaml @@ -1,5 +1,7 @@ name: intentcall_schema -description: PRE-RELEASE — Transport-agnostic agent result envelopes and JSON Schema validation. +description: >- + PRE-RELEASE — Wire contract for IntentCall: AgentResult envelopes, JSON Schema + validation, entity snapshots, and VM service wire parsing. Pure Dart. version: 0.6.0 license: MIT repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_schema diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index d5a4137..8109e84 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -204,12 +204,175 @@ control. @AgentProjection(surfaces: {AgentManifestSurface.webMcp: true}) ``` -Apple sub-channels (Siri, Spotlight, etc.) use `AgentManifestSurfaceExposure.options` -on handwritten `EntryProjection` rows until emitters support them. +**Manifest surface families** (dense export emits all keys with explicit `include`): + +| Enum | Manifest key | Default when platform enabled | +|------|--------------|------------------------------| +| `appleAppIntents` | `apple.appIntents` | `true` on `ios`/`macos` | +| `appleAppShortcuts` | `apple.appShortcuts` | `false` (opt-in, ADR 0016) | +| `appleSpotlight` | `apple.spotlight` | `false` | +| `appleEntities` | `apple.entities` | `false` | +| `androidShortcuts` | `android.shortcuts` | `true` on `android` | +| `webManifestShortcuts` | `web.manifestShortcuts` | `true` on `web` | +| `webProtocolHandlers` | `web.protocolHandlers` | `true` on `web` | +| `webMcp` | `web.webMcp` | `true` on `web` | +| `windowsProtocolActivation` | `windows.protocolActivation` | `true` on `windows` | +| `windowsMsixProtocol` | `windows.msixProtocol` | `true` on `windows` | +| `linuxSchemeHandler` | `linux.schemeHandler` | `true` on `linux` | + +`platforms.enabled` in `intentcall.yaml` scopes defaults; explicit +`defaults.surfaces` or per-entry `EntryProjection` overrides win. + +After changing registrations or projection policy, run: + +```bash +steward benchmark --scenario intentcall.projection-pipeline --json +``` + +Apple sub-channels (Siri phrases, Spotlight donation hints) use +`AgentManifestSurfaceExposure.options` on handwritten `EntryProjection` rows until +emitters consume them. + +--- + +## 4. Typed app entities (`@AgentEntity`) + +Use `@AgentEntity` when the app exposes indexable domain objects (projects, notes, +playlists) to native discovery surfaces. Entities are **additive projection +metadata** — Dart still owns the source of truth and writes JSON-safe snapshots +into a native cache. See +[ADR 0018](../../docs/decisions/0018-additive-actions-typed-entities-indexing-lifecycle.md) +and +[ADR 0023](../../docs/decisions/0023-entity-three-slot-projection.md). + +### Three-slot projection model + +Native platforms expose a fixed display surface. Manifest export maps descriptor +properties onto three slots: + +| Slot | Manifest key | Role enum | Typical use | +|------|--------------|-----------|-------------| +| Primary line | `titleKey` | `title` | Display name | +| Secondary line | `subtitleKey` | `subtitle` | Summary or context | +| Search tokens | `keywordsKey` | `keywords` | Tags array (`valueType: 'array'`) | + +`AgentEntitySnapshotKeys.fromDescriptor()` in `intentcall_core` resolves slots. +Prefer **explicit roles** over implicit `isDisplay` / `isSearchable` ordering. + +### Annotate an entity type + +```dart +import 'package:intentcall_codegen/intentcall_codegen.dart'; + +@AgentEntity( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + displayName: 'Project', + properties: [ + AgentEntityProperty( + name: 'name', + valueType: 'string', + description: 'Display name', + isDisplay: true, + role: 'title', + ), + AgentEntityProperty( + name: 'summary', + valueType: 'string', + description: 'Searchable summary', + isSearchable: true, + role: 'subtitle', + ), + AgentEntityProperty( + name: 'tags', + valueType: 'array', + description: 'Search keywords', + isSearchable: true, + role: 'keywords', + ), + ], +) +final class AppProjectEntityDescriptor {} +``` + +Codegen emits `AppProjectEntityFields` constants and an +`agentEntityTypeDescriptors` row in `lib/generated/agent_catalog.g.dart`. +Run `build_runner`, then `intentcall manifest export --check`. + +### Entity-level property overrides + +When property names are stable but you prefer declaration at the type level: + +```dart +@AgentEntity( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + titleProperty: 'name', + subtitleProperty: 'summary', + keywordsProperty: 'tags', + properties: [ /* … */ ], +) +``` + +Entity-level overrides win over per-property `role` when they name the same field. + +### Build cache rows with typed field constants + +```dart +import 'package:intentcall_codegen/intentcall_codegen.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:your_app/generated/agent_catalog.g.dart'; + +final descriptor = agentEntityTypeDescriptors.single; +final builder = AgentEntitySnapshotBuilder(descriptor); + +final cacheRow = builder.buildProperties( + identifier: 'project-1', + values: { + AppProjectEntityFields.name: 'Launch', + AppProjectEntityFields.summary: 'Q3 launch', + AppProjectEntityFields.tags: ['launch', 'work'], + }, +); + +await entityIndex.upsertSnapshots( + entityType: descriptor.qualifiedName, + snapshots: [cacheRow], +); +``` + +When upserting `AgentEntitySnapshot` models, prefer +`upsertAgentSnapshotsForType` — it projects descriptor property names and +display slots (`title` / `subtitle` / `keywords`) via +`projectAgentEntitySnapshot()`. Use `upsertSnapshots` with +`AgentEntitySnapshotBuilder` when you already have property-map rows. + +### Enable native entity surfaces + +Entity Swift / query codegen is gated by manifest surfaces. Opt in per tool or +globally in `intentcall.yaml` / `@AgentProjection`: + +| Surface | Manifest key | When to enable | +|---------|--------------|----------------| +| `appleEntities` | `apple.entities` | `AppEntity`, `EntityQuery`, open-intent scaffolds | +| `appleSpotlight` | `apple.spotlight` | CoreSpotlight indexing helpers | + +Apple `AppEntity` structs always read `titleKey`, `subtitleKey`, and +`keywordsKey` from the manifest — keep roles aligned with what you upsert into +the native cache. + +### Validation rules (codegen) + +- At most one property per role: `title`, `subtitle`, `keywords`. +- `keywords` role requires `valueType: 'array'`. +- Override property names must exist in `properties`. +- `role` wins over conflicting `isDisplay` / `isSearchable` flags (warning logged). --- -## 4. After Changing Registrations +## 5. After Changing Registrations Run the package tests that cover the registered handler. When changing this repository rather than only a downstream app, also run: @@ -223,6 +386,7 @@ adapter contract test and run: ```bash steward benchmark --scenario intentcall.adapter-contract --json +steward benchmark --scenario intentcall.projection-pipeline --json ``` --- @@ -231,3 +395,4 @@ steward benchmark --scenario intentcall.adapter-contract --json - [DESIGN_FAQ.mdx](../../docs/DESIGN_FAQ.mdx) — Why IntentCall is designed this way. - [DX_FAQ.mdx](../../docs/DX_FAQ.mdx) — General workflow and CLI commands. +- [ADR 0023](../../docs/decisions/0023-entity-three-slot-projection.md) — Entity three-slot projection and `AgentEntityPropertyRole`. diff --git a/steward.yaml b/steward.yaml index a9978c4..73b2556 100644 --- a/steward.yaml +++ b/steward.yaml @@ -235,6 +235,80 @@ actions: evidence: redaction: steward/redaction/v1 summary_fields: [exit_code, duration_ms, output_digest] + intentcall.projection-pipeline-check: + kind: command + desc: Run Layer 5 projection pipeline gates (merger, dense export, emitters, codegen example sync). + command: + argv: [just, projection-pipeline-check] + shell: false + cwd: . + effects: + fs_read: + - justfile + - packages/intentcall_platform_sync/** + - packages/intentcall_codegen/** + - packages/intentcall_cli/** + - pubspec.yaml + - pubspec.lock + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 500000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] + intentcall.projection-alignment-test: + kind: command + desc: Run cross-layer projection alignment matrix test. + command: + argv: + [ + dart, + test, + packages/intentcall_platform_sync/test/projection_alignment_test.dart, + ] + shell: false + cwd: . + effects: + fs_read: + - packages/intentcall_platform_sync/** + - pubspec.yaml + - pubspec.lock + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 200000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] intentcall.docs-check: kind: command desc: Validate docs.page config (docs.json) and internal documentation links. @@ -287,6 +361,8 @@ provenance: benchmarks: - id: intentcall.adapter-contract manifest: steward/scenarios/intentcall.adapter-contract.yaml + - id: intentcall.projection-pipeline + manifest: steward/scenarios/intentcall.projection-pipeline.yaml branding: banned_words: [unlock, supercharge, ultimate, leverage] ignored_paths: [] diff --git a/steward/scenarios/intentcall.projection-pipeline.yaml b/steward/scenarios/intentcall.projection-pipeline.yaml new file mode 100644 index 0000000..d94f78e --- /dev/null +++ b/steward/scenarios/intentcall.projection-pipeline.yaml @@ -0,0 +1,64 @@ +schema: steward/scenario-manifest/v1 +repo: intentcall +scenario: intentcall.projection-pipeline +status: runnable +source: + git: https://github.com/Arenukvern/intentcall + commit: 2e4b137b8ef0b13a38f43be91d8a831d1a3191ab + steward_contract: steward.yaml +safe_first_probe: intentcall.validate +required_actions: + - intentcall.projection-alignment-test + - intentcall.projection-pipeline-check +steps: + - run: dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart + - run: dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart + - run: dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart + - run: dart test packages/intentcall_platform_sync/test/native_emitters_test.dart + - working_directory: packages/intentcall_codegen/example + run: dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check + - working_directory: packages/intentcall_codegen/example + run: dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check +artifacts: + - id: steward_contract + kind: yaml + path: steward.yaml + required: true + durability: input + - id: projection_alignment_test + kind: dart + path: packages/intentcall_platform_sync/test/projection_alignment_test.dart + required: true + durability: behavior + - id: manifest_merger_test + kind: dart + path: packages/intentcall_platform_sync/test/manifest_merger_test.dart + required: true + durability: behavior + - id: dense_manifest_test + kind: dart + path: packages/intentcall_platform_sync/test/dense_manifest_test.dart + required: true + durability: behavior + - id: manifest_projection_test + kind: dart + path: packages/intentcall_codegen/example/test/manifest_projection_test.dart + required: true + durability: behavior + - id: native_emitters_test + kind: dart + path: packages/intentcall_platform_sync/test/native_emitters_test.dart + required: true + durability: behavior + - id: intentcall_cli + kind: dart + path: packages/intentcall_cli/bin/intentcall.dart + required: true + durability: input + - id: codegen_example_intentcall_yaml + kind: yaml + path: packages/intentcall_codegen/example/intentcall.yaml + required: true + durability: input +blocked_by: null +owner: intentcall From e61d9d1095735bd2edd5672a9944d9a78c65baae Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 05:31:11 +0300 Subject: [PATCH 08/27] feat: enhance resource URI handling by introducing protocol scheme support across agent manifest generation and related components --- .../lib/src/agent_manifest_generator.dart | 29 +++++++++++++++--- .../lib/src/agent_manifest_generator.dart | 25 ++++++++++++++-- .../test/agent_manifest_generator_test.dart | 7 +++-- .../src/intent/agent_intent_descriptor.dart | 12 +++++--- .../registration_public_surface_test.dart | 6 ++-- .../lib/src/mcp_publish_adapter.dart | 22 ++++++++++++-- .../test/mcp_publish_adapter_test.dart | 4 +-- .../test/mcp_resource_mapper_test.dart | 1 + ...istration_reexport_compatibility_test.dart | 16 +++++----- .../lib/src/emitters/emitter_utils.dart | 14 +++++++++ .../lib/src/projection/manifest_merger.dart | 23 +++++++++++++- .../test/manifest_merger_test.dart | 30 ++++++++++++++++++- packages/intentcall_schema/README.md | 3 +- .../lib/intentcall_schema.dart | 1 + .../lib/src/agent_result_envelope.dart | 22 +++++--------- .../lib/src/resource_uri.dart | 15 ++++++++++ .../test/agent_result_envelope_test.dart | 5 ++-- .../test/resource_uri_test.dart | 21 +++++++++++++ 18 files changed, 209 insertions(+), 47 deletions(-) create mode 100644 packages/intentcall_schema/lib/src/resource_uri.dart create mode 100644 packages/intentcall_schema/test/resource_uri_test.dart diff --git a/packages/intentcall_android/lib/src/agent_manifest_generator.dart b/packages/intentcall_android/lib/src/agent_manifest_generator.dart index da2fc4b..09e3707 100644 --- a/packages/intentcall_android/lib/src/agent_manifest_generator.dart +++ b/packages/intentcall_android/lib/src/agent_manifest_generator.dart @@ -4,18 +4,22 @@ import 'package:intentcall_core/intentcall_core.dart'; /// Builds `agent_manifest.json` for Android App Actions / shortcuts (Phase 3). String generateAndroidAgentManifest( - final Iterable descriptors, -) { + final Iterable descriptors, { + final String? protocolScheme, +}) { final shortcuts = >[]; for (final descriptor in descriptors) { + final resolvedResourceUri = _manifestResourceUri( + descriptor, + protocolScheme, + ); shortcuts.add({ 'qualifiedName': descriptor.qualifiedName, 'namespace': descriptor.namespace, 'name': descriptor.name, 'description': descriptor.description, 'kind': descriptor.kind.name, - if (descriptor.kind == AgentIntentKind.resource) - 'resourceUri': descriptor.effectiveResourceUri, + 'resourceUri': ?resolvedResourceUri, 'inputSchema': descriptor.inputSchema, }); } @@ -25,3 +29,20 @@ String generateAndroidAgentManifest( 'shortcuts': shortcuts, }); } + +String? _manifestResourceUri( + final AgentIntentDescriptor descriptor, + final String? protocolScheme, +) { + if (descriptor.resourceUri != null) { + return descriptor.resourceUri; + } + if (descriptor.kind != AgentIntentKind.resource) { + return null; + } + final scheme = protocolScheme?.trim() ?? ''; + if (scheme.isEmpty) { + return null; + } + return descriptor.effectiveResourceUri(scheme); +} diff --git a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart b/packages/intentcall_apple/lib/src/agent_manifest_generator.dart index 9fe2376..38ff57f 100644 --- a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart +++ b/packages/intentcall_apple/lib/src/agent_manifest_generator.dart @@ -7,17 +7,21 @@ String generateAppleAgentManifest( final Iterable descriptors, { final Iterable entityTypeDescriptors = const [], final Iterable> entityTypes = const [], + final String? protocolScheme, }) { final intents = >[]; for (final descriptor in descriptors) { + final resolvedResourceUri = _manifestResourceUri( + descriptor, + protocolScheme, + ); intents.add({ 'qualifiedName': descriptor.qualifiedName, 'namespace': descriptor.namespace, 'name': descriptor.name, 'description': descriptor.description, 'kind': descriptor.kind.name, - if (descriptor.kind == AgentIntentKind.resource) - 'resourceUri': descriptor.effectiveResourceUri, + 'resourceUri': ?resolvedResourceUri, if (descriptor.mimeType != null) 'mimeType': descriptor.mimeType, 'inputSchema': descriptor.inputSchema, }); @@ -34,6 +38,23 @@ String generateAppleAgentManifest( }); } +String? _manifestResourceUri( + final AgentIntentDescriptor descriptor, + final String? protocolScheme, +) { + if (descriptor.resourceUri != null) { + return descriptor.resourceUri; + } + if (descriptor.kind != AgentIntentKind.resource) { + return null; + } + final scheme = protocolScheme?.trim() ?? ''; + if (scheme.isEmpty) { + return null; + } + return descriptor.effectiveResourceUri(scheme); +} + Map _entityTypeDescriptorManifest( final AgentEntityTypeDescriptor descriptor, ) { diff --git a/packages/intentcall_apple/test/agent_manifest_generator_test.dart b/packages/intentcall_apple/test/agent_manifest_generator_test.dart index 5925ce7..21004a9 100644 --- a/packages/intentcall_apple/test/agent_manifest_generator_test.dart +++ b/packages/intentcall_apple/test/agent_manifest_generator_test.dart @@ -22,13 +22,16 @@ void main() { inputSchema: const {'type': 'object'}, mimeType: 'application/json', ), - ]); + ], protocolScheme: 'demoapp'); final map = jsonDecode(json) as Map; expect(map['platform'], 'apple'); final intents = map['intents']! as List; expect(intents, hasLength(2)); - expect((intents[1] as Map)['resourceUri'], isNotNull); + expect( + (intents[1] as Map)['resourceUri'], + 'demoapp://resource/diagnostics', + ); }); test('generateAppleAgentManifest includes raw entityTypes section', () { diff --git a/packages/intentcall_core/lib/src/intent/agent_intent_descriptor.dart b/packages/intentcall_core/lib/src/intent/agent_intent_descriptor.dart index 5f60989..836e953 100644 --- a/packages/intentcall_core/lib/src/intent/agent_intent_descriptor.dart +++ b/packages/intentcall_core/lib/src/intent/agent_intent_descriptor.dart @@ -1,4 +1,4 @@ -import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:intentcall_schema/intentcall_schema.dart' as schema; import 'package:meta/meta.dart'; import '../naming/qualified_name.dart'; @@ -24,7 +24,7 @@ final class AgentIntentDescriptor { final String name; final String description; final AgentIntentKind kind; - final InputSchema inputSchema; + final schema.InputSchema inputSchema; final String? methodName; final String? resourceUri; final String? mimeType; @@ -33,6 +33,10 @@ final class AgentIntentDescriptor { String get effectiveMethodName => methodName ?? name; - String get effectiveResourceUri => - resourceUri ?? AgentResultEnvelope.resourceUriForName(name); + String effectiveResourceUri(final String protocolScheme) => + resourceUri ?? + schema.resourceUri( + protocolScheme: protocolScheme, + resourceName: name, + ); } diff --git a/packages/intentcall_core/test/registration_public_surface_test.dart b/packages/intentcall_core/test/registration_public_surface_test.dart index ca9518f..61a93c1 100644 --- a/packages/intentcall_core/test/registration_public_surface_test.dart +++ b/packages/intentcall_core/test/registration_public_surface_test.dart @@ -25,14 +25,14 @@ void main() { test('exports neutral resource registration vocabulary', () async { expectCoreResourceHandler(_resourceHandler); const resource = ResourceRegistration( - uri: 'intentcall://resource/app/state', + uri: 'demoapp://resource/app/state', name: 'app_state', description: 'App state', mimeType: 'application/json', handler: _resourceHandler, ); const template = ResourceTemplateRegistration( - uriTemplate: 'intentcall://resource/app/{id}', + uriTemplate: 'demoapp://resource/app/{id}', name: 'app_resource', description: 'App resource', mimeType: 'application/json', @@ -42,7 +42,7 @@ void main() { expect(resource.mimeType, 'application/json'); expect(template.uriTemplate, contains('{id}')); expect( - await template.handler('intentcall://resource/app/1'), + await template.handler('demoapp://resource/app/1'), isA(), ); }); diff --git a/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart b/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart index 85ebd9a..579f4cb 100644 --- a/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart +++ b/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart @@ -38,6 +38,7 @@ final class McpPublishAdapter implements AgentAdapter { this.publishResource, this.unpublishResource, this.publishResourceTemplate, + this.protocolScheme, }); final McpToolPublisher publishTool; @@ -46,6 +47,9 @@ final class McpPublishAdapter implements AgentAdapter { final McpResourceUnpublisher? unpublishResource; final McpResourceTemplatePublisher? publishResourceTemplate; + /// App-owned scheme used when a resource descriptor has no explicit [AgentIntentDescriptor.resourceUri]. + final String? protocolScheme; + final Set _publishedTools = {}; final Set _publishedResources = {}; final Set _publishedResourceTemplates = {}; @@ -231,7 +235,7 @@ final class McpPublishAdapter implements AgentAdapter { ); publish( Resource( - uri: registration?.uri ?? d.effectiveResourceUri, + uri: registration?.uri ?? _resolvedResourceUri(d), name: registration?.name ?? d.name, description: registration?.description ?? d.description, mimeType: registration?.mimeType ?? d.mimeType ?? 'application/json', @@ -247,7 +251,7 @@ final class McpPublishAdapter implements AgentAdapter { if (publishTemplate == null || _publishedResourceTemplates.contains(key)) { return; } - final uriTemplate = registration?.uri ?? d.effectiveResourceUri; + final uriTemplate = registration?.uri ?? _resolvedResourceUri(d); if (_publishedResourceTemplatePatterns.contains(uriTemplate)) { return; } @@ -311,6 +315,20 @@ final class McpPublishAdapter implements AgentAdapter { _resourceTemplatePatternByKey[key] = uriTemplate; } + String _resolvedResourceUri(final AgentIntentDescriptor descriptor) { + if (descriptor.resourceUri != null) { + return descriptor.resourceUri!; + } + final scheme = protocolScheme?.trim() ?? ''; + if (scheme.isEmpty) { + throw StateError( + 'McpPublishAdapter needs protocolScheme for resource ' + '"${descriptor.qualifiedName}" without an explicit resourceUri.', + ); + } + return descriptor.effectiveResourceUri(scheme); + } + void _unpublishTransportKey(final String key) { if (_publishedTools.remove(key)) { unpublishTool(key); diff --git a/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart b/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart index 7716e9d..e0f1621 100644 --- a/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart +++ b/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart @@ -214,7 +214,7 @@ void main() { FutureOr Function(ReadResourceRequest) >{}; final publishedTemplates = []; - const uri = 'intentcall://resource/app/state'; + const uri = 'demoapp://resource/app/state'; final adapter = McpPublishAdapter( publishTool: (_, final _) {}, @@ -306,7 +306,7 @@ void main() { 'McpPublishAdapter clears resource template patterns on detach and reattach', () async { final registry = InMemoryAgentRegistry(); - const template = 'intentcall://resource/app/{id}'; + const template = 'demoapp://resource/app/{id}'; registry.register( RegisteredAgentIntent( descriptor: AgentIntentDescriptor( diff --git a/packages/intentcall_mcp/test/mcp_resource_mapper_test.dart b/packages/intentcall_mcp/test/mcp_resource_mapper_test.dart index 314e830..7472d3c 100644 --- a/packages/intentcall_mcp/test/mcp_resource_mapper_test.dart +++ b/packages/intentcall_mcp/test/mcp_resource_mapper_test.dart @@ -6,6 +6,7 @@ import 'package:test/test.dart'; void main() { test('agentResultToReadResourceResult maps resource envelope', () { final result = AgentResultEnvelope.resourceEnvelope( + protocolScheme: 'demoapp', resourceName: 'app_errors', snapshot: const {'count': 0}, ); diff --git a/packages/intentcall_mcp/test/registration_reexport_compatibility_test.dart b/packages/intentcall_mcp/test/registration_reexport_compatibility_test.dart index 5b06b03..bb265d5 100644 --- a/packages/intentcall_mcp/test/registration_reexport_compatibility_test.dart +++ b/packages/intentcall_mcp/test/registration_reexport_compatibility_test.dart @@ -15,14 +15,14 @@ void main() { handler: _toolHandler, ); const mcpResource = mcp.ResourceRegistration( - uri: 'intentcall://resource/app/state', + uri: 'demoapp://resource/app/state', name: 'app_state', description: 'App state', mimeType: 'application/json', handler: _resourceHandler, ); const mcpTemplate = mcp.ResourceTemplateRegistration( - uriTemplate: 'intentcall://resource/app/{id}', + uriTemplate: 'demoapp://resource/app/{id}', name: 'app_resource', description: 'App resource', mimeType: 'application/json', @@ -34,8 +34,8 @@ void main() { const core.ResourceTemplateRegistration coreTemplate = mcpTemplate; expect(coreTool.name, 'echo'); - expect(coreResource.uri, 'intentcall://resource/app/state'); - expect(coreTemplate.uriTemplate, 'intentcall://resource/app/{id}'); + expect(coreResource.uri, 'demoapp://resource/app/state'); + expect(coreTemplate.uriTemplate, 'demoapp://resource/app/{id}'); expect( await coreTool.handler(const {}), isA(), @@ -71,7 +71,7 @@ void main() { AgentResult.success(data: arguments), ); final coreResource = core.ResourceRegistration( - uri: 'intentcall://resource/app/state', + uri: 'demoapp://resource/app/state', name: 'app_state', description: 'App state', mimeType: 'application/json', @@ -79,7 +79,7 @@ void main() { AgentResult.success(data: {'uri': uri}), ); final coreTemplate = core.ResourceTemplateRegistration( - uriTemplate: 'intentcall://resource/app/{id}', + uriTemplate: 'demoapp://resource/app/{id}', name: 'app_resource', description: 'App resource', mimeType: 'application/json', @@ -109,8 +109,8 @@ void main() { await Future.delayed(Duration.zero); expect(publishedTools, contains('app_echo')); - expect(publishedResources, contains('intentcall://resource/app/state')); - expect(publishedTemplates, contains('intentcall://resource/app/{id}')); + expect(publishedResources, contains('demoapp://resource/app/state')); + expect(publishedTemplates, contains('demoapp://resource/app/{id}')); await adapter.detach(); }, diff --git a/packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart b/packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart index 4060ae3..925284b 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart @@ -1,3 +1,5 @@ +import 'package:intentcall_schema/intentcall_schema.dart' as schema; + /// Shared helpers for platform artifact emitters. String humanizeAgentName(final String name) => name.split('_').map(_titleCaseWord).join(' '); @@ -46,3 +48,15 @@ String invokeUri({ required final String protocolScheme, required final String qualifiedName, }) => '$protocolScheme://invoke/$qualifiedName'; + +/// Builds `$protocolScheme://resource/...` from underscore-separated segments. +/// +/// Example: `cool_runtime_snapshot` with scheme `demoapp` → +/// `demoapp://resource/cool/runtime/snapshot`. +String resourceUri({ + required final String protocolScheme, + required final String resourceName, +}) => schema.resourceUri( + protocolScheme: protocolScheme, + resourceName: resourceName, +); diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart index 097c620..813bfe2 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart @@ -7,6 +7,7 @@ import 'package:yaml/yaml.dart'; import '../agent_manifest.dart'; import '../catalog/agent_registry_catalog.dart'; +import '../emitters/emitter_utils.dart'; import 'projection_policy.dart'; import 'surface_resolver.dart'; @@ -73,7 +74,10 @@ final class ManifestMerger { dispatchMode: dispatchMode, inlineRuntime: inlineRuntime, surfaces: surfaces, - resourceUri: descriptor.resourceUri, + resourceUri: _resolvedResourceUri( + descriptor: descriptor, + protocolScheme: protocolScheme, + ), ), ); } @@ -205,6 +209,23 @@ final class ManifestMerger { } } +String? _resolvedResourceUri({ + required final AgentIntentDescriptor descriptor, + required final String? protocolScheme, +}) { + if (descriptor.resourceUri != null) { + return descriptor.resourceUri; + } + if (descriptor.kind != AgentIntentKind.resource) { + return null; + } + final scheme = protocolScheme?.trim() ?? ''; + if (scheme.isEmpty) { + return null; + } + return resourceUri(protocolScheme: scheme, resourceName: descriptor.name); +} + AgentManifestEntityType _entityFromDescriptor( final AgentEntityTypeDescriptor descriptor, ) { diff --git a/packages/intentcall_platform_sync/test/manifest_merger_test.dart b/packages/intentcall_platform_sync/test/manifest_merger_test.dart index 728a72f..3b48d3f 100644 --- a/packages/intentcall_platform_sync/test/manifest_merger_test.dart +++ b/packages/intentcall_platform_sync/test/manifest_merger_test.dart @@ -40,6 +40,31 @@ void main() { ); }); + test('ManifestMerger derives resourceUri from protocolScheme', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_diagnostics', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'diagnostics', + description: 'Diagnostics', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + protocolScheme: 'demoapp', + ); + expect( + manifest.entries.single.resourceUri, + 'demoapp://resource/diagnostics', + ); + }); + test('web-only enabledPlatforms scopes default surfaces', () { const merger = ManifestMerger(); const policy = ProjectionPolicy(); @@ -65,7 +90,10 @@ void main() { isTrue, ); expect( - surfaces.includes(AgentManifestSurface.androidShortcuts, defaultValue: false), + surfaces.includes( + AgentManifestSurface.androidShortcuts, + defaultValue: false, + ), isFalse, ); expect( diff --git a/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index ba37be5..8945043 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -81,10 +81,11 @@ Use envelopes when the consumer needs a versioned JSON snapshot (MCP resources, ```dart final result = AgentResultEnvelope.resourceEnvelope( + protocolScheme: 'demoapp', resourceName: 'spark_runtime_snapshot', snapshot: {'phase': 'playing'}, ); -// result.data['resource_uri'] == 'intentcall://resource/spark/runtime/snapshot' +// result.data['resource_uri'] == 'demoapp://resource/spark/runtime/snapshot' ``` ### Entity snapshots (agent-visible app state) diff --git a/packages/intentcall_schema/lib/intentcall_schema.dart b/packages/intentcall_schema/lib/intentcall_schema.dart index 84ca8b2..3eddf74 100644 --- a/packages/intentcall_schema/lib/intentcall_schema.dart +++ b/packages/intentcall_schema/lib/intentcall_schema.dart @@ -29,5 +29,6 @@ export 'src/agent_result_envelope.dart'; export 'src/agent_validation_exception.dart'; export 'src/agent_wire_args.dart'; export 'src/client_resource_input_schemas.dart'; +export 'src/resource_uri.dart'; export 'src/schema_coercion.dart'; export 'src/schema_validator.dart'; diff --git a/packages/intentcall_schema/lib/src/agent_result_envelope.dart b/packages/intentcall_schema/lib/src/agent_result_envelope.dart index ae79e05..603d0ca 100644 --- a/packages/intentcall_schema/lib/src/agent_result_envelope.dart +++ b/packages/intentcall_schema/lib/src/agent_result_envelope.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'agent_result.dart'; +import 'resource_uri.dart'; /// Builders for versioned snapshot payloads inside [AgentResult] data maps. /// @@ -42,14 +43,18 @@ extension AgentResultEnvelope on AgentResult { /// /// Populates `resource_uri`, `resource`, and `contents` so clients can treat /// the payload like a resource read response. [resourceName] uses underscore - /// segments that map to path segments in the URI (see [resourceUriForName]). + /// segments that map to path segments in the URI (see [resourceUri]). static AgentResult resourceEnvelope({ + required final String protocolScheme, required final String resourceName, required final Map snapshot, final String mimeType = 'application/json', final int schemaVersion = 1, }) { - final uri = resourceUriForName(resourceName); + final uri = resourceUri( + protocolScheme: protocolScheme, + resourceName: resourceName, + ); final text = jsonEncode(snapshot); final resource = { 'uri': uri, @@ -71,17 +76,4 @@ extension AgentResultEnvelope on AgentResult { }, ); } - - /// Builds `intentcall://resource/...` from underscore-separated [name] segments. - /// - /// Example: `spark_runtime_snapshot` → - /// `intentcall://resource/spark/runtime/snapshot`. - /// - /// Returns `intentcall://resource/unknown` when [name] is empty. - static String resourceUriForName(final String name) { - if (name.isEmpty) { - return 'intentcall://resource/unknown'; - } - return 'intentcall://resource/${name.split('_').join('/')}'; - } } diff --git a/packages/intentcall_schema/lib/src/resource_uri.dart b/packages/intentcall_schema/lib/src/resource_uri.dart new file mode 100644 index 0000000..078ac16 --- /dev/null +++ b/packages/intentcall_schema/lib/src/resource_uri.dart @@ -0,0 +1,15 @@ +/// Builds `$protocolScheme://resource/...` from underscore-separated segments. +/// +/// Example: `spark_runtime_snapshot` with scheme `demoapp` → +/// `demoapp://resource/spark/runtime/snapshot`. +/// +/// Returns `$protocolScheme://resource/unknown` when [resourceName] is empty. +String resourceUri({ + required final String protocolScheme, + required final String resourceName, +}) { + if (resourceName.isEmpty) { + return '$protocolScheme://resource/unknown'; + } + return '$protocolScheme://resource/${resourceName.split('_').join('/')}'; +} diff --git a/packages/intentcall_schema/test/agent_result_envelope_test.dart b/packages/intentcall_schema/test/agent_result_envelope_test.dart index 189dc42..602a1a7 100644 --- a/packages/intentcall_schema/test/agent_result_envelope_test.dart +++ b/packages/intentcall_schema/test/agent_result_envelope_test.dart @@ -2,15 +2,16 @@ import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:test/test.dart'; void main() { - test('resourceEnvelope builds intentcall resource uri', () { + test('resourceEnvelope builds app-owned resource uri', () { final result = AgentResultEnvelope.resourceEnvelope( + protocolScheme: 'demoapp', resourceName: 'spark_runtime_snapshot', snapshot: {'phase': 'playing'}, ); expect(result.ok, isTrue); expect( result.data['resource_uri'], - 'intentcall://resource/spark/runtime/snapshot', + 'demoapp://resource/spark/runtime/snapshot', ); }); } diff --git a/packages/intentcall_schema/test/resource_uri_test.dart b/packages/intentcall_schema/test/resource_uri_test.dart new file mode 100644 index 0000000..a682050 --- /dev/null +++ b/packages/intentcall_schema/test/resource_uri_test.dart @@ -0,0 +1,21 @@ +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:test/test.dart'; + +void main() { + test('resourceUri builds app-owned scheme paths', () { + expect( + resourceUri( + protocolScheme: 'demoapp', + resourceName: 'spark_runtime_snapshot', + ), + 'demoapp://resource/spark/runtime/snapshot', + ); + }); + + test('resourceUri returns unknown segment for empty name', () { + expect( + resourceUri(protocolScheme: 'demoapp', resourceName: ''), + 'demoapp://resource/unknown', + ); + }); +} From e5d6f6c08b71d7231d9f3dfddc6e9afb2abaa897 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 05:41:07 +0300 Subject: [PATCH 09/27] feat: add manifest resource URI check command and enhance entity snapshot handling in example app --- justfile | 4 + .../test/manifest_entity_export_test.dart | 6 +- .../lib/entities/demo_project_entity.dart | 33 +++ .../example/lib/entity_snapshot_seed.dart | 36 +++ .../lib/generated/agent_catalog.g.dart | 89 +++++--- .../intentcall_codegen/example/lib/main.dart | 31 +++ .../intentcall_codegen/example/pubspec.lock | 107 ++++++++- .../intentcall_codegen/example/pubspec.yaml | 6 + .../test/entity_snapshot_smoke_test.dart | 38 ++++ .../test/manifest_projection_test.dart | 22 +- .../example/web/agent_manifest.json | 44 ++++ .../lib/src/agent_entity.dart | 2 +- .../test/agent_catalog_generator_test.dart | 5 + .../manifest_resource_uri_policy_test.dart | 205 ++++++++++++++++++ skills/register-intents/SKILL.md | 2 + steward.yaml | 37 ++++ .../intentcall.manifest-resource-uri.yaml | 56 +++++ 17 files changed, 685 insertions(+), 38 deletions(-) create mode 100644 packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart create mode 100644 packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart create mode 100644 packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart create mode 100644 packages/intentcall_platform_sync/test/manifest_resource_uri_policy_test.dart create mode 100644 steward/scenarios/intentcall.manifest-resource-uri.yaml diff --git a/justfile b/justfile index 1f21c88..530b597 100644 --- a/justfile +++ b/justfile @@ -35,6 +35,10 @@ projection-pipeline-check: cd packages/intentcall_codegen/example && dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check cd packages/intentcall_codegen/example && dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check +# Manifest export must not emit package-wide intentcall:// resource URIs. +manifest-resource-uri-check: + dart test packages/intentcall_platform_sync/test/manifest_resource_uri_policy_test.dart + # Verify platform artifact sync on fixture projects platform-sync-check: dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/flutter_project --platform web --check diff --git a/packages/intentcall_cli/test/manifest_entity_export_test.dart b/packages/intentcall_cli/test/manifest_entity_export_test.dart index c760a6d..09d9fdd 100644 --- a/packages/intentcall_cli/test/manifest_entity_export_test.dart +++ b/packages/intentcall_cli/test/manifest_entity_export_test.dart @@ -98,8 +98,10 @@ void main() { expect(manifest.entityTypes.single.titleKey, 'name'); expect(manifest.entityTypes.single.subtitleKey, 'summary'); expect( - (manifest.entityTypes.single.snapshotSchema['properties']! - as Map)['name']['x-intentcall-role'], + ((manifest.entityTypes.single.snapshotSchema['properties']! + as Map)['name']! + as Map)['x-intentcall-role'] + as String?, 'title', ); }); diff --git a/packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart b/packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart new file mode 100644 index 0000000..c22bec2 --- /dev/null +++ b/packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart @@ -0,0 +1,33 @@ +import 'package:intentcall_codegen/intentcall_codegen.dart'; + +/// Codegen-discovered entity type for manifest export and snapshot seed demos. +@AgentEntity( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + displayName: 'Project', + properties: [ + AgentEntityProperty( + name: 'name', + valueType: 'string', + description: 'Display name', + isDisplay: true, + role: 'title', + ), + AgentEntityProperty( + name: 'summary', + valueType: 'string', + description: 'Searchable summary', + isSearchable: true, + role: 'subtitle', + ), + AgentEntityProperty( + name: 'tags', + valueType: 'array', + description: 'Search keywords', + isSearchable: true, + role: 'keywords', + ), + ], +) +final class DemoProjectEntity {} diff --git a/packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart b/packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart new file mode 100644 index 0000000..ca743ff --- /dev/null +++ b/packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart @@ -0,0 +1,36 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +import 'generated/agent_catalog.g.dart'; + +/// Builds demo [AgentEntitySnapshot] rows for the generated project entity. +Iterable demoProjectEntitySnapshots() sync* { + if (agentEntityTypeDescriptors.isEmpty) { + return; + } + final descriptor = agentEntityTypeDescriptors.single; + yield AgentEntitySnapshot( + ref: AgentEntityRef( + namespace: descriptor.namespace, + typeName: descriptor.name, + identifier: 'project-1', + ), + title: 'Codegen project', + subtitle: 'Entity snapshot seed', + keywords: const ['demo', 'codegen'], + properties: { + AppProjectEntityFields.name: 'Codegen project', + AppProjectEntityFields.summary: 'Entity snapshot seed', + AppProjectEntityFields.tags: ['demo', 'codegen'], + }, + ); +} + +/// Projects demo snapshots through descriptor-aligned cache keys. +Map demoProjectSnapshotRow() { + final descriptor = agentEntityTypeDescriptors.single; + return projectAgentEntitySnapshot( + demoProjectEntitySnapshots().single, + descriptor, + ); +} diff --git a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart index 1f525bd..cad7836 100644 --- a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -6,36 +6,63 @@ import '../tools/demo_ping_tool.dart'; final List agentCatalogEntries = [ - AgentRegistryCatalogEntry( - registryKey: 'app_demo_host_status', - entry: DemoHostTools.shared.demoHostStatusCallEntry, - projection: EntryProjection( - dispatchMode: AgentManifestDispatchMode.openApp, - surfaces: { - AgentManifestSurface.webMcp: true, - }, - ), - ), - AgentRegistryCatalogEntry( - registryKey: 'app_demo_ping', - entry: demoPingCallEntry, - ), - AgentRegistryCatalogEntry( - registryKey: 'app_demo_cart', - entry: demoCartCallEntry, - projection: EntryProjection( - dispatchMode: AgentManifestDispatchMode.openApp, - surfaces: { - AgentManifestSurface.webMcp: false, - }, - ), - ), - AgentRegistryCatalogEntry( - registryKey: 'app_demo_required_named', - entry: demoRequiredNamedCallEntry, - ), - ...DemoHostTools.demoHostCatalogEntries, - ]; + AgentRegistryCatalogEntry(registryKey: 'app_demo_host_status', entry: DemoHostTools.shared.demoHostStatusCallEntry, projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: {AgentManifestSurface.webMcp: true}, +)), + AgentRegistryCatalogEntry(registryKey: 'app_demo_ping', entry: demoPingCallEntry), + AgentRegistryCatalogEntry(registryKey: 'app_demo_cart', entry: demoCartCallEntry, projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: {AgentManifestSurface.webMcp: false}, +)), + AgentRegistryCatalogEntry(registryKey: 'app_demo_required_named', entry: demoRequiredNamedCallEntry), + ...DemoHostTools.demoHostCatalogEntries, +]; + +abstract final class AppProjectEntityFields { + static const String name = 'name'; + static const String summary = 'summary'; + static const String tags = 'tags'; +} final List agentEntityTypeDescriptors = - []; + [ + AgentEntityTypeDescriptor( + namespace: 'app', + name: 'project', + identifierName: 'projectId', + displayName: 'Project', + properties: [ + AgentEntityPropertyDescriptor( + name: 'name', + valueType: AgentEntityPropertyValueType.string, + description: 'Display name', + isDisplay: true, + isSearchable: false, + isIndexed: false, + role: AgentEntityPropertyRole.title, + ), + AgentEntityPropertyDescriptor( + name: 'summary', + valueType: AgentEntityPropertyValueType.string, + description: 'Searchable summary', + isDisplay: false, + isSearchable: true, + isIndexed: false, + role: AgentEntityPropertyRole.subtitle, + ), + AgentEntityPropertyDescriptor( + name: 'tags', + valueType: AgentEntityPropertyValueType.array, + description: 'Search keywords', + isDisplay: false, + isSearchable: true, + isIndexed: false, + role: AgentEntityPropertyRole.keywords, + ) + ], + privacy: AgentEntityPrivacy.private, + deepLinkBehavior: AgentEntityDeepLinkBehavior.unsupported, + openBehavior: AgentEntityOpenBehavior.unsupported, + ), +]; diff --git a/packages/intentcall_codegen/example/lib/main.dart b/packages/intentcall_codegen/example/lib/main.dart index f881730..9e29b79 100644 --- a/packages/intentcall_codegen/example/lib/main.dart +++ b/packages/intentcall_codegen/example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:intentcall_core/intentcall_core.dart'; +import 'entity_snapshot_seed.dart'; import 'generated/agent_catalog.g.dart'; import 'tools/demo_host_tools.dart'; @@ -9,6 +10,7 @@ import 'tools/demo_host_tools.dart'; /// /// ```text /// @AgentTool → tool implementation + (usually) catalog row +/// @AgentEntity → entity type descriptor + EntityFields constants /// handwritten getter → tool implementation only /// catalog row → @AgentCatalog list /// agent_catalog.g.dart → merge of all three sources @@ -77,4 +79,33 @@ Future main() async { 'demo_host_status smoke failed: expected codegen_instance source', ); } + + for (final descriptor in agentEntityTypeDescriptors) { + registry.registerEntityType(descriptor); + } + if (agentEntityTypeDescriptors.isNotEmpty) { + final row = demoProjectSnapshotRow(); + if (row['projectId'] != 'project-1') { + throw StateError('entity smoke failed: expected projectId project-1'); + } + if (row['name'] != 'Codegen project') { + throw StateError( + 'entity smoke failed: expected descriptor title key name', + ); + } + if (row['summary'] != 'Entity snapshot seed') { + throw StateError( + 'entity smoke failed: expected descriptor subtitle key summary', + ); + } + final tags = row['tags']; + if (tags is! List || + tags.length != 2 || + tags[0] != 'demo' || + tags[1] != 'codegen') { + throw StateError( + 'entity smoke failed: expected descriptor keywords tags', + ); + } + } } diff --git a/packages/intentcall_codegen/example/pubspec.lock b/packages/intentcall_codegen/example/pubspec.lock index a1e947c..54abed9 100644 --- a/packages/intentcall_codegen/example/pubspec.lock +++ b/packages/intentcall_codegen/example/pubspec.lock @@ -17,6 +17,38 @@ packages: url: "https://pub.dev" source: hosted version: "8.4.1" + app_links: + dependency: transitive + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" args: dependency: transitive description: @@ -89,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.6" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -145,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -161,6 +209,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + flutter: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -185,6 +243,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" http_multi_server: dependency: transitive description: @@ -215,6 +281,13 @@ packages: relative: true source: path version: "0.6.0" + intentcall_platform: + dependency: "direct dev" + description: + path: "../../intentcall_platform" + relative: true + source: path + version: "0.6.0" intentcall_platform_sync: dependency: "direct main" description: @@ -269,14 +342,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.18.0" mime: dependency: transitive description: @@ -309,6 +390,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -365,6 +454,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" source_gen: dependency: transitive description: @@ -469,6 +563,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" vm_service: dependency: transitive description: @@ -535,3 +637,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/packages/intentcall_codegen/example/pubspec.yaml b/packages/intentcall_codegen/example/pubspec.yaml index 5e2d3dc..b73d5f6 100644 --- a/packages/intentcall_codegen/example/pubspec.yaml +++ b/packages/intentcall_codegen/example/pubspec.yaml @@ -17,6 +17,10 @@ dependencies: dev_dependencies: build_runner: ^2.15.0 + flutter: + sdk: flutter + intentcall_platform: + path: ../../intentcall_platform lints: ^6.1.0 path: ^1.9.1 test: ^1.31.1 @@ -29,3 +33,5 @@ dependency_overrides: path: ../../intentcall_core intentcall_platform_sync: path: ../../intentcall_platform_sync + intentcall_platform: + path: ../../intentcall_platform diff --git a/packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart b/packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart new file mode 100644 index 0000000..062e436 --- /dev/null +++ b/packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart @@ -0,0 +1,38 @@ +import 'package:intentcall_platform/src/flutter/intentcall_entity_index.dart'; +import 'package:test/test.dart'; + +import '../lib/entity_snapshot_seed.dart'; +import '../lib/generated/agent_catalog.g.dart'; + +void main() { + test('upsertAgentSnapshotsForType seeds descriptor-aligned project row', () async { + expect(agentEntityTypeDescriptors, hasLength(1)); + + final calls = {}; + final index = IntentCallPlatformEntityIndex( + invoke: (final method, final arguments) async { + calls[method] = arguments; + return 1; + }, + ); + final descriptor = agentEntityTypeDescriptors.single; + + final count = await index.upsertAgentSnapshotsForType( + descriptor: descriptor, + snapshots: demoProjectEntitySnapshots(), + ); + + expect(count, 1); + final args = calls['upsertEntitySnapshots']! as Map; + expect(args['entityType'], 'app_project'); + final row = (args['snapshots']! as List).single as Map; + expect(row['projectId'], 'project-1'); + expect(row['id'], 'project-1'); + expect(row['name'], 'Codegen project'); + expect(row['summary'], 'Entity snapshot seed'); + expect(row['tags'], ['demo', 'codegen']); + expect(row['title'], 'Codegen project'); + expect(row['subtitle'], 'Entity snapshot seed'); + expect(row['keywords'], ['demo', 'codegen']); + }); +} diff --git a/packages/intentcall_codegen/example/test/manifest_projection_test.dart b/packages/intentcall_codegen/example/test/manifest_projection_test.dart index 3a8bd42..879979f 100644 --- a/packages/intentcall_codegen/example/test/manifest_projection_test.dart +++ b/packages/intentcall_codegen/example/test/manifest_projection_test.dart @@ -30,8 +30,11 @@ void main() { context = exporter.loadExportContext(projectRoot: projectRoot); }); - AgentManifest buildManifest() => - exporter.buildManifest(catalog: agentCatalogEntries, context: context); + AgentManifest buildManifest() => exporter.buildManifest( + catalog: agentCatalogEntries, + context: context, + entityTypeDescriptors: agentEntityTypeDescriptors, + ); test('demo_cart respects @AgentProjection(webMcp: false)', () { final manifest = buildManifest(); @@ -84,6 +87,21 @@ void main() { ); }); + test('exports @AgentEntity descriptor with three-slot keys', () { + final manifest = buildManifest(); + + expect(manifest.entityTypes, hasLength(1)); + final entity = manifest.entityTypes.single; + expect(entity.qualifiedName, 'app_project'); + expect(entity.titleKey, 'name'); + expect(entity.subtitleKey, 'summary'); + expect(entity.keywordsKey, 'tags'); + expect( + (entity.snapshotSchema['properties'] as Map)['name']['x-intentcall-role'], + 'title', + ); + }); + test('web-only example disables non-web default surfaces', () { expect(context.enabledPlatforms, contains('web')); diff --git a/packages/intentcall_codegen/example/web/agent_manifest.json b/packages/intentcall_codegen/example/web/agent_manifest.json index ae9c707..fada4a5 100644 --- a/packages/intentcall_codegen/example/web/agent_manifest.json +++ b/packages/intentcall_codegen/example/web/agent_manifest.json @@ -337,5 +337,49 @@ } } ], + "entityTypes": [ + { + "qualifiedName": "app_project", + "namespace": "app", + "name": "project", + "displayName": "Project", + "description": "", + "idKey": "projectId", + "titleKey": "name", + "subtitleKey": "summary", + "keywordsKey": "tags", + "urlKey": "url", + "defaultQueryLimit": 20, + "snapshotSchema": { + "type": "object", + "required": [ + "projectId" + ], + "properties": { + "projectId": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Display name", + "x-intentcall-display": true, + "x-intentcall-role": "title" + }, + "summary": { + "type": "string", + "description": "Searchable summary", + "x-intentcall-searchable": true, + "x-intentcall-role": "subtitle" + }, + "tags": { + "type": "array", + "description": "Search keywords", + "x-intentcall-searchable": true, + "x-intentcall-role": "keywords" + } + } + } + } + ], "protocolScheme": "intentcallcodegen" } diff --git a/packages/intentcall_codegen/lib/src/agent_entity.dart b/packages/intentcall_codegen/lib/src/agent_entity.dart index a6403f4..e27b846 100644 --- a/packages/intentcall_codegen/lib/src/agent_entity.dart +++ b/packages/intentcall_codegen/lib/src/agent_entity.dart @@ -1,4 +1,4 @@ -/// Declares one property on an [@AgentEntity] type for catalog projection. +/// Declares one property on an @[AgentEntity] type for catalog projection. class AgentEntityProperty { const AgentEntityProperty({ required this.name, diff --git a/packages/intentcall_codegen/test/agent_catalog_generator_test.dart b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart index 661adc9..596710a 100644 --- a/packages/intentcall_codegen/test/agent_catalog_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart @@ -32,6 +32,11 @@ void main() { expect(keys, contains('app_demo_handwritten')); }); + test('merges @AgentEntity descriptor rows from example', () { + expect(agentEntityTypeDescriptors, hasLength(1)); + expect(agentEntityTypeDescriptors.single.qualifiedName, 'app_project'); + }); + test('example agent_catalog has no duplicate registry keys', () { final keys = agentCatalogEntries .map((final row) => row.registryKey) diff --git a/packages/intentcall_platform_sync/test/manifest_resource_uri_policy_test.dart b/packages/intentcall_platform_sync/test/manifest_resource_uri_policy_test.dart new file mode 100644 index 0000000..5cb7c56 --- /dev/null +++ b/packages/intentcall_platform_sync/test/manifest_resource_uri_policy_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +const _bannedSchemePrefix = 'intentcall://'; + +void main() { + group('manifest resource URI policy', () { + test('derived resourceUri uses app protocolScheme', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_runtime_snapshot', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'runtime_snapshot', + description: 'Runtime snapshot', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + protocolScheme: 'demoapp', + ); + + expect(manifest.protocolScheme, 'demoapp'); + expect( + manifest.entries.single.resourceUri, + 'demoapp://resource/runtime/snapshot', + ); + }); + + test('explicit resourceUri is preserved during export', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + const explicitUri = 'visual://localhost/app/runtime/snapshot'; + final manifest = merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_runtime_snapshot', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'runtime_snapshot', + description: 'Runtime snapshot', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + resourceUri: explicitUri, + ), + ), + ], + policy: policy, + protocolScheme: 'demoapp', + ); + + expect(manifest.entries.single.resourceUri, explicitUri); + }); + + test('encoded manifest export never emits intentcall://', () { + const merger = ManifestMerger(); + const policy = ProjectionPolicy(); + final encoded = merger.encodeManifest( + merger.mergeManifest( + catalog: [ + AgentRegistryCatalogEntry( + registryKey: 'app_runtime_snapshot', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'runtime_snapshot', + description: 'Runtime snapshot', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + ), + ), + AgentRegistryCatalogEntry( + registryKey: 'app_ping', + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'ping', + description: 'Ping', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + ), + ], + policy: policy, + protocolScheme: 'demoapp', + ), + ); + + expect(encoded, isNot(contains(_bannedSchemePrefix))); + final decoded = jsonDecode(encoded) as Map; + _assertNoBannedScheme(decoded); + }); + + test('committed agent_manifest.json fixtures never emit intentcall://', () { + final repoRoot = _repoRoot(); + final manifests = [ + p.join( + repoRoot, + 'packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json', + ), + p.join( + repoRoot, + 'packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json', + ), + p.join( + repoRoot, + 'packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json', + ), + p.join( + repoRoot, + 'packages/intentcall_codegen/example/web/agent_manifest.json', + ), + ]; + + for (final manifestPath in manifests) { + final file = File(manifestPath); + expect(file.existsSync(), isTrue, reason: manifestPath); + final source = file.readAsStringSync(); + expect( + source, + isNot(contains(_bannedSchemePrefix)), + reason: manifestPath, + ); + + final manifest = AgentManifest.parse(source); + _assertDerivedResourceUrisUseProtocolScheme(manifest); + } + }); + }); +} + +String _repoRoot() { + var dir = Directory.current; + while (!File(p.join(dir.path, 'steward.yaml')).existsSync()) { + final parent = dir.parent; + if (parent.path == dir.path) { + throw StateError('Could not locate repository root from ${dir.path}'); + } + dir = parent; + } + return dir.path; +} + +void _assertNoBannedScheme(final Object? value) { + if (value is String) { + expect(value, isNot(contains(_bannedSchemePrefix))); + return; + } + if (value is Map) { + for (final entry in value.entries) { + _assertNoBannedScheme(entry.key); + _assertNoBannedScheme(entry.value); + } + return; + } + if (value is List) { + value.forEach(_assertNoBannedScheme); + } +} + +void _assertDerivedResourceUrisUseProtocolScheme(final AgentManifest manifest) { + final scheme = manifest.protocolScheme?.trim() ?? ''; + if (scheme.isEmpty) { + return; + } + + for (final entry in manifest.entries) { + if (entry.kind != AgentIntentKind.resource) { + continue; + } + final resourceUri = entry.resourceUri?.trim() ?? ''; + if (resourceUri.isEmpty) { + continue; + } + expect( + resourceUri, + isNot(startsWith(_bannedSchemePrefix)), + reason: entry.qualifiedName, + ); + + final descriptor = entry.toDescriptor(); + if (descriptor.resourceUri != null) { + continue; + } + + expect( + resourceUri, + descriptor.effectiveResourceUri(scheme), + reason: entry.qualifiedName, + ); + expect( + resourceUri, + startsWith('$scheme://resource/'), + reason: entry.qualifiedName, + ); + } +} diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index 8109e84..fe09adc 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -227,6 +227,7 @@ After changing registrations or projection policy, run: ```bash steward benchmark --scenario intentcall.projection-pipeline --json +steward benchmark --scenario intentcall.manifest-resource-uri --json ``` Apple sub-channels (Siri phrases, Spotlight donation hints) use @@ -387,6 +388,7 @@ adapter contract test and run: ```bash steward benchmark --scenario intentcall.adapter-contract --json steward benchmark --scenario intentcall.projection-pipeline --json +steward benchmark --scenario intentcall.manifest-resource-uri --json ``` --- diff --git a/steward.yaml b/steward.yaml index 73b2556..4307b3e 100644 --- a/steward.yaml +++ b/steward.yaml @@ -271,6 +271,41 @@ actions: evidence: redaction: steward/redaction/v1 summary_fields: [exit_code, duration_ms, output_digest] + intentcall.manifest-resource-uri-check: + kind: command + desc: Assert manifest export never emits intentcall:// and derived resource URIs use protocolScheme. + command: + argv: [just, manifest-resource-uri-check] + shell: false + cwd: . + effects: + fs_read: + - justfile + - packages/intentcall_platform_sync/** + - packages/intentcall_schema/** + - packages/intentcall_cli/** + - packages/intentcall_codegen/** + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 200000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] intentcall.projection-alignment-test: kind: command desc: Run cross-layer projection alignment matrix test. @@ -363,6 +398,8 @@ provenance: manifest: steward/scenarios/intentcall.adapter-contract.yaml - id: intentcall.projection-pipeline manifest: steward/scenarios/intentcall.projection-pipeline.yaml + - id: intentcall.manifest-resource-uri + manifest: steward/scenarios/intentcall.manifest-resource-uri.yaml branding: banned_words: [unlock, supercharge, ultimate, leverage] ignored_paths: [] diff --git a/steward/scenarios/intentcall.manifest-resource-uri.yaml b/steward/scenarios/intentcall.manifest-resource-uri.yaml new file mode 100644 index 0000000..cf22e56 --- /dev/null +++ b/steward/scenarios/intentcall.manifest-resource-uri.yaml @@ -0,0 +1,56 @@ +schema: steward/scenario-manifest/v1 +repo: intentcall +scenario: intentcall.manifest-resource-uri +status: runnable +source: + git: https://github.com/Arenukvern/intentcall + commit: ec1fbb07775c3125e7fc5e5446995b5ef6042ac4 + steward_contract: steward.yaml +safe_first_probe: intentcall.validate +required_actions: + - intentcall.manifest-resource-uri-check +steps: + - run: dart test packages/intentcall_platform_sync/test/manifest_resource_uri_policy_test.dart +artifacts: + - id: steward_contract + kind: yaml + path: steward.yaml + required: true + durability: input + - id: manifest_resource_uri_policy_test + kind: dart + path: packages/intentcall_platform_sync/test/manifest_resource_uri_policy_test.dart + required: true + durability: behavior + - id: resource_uri_helper + kind: dart + path: packages/intentcall_schema/lib/src/resource_uri.dart + required: true + durability: behavior + - id: manifest_merger + kind: dart + path: packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart + required: true + durability: behavior + - id: flutter_fixture_manifest + kind: json + path: packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json + required: true + durability: input + - id: jaspr_fixture_manifest + kind: json + path: packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json + required: true + durability: input + - id: codegen_fixture_manifest + kind: json + path: packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json + required: true + durability: input + - id: codegen_example_manifest + kind: json + path: packages/intentcall_codegen/example/web/agent_manifest.json + required: true + durability: input +blocked_by: null +owner: intentcall From e135c8f49cbdf408514eb9587e9e50a948ed3523 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 05:47:08 +0300 Subject: [PATCH 10/27] refactor: clean up library declarations and improve formatting in intentcall packages --- .../lib/intentcall_apple.dart | 2 - .../intentcall_cli/lib/intentcall_cli.dart | 2 - .../lib/src/catalog/catalog_loader.dart | 11 +- .../lib/src/command_runner.dart | 41 ++--- .../commands/apple_app_intents_testing.dart | 161 +++++++++--------- .../lib/src/config/host_profiles.dart | 14 +- .../lib/src/config/intentcall_config.dart | 11 +- .../fixtures/flutter_project/pubspec.yaml | 4 +- packages/intentcall_codegen/example/README.md | 48 ++++++ .../example/intentcall.yaml | 2 + .../lib/entities/demo_project_entity.dart | 33 ---- .../example/lib/entity_snapshot_seed.dart | 36 ---- .../lib/generated/agent_catalog.g.dart | 51 +----- .../intentcall_codegen/example/lib/main.dart | 41 ++--- .../example/lib/tools/demo_ping_tool.dart | 20 +++ .../example/lib/tools/demo_ping_tool.g.dart | 24 +++ .../intentcall_codegen/example/pubspec.lock | 103 ----------- .../intentcall_codegen/example/pubspec.yaml | 6 - .../test/entity_snapshot_smoke_test.dart | 38 ----- .../test/manifest_projection_test.dart | 50 ++++-- .../example/web/agent_manifest.json | 111 ++++++------ .../generators/agent_catalog_generator.dart | 5 +- packages/intentcall_codegen/pubspec.yaml | 4 +- .../test/agent_catalog_generator_test.dart | 6 +- .../test/agent_tool_codegen_test.dart | 25 ++- .../test/agent_tool_generator_test.dart | 27 +-- .../intentcall_core/lib/intentcall_core.dart | 2 - .../lib/intentcall_core_migration.dart | 2 - .../src/intent/registered_agent_intent.dart | 5 +- .../test/agent_entity_snapshot_keys_test.dart | 16 +- .../intentcall_mcp/lib/intentcall_mcp.dart | 2 - .../src/flutter/intentcall_host_events.dart | 3 +- .../lib/src/projection/manifest_exporter.dart | 2 +- .../lib/src/sync/platform_sync.dart | 6 +- .../lib/intentcall_session.dart | 2 - .../lib/intentcall_testing.dart | 2 - .../lib/intentcall_webmcp.dart | 2 - skills/register-intents/SKILL.md | 18 ++ 38 files changed, 402 insertions(+), 536 deletions(-) delete mode 100644 packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart delete mode 100644 packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart delete mode 100644 packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart diff --git a/packages/intentcall_apple/lib/intentcall_apple.dart b/packages/intentcall_apple/lib/intentcall_apple.dart index a369c1a..9ab470b 100644 --- a/packages/intentcall_apple/lib/intentcall_apple.dart +++ b/packages/intentcall_apple/lib/intentcall_apple.dart @@ -1,3 +1 @@ -library; - export 'src/agent_manifest_generator.dart'; diff --git a/packages/intentcall_cli/lib/intentcall_cli.dart b/packages/intentcall_cli/lib/intentcall_cli.dart index 5a277d3..336c3bf 100644 --- a/packages/intentcall_cli/lib/intentcall_cli.dart +++ b/packages/intentcall_cli/lib/intentcall_cli.dart @@ -1,5 +1,3 @@ -library; - export 'src/command_runner.dart'; export 'src/config/host_profiles.dart'; export 'src/config/intentcall_config.dart'; diff --git a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart index ab49fcb..6bab5ae 100644 --- a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart +++ b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart @@ -57,12 +57,11 @@ final class CatalogLoader { return null; } - final probeDir = Directory(p.join(projectRoot, '.dart_tool')); - probeDir.createSync(recursive: true); - final probeFile = File( - p.join(probeDir.path, 'intentcall_catalog_probe.dart'), - ); - probeFile.writeAsStringSync(''' + final probeDir = Directory(p.join(projectRoot, '.dart_tool')) + ..createSync(recursive: true); + final probeFile = + File(p.join(probeDir.path, 'intentcall_catalog_probe.dart')) + ..writeAsStringSync(''' // ignore_for_file: avoid_print import 'dart:convert'; import 'package:$packageName/generated/agent_catalog.g.dart'; diff --git a/packages/intentcall_cli/lib/src/command_runner.dart b/packages/intentcall_cli/lib/src/command_runner.dart index 5743ecd..6ee08de 100644 --- a/packages/intentcall_cli/lib/src/command_runner.dart +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -29,6 +29,7 @@ final class IntentCallCommandRunner extends CommandRunner { } } +// ignore: avoid_classes_with_only_static_members final class _ProjectDirOption { static void add(final ArgParser parser) { parser.addOption( @@ -55,7 +56,7 @@ final class _DoctorCommand extends Command { String get description => 'Check developer environment health.'; @override - Future run() async => + Future run() => _runDoctor(asJson: argResults!['json'] as bool? ?? false); @override @@ -96,7 +97,10 @@ final class _DoctorCommand extends Command { } await checkTool('dart', ['dart', '--version']); - await checkTool('flutter', ['flutter', '--version'], required: false); + await checkTool('flutter', [ + 'flutter', + '--version', + ], required: false); await checkTool('just', ['just', '--version'], required: false); final lockExists = File('pubspec.lock').existsSync(); @@ -111,10 +115,7 @@ final class _DoctorCommand extends Command { }); if (asJson) { - printJson({ - 'healthy': healthy, - 'checks': checks, - }); + printJson({'healthy': healthy, 'checks': checks}); } else { stdout.writeln('== IntentCall Doctor =='); for (final check in checks) { @@ -161,8 +162,9 @@ final class _ConfigShowCommand extends Command { if (results['json'] as bool? ?? false) { printJson(enriched); } else { - stdout.writeln('# intentcall.yaml (${config.sourcePath})'); - stdout.writeln(encodePrettyJson(enriched)); + stdout + ..writeln('# intentcall.yaml (${config.sourcePath})') + ..writeln(encodePrettyJson(enriched)); } return 0; } @@ -220,10 +222,11 @@ final class _ManifestValidateCommand extends Command { 'entityTypeCount': manifest.entityTypes.length, }); } else { - stdout.writeln('OK: valid manifest at ${manifestPath.path}'); - stdout.writeln( - ' tools=${manifest.tools.length} entityTypes=${manifest.entityTypes.length}', - ); + stdout + ..writeln('OK: valid manifest at ${manifestPath.path}') + ..writeln( + ' tools=${manifest.tools.length} entityTypes=${manifest.entityTypes.length}', + ); } return 0; } on FormatException catch (error) { @@ -263,7 +266,7 @@ final class _ManifestExportCommand extends Command { const exporter = ManifestExporter(); final context = exporter.loadExportContext(projectRoot: projectRoot); - final catalogLoader = const CatalogLoader(); + const catalogLoader = CatalogLoader(); final catalog = await catalogLoader.load(projectRoot: projectRoot); final entityTypeDescriptors = await catalogLoader.loadEntityTypeDescriptors( projectRoot: projectRoot, @@ -438,10 +441,7 @@ final class _PlatformHooksInitCommand extends Command { final checkOnly = results['check'] as bool? ?? false; if (host == IntentCallHost.jaspr.name) { - return _initJasprHooks( - projectRoot: projectRoot, - checkOnly: checkOnly, - ); + return _initJasprHooks(projectRoot: projectRoot, checkOnly: checkOnly); } final report = await const PlatformHooksInit().run( @@ -540,9 +540,10 @@ final class _PlatformHooksPrintCommand extends Command { ? ['jaspr', 'web'] : ['android', 'ios', 'macos']; for (final key in keys) { - stdout.writeln('== $key =='); - stdout.writeln(snippets[key]!.trim()); - stdout.writeln(); + stdout + ..writeln('== $key ==') + ..writeln(snippets[key]!.trim()) + ..writeln(); } return 0; } diff --git a/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart b/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart index c456ff8..0b96c01 100644 --- a/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart +++ b/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart @@ -38,7 +38,10 @@ Future _generateTests( return usageExitCode(); } - final manifestPath = resolveProjectPath(projectRoot, '${command['manifest']}'); + final manifestPath = resolveProjectPath( + projectRoot, + '${command['manifest']}', + ); if (!manifestPath.existsSync()) { printUsageError('manifest not found: ${manifestPath.path}'); return inputMissingExitCode(); @@ -102,7 +105,10 @@ Future _generateFixtures( return usageExitCode(); } - final manifestPath = resolveProjectPath(projectRoot, '${command['manifest']}'); + final manifestPath = resolveProjectPath( + projectRoot, + '${command['manifest']}', + ); if (!manifestPath.existsSync()) { printUsageError('manifest not found: ${manifestPath.path}'); return inputMissingExitCode(); @@ -254,7 +260,7 @@ Map> _sampleArgumentsTemplate( } final required = { ...((tool.inputSchema['required'] is List) - ? tool.inputSchema['required'] as List + ? tool.inputSchema['required']! as List : const []) .whereType(), }; @@ -299,18 +305,17 @@ Object? _sampleValueForSchema( final String schemaType, { required final String qualifiedName, required final String parameterName, -}) => - switch (schemaType) { - 'string' => '', - 'integer' => 1, - 'number' => 1.0, - 'boolean' => true, - _ => throw UnsupportedError( - 'AppIntentsTesting fixture templates support only primitive ' - 'string/integer/number/boolean parameters in $qualifiedName; ' - '"$parameterName" has unsupported type "$schemaType".', - ), - }; +}) => switch (schemaType) { + 'string' => '', + 'integer' => 1, + 'number' => 1.0, + 'boolean' => true, + _ => throw UnsupportedError( + 'AppIntentsTesting fixture templates support only primitive ' + 'string/integer/number/boolean parameters in $qualifiedName; ' + '"$parameterName" has unsupported type "$schemaType".', + ), +}; Map> _readSampleArguments(final File file) { final raw = readJsonObjectFile(file); @@ -355,68 +360,64 @@ Map _readEntityFixtures( }); } -ArgParser buildAppleAppIntentsTestingParser() { - return ArgParser() - ..addCommand( - 'generate-tests', - ArgParser() - ..addOption( - 'manifest', - abbr: 'm', - help: 'Path to agent_manifest.json.', - defaultsTo: 'web/agent_manifest.json', - ) - ..addOption( - 'bundle-id', - help: 'Bundle identifier for IntentDefinitions lookup.', - ) - ..addOption( - 'output', - abbr: 'o', - help: 'Swift output path. Defaults to stdout.', - ) - ..addOption( - 'test-class', - help: 'Generated XCTest class name.', - defaultsTo: 'IntentCallAppIntentsLiveInvocationTests', - ) - ..addOption( - 'sample-arguments', - help: - 'Optional JSON file keyed by manifest qualifiedName with primitive App Intent argument fixtures.', - ) - ..addOption( - 'entity-fixtures', - help: - 'Optional JSON file keyed by entity qualifiedName with identifier/search/expectedTitle fixtures.', - ), - ) - ..addCommand( - 'generate-fixtures', - ArgParser() - ..addOption( - 'manifest', - abbr: 'm', - help: 'Path to agent_manifest.json.', - defaultsTo: 'web/agent_manifest.json', - ) - ..addOption( - 'sample-arguments-output', - help: - 'JSON output path for generated primitive argument fixtures.', - ) - ..addOption( - 'entity-fixtures-output', - help: 'JSON output path for generated AppEntity query fixtures.', - ), - ) - ..addCommand( - 'typecheck', - ArgParser() - ..addOption( - 'xcode', - help: 'Xcode.app path containing AppIntentsTesting.framework.', - defaultsTo: '/Applications/Xcode-beta.app', - ), - ); -} +ArgParser buildAppleAppIntentsTestingParser() => ArgParser() + ..addCommand( + 'generate-tests', + ArgParser() + ..addOption( + 'manifest', + abbr: 'm', + help: 'Path to agent_manifest.json.', + defaultsTo: 'web/agent_manifest.json', + ) + ..addOption( + 'bundle-id', + help: 'Bundle identifier for IntentDefinitions lookup.', + ) + ..addOption( + 'output', + abbr: 'o', + help: 'Swift output path. Defaults to stdout.', + ) + ..addOption( + 'test-class', + help: 'Generated XCTest class name.', + defaultsTo: 'IntentCallAppIntentsLiveInvocationTests', + ) + ..addOption( + 'sample-arguments', + help: + 'Optional JSON file keyed by manifest qualifiedName with primitive App Intent argument fixtures.', + ) + ..addOption( + 'entity-fixtures', + help: + 'Optional JSON file keyed by entity qualifiedName with identifier/search/expectedTitle fixtures.', + ), + ) + ..addCommand( + 'generate-fixtures', + ArgParser() + ..addOption( + 'manifest', + abbr: 'm', + help: 'Path to agent_manifest.json.', + defaultsTo: 'web/agent_manifest.json', + ) + ..addOption( + 'sample-arguments-output', + help: 'JSON output path for generated primitive argument fixtures.', + ) + ..addOption( + 'entity-fixtures-output', + help: 'JSON output path for generated AppEntity query fixtures.', + ), + ) + ..addCommand( + 'typecheck', + ArgParser()..addOption( + 'xcode', + help: 'Xcode.app path containing AppIntentsTesting.framework.', + defaultsTo: '/Applications/Xcode-beta.app', + ), + ); diff --git a/packages/intentcall_cli/lib/src/config/host_profiles.dart b/packages/intentcall_cli/lib/src/config/host_profiles.dart index 23d39d9..4d2489e 100644 --- a/packages/intentcall_cli/lib/src/config/host_profiles.dart +++ b/packages/intentcall_cli/lib/src/config/host_profiles.dart @@ -24,11 +24,7 @@ const kHostProfiles = { 'linux', 'windows', ], - hookTemplateKeys: [ - 'android', - 'ios', - 'macos', - ], + hookTemplateKeys: ['android', 'ios', 'macos'], ), IntentCallHost.jaspr: HostProfile( host: IntentCallHost.jaspr, @@ -37,13 +33,13 @@ const kHostProfiles = { ), IntentCallHost.dart: HostProfile( host: IntentCallHost.dart, - defaultPlatforms: const [], - hookTemplateKeys: const [], + defaultPlatforms: [], + hookTemplateKeys: [], ), IntentCallHost.custom: HostProfile( host: IntentCallHost.custom, - defaultPlatforms: const [], - hookTemplateKeys: const [], + defaultPlatforms: [], + hookTemplateKeys: [], ), }; diff --git a/packages/intentcall_cli/lib/src/config/intentcall_config.dart b/packages/intentcall_cli/lib/src/config/intentcall_config.dart index 017243d..c3a654e 100644 --- a/packages/intentcall_cli/lib/src/config/intentcall_config.dart +++ b/packages/intentcall_cli/lib/src/config/intentcall_config.dart @@ -53,10 +53,13 @@ final class IntentCallConfig { ); } - factory IntentCallConfig.parse(final String yamlText, {final String? sourcePath}) { + factory IntentCallConfig.parse( + final String yamlText, { + final String? sourcePath, + }) { final doc = loadYaml(yamlText); if (doc is! YamlMap) { - throw FormatException('intentcall.yaml must be a YAML mapping.'); + throw const FormatException('intentcall.yaml must be a YAML mapping.'); } return IntentCallConfig.fromYamlMap(doc, sourcePath: sourcePath); } @@ -139,9 +142,7 @@ final class IntentCallPlatforms { final List enabled; - Map toJson() => { - 'enabled': enabled, - }; + Map toJson() => {'enabled': enabled}; } /// Global projection defaults merged into [ProjectionPolicy]. diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml b/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml index a1c0c1a..64bc2ce 100644 --- a/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml +++ b/packages/intentcall_cli/test/fixtures/flutter_project/pubspec.yaml @@ -19,9 +19,9 @@ dev_dependencies: build_runner: ^2.15.0 dependency_overrides: - intentcall_schema: - path: ../../../../intentcall_schema intentcall_core: path: ../../../../intentcall_core + intentcall_schema: + path: ../../../../intentcall_schema intentcall_platform_sync: path: ../../../../intentcall_platform_sync diff --git a/packages/intentcall_codegen/example/README.md b/packages/intentcall_codegen/example/README.md index e69de29..3d1ef01 100644 --- a/packages/intentcall_codegen/example/README.md +++ b/packages/intentcall_codegen/example/README.md @@ -0,0 +1,48 @@ +# intentcall_codegen example + +Dart-only dogfood host for `@AgentTool`, `@AgentCatalog`, and manifest export. +It proves catalog merge, projection surfaces, and registry smoke wiring without +a Flutter shell. + +## What this example teaches + +| Topic | Where | +|-------|--------| +| Top-level `@AgentTool` codegen | `lib/tools/demo_ping_tool.dart` | +| Instance-bound tools + `@AgentCatalog` | `lib/tools/demo_host_tools.dart` | +| Siri / Shortcuts verb discovery | `app_demo_set_greeting` with `apple.appIntents` + `apple.appShortcuts` | +| Web MCP projection | `demo_host_status`, `demo_inbox` | +| Manifest export | `intentcall.yaml` → `web/agent_manifest.json` | + +## What belongs elsewhere + +**`@AgentEntity`, native entity cache, and Spotlight indexing** are not modeled +here. That path needs a Flutter host, platform sync, and runtime snapshot seeding. +Use the MCP Flutter showcase instead: + +- [`mcp_flutter/flutter_test_app`](https://github.com/Arenukvern/mcp_flutter/tree/main/flutter_test_app) — `app_screen` entities, `upsertAgentSnapshotsForType`, iOS generated Swift +- [`packages/intentcall_cli/test/fixtures/entity_catalog_project`](../../intentcall_cli/test/fixtures/entity_catalog_project) — codegen + manifest export unit tests for `@AgentEntity` + +Entity codegen fixtures also live under +`packages/intentcall_codegen/test/fixtures/catalog/`. + +## Apple surfaces in this example + +`intentcall.yaml` enables `web`, `ios`, and `macos` so manifest export emits +Apple App Intent scaffolds for tools. Only curated product verbs opt into +`apple.appShortcuts` (Siri phrases). See `demo_set_greeting` in +`demo_ping_tool.dart`. + +Run `intentcall platform sync` from a Flutter app with an `ios/` or `macos/` +target to materialize generated Swift. This dart-only package does not ship +native runners. + +## Commands + +```bash +dart pub get +dart run build_runner build --delete-conflicting-outputs +dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check +dart run lib/main.dart +dart test +``` diff --git a/packages/intentcall_codegen/example/intentcall.yaml b/packages/intentcall_codegen/example/intentcall.yaml index 57346c7..f53daaf 100644 --- a/packages/intentcall_codegen/example/intentcall.yaml +++ b/packages/intentcall_codegen/example/intentcall.yaml @@ -6,5 +6,7 @@ layout: platforms: enabled: - web + - ios + - macos defaults: dispatchMode: openApp diff --git a/packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart b/packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart deleted file mode 100644 index c22bec2..0000000 --- a/packages/intentcall_codegen/example/lib/entities/demo_project_entity.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:intentcall_codegen/intentcall_codegen.dart'; - -/// Codegen-discovered entity type for manifest export and snapshot seed demos. -@AgentEntity( - namespace: 'app', - name: 'project', - identifierName: 'projectId', - displayName: 'Project', - properties: [ - AgentEntityProperty( - name: 'name', - valueType: 'string', - description: 'Display name', - isDisplay: true, - role: 'title', - ), - AgentEntityProperty( - name: 'summary', - valueType: 'string', - description: 'Searchable summary', - isSearchable: true, - role: 'subtitle', - ), - AgentEntityProperty( - name: 'tags', - valueType: 'array', - description: 'Search keywords', - isSearchable: true, - role: 'keywords', - ), - ], -) -final class DemoProjectEntity {} diff --git a/packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart b/packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart deleted file mode 100644 index ca743ff..0000000 --- a/packages/intentcall_codegen/example/lib/entity_snapshot_seed.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_schema/intentcall_schema.dart'; - -import 'generated/agent_catalog.g.dart'; - -/// Builds demo [AgentEntitySnapshot] rows for the generated project entity. -Iterable demoProjectEntitySnapshots() sync* { - if (agentEntityTypeDescriptors.isEmpty) { - return; - } - final descriptor = agentEntityTypeDescriptors.single; - yield AgentEntitySnapshot( - ref: AgentEntityRef( - namespace: descriptor.namespace, - typeName: descriptor.name, - identifier: 'project-1', - ), - title: 'Codegen project', - subtitle: 'Entity snapshot seed', - keywords: const ['demo', 'codegen'], - properties: { - AppProjectEntityFields.name: 'Codegen project', - AppProjectEntityFields.summary: 'Entity snapshot seed', - AppProjectEntityFields.tags: ['demo', 'codegen'], - }, - ); -} - -/// Projects demo snapshots through descriptor-aligned cache keys. -Map demoProjectSnapshotRow() { - final descriptor = agentEntityTypeDescriptors.single; - return projectAgentEntitySnapshot( - demoProjectEntitySnapshots().single, - descriptor, - ); -} diff --git a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart index cad7836..d39b438 100644 --- a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -16,53 +16,12 @@ final List agentCatalogEntries = surfaces: {AgentManifestSurface.webMcp: false}, )), AgentRegistryCatalogEntry(registryKey: 'app_demo_required_named', entry: demoRequiredNamedCallEntry), + AgentRegistryCatalogEntry(registryKey: 'app_demo_set_greeting', entry: demoSetGreetingCallEntry, projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: {AgentManifestSurface.appleAppIntents: true, AgentManifestSurface.appleAppShortcuts: true}, +)), ...DemoHostTools.demoHostCatalogEntries, ]; -abstract final class AppProjectEntityFields { - static const String name = 'name'; - static const String summary = 'summary'; - static const String tags = 'tags'; -} - final List agentEntityTypeDescriptors = - [ - AgentEntityTypeDescriptor( - namespace: 'app', - name: 'project', - identifierName: 'projectId', - displayName: 'Project', - properties: [ - AgentEntityPropertyDescriptor( - name: 'name', - valueType: AgentEntityPropertyValueType.string, - description: 'Display name', - isDisplay: true, - isSearchable: false, - isIndexed: false, - role: AgentEntityPropertyRole.title, - ), - AgentEntityPropertyDescriptor( - name: 'summary', - valueType: AgentEntityPropertyValueType.string, - description: 'Searchable summary', - isDisplay: false, - isSearchable: true, - isIndexed: false, - role: AgentEntityPropertyRole.subtitle, - ), - AgentEntityPropertyDescriptor( - name: 'tags', - valueType: AgentEntityPropertyValueType.array, - description: 'Search keywords', - isDisplay: false, - isSearchable: true, - isIndexed: false, - role: AgentEntityPropertyRole.keywords, - ) - ], - privacy: AgentEntityPrivacy.private, - deepLinkBehavior: AgentEntityDeepLinkBehavior.unsupported, - openBehavior: AgentEntityOpenBehavior.unsupported, - ), -]; + []; diff --git a/packages/intentcall_codegen/example/lib/main.dart b/packages/intentcall_codegen/example/lib/main.dart index 9e29b79..93d3865 100644 --- a/packages/intentcall_codegen/example/lib/main.dart +++ b/packages/intentcall_codegen/example/lib/main.dart @@ -1,6 +1,5 @@ import 'package:intentcall_core/intentcall_core.dart'; -import 'entity_snapshot_seed.dart'; import 'generated/agent_catalog.g.dart'; import 'tools/demo_host_tools.dart'; @@ -10,7 +9,6 @@ import 'tools/demo_host_tools.dart'; /// /// ```text /// @AgentTool → tool implementation + (usually) catalog row -/// @AgentEntity → entity type descriptor + EntityFields constants /// handwritten getter → tool implementation only /// catalog row → @AgentCatalog list /// agent_catalog.g.dart → merge of all three sources @@ -24,6 +22,12 @@ import 'tools/demo_host_tools.dart'; /// (here `DemoHostTools.shared.demoHostStatusCallEntry`). /// - **Runtime** — the app may register a different live host instance when /// descriptors match (DI container, per-session state, tests). +/// +/// **Apple discovery** — Siri and Shortcuts discover registry **verbs** via +/// `apple.appIntents` + opt-in `apple.appShortcuts` on tools such as +/// `app_demo_set_greeting`. Indexable **nouns** (`@AgentEntity`, Spotlight) are +/// dogfooded in the Flutter showcase, not this dart-only host: +/// `mcp_flutter/flutter_test_app`. Future main() async { final registry = InMemoryAgentRegistry(); @@ -80,32 +84,13 @@ Future main() async { ); } - for (final descriptor in agentEntityTypeDescriptors) { - registry.registerEntityType(descriptor); + final greeting = await registry.invoke('app_demo_set_greeting', { + 'text': 'hello codegen', + }); + if (!greeting.ok) { + throw StateError('demo_set_greeting smoke failed: ${greeting.message}'); } - if (agentEntityTypeDescriptors.isNotEmpty) { - final row = demoProjectSnapshotRow(); - if (row['projectId'] != 'project-1') { - throw StateError('entity smoke failed: expected projectId project-1'); - } - if (row['name'] != 'Codegen project') { - throw StateError( - 'entity smoke failed: expected descriptor title key name', - ); - } - if (row['summary'] != 'Entity snapshot seed') { - throw StateError( - 'entity smoke failed: expected descriptor subtitle key summary', - ); - } - final tags = row['tags']; - if (tags is! List || - tags.length != 2 || - tags[0] != 'demo' || - tags[1] != 'codegen') { - throw StateError( - 'entity smoke failed: expected descriptor keywords tags', - ); - } + if (greeting.data['greeting'] != 'hello codegen') { + throw StateError('demo_set_greeting smoke failed: unexpected greeting'); } } diff --git a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart index 20c27fc..226f443 100644 --- a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart +++ b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart @@ -41,3 +41,23 @@ Future demoRequiredNamed({ }) async { return AgentResult.success(data: {'mode': mode}); } + +/// Curated Apple verb for Siri / Shortcuts discovery (see `intentcall.yaml` ios/macos). +@AgentTool( + namespace: 'app', + name: 'demo_set_greeting', + description: 'Set greeting text for the codegen demo host.', +) +@AgentProjection( + surfaces: { + AgentManifestSurface.appleAppIntents: true, + AgentManifestSurface.appleAppShortcuts: true, + }, +) +Future demoSetGreeting( + @AgentParam('Greeting text') String text, +) async { + return AgentResult.success( + data: {'greeting': text, 'kind': 'demo_set_greeting'}, + ); +} diff --git a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart index ffd2378..e16ae4e 100644 --- a/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart +++ b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart @@ -97,3 +97,27 @@ AgentCallEntry get demoRequiredNamedCallEntry => AgentCallEntry.tool( return await (result as Future); }, ); + +const _demo_set_greetingInputSchema = { + 'type': 'object', + 'properties': { + 'text': {'type': 'string', 'description': 'Greeting text'}, + }, + 'required': ['text'], +}; + +RegisteredAgentIntent get demoSetGreetingRegistration => + demoSetGreetingCallEntry.toRegistration(); + +AgentCallEntry get demoSetGreetingCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_set_greeting', + description: 'Set greeting text for the codegen demo host.', + inputSchema: _demo_set_greetingInputSchema, + handler: (final args) async { + final result = Function.apply(demoSetGreeting, [ + args['text'] as String, + ], {}); + return await (result as Future); + }, +); diff --git a/packages/intentcall_codegen/example/pubspec.lock b/packages/intentcall_codegen/example/pubspec.lock index 54abed9..09fc372 100644 --- a/packages/intentcall_codegen/example/pubspec.lock +++ b/packages/intentcall_codegen/example/pubspec.lock @@ -17,38 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.4.1" - app_links: - dependency: transitive - description: - name: app_links - sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - app_links_platform_interface: - dependency: transitive - description: - name: app_links_platform_interface - sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - app_links_web: - dependency: transitive - description: - name: app_links_web - sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 - url: "https://pub.dev" - source: hosted - version: "1.0.4" args: dependency: transitive description: @@ -121,14 +89,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.6" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" checked_yaml: dependency: transitive description: @@ -185,14 +145,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" file: dependency: transitive description: @@ -209,16 +161,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - flutter: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -243,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - gtk: - dependency: transitive - description: - name: gtk - sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" - url: "https://pub.dev" - source: hosted - version: "2.2.0" http_multi_server: dependency: transitive description: @@ -281,13 +215,6 @@ packages: relative: true source: path version: "0.6.0" - intentcall_platform: - dependency: "direct dev" - description: - path: "../../intentcall_platform" - relative: true - source: path - version: "0.6.0" intentcall_platform_sync: dependency: "direct main" description: @@ -342,14 +269,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.12.20" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" meta: dependency: transitive description: @@ -390,14 +309,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" pool: dependency: transitive description: @@ -454,11 +365,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" source_gen: dependency: transitive description: @@ -563,14 +469,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" vm_service: dependency: transitive description: @@ -637,4 +535,3 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" diff --git a/packages/intentcall_codegen/example/pubspec.yaml b/packages/intentcall_codegen/example/pubspec.yaml index b73d5f6..5e2d3dc 100644 --- a/packages/intentcall_codegen/example/pubspec.yaml +++ b/packages/intentcall_codegen/example/pubspec.yaml @@ -17,10 +17,6 @@ dependencies: dev_dependencies: build_runner: ^2.15.0 - flutter: - sdk: flutter - intentcall_platform: - path: ../../intentcall_platform lints: ^6.1.0 path: ^1.9.1 test: ^1.31.1 @@ -33,5 +29,3 @@ dependency_overrides: path: ../../intentcall_core intentcall_platform_sync: path: ../../intentcall_platform_sync - intentcall_platform: - path: ../../intentcall_platform diff --git a/packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart b/packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart deleted file mode 100644 index 062e436..0000000 --- a/packages/intentcall_codegen/example/test/entity_snapshot_smoke_test.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:intentcall_platform/src/flutter/intentcall_entity_index.dart'; -import 'package:test/test.dart'; - -import '../lib/entity_snapshot_seed.dart'; -import '../lib/generated/agent_catalog.g.dart'; - -void main() { - test('upsertAgentSnapshotsForType seeds descriptor-aligned project row', () async { - expect(agentEntityTypeDescriptors, hasLength(1)); - - final calls = {}; - final index = IntentCallPlatformEntityIndex( - invoke: (final method, final arguments) async { - calls[method] = arguments; - return 1; - }, - ); - final descriptor = agentEntityTypeDescriptors.single; - - final count = await index.upsertAgentSnapshotsForType( - descriptor: descriptor, - snapshots: demoProjectEntitySnapshots(), - ); - - expect(count, 1); - final args = calls['upsertEntitySnapshots']! as Map; - expect(args['entityType'], 'app_project'); - final row = (args['snapshots']! as List).single as Map; - expect(row['projectId'], 'project-1'); - expect(row['id'], 'project-1'); - expect(row['name'], 'Codegen project'); - expect(row['summary'], 'Entity snapshot seed'); - expect(row['tags'], ['demo', 'codegen']); - expect(row['title'], 'Codegen project'); - expect(row['subtitle'], 'Entity snapshot seed'); - expect(row['keywords'], ['demo', 'codegen']); - }); -} diff --git a/packages/intentcall_codegen/example/test/manifest_projection_test.dart b/packages/intentcall_codegen/example/test/manifest_projection_test.dart index 879979f..8f3d9e0 100644 --- a/packages/intentcall_codegen/example/test/manifest_projection_test.dart +++ b/packages/intentcall_codegen/example/test/manifest_projection_test.dart @@ -87,23 +87,37 @@ void main() { ); }); - test('exports @AgentEntity descriptor with three-slot keys', () { + test('exports demo_set_greeting with Apple verb surfaces', () { final manifest = buildManifest(); + final greeting = manifest.tools.singleWhere( + (final tool) => tool.qualifiedName == 'app_demo_set_greeting', + ); - expect(manifest.entityTypes, hasLength(1)); - final entity = manifest.entityTypes.single; - expect(entity.qualifiedName, 'app_project'); - expect(entity.titleKey, 'name'); - expect(entity.subtitleKey, 'summary'); - expect(entity.keywordsKey, 'tags'); + expect(greeting.dispatchMode, AgentManifestDispatchMode.openApp); expect( - (entity.snapshotSchema['properties'] as Map)['name']['x-intentcall-role'], - 'title', + greeting.surfaces.includes( + AgentManifestSurface.appleAppIntents, + defaultValue: false, + ), + isTrue, + ); + expect( + greeting.surfaces.includes( + AgentManifestSurface.appleAppShortcuts, + defaultValue: false, + ), + isTrue, ); }); - test('web-only example disables non-web default surfaces', () { - expect(context.enabledPlatforms, contains('web')); + test('example has no @AgentEntity rows (entities dogfood in mcp_flutter)', () { + final manifest = buildManifest(); + expect(manifest.entityTypes, isEmpty); + expect(agentEntityTypeDescriptors, isEmpty); + }); + + test('web + Apple platforms scope default surfaces', () { + expect(context.enabledPlatforms, containsAll(['web', 'ios', 'macos'])); final manifest = buildManifest(); final ping = manifest.tools.singleWhere( @@ -114,6 +128,20 @@ void main() { ping.surfaces.includes(AgentManifestSurface.webMcp, defaultValue: false), isTrue, ); + expect( + ping.surfaces.includes( + AgentManifestSurface.appleAppIntents, + defaultValue: false, + ), + isTrue, + ); + expect( + ping.surfaces.includes( + AgentManifestSurface.appleAppShortcuts, + defaultValue: true, + ), + isFalse, + ); expect( ping.surfaces.includes( AgentManifestSurface.androidShortcuts, diff --git a/packages/intentcall_codegen/example/web/agent_manifest.json b/packages/intentcall_codegen/example/web/agent_manifest.json index fada4a5..2b3dd53 100644 --- a/packages/intentcall_codegen/example/web/agent_manifest.json +++ b/packages/intentcall_codegen/example/web/agent_manifest.json @@ -11,7 +11,7 @@ "dispatchMode": "openApp", "surfaces": { "apple.appIntents": { - "include": false + "include": true }, "apple.appShortcuts": { "include": false @@ -66,7 +66,7 @@ "dispatchMode": "openApp", "surfaces": { "apple.appIntents": { - "include": false + "include": true }, "apple.appShortcuts": { "include": false @@ -121,7 +121,7 @@ "dispatchMode": "openApp", "surfaces": { "apple.appIntents": { - "include": false + "include": true }, "apple.appShortcuts": { "include": false @@ -180,7 +180,7 @@ "dispatchMode": "openApp", "surfaces": { "apple.appIntents": { - "include": false + "include": true }, "apple.appShortcuts": { "include": false @@ -226,6 +226,61 @@ ] } }, + { + "qualifiedName": "app_demo_set_greeting", + "namespace": "app", + "name": "demo_set_greeting", + "description": "Set greeting text for the codegen demo host.", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appIntents": { + "include": true + }, + "apple.appShortcuts": { + "include": true + }, + "apple.spotlight": { + "include": false + }, + "apple.entities": { + "include": false + }, + "android.shortcuts": { + "include": false + }, + "web.manifestShortcuts": { + "include": true + }, + "web.protocolHandlers": { + "include": true + }, + "web.webMcp": { + "include": true + }, + "windows.protocolActivation": { + "include": false + }, + "windows.msixProtocol": { + "include": false + }, + "linux.schemeHandler": { + "include": false + } + }, + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Greeting text" + } + }, + "required": [ + "text" + ] + } + }, { "qualifiedName": "app_demo_inbox", "namespace": "app", @@ -235,7 +290,7 @@ "dispatchMode": "openApp", "surfaces": { "apple.appIntents": { - "include": false + "include": true }, "apple.appShortcuts": { "include": false @@ -290,7 +345,7 @@ "dispatchMode": "openApp", "surfaces": { "apple.appIntents": { - "include": false + "include": true }, "apple.appShortcuts": { "include": false @@ -337,49 +392,5 @@ } } ], - "entityTypes": [ - { - "qualifiedName": "app_project", - "namespace": "app", - "name": "project", - "displayName": "Project", - "description": "", - "idKey": "projectId", - "titleKey": "name", - "subtitleKey": "summary", - "keywordsKey": "tags", - "urlKey": "url", - "defaultQueryLimit": 20, - "snapshotSchema": { - "type": "object", - "required": [ - "projectId" - ], - "properties": { - "projectId": { - "type": "string" - }, - "name": { - "type": "string", - "description": "Display name", - "x-intentcall-display": true, - "x-intentcall-role": "title" - }, - "summary": { - "type": "string", - "description": "Searchable summary", - "x-intentcall-searchable": true, - "x-intentcall-role": "subtitle" - }, - "tags": { - "type": "array", - "description": "Search keywords", - "x-intentcall-searchable": true, - "x-intentcall-role": "keywords" - } - } - } - } - ], "protocolScheme": "intentcallcodegen" } diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart index d974b14..4aa2392 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -301,7 +301,10 @@ $entityBlock final String? descriptorLiteral, final String? projectionLiteral, }) { - assert(entryReference != null || descriptorLiteral != null); + assert( + entryReference != null || descriptorLiteral != null, + 'entryReference or descriptorLiteral must be provided', + ); final target = entryReference != null ? 'entry: $entryReference' : 'descriptor: $descriptorLiteral'; diff --git a/packages/intentcall_codegen/pubspec.yaml b/packages/intentcall_codegen/pubspec.yaml index df72d94..8c0affc 100644 --- a/packages/intentcall_codegen/pubspec.yaml +++ b/packages/intentcall_codegen/pubspec.yaml @@ -18,11 +18,12 @@ resolution: workspace dependencies: analyzer: ^8.0.0 build: ^4.0.0 + glob: ^2.1.3 intentcall_core: ^0.6.0 intentcall_platform_sync: ^0.6.0 + intentcall_schema: ^0.6.0 path: ^1.9.1 source_gen: ^4.0.0 - glob: ^2.1.3 dev_dependencies: build_runner: ^2.15.0 @@ -31,3 +32,4 @@ dev_dependencies: package_config: ^2.2.0 test: ^1.31.1 xsoulspace_lints: ^0.1.2 + diff --git a/packages/intentcall_codegen/test/agent_catalog_generator_test.dart b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart index 596710a..8458a43 100644 --- a/packages/intentcall_codegen/test/agent_catalog_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart @@ -1,5 +1,6 @@ import 'package:test/test.dart'; +// ignore: avoid_relative_lib_imports import '../example/lib/generated/agent_catalog.g.dart'; void main() { @@ -32,9 +33,8 @@ void main() { expect(keys, contains('app_demo_handwritten')); }); - test('merges @AgentEntity descriptor rows from example', () { - expect(agentEntityTypeDescriptors, hasLength(1)); - expect(agentEntityTypeDescriptors.single.qualifiedName, 'app_project'); + test('example agent_catalog has no @AgentEntity descriptor rows', () { + expect(agentEntityTypeDescriptors, isEmpty); }); test('example agent_catalog has no duplicate registry keys', () { diff --git a/packages/intentcall_codegen/test/agent_tool_codegen_test.dart b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart index cf36a86..535edee 100644 --- a/packages/intentcall_codegen/test/agent_tool_codegen_test.dart +++ b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart @@ -31,21 +31,18 @@ void main() { packageConfig = config; }); - Future generateFixture(final String fixturePath) async => - resolveSources( - {'$_package|$fixturePath': useAssetReader}, - (final resolver) async { - final library = await resolver.libraryFor( - AssetId(_package, fixturePath), - ); - return generator.generate( - LibraryReader(library), - _FakeBuildStep(AssetId(_package, fixturePath)), - ); - }, - packageConfig: packageConfig, - readAllSourcesFromFilesystem: true, + Future generateFixture(final String fixturePath) => resolveSources( + {'$_package|$fixturePath': useAssetReader}, + (final resolver) async { + final library = await resolver.libraryFor(AssetId(_package, fixturePath)); + return generator.generate( + LibraryReader(library), + _FakeBuildStep(AssetId(_package, fixturePath)), ); + }, + packageConfig: packageConfig, + readAllSourcesFromFilesystem: true, + ); test( 'instance @AgentTool emits extension getter with this-bound handler', diff --git a/packages/intentcall_codegen/test/agent_tool_generator_test.dart b/packages/intentcall_codegen/test/agent_tool_generator_test.dart index 3350d1b..bcd70b1 100644 --- a/packages/intentcall_codegen/test/agent_tool_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_tool_generator_test.dart @@ -2,7 +2,9 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:test/test.dart'; +// ignore: avoid_relative_lib_imports import '../example/lib/tools/demo_host_tools.dart'; +// ignore: avoid_relative_lib_imports import '../example/lib/tools/demo_ping_tool.dart'; void main() { @@ -83,16 +85,19 @@ void main() { expect(result.data['mode'], 'slow'); }); - test('generated instance hostStatusCallEntry invokes shared host method', () async { - final result = await demoHostStatusRegistration.execute( - AgentInvocation( - descriptor: demoHostStatusRegistration.descriptor, - arguments: const {'label': 'primary'}, - ), - ); + test( + 'generated instance hostStatusCallEntry invokes shared host method', + () async { + final result = await demoHostStatusRegistration.execute( + AgentInvocation( + descriptor: demoHostStatusRegistration.descriptor, + arguments: const {'label': 'primary'}, + ), + ); - expect(result.ok, isTrue); - expect(result.data['label'], 'primary'); - expect(result.data['source'], 'codegen_instance'); - }); + expect(result.ok, isTrue); + expect(result.data['label'], 'primary'); + expect(result.data['source'], 'codegen_instance'); + }, + ); } diff --git a/packages/intentcall_core/lib/intentcall_core.dart b/packages/intentcall_core/lib/intentcall_core.dart index c5b4e5e..8b89b97 100644 --- a/packages/intentcall_core/lib/intentcall_core.dart +++ b/packages/intentcall_core/lib/intentcall_core.dart @@ -1,5 +1,3 @@ -library; - export 'src/adapter/agent_adapter.dart'; export 'src/authoring/agent_call_entry.dart'; export 'src/entity/agent_entity_index.dart'; diff --git a/packages/intentcall_core/lib/intentcall_core_migration.dart b/packages/intentcall_core/lib/intentcall_core_migration.dart index 7ed8605..e2d0da2 100644 --- a/packages/intentcall_core/lib/intentcall_core_migration.dart +++ b/packages/intentcall_core/lib/intentcall_core_migration.dart @@ -1,3 +1 @@ -library; - export 'src/migrate_agent_entries.dart'; diff --git a/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart b/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart index 8722d6a..0720298 100644 --- a/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart +++ b/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart @@ -10,10 +10,9 @@ typedef AgentValidator = void Function(AgentArguments arguments); final class RegisteredAgentIntent { RegisteredAgentIntent({ required this.descriptor, - required final AgentExecutor execute, + required this._execute, final AgentValidator? validate, - }) : _execute = execute, - _validate = + }) : _validate = validate ?? ((final args) { final coerced = coerceArgumentsForSchema( diff --git a/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart b/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart index d2b09c1..a86174e 100644 --- a/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart +++ b/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart @@ -4,15 +4,13 @@ import 'package:test/test.dart'; AgentEntityTypeDescriptor _projectFixtureDescriptor({ final Iterable properties = const [], -}) { - return AgentEntityTypeDescriptor( - namespace: 'app', - name: 'project', - identifierName: 'project_id', - displayName: 'Project', - properties: properties, - ); -} +}) => AgentEntityTypeDescriptor( + namespace: 'app', + name: 'project', + identifierName: 'project_id', + displayName: 'Project', + properties: properties, +); void main() { group('AgentEntitySnapshotKeys.fromDescriptor', () { diff --git a/packages/intentcall_mcp/lib/intentcall_mcp.dart b/packages/intentcall_mcp/lib/intentcall_mcp.dart index c9b01a6..fa66bb9 100644 --- a/packages/intentcall_mcp/lib/intentcall_mcp.dart +++ b/packages/intentcall_mcp/lib/intentcall_mcp.dart @@ -1,5 +1,3 @@ -library; - export 'src/agent_bridge.dart'; export 'src/mcp_publish_adapter.dart'; export 'src/mcp_resource_mapper.dart'; diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart index 7a288a3..acb8d53 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart @@ -1,6 +1,5 @@ -import 'package:intentcall_schema/intentcall_schema.dart'; - import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; /// Reason a pending native invocation drain was requested. enum IntentCallDrainTrigger { start, resume, deepLink, manual } diff --git a/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart index 2cb8643..3908cba 100644 --- a/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart @@ -10,7 +10,7 @@ import 'manifest_merger.dart'; final class ManifestExporter { const ManifestExporter(); - final _merger = const ManifestMerger(); + ManifestMerger get _merger => const ManifestMerger(); ManifestExportContext loadExportContext({ required final String projectRoot, diff --git a/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart b/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart index 2dcec41..ed3cc1e 100644 --- a/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart @@ -3,13 +3,13 @@ import 'dart:io'; import 'package:path/path.dart' as p; import '../agent_manifest.dart'; -import '../projection/manifest_merger.dart'; import '../emitters/android_shortcuts_xml_emitter.dart'; import '../emitters/apple_swift_app_intents_emitter.dart'; import '../emitters/linux_desktop_entry_emitter.dart'; import '../emitters/web_manifest_emitter.dart'; import '../emitters/web_mcp_js_emitter.dart'; import '../emitters/windows_protocol_emitter.dart'; +import '../projection/manifest_merger.dart'; import 'apple_info_plist_protocol_sync.dart'; import 'apple_xcode_project_sync.dart'; @@ -203,7 +203,9 @@ final class PlatformSync { final bool dryRun = false, }) { final manifest = readManifest(projectRoot); - final webDir = Directory(p.join(projectRoot, _resolveWebDirName(projectRoot))); + final webDir = Directory( + p.join(projectRoot, _resolveWebDirName(projectRoot)), + ); if (!webDir.existsSync()) { throw StateError('Missing web/ directory under $projectRoot'); } diff --git a/packages/intentcall_session/lib/intentcall_session.dart b/packages/intentcall_session/lib/intentcall_session.dart index b2ee74b..2154812 100644 --- a/packages/intentcall_session/lib/intentcall_session.dart +++ b/packages/intentcall_session/lib/intentcall_session.dart @@ -1,5 +1,3 @@ -library; - export 'src/agent_session_executor.dart'; export 'src/safe_writes.dart'; export 'src/session_connector.dart'; diff --git a/packages/intentcall_testing/lib/intentcall_testing.dart b/packages/intentcall_testing/lib/intentcall_testing.dart index 07cbcb5..ca5022f 100644 --- a/packages/intentcall_testing/lib/intentcall_testing.dart +++ b/packages/intentcall_testing/lib/intentcall_testing.dart @@ -1,5 +1,3 @@ -library; - export 'src/adapter_contract.dart'; export 'src/entry_test_helpers.dart'; export 'src/registry_contract.dart'; diff --git a/packages/intentcall_webmcp/lib/intentcall_webmcp.dart b/packages/intentcall_webmcp/lib/intentcall_webmcp.dart index 7d329a8..0026672 100644 --- a/packages/intentcall_webmcp/lib/intentcall_webmcp.dart +++ b/packages/intentcall_webmcp/lib/intentcall_webmcp.dart @@ -1,3 +1 @@ -library; - export 'src/webmcp_publish_adapter.dart'; diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index fe09adc..31a35fc 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -130,6 +130,15 @@ from the row. Do not duplicate tools already merged by `@AgentTool` codegen. `platforms.enabled` in `intentcall.yaml` scopes default manifest surface families (web-only hosts omit android/windows/linux defaults unless overridden). +**Siri / Shortcuts discovery** — registry **verbs** (`AgentCallEntry` / +`@AgentTool`) surface through `apple.appIntents` (default on `ios`/`macos`) and +opt-in `apple.appShortcuts`. The codegen example curates +`app_demo_set_greeting` in +[`demo_ping_tool.dart`](../../packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart). +**Spotlight / entity nouns** (`@AgentEntity`) are dogfooded in the Flutter +showcase [`mcp_flutter/flutter_test_app`](https://github.com/Arenukvern/mcp_flutter/tree/main/flutter_test_app), +not the dart-only codegen example. + --- ## 3. Code Generation (`@AgentTool`) @@ -238,6 +247,15 @@ emitters consume them. ## 4. Typed app entities (`@AgentEntity`) +> **Dogfood home:** The dart-only +> [`intentcall_codegen/example`](../../packages/intentcall_codegen/example) +> focuses on tool catalog and Apple **verb** discovery (`demo_set_greeting`). +> Full `@AgentEntity` flow — Flutter host, native cache seeding, Spotlight +> indexing, and `app_screen` nouns — lives in +> [`mcp_flutter/flutter_test_app`](https://github.com/Arenukvern/mcp_flutter/tree/main/flutter_test_app). +> Codegen/manifest unit tests use +> [`entity_catalog_project`](../../packages/intentcall_cli/test/fixtures/entity_catalog_project). + Use `@AgentEntity` when the app exposes indexable domain objects (projects, notes, playlists) to native discovery surfaces. Entities are **additive projection metadata** — Dart still owns the source of truth and writes JSON-safe snapshots From b39ff0c26544de4a9d4ddf4959b22c3f0fa52837 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 05:49:21 +0300 Subject: [PATCH 11/27] feat: add mcp-flutter-apple-sync-check command and enhance README with platform sync instructions for Apple Swift integration --- justfile | 4 + packages/intentcall_codegen/example/README.md | 91 ++++++++++++++++++- .../test/mcp_flutter_apple_sync_test.dart | 79 ++++++++++++++++ skills/register-intents/SKILL.md | 4 +- 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart diff --git a/justfile b/justfile index 530b597..6a1fa6a 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,10 @@ platform-sync-check: dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/jaspr_web_project --platform web --check dart run intentcall_cli:intentcall platform sync --project-dir packages/intentcall_cli/test/fixtures/codegen_dart_project --platform web --check +# Apple Swift drift + AppSetGreetingIntent proof against sibling mcp_flutter +mcp-flutter-apple-sync-check: + dart test packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart + # Manifest/registry parity gate manifest-parity: dart test packages/intentcall_cli/test/manifest_registry_parity_test.dart diff --git a/packages/intentcall_codegen/example/README.md b/packages/intentcall_codegen/example/README.md index 3d1ef01..5fe26c2 100644 --- a/packages/intentcall_codegen/example/README.md +++ b/packages/intentcall_codegen/example/README.md @@ -35,9 +35,96 @@ Apple App Intent scaffolds for tools. Only curated product verbs opt into Run `intentcall platform sync` from a Flutter app with an `ios/` or `macos/` target to materialize generated Swift. This dart-only package does not ship -native runners. +native runners — use the **mcp_flutter** showcase instead (below). + +## Platform sync against mcp_flutter (Apple Swift proof) + +The codegen example exports manifest rows (`app_demo_set_greeting`) but cannot +materialize `AppIntent` Swift without a Flutter/Xcode tree. The canonical Apple +dogfood app is **`mcp_flutter/flutter_test_app`**, which hand-registers +`app_set_greeting` with `apple.appIntents` + `apple.appShortcuts` opt-in. + +### Prerequisite + +Clone [mcp_flutter](https://github.com/Arenukvern/mcp_flutter) as a sibling of +this monorepo (or set `MCP_FLUTTER_ROOT` to the mcp_flutter checkout): + +```text +~/mcp/ + agentkit/ ← this repo + mcp_flutter/ + flutter_test_app/ + web/agent_manifest.json + ios/Runner/Generated/IntentCallGenerated.swift +``` + +### From agentkit (IntentCall CLI) + +```bash +# Drift check — manifest → generated Swift must match committed files +dart run intentcall_cli:intentcall platform sync \ + --platform ios,macos \ + --check \ + --project-dir ../mcp_flutter/flutter_test_app + +# Regenerate after manifest changes +dart run intentcall_cli:intentcall platform sync \ + --platform ios,macos \ + --project-dir ../mcp_flutter/flutter_test_app +``` + +### From mcp_flutter (toolkit wrapper — what CI runs) + +```bash +cd ../mcp_flutter +dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart codegen sync \ + --platform web,android,ios,macos,linux,windows \ + --project-dir flutter_test_app --check +``` + +Xcode build phases call `ios/intentcall_codegen.sh`, which runs +`intentcall manifest export --check` then `intentcall platform sync --platform ios,macos`. + +### Verify `SetGreeting` in generated Swift + +After sync, both iOS and macOS emit the same intent scaffold: + +```bash +rg 'AppSetGreetingIntent|app_set_greeting' \ + ../mcp_flutter/flutter_test_app/ios/Runner/Generated/IntentCallGenerated.swift \ + ../mcp_flutter/flutter_test_app/macos/Runner/Generated/IntentCallGenerated.swift +``` + +Expected symbols: + +| Symbol | Role | +|--------|------| +| `struct AppSetGreetingIntent: AppIntent` | Siri / Shortcuts verb | +| `qualifiedName: "app_set_greeting"` | Dart registry handoff | +| `AppShortcut(intent: AppSetGreetingIntent()` | Curated Siri phrase | + +Manifest source: `app_set_greeting` in +`mcp_flutter/flutter_test_app/web/agent_manifest.json` with +`apple.appShortcuts.include: true`. Registry source: +`lib/intentcall_showcase_entries.dart` → `buildSetGreetingEntry()`. + +### Automated check (agentkit) + +When the sibling repo is present, agentkit runs: + +```bash +dart test packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart +``` + +Or from `justfile`: `just mcp-flutter-apple-sync-check`. + +Full consumer gates live in mcp_flutter: +`make check-intentcall-hosted-consumer` (hosted) or +`make check-intentcall-integration` (sibling agentkit matrix). + +See also `mcp_flutter/flutter_test_app/INTENTCALL_PLATFORM.md` and +`docs/DX_FAQ.mdx` (Apple readiness checklist). -## Commands ```bash dart pub get diff --git a/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart b/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart new file mode 100644 index 0000000..deec124 --- /dev/null +++ b/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// Resolves `mcp_flutter/flutter_test_app` when cloned as a sibling of agentkit. +String? resolveMcpFlutterTestAppRoot() { + final envRoot = Platform.environment['MCP_FLUTTER_ROOT']; + if (envRoot != null && envRoot.isNotEmpty) { + final candidate = p.normalize(p.join(envRoot, 'flutter_test_app')); + if (File(p.join(candidate, 'web', 'agent_manifest.json')).existsSync()) { + return candidate; + } + } + + var dir = Directory.current; + for (var depth = 0; depth < 6; depth++) { + final candidate = p.normalize(p.join(dir.path, '..', 'mcp_flutter')); + final testApp = p.join(candidate, 'flutter_test_app'); + if (File(p.join(testApp, 'web', 'agent_manifest.json')).existsSync()) { + return testApp; + } + final parent = dir.parent; + if (parent.path == dir.path) { + break; + } + dir = parent; + } + return null; +} + +void main() { + final projectRoot = resolveMcpFlutterTestAppRoot(); + + group('mcp_flutter flutter_test_app Apple platform sync', () { + test( + 'ios/macos artifacts are fresh and emit AppSetGreetingIntent', + () { + final root = projectRoot!; + const sync = PlatformSync(); + expect( + sync.checkPlatforms(root, const ['ios', 'macos']), + isTrue, + reason: + 'run: intentcall platform sync --platform ios,macos ' + '--project-dir $root', + ); + + for (final platform in const ['ios', 'macos']) { + final swiftPath = p.join( + root, + platform, + 'Runner', + 'Generated', + 'IntentCallGenerated.swift', + ); + final swift = File(swiftPath); + expect(swift.existsSync(), isTrue, reason: swiftPath); + final source = swift.readAsStringSync(); + expect(source, contains('struct AppSetGreetingIntent: AppIntent')); + expect( + source, + contains( + 'IntentCallNativeBridge.enqueue(qualifiedName: "app_set_greeting"', + ), + ); + expect( + source, + contains('AppShortcut(intent: AppSetGreetingIntent()'), + ); + } + }, + skip: projectRoot == null + ? 'mcp_flutter sibling not found — clone ../mcp_flutter or set MCP_FLUTTER_ROOT' + : false, + ); + }); +} diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index 31a35fc..0a19ff7 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -137,7 +137,9 @@ opt-in `apple.appShortcuts`. The codegen example curates [`demo_ping_tool.dart`](../../packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart). **Spotlight / entity nouns** (`@AgentEntity`) are dogfooded in the Flutter showcase [`mcp_flutter/flutter_test_app`](https://github.com/Arenukvern/mcp_flutter/tree/main/flutter_test_app), -not the dart-only codegen example. +not the dart-only codegen example. To verify Apple Swift output (e.g. +`AppSetGreetingIntent`), run platform sync against that app — see +[`intentcall_codegen/example/README.md`](../../packages/intentcall_codegen/example/README.md#platform-sync-against-mcp_flutter-apple-swift-proof). --- From e2e31f2fd32603c274b65efd177623b37d40ea27 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Tue, 7 Jul 2026 05:53:51 +0300 Subject: [PATCH 12/27] feat: update documentation structure and enhance intentcall_schema references across various files --- docs.json | 61 ++++- docs/DX_FAQ.mdx | 2 + docs/packages/intentcall_schema.mdx | 210 ++++++++++++++++++ docs/start_here/docs_map.mdx | 2 + packages/intentcall_schema/README.md | 4 +- .../lib/src/resource_uri.dart | 2 +- .../test/agent_result_envelope_test.dart | 2 +- .../test/resource_uri_test.dart | 2 +- 8 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 docs/packages/intentcall_schema.mdx diff --git a/docs.json b/docs.json index 2ed22ed..3b79a7d 100644 --- a/docs.json +++ b/docs.json @@ -30,26 +30,65 @@ { "group": "Start Here", "pages": [ - { "title": "Overview", "href": "/" }, - { "title": "How it works", "href": "/start_here/how_it_works" }, - { "title": "Choose your path", "href": "/start_here/choose_your_path" }, - { "title": "Platform support", "href": "/start_here/platform_support" }, - { "title": "Roadmap", "href": "/start_here/roadmap" }, - { "title": "North Star", "href": "/NORTH_STAR" }, - { "title": "Docs map", "href": "/start_here/docs_map" } + { + "title": "Overview", + "href": "/" + }, + { + "title": "How it works", + "href": "/start_here/how_it_works" + }, + { + "title": "Choose your path", + "href": "/start_here/choose_your_path" + }, + { + "title": "Platform support", + "href": "/start_here/platform_support" + }, + { + "title": "Roadmap", + "href": "/start_here/roadmap" + }, + { + "title": "North Star", + "href": "/NORTH_STAR" + }, + { + "title": "Docs map", + "href": "/start_here/docs_map" + } + ] + }, + { + "group": "Packages", + "pages": [ + { + "title": "intentcall_schema", + "href": "/packages/intentcall_schema" + } ] }, { "group": "FAQs", "pages": [ - { "title": "Design FAQ", "href": "/DESIGN_FAQ" }, - { "title": "DX FAQ", "href": "/DX_FAQ" } + { + "title": "Design FAQ", + "href": "/DESIGN_FAQ" + }, + { + "title": "DX FAQ", + "href": "/DX_FAQ" + } ] }, { "group": "Decisions", "pages": [ - { "title": "Index", "href": "/decisions/README" }, + { + "title": "Index", + "href": "/decisions/README" + }, { "title": "0010 — IntentCall product name", "href": "/decisions/0010-adopt-intentcall-product-name" @@ -127,4 +166,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 0edfbed..335e49a 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -185,6 +185,8 @@ is fine for display-only catalog reads. Wire types (`AgentResult`, validation helpers, resource input schemas) live in `intentcall_schema`. Registry and authoring types (`AgentRegistry`, `AgentCallEntry`, `RegisteredAgentIntent`) live in `intentcall_core`. +Full wire-contract guide: [intentcall_schema](/packages/intentcall_schema) — package map, validate/coerce pipeline, entity JSON shape (AX), and envelope examples. + **Q: Where do tool/resource registration value objects live?** Use `intentcall_core` for neutral registration values: diff --git a/docs/packages/intentcall_schema.mdx b/docs/packages/intentcall_schema.mdx new file mode 100644 index 0000000..ffdcdf8 --- /dev/null +++ b/docs/packages/intentcall_schema.mdx @@ -0,0 +1,210 @@ +# intentcall_schema + +> ⚠️ **Pre-release train** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). + +Transport-agnostic **wire contract** for IntentCall: result envelopes, argument validation, entity snapshots, and VM-service wire parsing. Pure Dart — no Flutter dependency. + +Registry and invocation live in [`intentcall_core`](https://pub.dev/packages/intentcall_core). Adapters (`intentcall_mcp`, `intentcall_webmcp`, platform sync) translate between transports and these types. + +```bash +dart pub add intentcall_schema +``` + +Package source: [packages/intentcall_schema](https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_schema) · [pub.dev](https://pub.dev/packages/intentcall_schema) + +## Who this is for + +| Audience | Use `intentcall_schema` when you need… | +|----------|--------------------------------------| +| **DX** (app authors, adapter authors) | Typed `AgentResult`, JSON Schema validation before `registry.invoke`, coercion from string-key wire maps | +| **AX** (agents, MCP clients, codegen) | Stable JSON shapes for tool outcomes, entity snapshots, and resource read arguments | + +## Package map + +| Module | Primary types | Role | +|--------|---------------|------| +| Results | `AgentResult`, `AgentArtifact` | Success/failure outcomes from any handler | +| Envelopes | `AgentResultEnvelope` | Versioned snapshot payloads for tools and resources | +| Arguments | `AgentArguments`, `InputSchema`, `AgentWireArgs` | Tool input maps and VM extension parsing | +| Validation | `validateAgainstSchema`, `coerceArgumentsForSchema` | JSON Schema subset check + wire coercion | +| Entities | `AgentEntityRef`, `AgentEntitySnapshot` | Indexable app objects for shortcuts, deep links, and agent context | +| Resources | `clientResourceReadInputSchema`, … | Default MCP dynamic-resource input schemas | + +## Quick start + +### Return a tool result + +```dart +import 'package:intentcall_schema/intentcall_schema.dart'; + +AgentResult success() => AgentResult.success( + message: 'Saved', + data: {'id': 'note-42'}, +); + +AgentResult failure() => AgentResult.failure( + code: 'not_found', + message: 'Note does not exist.', + details: {'id': 'note-42'}, +); +``` + +### Validate and coerce arguments + +VM service extensions and some transports deliver `Map`. Coerce to schema types, then validate: + +```dart +const schema = { + 'type': 'object', + 'additionalProperties': false, + 'required': ['count'], + 'properties': { + 'count': {'type': 'integer', 'minimum': 0}, + 'label': {'type': 'string'}, + }, +}; + +final wire = AgentWireArgs({'count': '3', 'label': 'demo'}); +final args = coerceArgumentsForSchema(schema, wire.toAgentArguments()); +validateAgainstSchema(schema, args); +// args == {'count': 3, 'label': 'demo'} +``` + +On failure, `validateAgainstSchema` throws `AgentValidationException` with a human-readable `message` (safe to surface to agents). + +### Snapshot envelope (tools and resources) + +Use envelopes when the consumer needs a versioned JSON snapshot (MCP resources, inspector tools, codegen fixtures): + +```dart +final result = AgentResultEnvelope.resourceEnvelope( + protocolScheme: 'demoapp', + resourceName: 'cool_runtime_snapshot', + snapshot: {'phase': 'playing'}, +); +// result.data['resource_uri'] == 'demoapp://resource/spark/runtime/snapshot' +``` + +### Entity snapshots (agent-visible app state) + +Entities are stable, JSON-safe records agents can search, open, or reference: + +```dart +final snapshot = AgentEntitySnapshot( + ref: const AgentEntityRef( + namespace: 'notes', + typeName: 'note', + identifier: 'note-1', + ), + title: 'Inbox note', + keywords: const ['work', 'today'], + deepLink: 'cool_runtime_snapshot://notes/note-1', + properties: const { + 'pinned': true, + 'rank': 3, + 'tags': ['work', 'today'], + }, +); + +final json = snapshot.toJson(); // round-trips via AgentEntitySnapshot.fromJson +``` + +`effectiveTitle` resolves `title ?? displayName` for display surfaces. + +## JSON Schema subset + +`validateAgainstSchema` implements a **deliberately small** JSON Schema subset aligned with MCP tool `inputSchema` usage: + +| Feature | Supported | +|---------|-----------| +| Root `type: object` | Yes | +| `required`, `properties` | Yes | +| `additionalProperties: false` | Yes | +| Property types: `string`, `integer`, `number`, `boolean`, `object`, `array` | Yes | +| `enum` on strings | Yes | +| `minimum` / `maximum` on numbers | Yes | +| Array `items` when each item is `type: object` with `required` / `properties` | Yes | +| `pattern`, `format`, `oneOf`, nested object property validation (except array items) | No | +| Type coercion | Use `coerceArgumentsForSchema` first | + +Properties without a `type` are skipped. Unknown keys are allowed unless `additionalProperties` is `false`. + +## Wire types + +```dart +typedef AgentArguments = Map; +typedef InputSchema = Map; +typedef AgentWireMap = Map; +``` + +- **`AgentArguments`** — normalized tool invocation payload after coercion. +- **`InputSchema`** — JSON Schema–shaped map attached to tool/resource registrations. +- **`AgentWireArgs`** — extension type over `AgentWireMap` with `string`, `bool_`, `int_`, `double_`, `jsonObject`, and `toAgentArguments()`. + +## Entity JSON shape (AX) + +Agents and platform projection share this wire shape: + +```yaml +ref: + namespace: notes # app domain, e.g. notes, music + type_name: note # entity kind within namespace + identifier: note-1 # stable id within type +properties: # JSON-safe scalars, lists, nested maps only + pinned: true + rank: 3 +title: Inbox note # optional display fields +keywords: [work, today] +deep_link: intentcall://notes/note-1 +updated_at: 2026-06-29T12:00:00.000Z +deleted: false +version: rev-7 +freshness: fresh +``` + +`DateTime`, custom classes, and non-finite doubles are rejected at construction time so snapshots stay JSON-encodable. + +## Resource input schemas + +For MCP dynamic client resources: + +```dart +final schema = clientResourceReadInputSchema(); +// { type: object, required: [uri], properties: { uri: { type: string } } } + +final fromRegistration = inputSchemaFromDynamicRegistrationMap(registration); +``` + +Templates with variables (for example `count`) use `clientResourceTemplateReadInputSchema`. + +## Where this sits in the stack + +``` +Author handler → AgentResult + ↑ +AgentRegistry.invoke ← validateAgainstSchema(coerceArgumentsForSchema(...)) + ↑ +Adapter (MCP / WebMCP / platform) ← wire maps, entity snapshots +``` + +See also [How it works](/start_here/how_it_works) for the full registry and adapter flow. + +## Related packages + +| Package | Role | +|---------|------| +| [`intentcall_core`](https://pub.dev/packages/intentcall_core) | Registry, `AgentCallEntry`, adapter composition | +| [`intentcall_mcp`](https://pub.dev/packages/intentcall_mcp) | MCP `CallToolResult` mapping from `AgentResult` | +| [`intentcall_platform`](https://pub.dev/packages/intentcall_platform) | Flutter entity index and native snapshot store | +| [`intentcall_platform_sync`](https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync) | Manifest projection and entity export | + +## Further reading + +- [North Star](/NORTH_STAR) — charter and package boundaries +- [Design FAQ](/DESIGN_FAQ) — why wire types live in this package +- [DX FAQ](/DX_FAQ) — commands, adapters, publishing order +- [Choose your path](/start_here/choose_your_path) — when to add `intentcall_schema` to your dependency graph + +## API reference + +Run `dart doc` in the package, or browse [pub.dev documentation](https://pub.dev/documentation/intentcall_schema/latest/) after publish. diff --git a/docs/start_here/docs_map.mdx b/docs/start_here/docs_map.mdx index 9dc5e6a..5ea0e88 100644 --- a/docs/start_here/docs_map.mdx +++ b/docs/start_here/docs_map.mdx @@ -14,6 +14,7 @@ Index for the IntentCall documentation site and repository. | See current direction | [Roadmap](/start_here/roadmap) | | Charter and scope | [North Star](/NORTH_STAR) | | Commands, test, release | [DX FAQ](/DX_FAQ) | +| Wire types (`AgentResult`, validation, entities) | [intentcall_schema](/packages/intentcall_schema) | | Architectural why | [Design FAQ](/DESIGN_FAQ) · [Decisions](/decisions/README) | | Agent entry map | [AGENTS.md](https://github.com/Arenukvern/intentcall/blob/main/AGENTS.md) | | Contribute / PR checklist | [CONTRIBUTING.md](https://github.com/Arenukvern/intentcall/blob/main/CONTRIBUTING.md) | @@ -30,6 +31,7 @@ Index for the IntentCall documentation site and repository. - [North Star](/NORTH_STAR) - [Design FAQ](/DESIGN_FAQ) - [DX FAQ](/DX_FAQ) +- [intentcall_schema](/packages/intentcall_schema) - [Decisions](/decisions/README) - [Contributors](/contributing/contributors) - [Enable docs.page](/contributing/enable_docs_page) diff --git a/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index 8945043..2b7eb58 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -82,7 +82,7 @@ Use envelopes when the consumer needs a versioned JSON snapshot (MCP resources, ```dart final result = AgentResultEnvelope.resourceEnvelope( protocolScheme: 'demoapp', - resourceName: 'spark_runtime_snapshot', + resourceName: 'cool_runtime_snapshot', snapshot: {'phase': 'playing'}, ); // result.data['resource_uri'] == 'demoapp://resource/spark/runtime/snapshot' @@ -197,7 +197,7 @@ Adapter (MCP / WebMCP / platform) ← wire maps, entity snapshots - [`intentcall_platform`](../intentcall_platform) — Flutter entity index and native snapshot store - [`intentcall_platform_sync`](../intentcall_platform_sync) — manifest projection and entity export -Canonical design docs: [North Star](../../docs/NORTH_STAR.mdx), [Design FAQ](../../docs/DESIGN_FAQ.mdx), and [DX FAQ](../../docs/DX_FAQ.mdx). +Canonical design docs: [North Star](https://docs.page/Arenukvern/intentcall/NORTH_STAR), [Design FAQ](https://docs.page/Arenukvern/intentcall/DESIGN_FAQ), and [DX FAQ](https://docs.page/Arenukvern/intentcall/DX_FAQ). Published package guide: [intentcall_schema on docs.page](https://docs.page/Arenukvern/intentcall/packages/intentcall_schema). ## API reference diff --git a/packages/intentcall_schema/lib/src/resource_uri.dart b/packages/intentcall_schema/lib/src/resource_uri.dart index 078ac16..0fd6762 100644 --- a/packages/intentcall_schema/lib/src/resource_uri.dart +++ b/packages/intentcall_schema/lib/src/resource_uri.dart @@ -1,6 +1,6 @@ /// Builds `$protocolScheme://resource/...` from underscore-separated segments. /// -/// Example: `spark_runtime_snapshot` with scheme `demoapp` → +/// Example: `cool_runtime_snapshot` with scheme `demoapp` → /// `demoapp://resource/spark/runtime/snapshot`. /// /// Returns `$protocolScheme://resource/unknown` when [resourceName] is empty. diff --git a/packages/intentcall_schema/test/agent_result_envelope_test.dart b/packages/intentcall_schema/test/agent_result_envelope_test.dart index 602a1a7..ef797f0 100644 --- a/packages/intentcall_schema/test/agent_result_envelope_test.dart +++ b/packages/intentcall_schema/test/agent_result_envelope_test.dart @@ -5,7 +5,7 @@ void main() { test('resourceEnvelope builds app-owned resource uri', () { final result = AgentResultEnvelope.resourceEnvelope( protocolScheme: 'demoapp', - resourceName: 'spark_runtime_snapshot', + resourceName: 'cool_runtime_snapshot', snapshot: {'phase': 'playing'}, ); expect(result.ok, isTrue); diff --git a/packages/intentcall_schema/test/resource_uri_test.dart b/packages/intentcall_schema/test/resource_uri_test.dart index a682050..4cb4434 100644 --- a/packages/intentcall_schema/test/resource_uri_test.dart +++ b/packages/intentcall_schema/test/resource_uri_test.dart @@ -6,7 +6,7 @@ void main() { expect( resourceUri( protocolScheme: 'demoapp', - resourceName: 'spark_runtime_snapshot', + resourceName: 'cool_runtime_snapshot', ), 'demoapp://resource/spark/runtime/snapshot', ); From 6272b7cdf853edc9880cfc13f709a4a6a8ddd765 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Wed, 8 Jul 2026 23:07:51 +0300 Subject: [PATCH 13/27] feat: introduce intentcall_hooks and intentcall_bridge packages, enhance testing commands, and update manifest for new dependencies --- .github/workflows/ci.yml | 3 + .release-please-manifest.json | 1 + .../0022-projection-pipeline-alignment.md | 21 +- .../0023-entity-three-slot-projection.md | 15 +- ...art-hooks-and-pigeon-bridge-consistency.md | 104 ++++ docs/decisions/README.md | 3 +- docs/evidence/hooks-native-bridge-plan.md | 265 ++++++++++ docs/evidence/projection-pipeline-spec.md | 448 ----------------- justfile | 25 +- packages/intentcall_bridge/LICENSE | 21 + .../intentcall_bridge/analysis_options.yaml | 1 + .../lib/intentcall_bridge.dart | 4 + .../lib/src/intentcall_platform_bridge.g.dart | 389 +++++++++++++++ .../pigeons/intentcall_platform_bridge.dart | 70 +++ packages/intentcall_bridge/pubspec.yaml | 27 + .../lib/src/catalog/catalog_loader.dart | 282 +---------- .../lib/src/command_runner.dart | 154 +++++- .../test/command_runner_test.dart | 27 + .../fixtures/jaspr_web_project/pubspec.lock | 31 ++ .../fixtures/jaspr_web_project/pubspec.yaml | 11 + .../web/intentcall_webmcp.generated.js | 16 + .../example/web/manifest.json | 10 + packages/intentcall_hooks/CHANGELOG.md | 3 + packages/intentcall_hooks/LICENSE | 21 + packages/intentcall_hooks/README.md | 40 ++ .../intentcall_hooks/analysis_options.yaml | 1 + packages/intentcall_hooks/hook/build.dart | 20 + .../lib/intentcall_hooks.dart | 1 + .../lib/src/intentcall_hook_runner.dart | 146 ++++++ packages/intentcall_hooks/pubspec.yaml | 26 + .../test/intentcall_hook_runner_test.dart | 63 +++ .../IntentCallPlatformBridge.g.kt | 471 ++++++++++++++++++ .../IntentCallPlatformPlugin.kt | 45 +- .../IntentCallNativeEntitySnapshotStore.swift | 201 -------- .../Classes/IntentCallPlatformPlugin.swift | 107 ---- .../ios/intentcall_platform.podspec | 2 +- .../IntentCallNativeHandoffStore.swift | 28 ++ .../IntentCallPlatformBridge.g.swift | 443 ++++++++++++++++ .../IntentCallPlatformPlugin.swift | 203 ++++---- .../lib/intentcall_platform_flutter.dart | 1 + .../src/flutter/intentcall_entity_index.dart | 36 +- .../intentcall_entity_index_channel.dart | 83 ++- .../flutter/intentcall_entity_key_bundle.dart | 17 + .../intentcall_pending_invocations.dart | 39 +- .../IntentCallNativeEntitySnapshotStore.swift | 201 -------- .../Classes/IntentCallPlatformPlugin.swift | 107 ---- .../macos/intentcall_platform.podspec | 2 +- .../IntentCallNativeHandoffStore.swift | 28 ++ .../IntentCallPlatformBridge.g.swift | 443 ++++++++++++++++ .../IntentCallPlatformPlugin.swift | 207 ++++---- packages/intentcall_platform/pubspec.yaml | 1 + .../test/pigeon_bridge_contract_test.dart | 137 +++++ .../lib/intentcall_platform_sync.dart | 4 + .../lib/src/catalog/catalog_loader.dart | 283 +++++++++++ .../lib/src/catalog/project_utils.dart | 16 + .../apple_swift_app_intents_emitter.dart | 15 +- .../lib/src/init/platform_hooks_init.dart | 8 +- .../src/projection/projection_platforms.dart | 58 +++ .../src/templates/platform_hook_spine.dart | 424 ++++++++++++++++ .../templates/platform_hook_templates.dart | 78 +-- .../test/apple_surface_matrix_test.dart | 1 + .../test/catalog_loader_test.dart | 51 ++ .../test/native_emitters_test.dart | 24 +- .../test/platform_hook_templates_test.dart | 96 ++++ .../test/projection_alignment_test.dart | 10 +- .../test/webmcp_bootstrap_surface_test.dart | 57 +++ pubspec.lock | 40 ++ pubspec.yaml | 2 + release-please-config.json | 6 + steward.yaml | 37 +- .../intentcall.projection-pipeline.yaml | 37 ++ tool/intentcall/bin/intentcall.dart | 5 +- tool/intentcall/bin/release_train.dart | 1 + 73 files changed, 4626 insertions(+), 1678 deletions(-) create mode 100644 docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md create mode 100644 docs/evidence/hooks-native-bridge-plan.md delete mode 100644 docs/evidence/projection-pipeline-spec.md create mode 100644 packages/intentcall_bridge/LICENSE create mode 100644 packages/intentcall_bridge/analysis_options.yaml create mode 100644 packages/intentcall_bridge/lib/intentcall_bridge.dart create mode 100644 packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart create mode 100644 packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart create mode 100644 packages/intentcall_bridge/pubspec.yaml create mode 100644 packages/intentcall_hooks/CHANGELOG.md create mode 100644 packages/intentcall_hooks/LICENSE create mode 100644 packages/intentcall_hooks/README.md create mode 100644 packages/intentcall_hooks/analysis_options.yaml create mode 100644 packages/intentcall_hooks/hook/build.dart create mode 100644 packages/intentcall_hooks/lib/intentcall_hooks.dart create mode 100644 packages/intentcall_hooks/lib/src/intentcall_hook_runner.dart create mode 100644 packages/intentcall_hooks/pubspec.yaml create mode 100644 packages/intentcall_hooks/test/intentcall_hook_runner_test.dart create mode 100644 packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt delete mode 100644 packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift delete mode 100644 packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift create mode 100644 packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift create mode 100644 packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift create mode 100644 packages/intentcall_platform/lib/src/flutter/intentcall_entity_key_bundle.dart delete mode 100644 packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift delete mode 100644 packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift create mode 100644 packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift create mode 100644 packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift create mode 100644 packages/intentcall_platform/test/pigeon_bridge_contract_test.dart create mode 100644 packages/intentcall_platform_sync/lib/src/catalog/catalog_loader.dart create mode 100644 packages/intentcall_platform_sync/lib/src/catalog/project_utils.dart create mode 100644 packages/intentcall_platform_sync/lib/src/projection/projection_platforms.dart create mode 100644 packages/intentcall_platform_sync/lib/src/templates/platform_hook_spine.dart create mode 100644 packages/intentcall_platform_sync/test/catalog_loader_test.dart create mode 100644 packages/intentcall_platform_sync/test/platform_hook_templates_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3263bcd..c65784a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: ADR 0019 validation gates run: just adr-gates + - name: projection pipeline check + run: just projection-pipeline-check + - name: analyze run: dart analyze . diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 654d4df..1eb5ffb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -8,6 +8,7 @@ "packages/intentcall_android": "0.6.0", "packages/intentcall_codegen": "0.6.0", "packages/intentcall_platform_sync": "0.6.0", + "packages/intentcall_hooks": "0.6.0", "packages/intentcall_cli": "0.6.0", "packages/intentcall_platform": "0.6.0", "packages/intentcall_testing": "0.6.0" diff --git a/docs/decisions/0022-projection-pipeline-alignment.md b/docs/decisions/0022-projection-pipeline-alignment.md index e6c2f7e..ca4b4e8 100644 --- a/docs/decisions/0022-projection-pipeline-alignment.md +++ b/docs/decisions/0022-projection-pipeline-alignment.md @@ -68,4 +68,23 @@ Layer 3 alignment before entity lifecycle and consumer harness work proceed. - [0018-additive-actions-typed-entities-indexing-lifecycle.md](0018-additive-actions-typed-entities-indexing-lifecycle.md) - [0020-platform-scoped-manifest-surfaces.md](0020-platform-scoped-manifest-surfaces.md) - [0021-agent-catalog-annotation.md](0021-agent-catalog-annotation.md) -- [projection-pipeline-spec.md](../evidence/projection-pipeline-spec.md) +- [0024-dart-hooks-and-pigeon-bridge-consistency.md](0024-dart-hooks-and-pigeon-bridge-consistency.md) +- [hooks-native-bridge-plan.md](../evidence/hooks-native-bridge-plan.md) +- [projection-pipeline-spec.md](../evidence/projection-pipeline-spec.md) (retired; see ADR 0024) + +## Verification inventory + +Primary test files for dense export, Apple sub-channels, and projection alignment: + +| Test file | Coverage | +|-----------|----------| +| [`dense_manifest_test.dart`](../../packages/intentcall_platform_sync/test/dense_manifest_test.dart) | Every exported tool row emits all surface keys with explicit `include` booleans | +| [`partial_defaults_platform_scope_test.dart`](../../packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart) | Partial yaml merge and platform-scoped default surface resolution | +| [`ios_shortcuts_opt_in_test.dart`](../../packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart) | `apple.appShortcuts` remains opt-in; not auto-enabled from `platforms.enabled` | +| [`apple_surface_matrix_test.dart`](../../packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart) | Apple sub-channel gating matrix (`appIntents`, `appShortcuts`, `entities`, `spotlight`) | +| [`native_emitters_test.dart`](../../packages/intentcall_platform_sync/test/native_emitters_test.dart) | Emitter output gated by dense manifest surface rows | +| [`projection_alignment_test.dart`](../../packages/intentcall_platform_sync/test/projection_alignment_test.dart) | End-to-end manifest → emitter alignment across surface families | +| [`platform_sync_layout_test.dart`](../../packages/intentcall_platform_sync/test/platform_sync_layout_test.dart) | Platform sync artifact layout and hook spine outputs | +| [`webmcp_bootstrap_surface_test.dart`](../../packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart) | WebMCP bootstrap surface projection | + +Aggregate gate: `just projection-pipeline-check` and steward scenario `intentcall.projection-pipeline`. diff --git a/docs/decisions/0023-entity-three-slot-projection.md b/docs/decisions/0023-entity-three-slot-projection.md index 3e3718c..142bdb8 100644 --- a/docs/decisions/0023-entity-three-slot-projection.md +++ b/docs/decisions/0023-entity-three-slot-projection.md @@ -146,6 +146,19 @@ Neutral: - [0018-additive-actions-typed-entities-indexing-lifecycle.md](0018-additive-actions-typed-entities-indexing-lifecycle.md) - [0021-agent-catalog-annotation.md](0021-agent-catalog-annotation.md) - [0022-projection-pipeline-alignment.md](0022-projection-pipeline-alignment.md) -- [projection-pipeline-spec.md](../evidence/projection-pipeline-spec.md) +- [0024-dart-hooks-and-pigeon-bridge-consistency.md](0024-dart-hooks-and-pigeon-bridge-consistency.md) +- [hooks-native-bridge-plan.md](../evidence/hooks-native-bridge-plan.md) +- [projection-pipeline-spec.md](../evidence/projection-pipeline-spec.md) (retired; see ADR 0024) - `packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart` - `packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart` + +## Verification inventory + +Primary test files for three-slot entity projection and manifest export: + +| Test file | Coverage | +|-----------|----------| +| [`manifest_entity_export_test.dart`](../../packages/intentcall_cli/test/manifest_entity_export_test.dart) | `entityTypes[]` export with `idKey` / `titleKey` / `subtitleKey` / `keywordsKey` slots | +| [`intentcall_entity_index_test.dart`](../../packages/intentcall_platform/test/intentcall_entity_index_test.dart) | Native entity index search over manifest slot keys | +| [`agent_entity_snapshot_projection_test.dart`](../../packages/intentcall_core/test/agent_entity_snapshot_projection_test.dart) | `projectAgentEntitySnapshot()` maps descriptor roles to cache rows | +| [`projection_alignment_test.dart`](../../packages/intentcall_platform_sync/test/projection_alignment_test.dart) — `entityTypes in export` and `single native entity snapshot store` groups | Manifest `entityTypes` passthrough and Apple emitter entity store wiring | diff --git a/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md b/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md new file mode 100644 index 0000000..78c46bf --- /dev/null +++ b/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md @@ -0,0 +1,104 @@ +# 0024. Dart Hooks and Pigeon Bridge Consistency + +Date: 2026-07-08 + +## Status + +Accepted + +## Context + +[ADR 0019](0019-framework-neutral-intentcall-cli.md) extracted `intentcall_platform_sync` and +`intentcall_cli`, defining a three-gate projection spine (`build_runner` → manifest export → +platform sync). [ADR 0022](0022-projection-pipeline-alignment.md) and +[ADR 0023](0023-entity-three-slot-projection.md) completed dense manifest export, Apple +sub-channels, and entity projection. + +Remaining gaps are **operational**, not policy: + +1. Host build hooks are hand-maintained Gradle/Xcode/Jaspr string templates that subprocess + `intentcall` on PATH. `hooks.syncCommand` in `intentcall.yaml` is parsed but unused. +2. The [Dart SDK hooks](https://dart.dev/tools/hooks) model (`hook/build.dart`, dependency + ordering, cache invalidation) is a better long-term orchestration surface for Jaspr and + plain Dart hosts. +3. Flutter runtime bridges use hand-written `MethodChannel` string dispatch. Generated Swift + duplicates handoff-store logic; entity channel keys and entity-open drain are inconsistent + with manifest projection (ADR 0015/0018). + +The retired [projection-pipeline-spec](../evidence/projection-pipeline-spec.md) execution +playbook is superseded by [hooks-native-bridge-plan](../evidence/hooks-native-bridge-plan.md). + +## Decision + +### 1. PlatformHookSpine (Phase 1) + +Add a single resolver in `intentcall_platform_sync` that reads `intentcall.yaml` and produces: + +- codegen, manifest export, and platform sync phase commands +- resolved platform list from `HostProfile` + `platforms.enabled` +- CLI invocation from `hooks.syncCommand` when set, else `dart run intentcall_cli:intentcall` + +Gradle, Xcode, and Jaspr templates are **generated from the spine**, not hand-maintained. + +### 2. Dart SDK build hook (Phase 2, phased) + +Publish `intentcall_hooks` with `hook/build.dart` that calls `ManifestExporter` and +`PlatformSync` **in-process** (no subprocess `intentcall`). + +- **Phase 2a:** Jaspr and plain Dart web hosts +- **Phase 2b:** Flutter hosts — only after `flutter build` hook timing is proven; Gradle/Xcode + templates may shrink to staleness checks meanwhile +- Hook v1 **requires** fresh `agent_catalog.g.dart`; does not spawn `build_runner` inside the hook +- Extract shared `CatalogLoader` for CLI and hook parity + +### 3. Pigeon bridge (Phase 3) + +Add `intentcall_bridge` with Pigeon IDL for: + +- `intentcall_platform/invocations` — pending invocation drain +- `intentcall_platform/entities` — entity snapshot cache CRUD/search + +**Out of Pigeon scope:** App Intents Swift, shortcuts, Android XML, web manifest, deep links, +`nativeInline` handler registry (remain manifest-driven emitters per ADR 0016/0017). + +Unify handoff store: single native implementation; generated Swift calls into it. + +Pass manifest `EntityKeyBundle` on entity channel calls. Close entity-open drain parity with +invocation drain. + +### 4. Harness (Phase 4) + +- Add `just platform-hooks-check` and steward action `intentcall.platform-hooks-check` +- Promote `just projection-pipeline-check` to CI and steward quick probe +- Add `just pigeon-codegen-check` when Pigeon lands +- mcp_flutter three-gate remains sibling-repo proof (not agentkit CI blocker for core packages) + +## Consequences + +Good: + +- One spine for all hosts; `hooks.syncCommand` becomes real +- Jaspr/Dart hosts lose PATH/subprocess fragility via Dart SDK hooks +- Plugin channels become typed and auditable; handoff duplication removed +- Three-gate semantics unchanged — only invocation surfaces evolve + +Tradeoffs: + +- Two new packages (`intentcall_hooks`, `intentcall_bridge`) in release train +- Pigeon adds codegen step to plugin development +- Flutter native hook migration deferred — dual hook systems temporarily + +## Non-goals + +- Fold `intentcall_platform_sync` into `intentcall_core` +- Pigeon or FFI for App Intents / platform semantic APIs +- Replace manifest emitters with hooks `CodeAsset` until `DataAsset` is stable for compile-time artifacts +- Live OS proof (Siri, Spotlight UX, signed-app discovery) + +## Related + +- [hooks-native-bridge-plan.md](../evidence/hooks-native-bridge-plan.md) +- [0015-dart-first-native-bridge.md](0015-dart-first-native-bridge.md) +- [0019-framework-neutral-intentcall-cli.md](0019-framework-neutral-intentcall-cli.md) +- [0022-projection-pipeline-alignment.md](0022-projection-pipeline-alignment.md) +- [0023-entity-three-slot-projection.md](0023-entity-three-slot-projection.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0caa6ae..0769e04 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,7 +3,7 @@ Architecture Decision Records (ADRs) for IntentCall. Format: [MADR](https://adr.github.io/madr/) — see any existing ADR for the template. -Next ADR number: **0024** +Next ADR number: **0025** --- @@ -25,6 +25,7 @@ Next ADR number: **0024** | [0021](0021-agent-catalog-annotation.md) | accepted | @AgentCatalog Annotation and Removal of Handwritten Catalog Path | 2026-07-07 | | [0022](0022-projection-pipeline-alignment.md) | accepted | Projection Pipeline Alignment — Dense Export and Apple Sub-Channels | 2026-07-07 | | [0023](0023-entity-three-slot-projection.md) | accepted | Entity Three-Slot Projection and Property Roles | 2026-07-07 | +| [0024](0024-dart-hooks-and-pigeon-bridge-consistency.md) | accepted | Dart Hooks and Pigeon Bridge Consistency | 2026-07-08 | --- diff --git a/docs/evidence/hooks-native-bridge-plan.md b/docs/evidence/hooks-native-bridge-plan.md new file mode 100644 index 0000000..d7b8ac5 --- /dev/null +++ b/docs/evidence/hooks-native-bridge-plan.md @@ -0,0 +1,265 @@ +# Hooks + Native Bridge Consistency Plan + +**Status:** Active implementation plan +**Date:** 2026-07-08 +**Supersedes:** [projection-pipeline-spec.md](projection-pipeline-spec.md) (retired) +**ADR:** [0024 — Dart Hooks and Pigeon Bridge Consistency](../decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md) +**Disposition:** `promote_to_artifact` + +**Related ADRs:** [0015](../decisions/0015-dart-first-native-bridge.md), [0016](../decisions/0016-dispatch-mode-handoff-contract.md), [0017](../decisions/0017-apple-inline-runtime-tracks.md), [0019](../decisions/0019-framework-neutral-intentcall-cli.md), [0022](../decisions/0022-projection-pipeline-alignment.md), [0023](../decisions/0023-entity-three-slot-projection.md) + +--- + +## 1. Problem statement + +IntentCall's projection pipeline (ADR 0019/0022/0023) is **implemented and tested** in agentkit. Remaining pain is **operational consistency**: + +1. **Build hooks** — Gradle/Xcode/Jaspr string templates duplicate the same three-gate spine; `hooks.syncCommand` in `intentcall.yaml` is parsed but unused; `intentcall` must be on PATH. +2. **Dart SDK hooks** — [dart.dev/tools/hooks](https://dart.dev/tools/hooks) offers package-scoped `hook/build.dart` with cache invalidation; IntentCall should adopt this for Jaspr/plain Dart first, then Flutter. +3. **Native bridge** — Flutter plugin uses hand-written `MethodChannel` string dispatch; generated Swift duplicates handoff-store logic; entity keys and entity-open drain are inconsistent. + +**Non-goals:** Fold `intentcall_platform_sync` into `core`; Pigeon App Intents; live OS proof (Siri/Spotlight UX). + +--- + +## 2. Target architecture + +```text +Layer 1 — Truth: intentcall_schema + intentcall_core + intentcall_codegen (build_runner) +Layer 2 — Projection: intentcall_platform_sync + intentcall_cli (+ intentcall_hooks hook/build.dart) +Layer 3 — Bridge: intentcall_bridge (Pigeon) + intentcall_platform (Flutter plugin) +Layer 4 — Adapters: intentcall_mcp | intentcall_webmcp +``` + +**Three-gate spine (semantics unchanged):** + +```text +build_runner → intentcall manifest export --check → intentcall platform sync --check +``` + +Invocation surfaces evolve; gate meaning does not. + +--- + +## 3. Acceptance criteria + +| # | Criterion | Phase | +|---|-----------|-------| +| H1 | `PlatformHookSpine` resolves phases + CLI invocation from `intentcall.yaml` | P1 | +| H2 | Gradle/Xcode/Jaspr templates generated from spine (not hand-maintained const strings) | P1 | +| H3 | `hooks.syncCommand` honored when set | P1 | +| H4 | `just platform-hooks-check` + steward action pass | P1, P4 | +| H5 | `intentcall_hooks` package with `hook/build.dart` runs export+sync in-process for Jaspr fixture | P2 | +| H6 | Shared `CatalogLoader` used by CLI and Dart hook | P2 | +| H7 | Pigeon IDL for invocations + entities channels; plugin uses generated HostApi | P3 | +| H8 | Single handoff store (no duplicate Swift implementations) | P3 | +| H9 | `EntityKeyBundle` from manifest on entity channel calls | P3 | +| H10 | `projection-pipeline-check` in CI + quick probe | P4 | +| H11 | mcp_flutter three-gate (sibling repo) | P4 | + +**Claim ceiling:** artifact + static CI. **Non-claims:** live Siri ranking, signed-app Spotlight UX, exactly-once native delivery. + +--- + +## 4. Phase 0 — Doc hygiene + +**Goal:** Retire stale spec; record decision in ADR 0024. + +| Lane | Scope | +|------|-------| +| P0-doc | Tombstone `projection-pipeline-spec.md`; ADR 0024; verification appendices on ADR 0022/0023; update `docs/decisions/README.md` | + +**Gate:** Doc links valid; `intentcall validate` passes. + +--- + +## 5. Phase 1 — Hook spine unification + +**Goal:** One resolver, three host renderers. No Dart SDK hook yet. + +### 5.1 `PlatformHookSpine` + +**Location:** `packages/intentcall_platform_sync/lib/src/templates/platform_hook_spine.dart` + +```yaml +inputs: + intentcall.yaml: [host, platforms.enabled, hooks.syncCommand, layout] + HostProfile from host_profiles.dart +outputs: + codegen_phase: dart run build_runner build --delete-conflicting-outputs + manifest_phase: manifest export --check + sync_phase: platform sync --platform [--check] + cli_invocation: hooks.syncCommand ?? dart run intentcall_cli:intentcall + platform_list: from HostProfile + platforms.enabled +``` + +### 5.2 Parallel lanes + +| Lane | Agent | Write set | Gate | +|------|-------|-----------|------| +| **P1a-spine** | Hook resolver | `platform_hook_spine.dart`, refactor `platform_hook_templates.dart` | `platform_hook_templates_test.dart` | +| **P1b-init** | Hooks init | `platform_hooks_init.dart`, wire `hooks.syncCommand` | `platform_hooks_init_test.dart` | +| **P1c-cli** | CLI | `intentcall hooks render`, `intentcall hooks spine --json` | `command_runner_test.dart` | + +**Order:** P1a → P1b ∥ P1c + +**Aggregate gate:** + +```bash +just platform-hooks-check +``` + +--- + +## 6. Phase 2 — Dart SDK build hook + +**Goal:** Replace shell hooks for Jaspr/plain Dart hosts. + +### 6.1 New package `intentcall_hooks` + +``` +packages/intentcall_hooks/ + hook/build.dart # calls ManifestExporter + PlatformSync in-process + pubspec.yaml # depends: hooks, code_assets, intentcall_platform_sync +``` + +**v1 rules:** + +- Require fresh `agent_catalog.g.dart` (do not spawn build_runner inside hook) +- Register `output.dependencies` on `intentcall.yaml`, catalog, manifest +- Use `hooks.user_defines.intentcall_hooks` for `platforms`, `check_only`, `project_root` + +### 6.2 Parallel lanes + +| Lane | Agent | Write set | Gate | +|------|-------|-----------|------| +| **P2a-hook-pkg** | Dart hooks | `packages/intentcall_hooks/` | Jaspr fixture spine test | +| **P2b-catalog-loader** | Shared lib | Extract `CatalogLoader` from CLI | CLI + hook parity tests | + +**Order:** P2b → P2a (loader first) + +**Defer:** Flutter iOS/Android Gradle/Xcode hook removal until Flutter hook timing proof. + +--- + +## 7. Phase 3 — Pigeon bridge + +**Goal:** Typed plugin channels; collapse handoff duplication. + +### 7.1 New package `intentcall_bridge` + +``` +packages/intentcall_bridge/ + pigeons/intentcall_platform_bridge.dart + lib/intentcall_bridge.dart +``` + +**Pigeon surfaces:** + +- `IntentCallInvocationsHostApi.takePendingInvocations()` +- `IntentCallEntitiesHostApi` — upsert/search/delete/clear with `EntityKeyBundle` + +**Do NOT Pigeon:** App Intents, shortcuts, deep links, `nativeInline` registry. + +### 7.2 Parallel lanes + +| Lane | Agent | Write set | Gate | +|------|-------|-----------|------| +| **P3a-idl** | IDL author | `intentcall_bridge` pigeons + codegen | `just pigeon-codegen-check` | +| **P3b-plugin** | Plugin | Refactor `IntentCallPlatformPlugin`; dedupe Swift | `pigeon_bridge_contract_test.dart` | +| **P3c-entity** | Entity bridge | `EntityKeyBundle`; entity-open drain parity | `intentcall_entity_index_test.dart` | + +**Order:** P3a → P3b ∥ P3c + +--- + +## 8. Phase 4 — Harness + consumer + +**Goal:** Promote gates; close L5 sibling gap. + +| Lane | Agent | Scope | Gate | +|------|-------|-------|------| +| **P4a-harness** | Steward | CI + quick probe; new steward actions | `steward benchmark --scenario intentcall.projection-pipeline` | +| **P4b-tests** | Tests | Extend `projection-pipeline-check`; A5/A8 gaps | `just projection-pipeline-check` | +| **P4c-mcp-jaspr** | Sibling | mcp_flutter Jaspr three-gate | `make check-contracts` | +| **P4d-mcp-flutter** | Sibling | flutter_test_app migration | hosted consumer script | + +**Harness additions:** + +```bash +just platform-hooks-check # P1 +just pigeon-codegen-check # P3 +just projection-pipeline-check # promote to CI +``` + +**mcp_flutter three-gate (sibling repo, not agentkit CI blocker):** + +```bash +# Jaspr web example (hook presence → manifest export --check → platform sync --check) +cd ../mcp_flutter && make check-contracts +# Or directly: +bash tool/contracts/check_intentcall_jaspr_three_gate.sh +``` + +Gate 1 may apply hooks when `--check` fails; gates 2–3 are pure `--check` invocations. +See also `projection_alignment_test.dart` → `mcp_flutter three-gate` group. + +--- + +## 9. Execution timeline + +```text +P0 (doc) ─────────────────────────────────────────► +P1 (spine) ──────► [P1a → P1b ∥ P1c] +P2 (dart hook) ──────► [P2b → P2a] (after P1 gate) +P3 (pigeon) ──────► [P3a → P3b ∥ P3c] (P3a after P0; parallel to P2) +P4 (harness) ──────► [P4a ∥ P4b ∥ P4c ∥ P4d] +``` + +**Dependencies:** + +- P2 requires P1 (`PlatformHookSpine` + shared catalog loader path) +- P3b requires P3a +- P4c/P4d require P1 minimum + +--- + +## 10. Master subagent batch contract + +```markdown +| Lane | Phase | Role | Write set | Forbidden | Native gate | +|------|-------|------|-----------|-----------|-------------| +| P0-doc | 0 | ADR + doc hygiene | docs/** | code | intentcall validate | +| P1a-spine | 1 | Hook resolver | platform_hook_spine, templates | pigeon, mcp_flutter | platform_hook_templates_test | +| P1b-init | 1 | Hooks init | platform_hooks_init | CLI surface break | platform_hooks_init_test | +| P1c-cli | 1 | CLI hooks commands | intentcall_cli | emitter logic | command_runner_test | +| P2b-catalog | 2 | CatalogLoader extract | platform_sync or hooks | emitters | catalog loader tests | +| P2a-hook-pkg | 2 | Dart hook package | intentcall_hooks | Gradle/Xcode removal | jaspr fixture | +| P3a-idl | 3 | Pigeon IDL | intentcall_bridge | App Intents emitters | pigeon-codegen-check | +| P3b-plugin | 3 | Plugin refactor | intentcall_platform | emitters | pigeon_bridge_contract_test | +| P3c-entity | 3 | Entity bridge | entity_index, plugin | mcp_flutter | entity_index_test | +| P4a-harness | 4 | Steward/CI | steward.yaml, justfile, ci.yml | app code | steward benchmark | +| P4b-tests | 4 | Test promotion | projection tests | — | projection-pipeline-check | +| P4c-mcp-jaspr | 4 | Sibling Jaspr | mcp_flutter | agentkit core | check-contracts | +| P4d-mcp-flutter | 4 | Sibling Flutter | mcp_flutter | agentkit core | hosted consumer | +``` + +Parent blocks phase N+1 until phase N aggregate gate passes (except P3a after P0, P4 doc-only lanes). + +--- + +## 11. Risk register + +| Risk | Mitigation | +|------|------------| +| Flutter hook timing vs Xcode compile | Keep Gradle/Xcode until dogfood proof | +| `DataAsset` not stable for Swift/XML artifacts | Hook writes project tree via `PlatformSync` (same as CLI) | +| Pigeon breaks plugin consumers | Deprecation period; channel names unchanged | +| mcp_flutter path deps | Land agentkit first; bump hosted versions | +| New packages in release train | Add to `release_train.dart`, `PUBLISHING.md` | + +--- + +## 12. MoE audit origin + +Synthesized from Mixture-of-Experts audit (2026-07-08): Dart SDK Hooks lens, Pigeon/Bridge lens, Generational Architecture Skeptic, Harness QA. Prior projection-pipeline MoE (2026-07-07) layers 1–4 are complete per ADR 0022/0023. diff --git a/docs/evidence/projection-pipeline-spec.md b/docs/evidence/projection-pipeline-spec.md deleted file mode 100644 index d563097..0000000 --- a/docs/evidence/projection-pipeline-spec.md +++ /dev/null @@ -1,448 +0,0 @@ -# Platform Projection Completion Spec - -**Status:** Draft for implementation -**Date:** 2026-07-07 -**Goal:** Make manifest export, emitters, runtime bootstrap, Apple sub-channels, and entity lifecycle **one aligned pipeline** — with per-layer subagent lanes and native gates that prove each layer before the next starts. - -**Disposition target:** `promote_to_artifact` → ADR `0021-projection-pipeline-alignment.md` + harness checks after Layer 5. - -**Related:** - -- [ADR 0016 — Dispatch mode handoff](../decisions/0016-dispatch-mode-handoff-contract.md) -- [ADR 0018 — Additive actions, typed entities, indexing](../decisions/0018-additive-actions-typed-entities-indexing-lifecycle.md) -- [ADR 0020 — Platform-scoped manifest surfaces](../decisions/0020-platform-scoped-manifest-surfaces.md) - ---- - -## 1. Acceptance criteria (parent) - -The work is done when **all** of the following hold: - -| # | Criterion | -|---|-----------| -| A1 | Every exported `agent_manifest.json` tool row has **all 8 surfaces** with explicit `include: true/false` | -| A2 | Every emitter treats absent surface as **excluded** (`defaultValue: false`) | -| A3 | `platforms.enabled` scopes defaults correctly even when `defaults.surfaces` is partial | -| A4 | `apple.appShortcuts` stays **opt-in** when `ios`/`macos` enabled (ADR 0016 preserved) | -| A5 | Dart WebMCP bootstrap respects `web.webMcp` from manifest/catalog | -| A6 | `PlatformSync` reads manifest from `layout.manifest` and web dir from `layout.webDir` | -| A7 | Apple sub-channels are independently controllable (new surfaces + emitters) | -| A8 | Entity types export from catalog; single snapshot store; Spotlight indexing wired after upsert | -| A9 | `mcp_flutter` Jaspr + `flutter_test_app` pass three-gate CI | -| A10 | No orphan/broken emitter copies in `intentcall_platform` | - -**Claim ceiling after full completion:** artifact + static CI proof. -**Non-claims:** live Siri ranking, App Store discovery, signed-app Spotlight UX, exactly-once native delivery. - ---- - -## 2. Cross-layer invariants (never break) - -```yaml -invariants: - source_of_truth: AgentRegistry + agent_catalog.g.dart + intentcall.yaml - pipeline_order: - - build_runner # catalog - - manifest export # policy merge → dense manifest - - platform sync # emitters read manifest only - - runtime bootstrap # must not widen beyond manifest - surface_keys_json: dotted # web.webMcp, apple.appShortcuts, … - surface_keys_yaml: camelCase_or_dotted # both accepted in yaml - apple_shortcuts_default: opt_in # ADR 0016 - missing_surface_semantics: exclude # after Layer 1 -``` - ---- - -## 3. ADR prerequisites (before Layer 3) - -Create **`docs/decisions/0021-projection-pipeline-alignment.md`** amending/clarifying 0020: - -1. **Dense export** — export always emits all surfaces explicitly. -2. **Partial yaml merge** — `defaults.surfaces` per-key overrides platform-scoped fill; absent keys use platform scope. -3. **Apple shortcuts** — `apple.appShortcuts` never auto-enables from `platforms.enabled`; only `apple.appIntents` (new) may default on for ios/macos. -4. **New Apple surfaces** — `apple.appIntents`, `apple.spotlight`, `apple.entities` (names TBD, see Layer 3). -5. **Retire** `intentcall_apple` manifest generator from main path (deprecation note). - -Layers 1–2 can start before ADR merge; Layer 3+ requires ADR accepted. - ---- - -## 4. Layer specifications - -### Layer 1 — Policy correctness (foundation) - -**Outcome:** Export and policy are truthful; emitters cannot misread sparse manifests. - -#### 4.1.1 Code changes - -| Area | File(s) | Change | -|------|---------|--------| -| Dense `toJson` | `agent_manifest.dart` → `AgentManifestSurfacePolicy.toJson` | Emit all `AgentManifestSurface.values` with resolved `include` | -| Resolved surfaces helper | `manifest_merger.dart` or new `surface_resolver.dart` | `resolveEntrySurfaces(overlay, defaultSurfaces)` returns full map | -| Partial yaml merge | `projection_policy.dart` → `resolvedDefaultSurfaces` | If `defaultSurfaces` partial: merge each enum — explicit override → else platform-scoped default → else legacy default | -| Apple opt-in fix | `projection_policy.dart` → `defaultSurfaceIncludeForPlatforms` | `appleAppShortcuts` always `false` unless explicit yaml/catalog overlay | -| Emitter defaults | All emitters in `intentcall_platform_sync/lib/src/emitters/` | `defaultValue: false` everywhere | -| Tests | `manifest_merger_test.dart` | + partial defaults + ios enabled + apple shortcuts stay false | -| Fixtures | `intentcall_codegen/example`, `intentcall_cli/test/fixtures/*`, `mcp_flutter/jaspr_web_example` | Regenerate manifests | - -#### 4.1.2 Subagent lane L1 - -| Field | Value | -|-------|-------| -| Role | Policy + export engineer | -| Write set | `intentcall_platform_sync/lib/src/{agent_manifest,projection}/*`, `test/manifest_merger_test.dart`, fixtures | -| Forbidden | Apple emitters, runtime bootstrap, mcp_flutter | -| Native gate | `dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart` | -| Direct fix | yes | - -#### 4.1.3 Layer 1 verification gate - -```bash -# Gate L1-A: unit -dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart - -# Gate L1-B: codegen example (web-only scoping + dense surfaces) -dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart - -# Gate L1-C: every tool row has 8 surface keys (new test) -dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart # add - -# Gate L1-D: emitter unit — opt-out honored -dart test packages/intentcall_platform_sync/test/native_emitters_test.dart -``` - -**Alignment check L1→L2:** Parse exported manifest; for each tool assert `surfaces.keys.length == 8`; run `platform sync --check` on web-only fixture — **zero** android/windows/linux artifact changes. - ---- - -### Layer 2 — Runtime + sync parity - -**Outcome:** Sync reads correct paths; runtime does not widen beyond manifest. - -#### 4.2.1 Code changes - -| Area | File(s) | Change | -|------|---------|--------| -| Layout wiring | `platform_sync.dart` | Load `layout.manifest`, `layout.webDir` from yaml (reuse `ManifestMerger` readers); `_resolveManifestFile` uses both | -| Export context consumption | `manifest_exporter.dart` | Pass `manifestRelativePath` to CLI `resolveManifestOutput` if not already unified | -| WebMCP bootstrap filter | `agent_web_mcp_bootstrap_web.dart`, `intentcall_flutter_host.dart` | Load manifest or accept `Set webMcpQualifiedNames` from catalog projection; skip tools with `web.webMcp: false` | -| Manifest loader for runtime | New `ManifestSurfaceIndex` in `platform_sync` | `includes(qualifiedName, surface)` from parsed manifest | -| Web hardcoding (optional L2.1) | `web_manifest_emitter.dart` | Document `web+intentcall` vs `protocolScheme`; defer config to Layer 5 docs unless trivial | -| Orphan cleanup | `intentcall_platform/lib/src/emitters/*` | Delete or replace with `export` from `intentcall_platform_sync` | -| Stale JS | `intentcall_codegen/example/web/intentcall_webmcp.generated.js` | Regenerate; `app_demo_cart` must be absent | - -#### 4.2.2 Subagent lanes (parallel after L1 gate passes) - -| Lane | Role | Scope | Gate | -|------|------|-------|------| -| **L2a** | Sync/layout engineer | `platform_sync.dart`, `platform_sync_test.dart` | `dart test .../platform_sync_test.dart` | -| **L2b** | WebMCP runtime engineer | bootstrap + flutter host | New `webmcp_bootstrap_surface_test.dart` | -| **L2c** | Package hygiene | `intentcall_platform` orphan removal | `dart analyze packages/intentcall_platform` | - -#### 4.2.3 Layer 2 verification gate - -```bash -# Gate L2-A: custom layout.manifest -# Test fixture: intentcall.yaml layout.manifest: assets/agent_manifest.json -dart test packages/intentcall_platform_sync/test/platform_sync_layout_test.dart # add - -# Gate L2-B: WebMCP bootstrap respects opt-out -dart test packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart # add - -# Gate L2-C: sync --check on web-only project does not touch android/ -cd packages/intentcall_codegen/example && \ - dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check && \ - dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check - -# Gate L2-D: generated JS excludes webMcp:false tools -rg -l 'app_demo_cart' packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js && exit 1 || true -``` - -**Alignment check L2→L3:** Manifest `web.webMcp: false` → absent from JS **and** Dart bootstrap registration in integration test. - ---- - -### Layer 3 — Apple sub-channels - -**Outcome:** Siri, Spotlight, Shortcuts, and base App Intents are independently gated. - -#### 4.3.1 Surface model (proposed) - -```yaml -new_surfaces: - appleAppIntents: # Swift AppIntent struct emission (all tools default on ios/macos) - manifest_key: apple.appIntents - default_ios_macos: true - appleAppShortcuts: # AppShortcutsProvider phrases (opt-in, ADR 0016) - manifest_key: apple.appShortcuts - default: false - appleSpotlight: # CSSearchableIndex / IndexedEntity indexing - manifest_key: apple.spotlight - default: false - appleEntities: # AppEntity + EntityQuery generation - manifest_key: apple.entities - default: false # true when entityTypes present + explicit or default policy -``` - -`AgentManifestSurfaceExposure.options` for future Siri phrase templates, donation hints — parsed in yaml/codegen in this layer minimally (store + round-trip), emitters read in Layer 4. - -#### 4.3.2 Code changes - -| Area | File(s) | Change | -|------|---------|--------| -| Enum + keys | `agent_manifest.dart` | Add 3 surfaces; bump schema doc only (version stays 1 if additive) | -| Policy defaults | `projection_policy.dart` | Map new surfaces to `ios`/`macos` | -| Codegen | `agent_projection.dart`, `agent_catalog_generator.dart` | Support new `@AgentProjection` keys | -| Apple emitter | `apple_swift_app_intents_emitter.dart` | Gate: struct emission ← `appleAppIntents`; shortcuts ← `appleAppShortcuts`; entity block ← `appleEntities`; indexing ← `appleSpotlight` | -| Testing emitter | `apple_app_intents_testing_emitter.dart` | Filter tools by relevant surfaces | -| Tests | `native_emitters_test.dart` | Matrix: each surface independently on/off | -| Docs/skills | `register-intents/SKILL.md`, `DX_FAQ.mdx` | Update surface table | -| Deprecate | `intentcall_apple` | README deprecation banner; no new features | - -#### 4.3.3 Subagent lanes - -| Lane | Role | Scope | Gate | -|------|------|-------|------| -| **L3a** | Schema + policy | manifest enum, projection_policy, merger tests | L1 gates + new surface tests | -| **L3b** | Apple emitter split | `apple_swift_app_intents_emitter.dart` | `native_emitters_test.dart` | -| **L3c** | Codegen + annotations | `intentcall_codegen` | `agent_catalog_generator_test.dart`, example export | -| **L3d** | ADR author | `docs/decisions/0021-*.md` | Review only | - -**Merge order:** L3d (ADR) → L3a → L3b ∥ L3c → parent synthesis. - -#### 4.3.4 Layer 3 verification gate - -```bash -dart test packages/intentcall_platform_sync/test/native_emitters_test.dart - -# New: apple surface independence matrix -dart test packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart # add - -# ios sync generates Swift only for appleAppIntents:true tools -# shortcuts provider empty when all appleAppShortcuts:false -``` - -**Alignment check L3→L4:** Tool with `apple.appIntents: true`, `apple.appShortcuts: false` → Swift struct exists, no `AppShortcut` row. Entity types only when `apple.entities: true` or global entityTypes + policy. - ---- - -### Layer 4 — Entity lifecycle (ADR 0018 completion) - -**Outcome:** Entity types flow catalog → manifest → native cache → Spotlight; one store. - -#### 4.4.1 Code changes - -| Area | File(s) | Change | -|------|---------|--------| -| Catalog entity rows | `agent_catalog_generator.dart` | Emit `@AgentEntity` / descriptor rows into catalog | -| CLI export | `command_runner.dart`, `manifest_exporter.dart` | Pass `entityTypeDescriptors` from catalog loader | -| Catalog loader | `catalog_loader.dart` | Load entity descriptors from generated catalog | -| Unify snapshot store | `IntentCallPlatformPlugin.swift` (all copies) | Delegate to generated `IntentCallNativeEntitySnapshotStore` or delete hand-written store | -| Dart API | `intentcall_entity_index.dart` | Remove legacy `upsertAgentSnapshots`; canonical API is `upsertAgentSnapshotsForType` (descriptor-aligned projection) | -| Indexing hook | Plugin method channel | After upsert → call generated `IntentCallAppEntityIndexer.indexAppEntities()` | -| Emitter | `apple_swift_app_intents_emitter.dart` | Indexing gated by `apple.spotlight` | - -#### 4.4.2 Subagent lanes - -| Lane | Role | Scope | Gate | -|------|------|-------|------| -| **L4a** | Export + catalog | CLI, catalog loader, codegen | `manifest export` includes `entityTypes` | -| **L4b** | Native store unification | iOS/macOS plugin Swift | `intentcall_entity_index_test.dart` | -| **L4c** | Indexing lifecycle | emitter + plugin bridge | AppIntentsTesting scaffold compile | - -#### 4.4.3 Layer 4 verification gate - -```bash -dart test packages/intentcall_core/test/agent_entity_snapshot_projection_test.dart -dart test packages/intentcall_platform/test/intentcall_entity_index_test.dart - -# New: export includes entityTypes from fixture catalog -dart test packages/intentcall_cli/test/manifest_entity_export_test.dart # add - -# AppIntentsTesting compile (if SDK available) -dart run packages/intentcall_cli/bin/intentcall.dart apple-appintents-testing generate --check # if fixture exists -``` - -**Alignment check L4→L5:** `entityTypes` in manifest match catalog; upsert uses descriptor keys; generated entity query reads same keys cold. - ---- - -### Layer 5 — Consumer migration + harness proof - -**Outcome:** mcp_flutter and agentkit examples dogfood the full pipeline. - -#### 4.5.1 Code changes - -| Repo | Change | -|------|--------| -| **agentkit** | Steward scenario `intentcall.projection-pipeline` (new yaml) | -| **mcp_flutter** | `flutter_test_app`: add `intentcall.yaml`, catalog codegen, drop hand manifest | -| **mcp_flutter** | Extend `check_intentcall_hosted_consumer.sh` with `manifest export --check` | -| **mcp_flutter** | Regenerate `jaspr_web_example/web/agent_manifest.json` (web-only surfaces) | -| **agentkit** | `register-intents/SKILL.md` surface table + three-gate workflow | -| **optional** | `WebMcpPublishAdapter` in dogfood app | - -#### 4.5.2 Subagent lanes - -| Lane | Role | Scope | Gate | -|------|------|-------|------| -| **L5a** | agentkit harness | steward scenario, docs | `steward benchmark --scenario intentcall.projection-pipeline --json` | -| **L5b** | mcp_flutter Jaspr | jaspr_web_example fixtures | `tool/contracts/check_intentcall_jaspr_three_gate.sh` | -| **L5c** | mcp_flutter Flutter dogfood | flutter_test_app migration | `tool/contracts/check_intentcall_hosted_consumer.sh` | -| **L5d** | Docs/skills | DX_FAQ, platform_support, register-intents | Docs review only | - -#### 4.5.3 Layer 5 verification gate (aggregate) - -```bash -# agentkit root -steward doctor --json -steward probe --json --profile quick -dart test packages/intentcall_platform_sync -dart test packages/intentcall_codegen -dart test packages/intentcall_cli - -# sibling integration (when ../agentkit exists) -cd ~/mcp/mcp_flutter && make check-contracts -``` - ---- - -## 5. Master subagent batch contract - -```markdown -## Parallel Batch -Original goal: Align platform projection across export → manifest → emitters → runtime → consumers -Acceptance check: Criteria A1–A10 above -Product impact check: agent_manifest.json shape, generated artifacts, WebMCP registration, Apple Swift output, entity cache -Default native gate: dart test packages/intentcall_platform_sync -Aggregate gate: steward benchmark --scenario intentcall.projection-pipeline + mcp_flutter make check-contracts -Detour budget: No new surfaces beyond 0021; no runtime Siri proof rabbit holes -Claim ceiling: Static CI + artifact proof; no live OS discovery claims -Non-claims: Siri ranking, App Store approval, signed-app Spotlight UX, exactly-once delivery - -| Lane | Agent/role | Scope | Write set | Forbidden | Native gate | Layer | Terminal | -|------|------------|-------|-----------|-----------|-------------|-------|----------| -| L1 | policy-export | L1 | platform_sync projection/* | emitters, mcp_flutter | manifest_merger_test | 1 | pending | -| L2a | sync-layout | L2 | platform_sync.dart | apple, mcp_flutter | platform_sync_test | 2 | pending | -| L2b | webmcp-runtime | L2 | bootstrap, flutter_host | apple, mcp_flutter | webmcp_bootstrap_test | 2 | pending | -| L2c | pkg-hygiene | L2 | intentcall_platform emitters | codegen | dart analyze | 2 | pending | -| L3a | schema-policy | L3 | agent_manifest, projection_policy | emitters | merger + dense tests | 3 | pending | -| L3b | apple-emitter | L3 | apple_swift_* | web, mcp_flutter | native_emitters_test | 3 | pending | -| L3c | codegen-surfaces | L3 | intentcall_codegen | platform_sync emitters | catalog_generator_test | 3 | pending | -| L3d | adr-author | L3 | docs/decisions/0021-* | code | review | 3 | pending | -| L4a | entity-export | L4 | CLI, catalog_loader | plugin swift | manifest_entity_export_test | 4 | pending | -| L4b | native-store | L4 | plugin Swift, entity_index | emitters | entity_index_test | 4 | pending | -| L4c | indexing-bridge | L4 | plugin + emitter hook | mcp_flutter | platform tests | 4 | pending | -| L5a | harness | L5 | steward scenarios | app code | steward benchmark | 5 | pending | -| L5b | mcp-jaspr | L5 | jaspr_web_example | agentkit core | jaspr three-gate | 5 | pending | -| L5c | mcp-flutter | L5 | flutter_test_app | agentkit core | hosted consumer | 5 | pending | -| L5d | docs | L5 | skills, FAQ | code | doc review | 5 | pending | -``` - -**Execution order (strict):** - -``` -L1 → [L2a ∥ L2b ∥ L2c] → ADR 0021 → [L3a → L3b ∥ L3c] → [L4a → L4b ∥ L4c] → [L5a ∥ L5b ∥ L5c ∥ L5d] -``` - -Parent **blocks** layer N+1 until layer N aggregate gate passes. - ---- - -## 6. Cross-layer alignment matrix - -After each layer, parent runs this matrix (automate as `projection_alignment_test.dart` in Layer 5): - -| Check | L1 | L2 | L3 | L4 | L5 | -|-------|----|----|----|----|-----| -| 8 surfaces per tool in JSON | ✓ | ✓ | ✓ (+3 Apple) | ✓ | ✓ | -| web-only yaml → no non-web `include:true` | ✓ | ✓ | ✓ | ✓ | ✓ | -| emitter `defaultValue:false` | ✓ | ✓ | ✓ | ✓ | ✓ | -| sync uses layout.manifest | — | ✓ | ✓ | ✓ | ✓ | -| Dart WebMCP ⊆ manifest webMcp | — | ✓ | ✓ | ✓ | ✓ | -| apple shortcuts opt-in on ios | ✓ | ✓ | ✓ | ✓ | ✓ | -| apple struct gated separately | — | — | ✓ | ✓ | ✓ | -| entityTypes in export | — | — | — | ✓ | ✓ | -| single snapshot store | — | — | — | ✓ | ✓ | -| mcp_flutter three-gate | — | — | — | — | ✓ | - ---- - -## 7. New tests to add (checklist) - -| Test file | Layer | Asserts | -|-----------|-------|---------| -| `dense_manifest_test.dart` | 1 | 8 keys per tool; all `include` bool | -| `partial_defaults_platform_scope_test.dart` | 1 | yaml `{webMcp:true}` + `platforms:[web]` → android false | -| `ios_shortcuts_opt_in_test.dart` | 1 | `enabledPlatforms:[ios]` → `apple.appShortcuts` false | -| `platform_sync_layout_test.dart` | 2 | custom `layout.manifest` path | -| `webmcp_bootstrap_surface_test.dart` | 2 | opt-out tool not registered | -| `apple_surface_matrix_test.dart` | 3 | independent surface gating | -| `manifest_entity_export_test.dart` | 4 | `entityTypes` from catalog | -| `projection_alignment_test.dart` | 5 | full matrix above | - ---- - -## 8. Steward harness scenario (Layer 5) - -Add `steward/scenarios/intentcall.projection-pipeline.yaml`: - -```yaml -name: intentcall.projection-pipeline -steps: - - run: dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart - - run: dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart - - run: dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart - - run: dart test packages/intentcall_platform_sync/test/native_emitters_test.dart - - working_directory: packages/intentcall_codegen/example - run: dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check - - working_directory: packages/intentcall_codegen/example - run: dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check -``` - ---- - -## 9. Parent synthesis template (after each layer) - -```markdown -## Layer N synthesis -- Gates run: {list} -- Gates skipped: {reason} -- Lanes integrated: {ids} -- Lanes partial/timed_out: {ids} -- Alignment matrix row: {pass/fail per check} -- Claim ceiling: {what we can claim now} -- Blockers for layer N+1: {none | list} -``` - ---- - -## 10. Risk register - -| Risk | Mitigation | -|------|------------| -| Schema bump breaks consumers | Additive surfaces only; dense export backward-compatible | -| Breaking sparse manifest consumers | Layer 1 regenerates all fixtures in same PR | -| Apple SDK CI variance | AppIntentsTesting optional; compile proof labeled | -| mcp_flutter path deps | Land agentkit first; then bump hosted versions | -| Dual store migration breaks apps | Deprecation period; legacy API calls new store | - ---- - -## 11. Suggested first execution prompt - -> Execute Layer 1 per projection completion spec: dense manifest export, partial yaml + platform merge, appleAppShortcuts opt-in on ios, emitter defaultValue:false. Add dense_manifest_test + ios_shortcuts_opt_in_test. Regenerate codegen example and jaspr_web_example manifests. Run L1 gates only; do not start Layer 2. - ---- - -## 12. MoE audit origin - -This spec synthesizes findings from a Mixture-of-Experts audit (2026-07-07) covering: - -- Apple projection wiring (Siri, Spotlight, Shortcuts, entity pipeline) -- Web and multi-platform emitter/runtime gaps -- mcp_flutter consumer delegation and three-gate CI -- ADR 0020 partial implementation state - -Lens status at audit time: Apple and web wiring integrated; harness QA partial; disposition `promote_to_artifact`. diff --git a/justfile b/justfile index 6a1fa6a..e5bdc22 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,7 @@ default: # Run tests for all packages in the workspace test: - dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform_sync packages/intentcall_codegen packages/intentcall_cli packages/intentcall_platform packages/intentcall_testing tool/intentcall + dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform_sync packages/intentcall_hooks packages/intentcall_codegen packages/intentcall_cli packages/intentcall_platform packages/intentcall_testing tool/intentcall # Manifest freshness gate (build_runner catalog + export --check) manifest-export-check: @@ -25,12 +25,25 @@ adr-gates: just manifest-parity just platform-sync-check -# Layer 5 projection pipeline gate (spec section 8) +# Phase 1 hook spine gate (ADR 0024) +platform-hooks-check: + dart test packages/intentcall_platform_sync/test/platform_hook_templates_test.dart + dart test packages/intentcall_platform_sync/test/platform_hooks_init_test.dart + dart test packages/intentcall_cli/test/command_runner_test.dart + +# Layer 5 projection pipeline gate (ADR 0022/0023/0024) projection-pipeline-check: dart test packages/intentcall_platform_sync/test/manifest_merger_test.dart dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart dart test packages/intentcall_platform_sync/test/native_emitters_test.dart + dart test packages/intentcall_platform_sync/test/projection_alignment_test.dart + dart test packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart + dart test packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart + dart test packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart + dart test packages/intentcall_platform_sync/test/platform_sync_layout_test.dart + dart test packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart + dart test packages/intentcall_cli/test/manifest_entity_export_test.dart cd packages/intentcall_codegen/example && dart pub get && dart run build_runner build cd packages/intentcall_codegen/example && dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check cd packages/intentcall_codegen/example && dart run ../../intentcall_cli/bin/intentcall.dart platform sync --platform web --check @@ -138,7 +151,13 @@ validate: # Run the shared native adapter and platform bridge contract tests adapter-contract-test: - dart test packages/intentcall_testing/test/adapter_contract_test.dart packages/intentcall_mcp/test/mcp_adapter_contract_test.dart packages/intentcall_webmcp/test/webmcp_adapter_contract_test.dart packages/intentcall_gemma/test/gemma_adapter_contract_test.dart packages/intentcall_platform_sync/test/intentcall_invocation_test.dart packages/intentcall_platform_sync/test/web_emitters_test.dart packages/intentcall_platform_sync/test/agent_web_mcp_bootstrap_test.dart packages/intentcall_platform_sync/test/native_emitters_test.dart packages/intentcall_platform_sync/test/native_platform_sync_test.dart packages/intentcall_platform/test/intentcall_flutter_host_test.dart packages/intentcall_platform/test/intentcall_entity_index_test.dart + dart test packages/intentcall_testing/test/adapter_contract_test.dart packages/intentcall_mcp/test/mcp_adapter_contract_test.dart packages/intentcall_webmcp/test/webmcp_adapter_contract_test.dart packages/intentcall_gemma/test/gemma_adapter_contract_test.dart packages/intentcall_platform_sync/test/intentcall_invocation_test.dart packages/intentcall_platform_sync/test/web_emitters_test.dart packages/intentcall_platform_sync/test/agent_web_mcp_bootstrap_test.dart packages/intentcall_platform_sync/test/native_emitters_test.dart packages/intentcall_platform_sync/test/native_platform_sync_test.dart packages/intentcall_platform/test/intentcall_flutter_host_test.dart packages/intentcall_platform/test/intentcall_entity_index_test.dart packages/intentcall_platform/test/pigeon_bridge_contract_test.dart + +# Regenerate and verify Pigeon bridge outputs are committed (Phase 3 gate) +pigeon-codegen-check: + cd packages/intentcall_bridge && dart run pigeon --input pigeons/intentcall_platform_bridge.dart + cp packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift + git diff --exit-code packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt # List custom agent skills defined in this repository list-skills: diff --git a/packages/intentcall_bridge/LICENSE b/packages/intentcall_bridge/LICENSE new file mode 100644 index 0000000..ec57a7f --- /dev/null +++ b/packages/intentcall_bridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anton Malofeev (Arenukvern) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/intentcall_bridge/analysis_options.yaml b/packages/intentcall_bridge/analysis_options.yaml new file mode 100644 index 0000000..e755406 --- /dev/null +++ b/packages/intentcall_bridge/analysis_options.yaml @@ -0,0 +1 @@ +include: package:xsoulspace_lints/dart.yaml diff --git a/packages/intentcall_bridge/lib/intentcall_bridge.dart b/packages/intentcall_bridge/lib/intentcall_bridge.dart new file mode 100644 index 0000000..9f6cf93 --- /dev/null +++ b/packages/intentcall_bridge/lib/intentcall_bridge.dart @@ -0,0 +1,4 @@ +/// Pigeon-generated bindings for IntentCall platform bridge channels. +library; + +export 'src/intentcall_platform_bridge.g.dart'; diff --git a/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart b/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart new file mode 100644 index 0000000..82d3098 --- /dev/null +++ b/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart @@ -0,0 +1,389 @@ +// Autogenerated from Pigeon (v26.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a is double && b is double) { + if (a.isNaN && b.isNaN) { + return true; + } + return a == b; + } + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final MapEntry entryA in a.entries) { + bool found = false; + for (final MapEntry entryB in b.entries) { + if (_deepEquals(entryA.key, entryB.key)) { + if (_deepEquals(entryA.value, entryB.value)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; + } + return a == b; +} + +int _deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(_deepHash)); + } + if (value is Map) { + int result = 0; + for (final MapEntry entry in value.entries) { + result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value); + } + return result; + } + if (value is double && value.isNaN) { + // Normalize NaN to a consistent hash. + return 0x7FF8000000000000.hashCode; + } + if (value is double && value == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return 0.0.hashCode; + } + return value.hashCode; +} + + +/// Native invocation envelope drained from the handoff store. +class IntentCallInvocationEnvelopeDto { + IntentCallInvocationEnvelopeDto({ + required this.id, + required this.qualifiedName, + this.arguments, + required this.source, + required this.createdAt, + }); + + String id; + + String qualifiedName; + + Map? arguments; + + String source; + + String createdAt; + + List _toList() { + return [ + id, + qualifiedName, + arguments, + source, + createdAt, + ]; + } + + Object encode() { + return _toList(); } + + static IntentCallInvocationEnvelopeDto decode(Object result) { + result as List; + return IntentCallInvocationEnvelopeDto( + id: result[0]! as String, + qualifiedName: result[1]! as String, + arguments: (result[2] as Map?)?.cast(), + source: result[3]! as String, + createdAt: result[4]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! IntentCallInvocationEnvelopeDto || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(id, other.id) && _deepEquals(qualifiedName, other.qualifiedName) && _deepEquals(arguments, other.arguments) && _deepEquals(source, other.source) && _deepEquals(createdAt, other.createdAt); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +/// Manifest-projected entity field keys for snapshot CRUD and search. +class IntentCallEntityKeyBundle { + IntentCallEntityKeyBundle({ + required this.idKey, + required this.titleKey, + required this.subtitleKey, + required this.keywordsKey, + }); + + String idKey; + + String titleKey; + + String subtitleKey; + + String keywordsKey; + + List _toList() { + return [ + idKey, + titleKey, + subtitleKey, + keywordsKey, + ]; + } + + Object encode() { + return _toList(); } + + static IntentCallEntityKeyBundle decode(Object result) { + result as List; + return IntentCallEntityKeyBundle( + idKey: result[0]! as String, + titleKey: result[1]! as String, + subtitleKey: result[2]! as String, + keywordsKey: result[3]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! IntentCallEntityKeyBundle || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(idKey, other.idKey) && _deepEquals(titleKey, other.titleKey) && _deepEquals(subtitleKey, other.subtitleKey) && _deepEquals(keywordsKey, other.keywordsKey); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is IntentCallInvocationEnvelopeDto) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is IntentCallEntityKeyBundle) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return IntentCallInvocationEnvelopeDto.decode(readValue(buffer)!); + case 130: + return IntentCallEntityKeyBundle.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class IntentCallInvocationsHostApi { + /// Constructor for [IntentCallInvocationsHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + IntentCallInvocationsHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future> takePendingInvocations() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } +} + +class IntentCallEntitiesHostApi { + /// Constructor for [IntentCallEntitiesHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + IntentCallEntitiesHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future upsertEntitySnapshots(String entityType, List> snapshots, IntentCallEntityKeyBundle keys) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.upsertEntitySnapshots$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([entityType, snapshots, keys]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as int; + } + + Future deleteEntitySnapshots(String entityType, List ids, IntentCallEntityKeyBundle keys) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.deleteEntitySnapshots$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([entityType, ids, keys]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as int; + } + + Future clearEntityTypeSnapshots(String entityType) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.clearEntityTypeSnapshots$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([entityType]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as int; + } + + Future>> listEntitySnapshots(String entityType) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.listEntitySnapshots$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([entityType]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast>(); + } + + Future>> searchEntitySnapshots(String entityType, String query, int limit, IntentCallEntityKeyBundle keys) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.searchEntitySnapshots$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([entityType, query, limit, keys]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast>(); + } +} diff --git a/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart new file mode 100644 index 0000000..2e6b271 --- /dev/null +++ b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart @@ -0,0 +1,70 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/intentcall_platform_bridge.g.dart', + dartPackageName: 'intentcall_bridge', + swiftOut: + '../intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift', + swiftOptions: SwiftOptions(), + kotlinOut: + '../intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt', + kotlinOptions: KotlinOptions( + package: 'dev.intentcall.intentcall_platform', + ), + ), +) +/// Native invocation envelope drained from the handoff store. +class IntentCallInvocationEnvelopeDto { + late String id; + late String qualifiedName; + Map? arguments; + late String source; + late String createdAt; +} + +/// Manifest-projected entity field keys for snapshot CRUD and search. +class IntentCallEntityKeyBundle { + String idKey; + String titleKey; + String subtitleKey; + String keywordsKey; + + IntentCallEntityKeyBundle({ + this.idKey = 'id', + this.titleKey = 'title', + this.subtitleKey = 'subtitle', + this.keywordsKey = 'keywords', + }); +} + +@HostApi() +abstract class IntentCallInvocationsHostApi { + List takePendingInvocations(); +} + +@HostApi() +abstract class IntentCallEntitiesHostApi { + int upsertEntitySnapshots( + String entityType, + List> snapshots, + IntentCallEntityKeyBundle keys, + ); + + int deleteEntitySnapshots( + String entityType, + List ids, + IntentCallEntityKeyBundle keys, + ); + + int clearEntityTypeSnapshots(String entityType); + + List> listEntitySnapshots(String entityType); + + List> searchEntitySnapshots( + String entityType, + String query, + int limit, + IntentCallEntityKeyBundle keys, + ); +} diff --git a/packages/intentcall_bridge/pubspec.yaml b/packages/intentcall_bridge/pubspec.yaml new file mode 100644 index 0000000..fb007ab --- /dev/null +++ b/packages/intentcall_bridge/pubspec.yaml @@ -0,0 +1,27 @@ +name: intentcall_bridge +description: >- + PRE-RELEASE — Pigeon IDL and generated Dart bindings for IntentCall platform + bridge channels (invocations + entity snapshots). +version: 0.6.0 +license: MIT +repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_bridge +issue_tracker: https://github.com/Arenukvern/intentcall/issues +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_bridge +topics: + - mcp + - flutter + - agents + +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.24.0" +resolution: workspace + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + lints: ^6.1.0 + pigeon: ^26.0.0 + xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart index 6bab5ae..097a46e 100644 --- a/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart +++ b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart @@ -1,280 +1,2 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; -import 'package:path/path.dart' as p; - -import '../utils/cli_utils.dart'; - -/// Loads registry catalog rows for [ManifestMerger]. -final class CatalogLoader { - const CatalogLoader(); - - static const catalogRelativePath = 'lib/generated/agent_catalog.g.dart'; - - Future> load({ - required final String projectRoot, - }) async { - final bundle = await _loadBundle(projectRoot: projectRoot); - return bundle.entries; - } - - Future> loadEntityTypeDescriptors({ - required final String projectRoot, - }) async { - final bundle = await _loadBundle(projectRoot: projectRoot); - return bundle.entityTypeDescriptors; - } - - Future<_CatalogBundle> _loadBundle({ - required final String projectRoot, - }) async { - final root = p.normalize(p.absolute(projectRoot)); - final catalogFile = File(p.join(root, catalogRelativePath)); - if (!catalogFile.existsSync()) { - throw CatalogLoadException( - 'Missing $catalogRelativePath — run ' - '`dart run build_runner build --delete-conflicting-outputs`.', - ); - } - final fromProbe = await _loadFromGeneratedCatalog(root); - if (fromProbe != null) { - return fromProbe; - } - throw CatalogLoadException( - 'Failed to load $catalogRelativePath — run ' - '`dart run build_runner build --delete-conflicting-outputs` and ensure ' - 'the package compiles.', - ); - } - - Future<_CatalogBundle?> _loadFromGeneratedCatalog( - final String projectRoot, - ) async { - final packageName = readPackageName(projectRoot); - if (packageName == null) { - return null; - } - - final probeDir = Directory(p.join(projectRoot, '.dart_tool')) - ..createSync(recursive: true); - final probeFile = - File(p.join(probeDir.path, 'intentcall_catalog_probe.dart')) - ..writeAsStringSync(''' -// ignore_for_file: avoid_print -import 'dart:convert'; -import 'package:$packageName/generated/agent_catalog.g.dart'; -import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; - -void main() { - final rows = >[]; - for (final row in agentCatalogEntries) { - final descriptor = row.resolveDescriptor(); - rows.add({ - 'registryKey': row.registryKey, - 'qualifiedName': descriptor.qualifiedName, - 'namespace': descriptor.namespace, - 'name': descriptor.name, - 'description': descriptor.description, - 'kind': descriptor.kind.name, - 'inputSchema': descriptor.inputSchema, - if (descriptor.resourceUri != null) 'resourceUri': descriptor.resourceUri, - if (row.projection != null) 'projection': _projectionJson(row.projection!), - }); - } - final entities = agentEntityTypeDescriptors - .map(_entityDescriptorJson) - .toList(growable: false); - print(jsonEncode({ - 'entries': rows, - 'entityTypeDescriptors': entities, - })); -} - -Map _entityDescriptorJson(final AgentEntityTypeDescriptor descriptor) { - return { - 'namespace': descriptor.namespace, - 'name': descriptor.name, - 'identifierName': descriptor.identifierName, - if (descriptor.displayName != null) 'displayName': descriptor.displayName, - 'privacy': descriptor.privacy.name, - 'deepLinkBehavior': descriptor.deepLinkBehavior.name, - 'openBehavior': descriptor.openBehavior.name, - 'properties': descriptor.properties - .map( - (final property) => { - 'name': property.name, - 'valueType': property.valueType.name, - 'description': property.description, - 'isDisplay': property.isDisplay, - 'isSearchable': property.isSearchable, - 'isIndexed': property.isIndexed, - if (property.privacy != null) 'privacy': property.privacy!.name, - if (property.role != AgentEntityPropertyRole.none) - 'role': property.role.name, - }, - ) - .toList(growable: false), - }; -} - -Map _projectionJson(final EntryProjection projection) { - return { - if (projection.dispatchMode != null) - 'dispatchMode': projection.dispatchMode!.name, - 'surfaces': { - for (final entry in projection.surfaces.entries) - entry.key.manifestKey: entry.value, - }, - }; -} -'''); - - final result = await Process.run( - 'dart', - ['run', probeFile.path], - workingDirectory: projectRoot, - runInShell: true, - ); - if (result.exitCode != 0) { - return null; - } - final stdoutText = '${result.stdout}'.trim(); - if (stdoutText.isEmpty) { - return const _CatalogBundle( - entries: [], - entityTypeDescriptors: [], - ); - } - final decoded = jsonDecode(stdoutText); - if (decoded is List) { - return _CatalogBundle( - entries: _parseCatalogEntries(decoded), - entityTypeDescriptors: const [], - ); - } - if (decoded is! Map) { - return null; - } - final map = decoded.cast(); - final entriesRaw = map['entries']; - final entitiesRaw = map['entityTypeDescriptors']; - return _CatalogBundle( - entries: entriesRaw is List - ? _parseCatalogEntries(entriesRaw) - : const [], - entityTypeDescriptors: entitiesRaw is List - ? _parseEntityTypeDescriptors(entitiesRaw) - : const [], - ); - } - - List _parseCatalogEntries(final List decoded) => - decoded.map((final row) { - final map = (row as Map).cast(); - EntryProjection? projection; - final projectionRaw = map['projection']; - if (projectionRaw is Map) { - projection = _projectionFromJson( - projectionRaw.cast(), - ); - } - return AgentRegistryCatalogEntry( - registryKey: '${map['registryKey']}', - descriptor: AgentIntentDescriptor( - namespace: '${map['namespace']}', - name: '${map['name']}', - description: '${map['description']}', - kind: AgentIntentKind.values.byName('${map['kind']}'), - inputSchema: Map.from( - (map['inputSchema'] as Map?)?.cast() ?? - const {'type': 'object'}, - ), - resourceUri: map['resourceUri']?.toString(), - ), - projection: projection, - ); - }).toList(); - - List _parseEntityTypeDescriptors( - final List decoded, - ) => decoded.map((final row) { - final map = (row as Map).cast(); - final propertiesRaw = map['properties']; - final properties = propertiesRaw is List - ? propertiesRaw.map((final property) { - final propertyMap = (property as Map).cast(); - final privacyName = propertyMap['privacy']?.toString(); - final roleName = propertyMap['role']?.toString(); - return AgentEntityPropertyDescriptor( - name: '${propertyMap['name']}', - valueType: AgentEntityPropertyValueType.values.byName( - '${propertyMap['valueType']}', - ), - description: '${propertyMap['description'] ?? ''}', - isDisplay: propertyMap['isDisplay'] as bool? ?? false, - isSearchable: propertyMap['isSearchable'] as bool? ?? false, - isIndexed: propertyMap['isIndexed'] as bool? ?? false, - privacy: privacyName == null - ? null - : AgentEntityPrivacy.values.byName(privacyName), - role: roleName == null - ? AgentEntityPropertyRole.none - : AgentEntityPropertyRole.values.byName(roleName), - ); - }).toList() - : const []; - return AgentEntityTypeDescriptor( - namespace: '${map['namespace']}', - name: '${map['name']}', - identifierName: '${map['identifierName']}', - displayName: map['displayName']?.toString(), - properties: properties, - privacy: AgentEntityPrivacy.values.byName('${map['privacy']}'), - deepLinkBehavior: AgentEntityDeepLinkBehavior.values.byName( - '${map['deepLinkBehavior']}', - ), - openBehavior: AgentEntityOpenBehavior.values.byName( - '${map['openBehavior']}', - ), - ); - }).toList(); - - EntryProjection _projectionFromJson(final Map json) { - final dispatchName = json['dispatchMode']?.toString(); - final dispatchMode = dispatchName == null - ? null - : AgentManifestDispatchMode.values.byName(dispatchName); - final surfaces = {}; - final surfacesRaw = json['surfaces']; - if (surfacesRaw is Map) { - for (final entry in surfacesRaw.entries) { - surfaces[resolveAgentManifestSurface('${entry.key}')] = - entry.value as bool; - } - } - return EntryProjection(dispatchMode: dispatchMode, surfaces: surfaces); - } -} - -final class _CatalogBundle { - const _CatalogBundle({ - required this.entries, - required this.entityTypeDescriptors, - }); - - final List entries; - final List entityTypeDescriptors; -} - -/// Thrown when the generated catalog cannot be loaded. -final class CatalogLoadException implements Exception { - CatalogLoadException(this.message); - - final String message; - - @override - String toString() => message; -} +export 'package:intentcall_platform_sync/intentcall_platform_sync.dart' + show CatalogLoadException, CatalogLoader; diff --git a/packages/intentcall_cli/lib/src/command_runner.dart b/packages/intentcall_cli/lib/src/command_runner.dart index 6ee08de..0e9085c 100644 --- a/packages/intentcall_cli/lib/src/command_runner.dart +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -2,7 +2,8 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:args/command_runner.dart'; -import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart' + hide parsePlatformList; import 'package:path/path.dart' as p; import 'catalog/catalog_loader.dart'; @@ -24,6 +25,7 @@ final class IntentCallCommandRunner extends CommandRunner { addCommand(_ManifestCommand()); addCommand(_PlatformCommand()); addCommand(_CodegenCommand()); + addCommand(_HooksCommand()); addCommand(_McpCommand()); addCommand(_AppleAppIntentsTestingCommand()); } @@ -465,7 +467,8 @@ final class _PlatformHooksInitCommand extends Command { final hookFile = File( p.join(projectRoot, '.intentcall', 'web_build_hook.sh'), ); - final expected = '${kJasprWebCodegenHook.trim()}\n'; + final spine = PlatformHookSpine.resolveFromProjectRoot(projectRoot); + final expected = '${spine.renderJasprWeb().trim()}\n'; final exists = hookFile.existsSync(); final current = exists ? hookFile.readAsStringSync() : ''; final ok = exists && current.contains('intentcall-platform: begin'); @@ -515,25 +518,19 @@ final class _PlatformHooksPrintCommand extends Command { @override int run() { final results = argResults!; + final projectRoot = _ProjectDirOption.read(results); + final spine = PlatformHookSpine.resolveFromProjectRoot(projectRoot); final host = normalizeHostName('${results['host']}'); final platform = '${results['platform'] ?? ''}'.trim().toLowerCase(); - final snippets = { - 'android': kAndroidGradleCodegenHook, - 'ios': kAppleXcodeCodegenRunScript, - 'macos': kAppleXcodeCodegenRunScript, - 'jaspr': kJasprWebCodegenHook, - 'web': kJasprWebCodegenHook, - }; - if (platform.isNotEmpty) { - final snippet = snippets[platform]; - if (snippet == null) { - printUsageError('unknown platform "$platform" for hook print.'); + try { + stdout.writeln(spine.renderTemplate(platform).trim()); + return 0; + } on ArgumentError catch (error) { + printUsageError('$error'); return usageExitCode(); } - stdout.writeln(snippet.trim()); - return 0; } final keys = host == IntentCallHost.jaspr.name @@ -542,16 +539,135 @@ final class _PlatformHooksPrintCommand extends Command { for (final key in keys) { stdout ..writeln('== $key ==') - ..writeln(snippets[key]!.trim()) + ..writeln(spine.renderTemplate(key).trim()) + ..writeln(); + } + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser + ..addOption('host', defaultsTo: 'flutter') + ..addOption('platform', help: 'Print one platform snippet.'); + return parser; + } +} + +final class _HooksCommand extends Command { + _HooksCommand() { + addSubcommand(_HooksSpineCommand()); + addSubcommand(_HooksRenderCommand()); + } + + @override + String get name => 'hooks'; + + @override + String get description => + 'Resolve or render platform hook spine from intentcall.yaml.'; +} + +final class _HooksSpineCommand extends Command { + @override + String get name => 'spine'; + + @override + String get description => + 'Print resolved hook spine phases and CLI invocation.'; + + @override + int run() { + final results = argResults!; + final projectRoot = _ProjectDirOption.read(results); + final spine = PlatformHookSpine.resolveFromProjectRoot(projectRoot); + final asJson = results['json'] as bool? ?? false; + if (asJson) { + stdout.write(spine.encodeJson()); + } else { + stdout.writeln('host: ${spine.host}'); + stdout.writeln('cliInvocation: ${spine.cliInvocation}'); + stdout.writeln('platformList: ${spine.platformList}'); + stdout.writeln('codegen: ${spine.codegenPhase.shellLine}'); + stdout.writeln('manifest: ${spine.manifestPhase.shellLine}'); + stdout.writeln('sync: ${spine.syncPhase.shellLine}'); + } + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser.addFlag('json', negatable: false); + return parser; + } +} + +final class _HooksRenderCommand extends Command { + @override + String get name => 'render'; + + @override + String get description => 'Render hook snippets from the resolved spine.'; + + @override + int run() { + final results = argResults!; + final projectRoot = _ProjectDirOption.read(results); + final host = normalizeHostName('${results['host']}'); + final platform = '${results['platform'] ?? ''}'.trim().toLowerCase(); + final config = loadIntentCallConfig(projectRoot); + final spine = config == null + ? PlatformHookSpine.resolve(PlatformHookSpineInput(host: host)) + : PlatformHookSpine.resolve( + PlatformHookSpineInput( + host: config.host.name, + enabledPlatforms: resolveEnabledPlatforms(config), + syncCommand: config.hooks.syncCommand, + ), + ); + + if (platform.isNotEmpty) { + try { + stdout.writeln(spine.renderTemplate(platform).trim()); + return 0; + } on ArgumentError catch (error) { + printUsageError('$error'); + return usageExitCode(); + } + } + + final keys = host == IntentCallHost.jaspr.name + ? spine.hookTemplateKeys + : (spine.hookTemplateKeys.isEmpty + ? ['android', 'ios', 'macos'] + : spine.hookTemplateKeys); + for (final key in keys) { + stdout + ..writeln('== $key ==') + ..writeln(spine.renderTemplate(key).trim()) ..writeln(); } return 0; } @override - ArgParser get argParser => ArgParser() - ..addOption('host', defaultsTo: 'flutter') - ..addOption('platform', help: 'Print one platform snippet.'); + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser + ..addOption( + 'host', + help: 'Host profile: flutter or jaspr.', + defaultsTo: 'flutter', + allowed: ['flutter', 'jaspr'], + ) + ..addOption('platform', help: 'Render one platform snippet.'); + return parser; + } } final class _CodegenCommand extends Command { diff --git a/packages/intentcall_cli/test/command_runner_test.dart b/packages/intentcall_cli/test/command_runner_test.dart index 2d538ba..adc1b69 100644 --- a/packages/intentcall_cli/test/command_runner_test.dart +++ b/packages/intentcall_cli/test/command_runner_test.dart @@ -108,6 +108,33 @@ void main() { ]); expect(exitCode, 0); }); + + test('hooks spine --json resolves from fixture', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'hooks', + 'spine', + '--json', + '--project-dir', + flutterFixture.path, + ]); + expect(exitCode, 0); + }); + + test('hooks render emits jaspr snippet for host', () async { + final runner = IntentCallCommandRunner(); + final exitCode = await runner.run([ + 'hooks', + 'render', + '--host', + 'jaspr', + '--platform', + 'web', + '--project-dir', + jasprFixture.path, + ]); + expect(exitCode, 0); + }); }); group('IntentCallConfig', () { diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock index ff7e152..30e331f 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -153,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http_multi_server: dependency: transitive description: @@ -183,6 +199,13 @@ packages: relative: true source: path version: "0.6.0" + intentcall_hooks: + dependency: "direct dev" + description: + path: "../../../../intentcall_hooks" + relative: true + source: path + version: "0.6.0" intentcall_platform_sync: dependency: "direct main" description: @@ -277,6 +300,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shelf: dependency: transitive description: diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml index 613243a..33bceef 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml @@ -17,6 +17,15 @@ dependencies: dev_dependencies: build_runner: ^2.15.0 + intentcall_hooks: + path: ../../../../intentcall_hooks + +hooks: + user_defines: + intentcall_hooks: + project_root: . + platforms: web + check_only: false dependency_overrides: intentcall_schema: @@ -25,3 +34,5 @@ dependency_overrides: path: ../../../../intentcall_core intentcall_platform_sync: path: ../../../../intentcall_platform_sync + intentcall_hooks: + path: ../../../../intentcall_hooks diff --git a/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js index cd9b0f7..52530f2 100644 --- a/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js @@ -63,6 +63,22 @@ ] } }, + { + "name": "app_demo_set_greeting", + "description": "Set greeting text for the codegen demo host.", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Greeting text" + } + }, + "required": [ + "text" + ] + } + }, { "name": "app_demo_inbox", "description": "Read inbox folder", diff --git a/packages/intentcall_codegen/example/web/manifest.json b/packages/intentcall_codegen/example/web/manifest.json index b737a8c..7c9cabd 100644 --- a/packages/intentcall_codegen/example/web/manifest.json +++ b/packages/intentcall_codegen/example/web/manifest.json @@ -26,6 +26,12 @@ "description": "Returns a required named parameter", "url": "/agent/invoke?name=app_demo_required_named" }, + { + "name": "Demo Set Greeting", + "short_name": "Demo Set Greeting", + "description": "Set greeting text for the codegen demo host.", + "url": "/agent/invoke?name=app_demo_set_greeting" + }, { "name": "Demo Inbox", "short_name": "Demo Inbox", @@ -60,6 +66,10 @@ "protocol": "web+intentcall", "url": "/agent/invoke?name=app_demo_required_named&payload=%s" }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_demo_set_greeting&payload=%s" + }, { "protocol": "web+intentcall", "url": "/agent/invoke?name=app_demo_inbox&payload=%s" diff --git a/packages/intentcall_hooks/CHANGELOG.md b/packages/intentcall_hooks/CHANGELOG.md new file mode 100644 index 0000000..8deb76e --- /dev/null +++ b/packages/intentcall_hooks/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Unreleased \ No newline at end of file diff --git a/packages/intentcall_hooks/LICENSE b/packages/intentcall_hooks/LICENSE new file mode 100644 index 0000000..ec57a7f --- /dev/null +++ b/packages/intentcall_hooks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anton Malofeev (Arenukvern) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/intentcall_hooks/README.md b/packages/intentcall_hooks/README.md new file mode 100644 index 0000000..2a828aa --- /dev/null +++ b/packages/intentcall_hooks/README.md @@ -0,0 +1,40 @@ +# intentcall_hooks + +Dart SDK build hooks for IntentCall projection: manifest export and platform +sync in-process (no `intentcall` subprocess). + +## Usage + +Add a dev dependency and configure user-defines on the consuming package: + +```yaml +dev_dependencies: + intentcall_hooks: ^0.6.0 + +hooks: + user_defines: + intentcall_hooks: + project_root: . + platforms: web + check_only: false +``` + +Prerequisite: fresh `lib/generated/agent_catalog.g.dart` from `build_runner`. +The hook does **not** spawn `build_runner` in v1. + +## Manual verification (jaspr fixture) + +```bash +cd packages/intentcall_cli/test/fixtures/jaspr_web_project +dart pub get +dart run build_runner build --delete-conflicting-outputs +dart test ../../../intentcall_hooks/test/intentcall_hook_runner_test.dart +``` + +## Gates + +```bash +dart analyze packages/intentcall_hooks +dart test packages/intentcall_hooks +dart test packages/intentcall_platform_sync/test/catalog_loader_test.dart +``` diff --git a/packages/intentcall_hooks/analysis_options.yaml b/packages/intentcall_hooks/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/intentcall_hooks/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/intentcall_hooks/hook/build.dart b/packages/intentcall_hooks/hook/build.dart new file mode 100644 index 0000000..26b0c8a --- /dev/null +++ b/packages/intentcall_hooks/hook/build.dart @@ -0,0 +1,20 @@ +import 'package:hooks/hooks.dart'; +import 'package:intentcall_hooks/src/intentcall_hook_runner.dart'; + +void main(final List args) async { + await build(args, (final input, final output) async { + final defines = input.userDefines; + final checkOnly = parseHookCheckOnly(defines['check_only']); + final platforms = parseHookPlatforms(defines['platforms']); + final projectRootUri = defines.path('project_root') ?? input.packageRoot; + final projectRoot = projectRootUri.toFilePath(); + + final result = await const IntentCallHookRunner().run( + projectRoot: projectRoot, + platforms: platforms.isEmpty ? null : platforms, + checkOnly: checkOnly, + ); + + result.dependencies.forEach(output.dependencies.add); + }); +} diff --git a/packages/intentcall_hooks/lib/intentcall_hooks.dart b/packages/intentcall_hooks/lib/intentcall_hooks.dart new file mode 100644 index 0000000..0f02e83 --- /dev/null +++ b/packages/intentcall_hooks/lib/intentcall_hooks.dart @@ -0,0 +1 @@ +export 'src/intentcall_hook_runner.dart'; diff --git a/packages/intentcall_hooks/lib/src/intentcall_hook_runner.dart b/packages/intentcall_hooks/lib/src/intentcall_hook_runner.dart new file mode 100644 index 0000000..b5bc534 --- /dev/null +++ b/packages/intentcall_hooks/lib/src/intentcall_hook_runner.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; + +/// Result of running the IntentCall projection hook spine in-process. +final class IntentCallHookResult { + const IntentCallHookResult({ + required this.projectRoot, + required this.platforms, + required this.checkOnly, + required this.dependencies, + this.manifestPath, + this.syncChanged = false, + }); + + final String projectRoot; + final List platforms; + final bool checkOnly; + final List dependencies; + final String? manifestPath; + final bool syncChanged; +} + +/// In-process manifest export + platform sync spine for Dart SDK hooks. +final class IntentCallHookRunner { + const IntentCallHookRunner({ + this.exporter = const ManifestExporter(), + this.catalogLoader = const CatalogLoader(), + this.sync = const PlatformSync(), + }); + + final ManifestExporter exporter; + final CatalogLoader catalogLoader; + final PlatformSync sync; + + Future run({ + required final String projectRoot, + final Iterable? platforms, + final bool checkOnly = false, + }) async { + final root = p.normalize(p.absolute(projectRoot)); + final resolvedPlatforms = resolveProjectionPlatforms( + projectRoot: root, + overridePlatforms: platforms, + ); + if (resolvedPlatforms.isEmpty) { + throw StateError( + 'No projection platforms resolved — set platforms.enabled in ' + 'intentcall.yaml or hooks.user_defines.intentcall_hooks.platforms.', + ); + } + + final context = exporter.loadExportContext(projectRoot: root); + final manifestFile = File(p.join(root, context.manifestRelativePath)); + final catalog = await catalogLoader.load(projectRoot: root); + final entityTypeDescriptors = await catalogLoader.loadEntityTypeDescriptors( + projectRoot: root, + ); + + final manifestExitCode = exporter.exportToFile( + catalog: catalog, + context: context, + outPath: manifestFile, + entityTypeDescriptors: entityTypeDescriptors, + checkOnly: checkOnly, + ); + if (manifestExitCode != 0) { + throw StateError( + checkOnly + ? 'Manifest drift at ${manifestFile.path} — run intentcall manifest export' + : 'Failed to write manifest at ${manifestFile.path}', + ); + } + + var syncChanged = false; + if (checkOnly) { + final ok = sync.checkPlatforms(root, resolvedPlatforms); + if (!ok) { + throw StateError( + 'Platform artifact drift for $resolvedPlatforms — run intentcall platform sync', + ); + } + } else { + final result = sync.syncPlatforms( + projectRoot: root, + platforms: resolvedPlatforms, + ); + syncChanged = result.changed; + } + + return IntentCallHookResult( + projectRoot: root, + platforms: resolvedPlatforms, + checkOnly: checkOnly, + manifestPath: manifestFile.path, + syncChanged: syncChanged, + dependencies: projectionHookDependencies( + projectRoot: root, + manifestPath: manifestFile.path, + ), + ); + } +} + +/// Cache dependencies for the IntentCall projection hook spine. +List projectionHookDependencies({ + required final String projectRoot, + required final String manifestPath, +}) { + final root = p.normalize(p.absolute(projectRoot)); + return [ + File(p.join(root, 'intentcall.yaml')).uri, + File(p.join(root, CatalogLoader.catalogRelativePath)).uri, + File(manifestPath).uri, + ]; +} + +/// Parses `hooks.user_defines.intentcall_hooks.platforms`. +List parseHookPlatforms(final Object? raw) { + if (raw == null) { + return const []; + } + if (raw is String) { + return parsePlatformList([raw]); + } + if (raw is Iterable) { + return parsePlatformList(raw.map((final value) => '$value')); + } + throw const FormatException( + 'hooks.user_defines.intentcall_hooks.platforms must be a string or list.', + ); +} + +/// Reads optional `check_only` from hook user-defines. +bool parseHookCheckOnly(final Object? raw) { + if (raw == null) { + return false; + } + if (raw is bool) { + return raw; + } + throw const FormatException( + 'hooks.user_defines.intentcall_hooks.check_only must be a boolean.', + ); +} diff --git a/packages/intentcall_hooks/pubspec.yaml b/packages/intentcall_hooks/pubspec.yaml new file mode 100644 index 0000000..a23b922 --- /dev/null +++ b/packages/intentcall_hooks/pubspec.yaml @@ -0,0 +1,26 @@ +name: intentcall_hooks +description: PRE-RELEASE — Dart SDK build hooks for IntentCall manifest export and platform sync. +version: 0.6.0 +license: MIT +repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_hooks +issue_tracker: https://github.com/Arenukvern/intentcall/issues +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_hooks +topics: + - mcp + - agents + - hooks + +environment: + sdk: ">=3.12.0 <4.0.0" +resolution: workspace + +dependencies: + code_assets: ^1.0.0 + hooks: ^2.0.0 + intentcall_platform_sync: ^0.6.0 + path: ^1.9.1 + +dev_dependencies: + lints: ^6.1.0 + test: ^1.31.1 + xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_hooks/test/intentcall_hook_runner_test.dart b/packages/intentcall_hooks/test/intentcall_hook_runner_test.dart new file mode 100644 index 0000000..366c252 --- /dev/null +++ b/packages/intentcall_hooks/test/intentcall_hook_runner_test.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:intentcall_hooks/intentcall_hooks.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +String fixtureRoot(final String name) { + final candidates = [ + p.join('packages', 'intentcall_cli', 'test', 'fixtures', name), + p.join('test', 'fixtures', name), + ]; + for (final candidate in candidates) { + final dir = Directory(candidate); + if (dir.existsSync()) { + return p.normalize(p.absolute(candidate)); + } + } + throw StateError( + 'fixture $name not found from ${Directory.current.path}', + ); +} + +void main() { + group('IntentCallHookRunner', () { + late String projectRoot; + + setUpAll(() async { + projectRoot = fixtureRoot('jaspr_web_project'); + final result = await Process.run( + 'dart', + ['run', 'build_runner', 'build', '--delete-conflicting-outputs'], + workingDirectory: projectRoot, + runInShell: true, + ); + if (result.exitCode != 0) { + fail( + 'build_runner failed for jaspr fixture:\n${result.stdout}\n${result.stderr}', + ); + } + }); + + test('check spine passes on jaspr fixture', () async { + final result = await const IntentCallHookRunner().run( + projectRoot: projectRoot, + checkOnly: true, + ); + + expect(result.platforms, ['web']); + expect(result.manifestPath, endsWith('web/agent_manifest.json')); + expect(result.dependencies, hasLength(3)); + }); + + test('export+sync spine writes fresh artifacts', () async { + final result = await const IntentCallHookRunner().run( + projectRoot: projectRoot, + ); + + expect(result.platforms, ['web']); + expect(File(result.manifestPath!).existsSync(), isTrue); + expect(result.syncChanged, isFalse); + }); + }); +} diff --git a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt new file mode 100644 index 0000000..72670d1 --- /dev/null +++ b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt @@ -0,0 +1,471 @@ +// Autogenerated from Pigeon (v26.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package dev.intentcall.intentcall_platform + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object IntentCallPlatformBridgePigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun doubleEquals(a: Double, b: Double): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN()) + } + + fun floatEquals(a: Float, b: Float): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN()) + } + + fun doubleHash(d: Double): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (d == 0.0) 0.0 else d + val bits = java.lang.Double.doubleToLongBits(normalized) + return (bits xor (bits ushr 32)).toInt() + } + + fun floatHash(f: Float): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (f == 0.0f) 0.0f else f + return java.lang.Float.floatToIntBits(normalized) + } + + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a === b) { + return true + } + if (a == null || b == null) { + return false + } + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!doubleEquals(a[i], b[i])) return false + } + return true + } + if (a is FloatArray && b is FloatArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!floatEquals(a[i], b[i])) return false + } + return true + } + if (a is Array<*> && b is Array<*>) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!deepEquals(a[i], b[i])) return false + } + return true + } + if (a is List<*> && b is List<*>) { + if (a.size != b.size) return false + val iterA = a.iterator() + val iterB = b.iterator() + while (iterA.hasNext() && iterB.hasNext()) { + if (!deepEquals(iterA.next(), iterB.next())) return false + } + return true + } + if (a is Map<*, *> && b is Map<*, *>) { + if (a.size != b.size) return false + for (entry in a) { + val key = entry.key + var found = false + for (bEntry in b) { + if (deepEquals(key, bEntry.key)) { + if (deepEquals(entry.value, bEntry.value)) { + found = true + break + } else { + return false + } + } + } + if (!found) return false + } + return true + } + if (a is Double && b is Double) { + return doubleEquals(a, b) + } + if (a is Float && b is Float) { + return floatEquals(a, b) + } + return a == b + } + + fun deepHash(value: Any?): Int { + return when (value) { + null -> 0 + is ByteArray -> value.contentHashCode() + is IntArray -> value.contentHashCode() + is LongArray -> value.contentHashCode() + is DoubleArray -> { + var result = 1 + for (item in value) { + result = 31 * result + doubleHash(item) + } + result + } + is FloatArray -> { + var result = 1 + for (item in value) { + result = 31 * result + floatHash(item) + } + result + } + is Array<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is List<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is Map<*, *> -> { + var result = 0 + for (entry in value) { + result += ((deepHash(entry.key) * 31) xor deepHash(entry.value)) + } + result + } + is Double -> doubleHash(value) + is Float -> floatHash(value) + else -> value.hashCode() + } + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** + * Native invocation envelope drained from the handoff store. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class IntentCallInvocationEnvelopeDto ( + val id: String, + val qualifiedName: String, + val arguments: Map? = null, + val source: String, + val createdAt: String +) + { + companion object { + fun fromList(pigeonVar_list: List): IntentCallInvocationEnvelopeDto { + val id = pigeonVar_list[0] as String + val qualifiedName = pigeonVar_list[1] as String + val arguments = pigeonVar_list[2] as Map? + val source = pigeonVar_list[3] as String + val createdAt = pigeonVar_list[4] as String + return IntentCallInvocationEnvelopeDto(id, qualifiedName, arguments, source, createdAt) + } + } + fun toList(): List { + return listOf( + id, + qualifiedName, + arguments, + source, + createdAt, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as IntentCallInvocationEnvelopeDto + return IntentCallPlatformBridgePigeonUtils.deepEquals(this.id, other.id) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.qualifiedName, other.qualifiedName) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.arguments, other.arguments) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.source, other.source) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.createdAt, other.createdAt) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.id) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.qualifiedName) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.arguments) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.source) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.createdAt) + return result + } +} + +/** + * Manifest-projected entity field keys for snapshot CRUD and search. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class IntentCallEntityKeyBundle ( + val idKey: String, + val titleKey: String, + val subtitleKey: String, + val keywordsKey: String +) + { + companion object { + fun fromList(pigeonVar_list: List): IntentCallEntityKeyBundle { + val idKey = pigeonVar_list[0] as String + val titleKey = pigeonVar_list[1] as String + val subtitleKey = pigeonVar_list[2] as String + val keywordsKey = pigeonVar_list[3] as String + return IntentCallEntityKeyBundle(idKey, titleKey, subtitleKey, keywordsKey) + } + } + fun toList(): List { + return listOf( + idKey, + titleKey, + subtitleKey, + keywordsKey, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as IntentCallEntityKeyBundle + return IntentCallPlatformBridgePigeonUtils.deepEquals(this.idKey, other.idKey) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.titleKey, other.titleKey) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.subtitleKey, other.subtitleKey) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.keywordsKey, other.keywordsKey) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.idKey) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.titleKey) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.subtitleKey) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.keywordsKey) + return result + } +} +private open class IntentCallPlatformBridgePigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + IntentCallInvocationEnvelopeDto.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + IntentCallEntityKeyBundle.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is IntentCallInvocationEnvelopeDto -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is IntentCallEntityKeyBundle -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface IntentCallInvocationsHostApi { + fun takePendingInvocations(): List + + companion object { + /** The codec used by IntentCallInvocationsHostApi. */ + val codec: MessageCodec by lazy { + IntentCallPlatformBridgePigeonCodec() + } + /** Sets up an instance of `IntentCallInvocationsHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: IntentCallInvocationsHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.takePendingInvocations()) + } catch (exception: Throwable) { + IntentCallPlatformBridgePigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface IntentCallEntitiesHostApi { + fun upsertEntitySnapshots(entityType: String, snapshots: List>, keys: IntentCallEntityKeyBundle): Long + fun deleteEntitySnapshots(entityType: String, ids: List, keys: IntentCallEntityKeyBundle): Long + fun clearEntityTypeSnapshots(entityType: String): Long + fun listEntitySnapshots(entityType: String): List> + fun searchEntitySnapshots(entityType: String, query: String, limit: Long, keys: IntentCallEntityKeyBundle): List> + + companion object { + /** The codec used by IntentCallEntitiesHostApi. */ + val codec: MessageCodec by lazy { + IntentCallPlatformBridgePigeonCodec() + } + /** Sets up an instance of `IntentCallEntitiesHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: IntentCallEntitiesHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.upsertEntitySnapshots$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val entityTypeArg = args[0] as String + val snapshotsArg = args[1] as List> + val keysArg = args[2] as IntentCallEntityKeyBundle + val wrapped: List = try { + listOf(api.upsertEntitySnapshots(entityTypeArg, snapshotsArg, keysArg)) + } catch (exception: Throwable) { + IntentCallPlatformBridgePigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.deleteEntitySnapshots$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val entityTypeArg = args[0] as String + val idsArg = args[1] as List + val keysArg = args[2] as IntentCallEntityKeyBundle + val wrapped: List = try { + listOf(api.deleteEntitySnapshots(entityTypeArg, idsArg, keysArg)) + } catch (exception: Throwable) { + IntentCallPlatformBridgePigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.clearEntityTypeSnapshots$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val entityTypeArg = args[0] as String + val wrapped: List = try { + listOf(api.clearEntityTypeSnapshots(entityTypeArg)) + } catch (exception: Throwable) { + IntentCallPlatformBridgePigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.listEntitySnapshots$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val entityTypeArg = args[0] as String + val wrapped: List = try { + listOf(api.listEntitySnapshots(entityTypeArg)) + } catch (exception: Throwable) { + IntentCallPlatformBridgePigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.searchEntitySnapshots$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val entityTypeArg = args[0] as String + val queryArg = args[1] as String + val limitArg = args[2] as Long + val keysArg = args[3] as IntentCallEntityKeyBundle + val wrapped: List = try { + listOf(api.searchEntitySnapshots(entityTypeArg, queryArg, limitArg, keysArg)) + } catch (exception: Throwable) { + IntentCallPlatformBridgePigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt index ddccd5f..6d5d52b 100644 --- a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt +++ b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt @@ -2,9 +2,48 @@ package dev.intentcall.intentcall_platform import io.flutter.embedding.engine.plugins.FlutterPlugin -/** Thin plugin anchor; deep links use [app_links] from Dart. */ +/** Android stub for the Pigeon bridge; entity/invocation stores are iOS/macOS today. */ class IntentCallPlatformPlugin : FlutterPlugin { - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} + private val bridge = IntentCallPlatformBridgeStub() - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + IntentCallInvocationsHostApi.setUp(binding.binaryMessenger, bridge) + IntentCallEntitiesHostApi.setUp(binding.binaryMessenger, bridge) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + IntentCallInvocationsHostApi.setUp(binding.binaryMessenger, null) + IntentCallEntitiesHostApi.setUp(binding.binaryMessenger, null) + } +} + +private class IntentCallPlatformBridgeStub : + IntentCallInvocationsHostApi, + IntentCallEntitiesHostApi { + override fun takePendingInvocations(): List = + emptyList() + + override fun upsertEntitySnapshots( + entityType: String, + snapshots: List>, + keys: IntentCallEntityKeyBundle, + ): Long = 0 + + override fun deleteEntitySnapshots( + entityType: String, + ids: List, + keys: IntentCallEntityKeyBundle, + ): Long = 0 + + override fun clearEntityTypeSnapshots(entityType: String): Long = 0 + + override fun listEntitySnapshots(entityType: String): List> = + emptyList() + + override fun searchEntitySnapshots( + entityType: String, + query: String, + limit: Long, + keys: IntentCallEntityKeyBundle, + ): List> = emptyList() } diff --git a/packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift deleted file mode 100644 index 712732e..0000000 --- a/packages/intentcall_platform/ios/Classes/IntentCallNativeEntitySnapshotStore.swift +++ /dev/null @@ -1,201 +0,0 @@ -import Foundation -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - -/// Shared native cache for Dart-projected entity snapshots. -/// -/// Generated App Intents entity/query code and the Flutter plugin bridge both -/// read and write through this store. -public enum IntentCallNativeEntitySnapshotStore { - public static let snapshotsDidChangeNotification = Notification.Name( - "intentcall.entitySnapshotsDidChange" - ) - - public static var fallbackScheme: String? - - private static let snapshotsKeyPrefix = "intentcall.entity_snapshots." - private static let pendingOpenKey = "intentcall.pending_entity_opens" - - @discardableResult - public static func upsertSnapshots( - entityType: String, - snapshots: [[String: Any]], - idKey: String = "id" - ) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var existing = - UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - var byId = Dictionary( - uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in - guard let id = string(snapshot[idKey]) else { return nil } - return (id, snapshot) - } - ) - for snapshot in snapshots { - guard let id = string(snapshot[idKey]) else { continue } - byId[id] = snapshot - } - existing = Array(byId.values) - UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: type)) - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - return snapshots.count - } - - @discardableResult - public static func deleteSnapshots( - entityType: String, - ids: [String], - idKey: String = "id" - ) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - let deleted = Set(ids) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let existing = - UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - let kept = existing.filter { snapshot in - guard let id = string(snapshot[idKey]) else { return true } - return !deleted.contains(id) - } - UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: type)) - let removed = existing.count - kept.count - if removed > 0 { - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - } - return removed - } - - @discardableResult - public static func clearSnapshots(entityType: String) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - let existing = snapshots(entityType: type).count - UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: type)) - if existing > 0 { - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - } - return existing - } - - public static func snapshots(entityType: String) -> [[String: Any]] { - guard let type = validatedEntityType(entityType) else { return [] } - return UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - } - - public static func entities( - entityType: String, - identifiers: [String], - idKey: String, - limit: Int? - ) -> [[String: Any]] { - let wanted = Set(identifiers) - let matches = snapshots(entityType: entityType).filter { snapshot in - guard let id = string(snapshot[idKey]) else { return false } - return wanted.contains(id) - } - return applyingLimit(matches, limit: limit) - } - - public static func suggested(entityType: String, limit: Int) -> [[String: Any]] { - applyingLimit(snapshots(entityType: entityType), limit: limit) - } - - public static func search( - entityType: String, - query: String, - titleKey: String, - subtitleKey: String, - keywordsKey: String, - limit: Int - ) -> [[String: Any]] { - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let all = snapshots(entityType: entityType) - guard !needle.isEmpty else { - return suggested(entityType: entityType, limit: limit) - } - let matches = all.filter { snapshot in - let fields = - [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } - + strings(snapshot[keywordsKey]) - return fields.contains { $0.lowercased().contains(needle) } - } - return applyingLimit(matches, limit: limit) - } - - public static func recordOpen(entityType: String, id: String) async -> String { - let type = validatedEntityType(entityType) ?? entityType - let openId = UUID().uuidString - let item: [String: Any] = [ - "id": openId, - "entityType": type, - "entityId": id, - "source": "native.entity.generated", - "createdAt": ISO8601DateFormatter().string(from: Date()), - ] - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var pending = - UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] - pending.append(item) - UserDefaults.standard.set(pending, forKey: pendingOpenKey) - guard let scheme = fallbackScheme else { return openId } - let encodedEntityType = encodedPathComponent(type) - let encodedId = encodedPathComponent(id) - guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { - return openId - } - #if canImport(UIKit) - await UIApplication.shared.open(url) - #elseif canImport(AppKit) - NSWorkspace.shared.open(url) - #endif - return openId - } - - public static func string(_ value: Any?) -> String? { - if let value = value as? String { return value } - if let value = value as? CustomStringConvertible { return value.description } - return nil - } - - public static func strings(_ value: Any?) -> [String] { - if let values = value as? [String] { return values } - if let values = value as? [Any] { return values.compactMap { string($0) } } - if let value = string(value) { return [value] } - return [] - } - - private static func snapshotsKey(entityType: String) -> String { - snapshotsKeyPrefix + entityType - } - - private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] { - guard let limit else { return rows } - return Array(rows.prefix(max(0, limit))) - } - - private static func encodedPathComponent(_ value: String) -> String { - var allowedPath = CharacterSet.alphanumerics - allowedPath.insert(charactersIn: "_-.~") - return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value - } - - private static func validatedEntityType(_ value: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let range = NSRange(location: 0, length: trimmed.utf16.count) - guard - let regex = try? NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$"), - regex.firstMatch(in: trimmed, options: [], range: range) != nil - else { - return nil - } - return trimmed - } -} diff --git a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift deleted file mode 100644 index 3b58f5a..0000000 --- a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Flutter -import UIKit - -private enum IntentCallHandoffStore { - private static let pendingKey = "intentcall.pending_invocations" - - /// Current bridge semantics are at-most-once: taking pending rows clears them - /// before Dart execution reports success or failure. - static func takePendingInvocations() -> [[String: Any]] { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: pendingKey) - return pending - } -} - -/// Plugin bridge for pending native intent dispatch into Dart. -public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let invocations = FlutterMethodChannel( - name: "intentcall_platform/invocations", - binaryMessenger: registrar.messenger() - ) - let entities = FlutterMethodChannel( - name: "intentcall_platform/entities", - binaryMessenger: registrar.messenger() - ) - let instance = IntentCallPlatformPlugin() - registrar.addMethodCallDelegate(instance, channel: invocations) - registrar.addMethodCallDelegate(instance, channel: entities) - } -} - -extension IntentCallPlatformPlugin { - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "takePendingInvocations": - result(IntentCallHandoffStore.takePendingInvocations()) - case "upsertEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.upsertSnapshots( - entityType: entityType, - snapshots: snapshots, - idKey: idKey - ) - ) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.deleteSnapshots( - entityType: entityType, - ids: ids, - idKey: idKey - ) - ) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) - } - case "searchEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int ?? 20 - let titleKey = args["titleKey"] as? String ?? "title" - let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" - let keywordsKey = args["keywordsKey"] as? String ?? "keywords" - result( - IntentCallNativeEntitySnapshotStore.search( - entityType: entityType, - query: query, - titleKey: titleKey, - subtitleKey: subtitleKey, - keywordsKey: keywordsKey, - limit: limit - ) - ) - } - default: - result(FlutterMethodNotImplemented) - } - } - - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) -> Void - ) { - guard let args = call.arguments as? [String: Any], - let entityType = args["entityType"] as? String else { - result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) - return - } - body(args, entityType) - } -} diff --git a/packages/intentcall_platform/ios/intentcall_platform.podspec b/packages/intentcall_platform/ios/intentcall_platform.podspec index b984a7e..0437e28 100644 --- a/packages/intentcall_platform/ios/intentcall_platform.podspec +++ b/packages/intentcall_platform/ios/intentcall_platform.podspec @@ -9,7 +9,7 @@ Platform bridge for dispatching generated native invocation envelopes into Dart. s.license = { :file => '../LICENSE' } s.author = { 'Arenukvern' => 'intentcall@example.invalid' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' + s.source_files = 'intentcall_platform/Sources/**/*' s.dependency 'Flutter' s.platform = :ios, '13.0' diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift new file mode 100644 index 0000000..96b0a6e --- /dev/null +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Shared native queue for App Intents / deep-link handoff into Dart. +/// +/// Generated App Intents code appends via [append]; the Flutter plugin drains +/// via [takePendingInvocations] through the Pigeon bridge. +public enum IntentCallNativeHandoffStore { + private static let pendingKey = "intentcall.pending_invocations" + + public static func append(_ item: [String: Any]) { + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = + UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingKey) + } + + /// At-most-once drain semantics: clears pending rows before Dart reports success. + public static func takePendingInvocations() -> [[String: Any]] { + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let pending = + UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: pendingKey) + return pending + } +} diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift new file mode 100644 index 0000000..2689913 --- /dev/null +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift @@ -0,0 +1,443 @@ +// Autogenerated from Pigeon (v26.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +private func doubleEqualsIntentCallPlatformBridge(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs +} + +private func doubleHashIntentCallPlatformBridge(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } +} + +func deepEqualsIntentCallPlatformBridge(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEqualsIntentCallPlatformBridge(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEqualsIntentCallPlatformBridge(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEqualsIntentCallPlatformBridge(lhsKey, rhsKey) { + if deepEqualsIntentCallPlatformBridge(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEqualsIntentCallPlatformBridge(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } +} + +func deepHashIntentCallPlatformBridge(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHashIntentCallPlatformBridge(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHashIntentCallPlatformBridge(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHashIntentCallPlatformBridge(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHashIntentCallPlatformBridge(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHashIntentCallPlatformBridge(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } +} + + +/// Native invocation envelope drained from the handoff store. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct IntentCallInvocationEnvelopeDto: Hashable { + var id: String + var qualifiedName: String + var arguments: [String?: Any?]? = nil + var source: String + var createdAt: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallInvocationEnvelopeDto? { + let id = pigeonVar_list[0] as! String + let qualifiedName = pigeonVar_list[1] as! String + let arguments: [String?: Any?]? = nilOrValue(pigeonVar_list[2]) + let source = pigeonVar_list[3] as! String + let createdAt = pigeonVar_list[4] as! String + + return IntentCallInvocationEnvelopeDto( + id: id, + qualifiedName: qualifiedName, + arguments: arguments, + source: source, + createdAt: createdAt + ) + } + func toList() -> [Any?] { + return [ + id, + qualifiedName, + arguments, + source, + createdAt, + ] + } + static func == (lhs: IntentCallInvocationEnvelopeDto, rhs: IntentCallInvocationEnvelopeDto) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsIntentCallPlatformBridge(lhs.id, rhs.id) && deepEqualsIntentCallPlatformBridge(lhs.qualifiedName, rhs.qualifiedName) && deepEqualsIntentCallPlatformBridge(lhs.arguments, rhs.arguments) && deepEqualsIntentCallPlatformBridge(lhs.source, rhs.source) && deepEqualsIntentCallPlatformBridge(lhs.createdAt, rhs.createdAt) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("IntentCallInvocationEnvelopeDto") + deepHashIntentCallPlatformBridge(value: id, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: qualifiedName, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: arguments, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: source, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: createdAt, hasher: &hasher) + } +} + +/// Manifest-projected entity field keys for snapshot CRUD and search. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct IntentCallEntityKeyBundle: Hashable { + var idKey: String + var titleKey: String + var subtitleKey: String + var keywordsKey: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallEntityKeyBundle? { + let idKey = pigeonVar_list[0] as! String + let titleKey = pigeonVar_list[1] as! String + let subtitleKey = pigeonVar_list[2] as! String + let keywordsKey = pigeonVar_list[3] as! String + + return IntentCallEntityKeyBundle( + idKey: idKey, + titleKey: titleKey, + subtitleKey: subtitleKey, + keywordsKey: keywordsKey + ) + } + func toList() -> [Any?] { + return [ + idKey, + titleKey, + subtitleKey, + keywordsKey, + ] + } + static func == (lhs: IntentCallEntityKeyBundle, rhs: IntentCallEntityKeyBundle) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsIntentCallPlatformBridge(lhs.idKey, rhs.idKey) && deepEqualsIntentCallPlatformBridge(lhs.titleKey, rhs.titleKey) && deepEqualsIntentCallPlatformBridge(lhs.subtitleKey, rhs.subtitleKey) && deepEqualsIntentCallPlatformBridge(lhs.keywordsKey, rhs.keywordsKey) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("IntentCallEntityKeyBundle") + deepHashIntentCallPlatformBridge(value: idKey, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: titleKey, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: subtitleKey, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: keywordsKey, hasher: &hasher) + } +} + +private class IntentCallPlatformBridgePigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return IntentCallInvocationEnvelopeDto.fromList(self.readValue() as! [Any?]) + case 130: + return IntentCallEntityKeyBundle.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class IntentCallPlatformBridgePigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? IntentCallInvocationEnvelopeDto { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? IntentCallEntityKeyBundle { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class IntentCallPlatformBridgePigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return IntentCallPlatformBridgePigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return IntentCallPlatformBridgePigeonCodecWriter(data: data) + } +} + +class IntentCallPlatformBridgePigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = IntentCallPlatformBridgePigeonCodec(readerWriter: IntentCallPlatformBridgePigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol IntentCallInvocationsHostApi { + func takePendingInvocations() throws -> [IntentCallInvocationEnvelopeDto] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class IntentCallInvocationsHostApiSetup { + static var codec: FlutterStandardMessageCodec { IntentCallPlatformBridgePigeonCodec.shared } + /// Sets up an instance of `IntentCallInvocationsHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: IntentCallInvocationsHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let takePendingInvocationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + takePendingInvocationsChannel.setMessageHandler { _, reply in + do { + let result = try api.takePendingInvocations() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + takePendingInvocationsChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol IntentCallEntitiesHostApi { + func upsertEntitySnapshots(entityType: String, snapshots: [[String?: Any?]], keys: IntentCallEntityKeyBundle) throws -> Int64 + func deleteEntitySnapshots(entityType: String, ids: [String], keys: IntentCallEntityKeyBundle) throws -> Int64 + func clearEntityTypeSnapshots(entityType: String) throws -> Int64 + func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] + func searchEntitySnapshots(entityType: String, query: String, limit: Int64, keys: IntentCallEntityKeyBundle) throws -> [[String?: Any?]] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class IntentCallEntitiesHostApiSetup { + static var codec: FlutterStandardMessageCodec { IntentCallPlatformBridgePigeonCodec.shared } + /// Sets up an instance of `IntentCallEntitiesHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: IntentCallEntitiesHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let upsertEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.upsertEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + upsertEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + let snapshotsArg = args[1] as! [[String?: Any?]] + let keysArg = args[2] as! IntentCallEntityKeyBundle + do { + let result = try api.upsertEntitySnapshots(entityType: entityTypeArg, snapshots: snapshotsArg, keys: keysArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + upsertEntitySnapshotsChannel.setMessageHandler(nil) + } + let deleteEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.deleteEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + deleteEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + let idsArg = args[1] as! [String] + let keysArg = args[2] as! IntentCallEntityKeyBundle + do { + let result = try api.deleteEntitySnapshots(entityType: entityTypeArg, ids: idsArg, keys: keysArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + deleteEntitySnapshotsChannel.setMessageHandler(nil) + } + let clearEntityTypeSnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.clearEntityTypeSnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearEntityTypeSnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + do { + let result = try api.clearEntityTypeSnapshots(entityType: entityTypeArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + clearEntityTypeSnapshotsChannel.setMessageHandler(nil) + } + let listEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.listEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + listEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + do { + let result = try api.listEntitySnapshots(entityType: entityTypeArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + listEntitySnapshotsChannel.setMessageHandler(nil) + } + let searchEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.searchEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + searchEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + let queryArg = args[1] as! String + let limitArg = args[2] as! Int64 + let keysArg = args[3] as! IntentCallEntityKeyBundle + do { + let result = try api.searchEntitySnapshots(entityType: entityTypeArg, query: queryArg, limit: limitArg, keys: keysArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + searchEntitySnapshotsChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift index 3b58f5a..02e66a7 100644 --- a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift @@ -1,107 +1,130 @@ import Flutter import UIKit -private enum IntentCallHandoffStore { - private static let pendingKey = "intentcall.pending_invocations" - - /// Current bridge semantics are at-most-once: taking pending rows clears them - /// before Dart execution reports success or failure. - static func takePendingInvocations() -> [[String: Any]] { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: pendingKey) - return pending - } -} - -/// Plugin bridge for pending native intent dispatch into Dart. +/// Plugin bridge for pending native intent dispatch and entity snapshot cache. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { - let invocations = FlutterMethodChannel( - name: "intentcall_platform/invocations", - binaryMessenger: registrar.messenger() + let bridge = IntentCallPlatformBridgeHostApiImpl() + IntentCallInvocationsHostApiSetup.setUp( + binaryMessenger: registrar.messenger(), + api: bridge ) - let entities = FlutterMethodChannel( - name: "intentcall_platform/entities", - binaryMessenger: registrar.messenger() + IntentCallEntitiesHostApiSetup.setUp( + binaryMessenger: registrar.messenger(), + api: bridge ) - let instance = IntentCallPlatformPlugin() - registrar.addMethodCallDelegate(instance, channel: invocations) - registrar.addMethodCallDelegate(instance, channel: entities) } } -extension IntentCallPlatformPlugin { - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "takePendingInvocations": - result(IntentCallHandoffStore.takePendingInvocations()) - case "upsertEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.upsertSnapshots( - entityType: entityType, - snapshots: snapshots, - idKey: idKey - ) - ) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.deleteSnapshots( - entityType: entityType, - ids: ids, - idKey: idKey - ) - ) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) - } - case "searchEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int ?? 20 - let titleKey = args["titleKey"] as? String ?? "title" - let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" - let keywordsKey = args["keywordsKey"] as? String ?? "keywords" - result( - IntentCallNativeEntitySnapshotStore.search( - entityType: entityType, - query: query, - titleKey: titleKey, - subtitleKey: subtitleKey, - keywordsKey: keywordsKey, - limit: limit - ) - ) +private final class IntentCallPlatformBridgeHostApiImpl: IntentCallInvocationsHostApi, + IntentCallEntitiesHostApi +{ + func takePendingInvocations() throws -> [IntentCallInvocationEnvelopeDto] { + IntentCallNativeHandoffStore.takePendingInvocations().compactMap(envelopeDto(from:)) + } + + func upsertEntitySnapshots( + entityType: String, + snapshots: [[String?: Any?]], + keys: IntentCallEntityKeyBundle + ) throws -> Int64 { + Int64( + IntentCallNativeEntitySnapshotStore.upsertSnapshots( + entityType: entityType, + snapshots: snapshotRows(from: snapshots), + idKey: keys.idKey + ) + ) + } + + func deleteEntitySnapshots( + entityType: String, + ids: [String], + keys: IntentCallEntityKeyBundle + ) throws -> Int64 { + Int64( + IntentCallNativeEntitySnapshotStore.deleteSnapshots( + entityType: entityType, + ids: ids, + idKey: keys.idKey + ) + ) + } + + func clearEntityTypeSnapshots(entityType: String) throws -> Int64 { + Int64(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) + } + + func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] { + snapshotRowsToPigeon( + IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType) + ) + } + + func searchEntitySnapshots( + entityType: String, + query: String, + limit: Int64, + keys: IntentCallEntityKeyBundle + ) throws -> [[String?: Any?]] { + snapshotRowsToPigeon( + IntentCallNativeEntitySnapshotStore.search( + entityType: entityType, + query: query, + titleKey: keys.titleKey, + subtitleKey: keys.subtitleKey, + keywordsKey: keys.keywordsKey, + limit: Int(limit) + ) + ) + } + + private func envelopeDto(from row: [String: Any]) -> IntentCallInvocationEnvelopeDto? { + guard + let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), + let qualifiedName = IntentCallNativeEntitySnapshotStore.string(row["qualifiedName"]), + let source = IntentCallNativeEntitySnapshotStore.string(row["source"]), + let createdAt = IntentCallNativeEntitySnapshotStore.string(row["createdAt"]) + else { + return nil + } + let arguments = pigeonMap(from: row["arguments"] as? [String: Any]) + return IntentCallInvocationEnvelopeDto( + id: id, + qualifiedName: qualifiedName, + arguments: arguments, + source: source, + createdAt: createdAt + ) + } + + private func pigeonMap(from row: [String: Any]?) -> [String?: Any?]? { + guard let row else { return nil } + var normalized = [String?: Any?]() + for (key, value) in row { + normalized[key] = value + } + return normalized + } + + private func snapshotRows(from rows: [[String?: Any?]]) -> [[String: Any]] { + rows.map { row in + var normalized = [String: Any]() + for (key, value) in row { + guard let key else { continue } + normalized[key] = value as Any } - default: - result(FlutterMethodNotImplemented) + return normalized } } - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) -> Void - ) { - guard let args = call.arguments as? [String: Any], - let entityType = args["entityType"] as? String else { - result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) - return + private func snapshotRowsToPigeon(_ rows: [[String: Any]]) -> [[String?: Any?]] { + rows.map { row in + var normalized = [String?: Any?]() + for (key, value) in row { + normalized[key] = value + } + return normalized } - body(args, entityType) } } diff --git a/packages/intentcall_platform/lib/intentcall_platform_flutter.dart b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart index da00934..82fc722 100644 --- a/packages/intentcall_platform/lib/intentcall_platform_flutter.dart +++ b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart @@ -2,6 +2,7 @@ library; export 'src/flutter/intentcall_entity_index.dart'; +export 'src/flutter/intentcall_entity_key_bundle.dart'; export 'src/flutter/intentcall_flutter_host.dart'; export 'src/flutter/intentcall_host_events.dart'; export 'src/flutter/intentcall_invoke_link.dart'; diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart index 2d9c77f..2fb7801 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart @@ -3,10 +3,32 @@ import 'package:intentcall_schema/intentcall_schema.dart'; import 'intentcall_entity_index_channel_stub.dart' if (dart.library.ui) 'intentcall_entity_index_channel.dart'; +import 'intentcall_entity_key_bundle.dart'; typedef IntentCallPlatformInvoke = Future Function(String method, Object? arguments); +/// Manifest-projected entity field keys for native snapshot channels. +IntentCallEntityKeyBundle entityKeyBundleFromDescriptor( + final AgentEntityTypeDescriptor descriptor, +) { + String? byRole(final AgentEntityPropertyRole role) { + for (final property in descriptor.properties) { + if (property.role == role) { + return property.name; + } + } + return null; + } + + return IntentCallEntityKeyBundle( + idKey: descriptor.identifierName, + titleKey: byRole(AgentEntityPropertyRole.title) ?? 'title', + subtitleKey: byRole(AgentEntityPropertyRole.subtitle) ?? 'subtitle', + keywordsKey: byRole(AgentEntityPropertyRole.keywords) ?? 'keywords', + ); +} + /// Dart-facing writer for native entity snapshots used by platform projections. /// /// The index is a platform cache, not the app's source of truth. App logic owns @@ -26,14 +48,20 @@ final class IntentCallPlatformEntityIndex { snapshots: snapshots.map( (final snapshot) => projectAgentEntitySnapshot(snapshot, descriptor), ), + keys: entityKeyBundleFromDescriptor(descriptor), ); Future deleteAgentRefs({ required final Iterable refs, + final IntentCallEntityKeyBundle? keys, }) async { var count = 0; for (final entry in _refsByEntityType(refs).entries) { - count += await deleteSnapshots(entityType: entry.key, ids: entry.value); + count += await deleteSnapshots( + entityType: entry.key, + ids: entry.value, + keys: keys, + ); } return count; } @@ -41,11 +69,13 @@ final class IntentCallPlatformEntityIndex { Future upsertSnapshots({ required final String entityType, required final Iterable> snapshots, + final IntentCallEntityKeyBundle? keys, }) async { final rows = snapshots.map(_snapshotRow).toList(growable: false); final result = await _invoke('upsertEntitySnapshots', { 'entityType': _entityType(entityType), 'snapshots': rows, + 'keys': keys ?? intentCallDefaultEntityKeyBundle(), }); return _intResult(result, fallback: rows.length); } @@ -53,11 +83,13 @@ final class IntentCallPlatformEntityIndex { Future deleteSnapshots({ required final String entityType, required final Iterable ids, + final IntentCallEntityKeyBundle? keys, }) async { final idRows = ids.map(_entityId).toList(growable: false); final result = await _invoke('deleteEntitySnapshots', { 'entityType': _entityType(entityType), 'ids': idRows, + 'keys': keys ?? intentCallDefaultEntityKeyBundle(), }); return _intResult(result, fallback: idRows.length); } @@ -82,10 +114,12 @@ final class IntentCallPlatformEntityIndex { required final String entityType, required final String query, final int? limit, + final IntentCallEntityKeyBundle? keys, }) async { final arguments = { 'entityType': _entityType(entityType), 'query': query.trim(), + 'keys': keys ?? intentCallDefaultEntityKeyBundle(), }; if (limit != null) { arguments['limit'] = limit; diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart index cae114b..5257e46 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart @@ -1,8 +1,85 @@ -import 'package:flutter/services.dart'; +import 'package:intentcall_bridge/intentcall_bridge.dart' as bridge; -const _channel = MethodChannel('intentcall_platform/entities'); +import 'intentcall_entity_key_bundle.dart'; + +final _entitiesHostApi = bridge.IntentCallEntitiesHostApi(); Future defaultIntentCallPlatformEntityInvoke( final String method, final Object? arguments, -) => _channel.invokeMethod(method, arguments); +) async { + final args = Map.from(arguments as Map? ?? const {}); + final entityType = '${args['entityType'] ?? ''}'; + final keys = _toBridgeKeyBundle(_readKeyBundle(args)); + + switch (method) { + case 'upsertEntitySnapshots': + final snapshots = + (args['snapshots'] as List?) + ?.map( + (final row) => Map.from(row as Map), + ) + .toList(growable: false) ?? + const >[]; + return _entitiesHostApi.upsertEntitySnapshots( + entityType, + snapshots, + keys, + ); + case 'deleteEntitySnapshots': + final ids = + (args['ids'] as List?)?.map((final id) => '$id').toList( + growable: false, + ) ?? + const []; + return _entitiesHostApi.deleteEntitySnapshots(entityType, ids, keys); + case 'clearEntityTypeSnapshots': + return _entitiesHostApi.clearEntityTypeSnapshots(entityType); + case 'listEntitySnapshots': + final rows = await _entitiesHostApi.listEntitySnapshots(entityType); + return rows + .map((final row) => Map.from(row)) + .toList(growable: false); + case 'searchEntitySnapshots': + final query = '${args['query'] ?? ''}'; + final limit = args['limit'] as int? ?? 20; + final rows = await _entitiesHostApi.searchEntitySnapshots( + entityType, + query, + limit, + keys, + ); + return rows + .map((final row) => Map.from(row)) + .toList(growable: false); + default: + throw UnsupportedError('Unknown entity bridge method: $method'); + } +} + +IntentCallEntityKeyBundle _readKeyBundle(final Map args) { + final bundle = args['keys']; + if (bundle is IntentCallEntityKeyBundle) { + return bundle; + } + if (bundle is Map) { + return IntentCallEntityKeyBundle( + idKey: '${bundle['idKey'] ?? 'id'}', + titleKey: '${bundle['titleKey'] ?? 'title'}', + subtitleKey: '${bundle['subtitleKey'] ?? 'subtitle'}', + keywordsKey: '${bundle['keywordsKey'] ?? 'keywords'}', + ); + } + return intentCallDefaultEntityKeyBundle(); +} + +bridge.IntentCallEntityKeyBundle _toBridgeKeyBundle( + final IntentCallEntityKeyBundle keys, +) { + return bridge.IntentCallEntityKeyBundle( + idKey: keys.idKey, + titleKey: keys.titleKey, + subtitleKey: keys.subtitleKey, + keywordsKey: keys.keywordsKey, + ); +} diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_key_bundle.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_key_bundle.dart new file mode 100644 index 0000000..c93722d --- /dev/null +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_key_bundle.dart @@ -0,0 +1,17 @@ +/// Manifest-projected entity field keys for native snapshot channels. +final class IntentCallEntityKeyBundle { + const IntentCallEntityKeyBundle({ + this.idKey = 'id', + this.titleKey = 'title', + this.subtitleKey = 'subtitle', + this.keywordsKey = 'keywords', + }); + + final String idKey; + final String titleKey; + final String subtitleKey; + final String keywordsKey; +} + +IntentCallEntityKeyBundle intentCallDefaultEntityKeyBundle() => + const IntentCallEntityKeyBundle(); diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart index 13510ff..21e0ddc 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart @@ -1,28 +1,27 @@ -import 'package:flutter/services.dart'; - +import 'package:intentcall_bridge/intentcall_bridge.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; final class IntentCallPendingInvocations { - const IntentCallPendingInvocations({ - this.channel = const MethodChannel('intentcall_platform/invocations'), - }); + IntentCallPendingInvocations({ + final IntentCallInvocationsHostApi? hostApi, + }) : _hostApi = hostApi ?? IntentCallInvocationsHostApi(); - final MethodChannel channel; + final IntentCallInvocationsHostApi _hostApi; Future> takePending() async { - final rows = await channel.invokeListMethod( - 'takePendingInvocations', - ); - if (rows == null) { - return const []; - } - return rows - .whereType() - .map( - (final row) => IntentCallInvocationEnvelope.fromJson( - Map.from(row), - ), - ) - .toList(growable: false); + final rows = await _hostApi.takePendingInvocations(); + return rows.map(_toEnvelope).toList(growable: false); } } + +IntentCallInvocationEnvelope _toEnvelope( + final IntentCallInvocationEnvelopeDto dto, +) { + return IntentCallInvocationEnvelope( + id: dto.id, + qualifiedName: dto.qualifiedName, + arguments: Map.from(dto.arguments ?? const {}), + source: dto.source, + createdAt: DateTime.tryParse(dto.createdAt), + ); +} diff --git a/packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift deleted file mode 100644 index 712732e..0000000 --- a/packages/intentcall_platform/macos/Classes/IntentCallNativeEntitySnapshotStore.swift +++ /dev/null @@ -1,201 +0,0 @@ -import Foundation -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - -/// Shared native cache for Dart-projected entity snapshots. -/// -/// Generated App Intents entity/query code and the Flutter plugin bridge both -/// read and write through this store. -public enum IntentCallNativeEntitySnapshotStore { - public static let snapshotsDidChangeNotification = Notification.Name( - "intentcall.entitySnapshotsDidChange" - ) - - public static var fallbackScheme: String? - - private static let snapshotsKeyPrefix = "intentcall.entity_snapshots." - private static let pendingOpenKey = "intentcall.pending_entity_opens" - - @discardableResult - public static func upsertSnapshots( - entityType: String, - snapshots: [[String: Any]], - idKey: String = "id" - ) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var existing = - UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - var byId = Dictionary( - uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in - guard let id = string(snapshot[idKey]) else { return nil } - return (id, snapshot) - } - ) - for snapshot in snapshots { - guard let id = string(snapshot[idKey]) else { continue } - byId[id] = snapshot - } - existing = Array(byId.values) - UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: type)) - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - return snapshots.count - } - - @discardableResult - public static func deleteSnapshots( - entityType: String, - ids: [String], - idKey: String = "id" - ) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - let deleted = Set(ids) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let existing = - UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - let kept = existing.filter { snapshot in - guard let id = string(snapshot[idKey]) else { return true } - return !deleted.contains(id) - } - UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: type)) - let removed = existing.count - kept.count - if removed > 0 { - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - } - return removed - } - - @discardableResult - public static func clearSnapshots(entityType: String) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - let existing = snapshots(entityType: type).count - UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: type)) - if existing > 0 { - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - } - return existing - } - - public static func snapshots(entityType: String) -> [[String: Any]] { - guard let type = validatedEntityType(entityType) else { return [] } - return UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - } - - public static func entities( - entityType: String, - identifiers: [String], - idKey: String, - limit: Int? - ) -> [[String: Any]] { - let wanted = Set(identifiers) - let matches = snapshots(entityType: entityType).filter { snapshot in - guard let id = string(snapshot[idKey]) else { return false } - return wanted.contains(id) - } - return applyingLimit(matches, limit: limit) - } - - public static func suggested(entityType: String, limit: Int) -> [[String: Any]] { - applyingLimit(snapshots(entityType: entityType), limit: limit) - } - - public static func search( - entityType: String, - query: String, - titleKey: String, - subtitleKey: String, - keywordsKey: String, - limit: Int - ) -> [[String: Any]] { - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let all = snapshots(entityType: entityType) - guard !needle.isEmpty else { - return suggested(entityType: entityType, limit: limit) - } - let matches = all.filter { snapshot in - let fields = - [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } - + strings(snapshot[keywordsKey]) - return fields.contains { $0.lowercased().contains(needle) } - } - return applyingLimit(matches, limit: limit) - } - - public static func recordOpen(entityType: String, id: String) async -> String { - let type = validatedEntityType(entityType) ?? entityType - let openId = UUID().uuidString - let item: [String: Any] = [ - "id": openId, - "entityType": type, - "entityId": id, - "source": "native.entity.generated", - "createdAt": ISO8601DateFormatter().string(from: Date()), - ] - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var pending = - UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] - pending.append(item) - UserDefaults.standard.set(pending, forKey: pendingOpenKey) - guard let scheme = fallbackScheme else { return openId } - let encodedEntityType = encodedPathComponent(type) - let encodedId = encodedPathComponent(id) - guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { - return openId - } - #if canImport(UIKit) - await UIApplication.shared.open(url) - #elseif canImport(AppKit) - NSWorkspace.shared.open(url) - #endif - return openId - } - - public static func string(_ value: Any?) -> String? { - if let value = value as? String { return value } - if let value = value as? CustomStringConvertible { return value.description } - return nil - } - - public static func strings(_ value: Any?) -> [String] { - if let values = value as? [String] { return values } - if let values = value as? [Any] { return values.compactMap { string($0) } } - if let value = string(value) { return [value] } - return [] - } - - private static func snapshotsKey(entityType: String) -> String { - snapshotsKeyPrefix + entityType - } - - private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] { - guard let limit else { return rows } - return Array(rows.prefix(max(0, limit))) - } - - private static func encodedPathComponent(_ value: String) -> String { - var allowedPath = CharacterSet.alphanumerics - allowedPath.insert(charactersIn: "_-.~") - return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value - } - - private static func validatedEntityType(_ value: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let range = NSRange(location: 0, length: trimmed.utf16.count) - guard - let regex = try? NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$"), - regex.firstMatch(in: trimmed, options: [], range: range) != nil - else { - return nil - } - return trimmed - } -} diff --git a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift deleted file mode 100644 index a95f037..0000000 --- a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Cocoa -import FlutterMacOS - -private enum IntentCallHandoffStore { - private static let pendingKey = "intentcall.pending_invocations" - - /// Current bridge semantics are at-most-once: taking pending rows clears them - /// before Dart execution reports success or failure. - static func takePendingInvocations() -> [[String: Any]] { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: pendingKey) - return pending - } -} - -/// Plugin bridge for pending native intent dispatch into Dart. -public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let invocations = FlutterMethodChannel( - name: "intentcall_platform/invocations", - binaryMessenger: registrar.messenger - ) - let entities = FlutterMethodChannel( - name: "intentcall_platform/entities", - binaryMessenger: registrar.messenger - ) - let instance = IntentCallPlatformPlugin() - registrar.addMethodCallDelegate(instance, channel: invocations) - registrar.addMethodCallDelegate(instance, channel: entities) - } -} - -extension IntentCallPlatformPlugin { - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "takePendingInvocations": - result(IntentCallHandoffStore.takePendingInvocations()) - case "upsertEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.upsertSnapshots( - entityType: entityType, - snapshots: snapshots, - idKey: idKey - ) - ) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.deleteSnapshots( - entityType: entityType, - ids: ids, - idKey: idKey - ) - ) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) - } - case "searchEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int ?? 20 - let titleKey = args["titleKey"] as? String ?? "title" - let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" - let keywordsKey = args["keywordsKey"] as? String ?? "keywords" - result( - IntentCallNativeEntitySnapshotStore.search( - entityType: entityType, - query: query, - titleKey: titleKey, - subtitleKey: subtitleKey, - keywordsKey: keywordsKey, - limit: limit - ) - ) - } - default: - result(FlutterMethodNotImplemented) - } - } - - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) -> Void - ) { - guard let args = call.arguments as? [String: Any], - let entityType = args["entityType"] as? String else { - result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) - return - } - body(args, entityType) - } -} diff --git a/packages/intentcall_platform/macos/intentcall_platform.podspec b/packages/intentcall_platform/macos/intentcall_platform.podspec index 3410b2e..797c731 100644 --- a/packages/intentcall_platform/macos/intentcall_platform.podspec +++ b/packages/intentcall_platform/macos/intentcall_platform.podspec @@ -9,7 +9,7 @@ Platform bridge for dispatching generated native invocation envelopes into Dart. s.license = { :file => '../LICENSE' } s.author = { 'Arenukvern' => 'intentcall@example.invalid' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' + s.source_files = 'intentcall_platform/Sources/**/*' s.dependency 'FlutterMacOS' s.platform = :osx, '10.14' diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift new file mode 100644 index 0000000..96b0a6e --- /dev/null +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Shared native queue for App Intents / deep-link handoff into Dart. +/// +/// Generated App Intents code appends via [append]; the Flutter plugin drains +/// via [takePendingInvocations] through the Pigeon bridge. +public enum IntentCallNativeHandoffStore { + private static let pendingKey = "intentcall.pending_invocations" + + public static func append(_ item: [String: Any]) { + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = + UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingKey) + } + + /// At-most-once drain semantics: clears pending rows before Dart reports success. + public static func takePendingInvocations() -> [[String: Any]] { + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let pending = + UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: pendingKey) + return pending + } +} diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift new file mode 100644 index 0000000..2689913 --- /dev/null +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift @@ -0,0 +1,443 @@ +// Autogenerated from Pigeon (v26.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +private func doubleEqualsIntentCallPlatformBridge(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs +} + +private func doubleHashIntentCallPlatformBridge(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } +} + +func deepEqualsIntentCallPlatformBridge(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEqualsIntentCallPlatformBridge(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEqualsIntentCallPlatformBridge(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEqualsIntentCallPlatformBridge(lhsKey, rhsKey) { + if deepEqualsIntentCallPlatformBridge(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEqualsIntentCallPlatformBridge(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } +} + +func deepHashIntentCallPlatformBridge(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHashIntentCallPlatformBridge(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHashIntentCallPlatformBridge(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHashIntentCallPlatformBridge(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHashIntentCallPlatformBridge(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHashIntentCallPlatformBridge(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } +} + + +/// Native invocation envelope drained from the handoff store. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct IntentCallInvocationEnvelopeDto: Hashable { + var id: String + var qualifiedName: String + var arguments: [String?: Any?]? = nil + var source: String + var createdAt: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallInvocationEnvelopeDto? { + let id = pigeonVar_list[0] as! String + let qualifiedName = pigeonVar_list[1] as! String + let arguments: [String?: Any?]? = nilOrValue(pigeonVar_list[2]) + let source = pigeonVar_list[3] as! String + let createdAt = pigeonVar_list[4] as! String + + return IntentCallInvocationEnvelopeDto( + id: id, + qualifiedName: qualifiedName, + arguments: arguments, + source: source, + createdAt: createdAt + ) + } + func toList() -> [Any?] { + return [ + id, + qualifiedName, + arguments, + source, + createdAt, + ] + } + static func == (lhs: IntentCallInvocationEnvelopeDto, rhs: IntentCallInvocationEnvelopeDto) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsIntentCallPlatformBridge(lhs.id, rhs.id) && deepEqualsIntentCallPlatformBridge(lhs.qualifiedName, rhs.qualifiedName) && deepEqualsIntentCallPlatformBridge(lhs.arguments, rhs.arguments) && deepEqualsIntentCallPlatformBridge(lhs.source, rhs.source) && deepEqualsIntentCallPlatformBridge(lhs.createdAt, rhs.createdAt) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("IntentCallInvocationEnvelopeDto") + deepHashIntentCallPlatformBridge(value: id, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: qualifiedName, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: arguments, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: source, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: createdAt, hasher: &hasher) + } +} + +/// Manifest-projected entity field keys for snapshot CRUD and search. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct IntentCallEntityKeyBundle: Hashable { + var idKey: String + var titleKey: String + var subtitleKey: String + var keywordsKey: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallEntityKeyBundle? { + let idKey = pigeonVar_list[0] as! String + let titleKey = pigeonVar_list[1] as! String + let subtitleKey = pigeonVar_list[2] as! String + let keywordsKey = pigeonVar_list[3] as! String + + return IntentCallEntityKeyBundle( + idKey: idKey, + titleKey: titleKey, + subtitleKey: subtitleKey, + keywordsKey: keywordsKey + ) + } + func toList() -> [Any?] { + return [ + idKey, + titleKey, + subtitleKey, + keywordsKey, + ] + } + static func == (lhs: IntentCallEntityKeyBundle, rhs: IntentCallEntityKeyBundle) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsIntentCallPlatformBridge(lhs.idKey, rhs.idKey) && deepEqualsIntentCallPlatformBridge(lhs.titleKey, rhs.titleKey) && deepEqualsIntentCallPlatformBridge(lhs.subtitleKey, rhs.subtitleKey) && deepEqualsIntentCallPlatformBridge(lhs.keywordsKey, rhs.keywordsKey) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("IntentCallEntityKeyBundle") + deepHashIntentCallPlatformBridge(value: idKey, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: titleKey, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: subtitleKey, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: keywordsKey, hasher: &hasher) + } +} + +private class IntentCallPlatformBridgePigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return IntentCallInvocationEnvelopeDto.fromList(self.readValue() as! [Any?]) + case 130: + return IntentCallEntityKeyBundle.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class IntentCallPlatformBridgePigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? IntentCallInvocationEnvelopeDto { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? IntentCallEntityKeyBundle { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class IntentCallPlatformBridgePigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return IntentCallPlatformBridgePigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return IntentCallPlatformBridgePigeonCodecWriter(data: data) + } +} + +class IntentCallPlatformBridgePigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = IntentCallPlatformBridgePigeonCodec(readerWriter: IntentCallPlatformBridgePigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol IntentCallInvocationsHostApi { + func takePendingInvocations() throws -> [IntentCallInvocationEnvelopeDto] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class IntentCallInvocationsHostApiSetup { + static var codec: FlutterStandardMessageCodec { IntentCallPlatformBridgePigeonCodec.shared } + /// Sets up an instance of `IntentCallInvocationsHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: IntentCallInvocationsHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let takePendingInvocationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + takePendingInvocationsChannel.setMessageHandler { _, reply in + do { + let result = try api.takePendingInvocations() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + takePendingInvocationsChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol IntentCallEntitiesHostApi { + func upsertEntitySnapshots(entityType: String, snapshots: [[String?: Any?]], keys: IntentCallEntityKeyBundle) throws -> Int64 + func deleteEntitySnapshots(entityType: String, ids: [String], keys: IntentCallEntityKeyBundle) throws -> Int64 + func clearEntityTypeSnapshots(entityType: String) throws -> Int64 + func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] + func searchEntitySnapshots(entityType: String, query: String, limit: Int64, keys: IntentCallEntityKeyBundle) throws -> [[String?: Any?]] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class IntentCallEntitiesHostApiSetup { + static var codec: FlutterStandardMessageCodec { IntentCallPlatformBridgePigeonCodec.shared } + /// Sets up an instance of `IntentCallEntitiesHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: IntentCallEntitiesHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let upsertEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.upsertEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + upsertEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + let snapshotsArg = args[1] as! [[String?: Any?]] + let keysArg = args[2] as! IntentCallEntityKeyBundle + do { + let result = try api.upsertEntitySnapshots(entityType: entityTypeArg, snapshots: snapshotsArg, keys: keysArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + upsertEntitySnapshotsChannel.setMessageHandler(nil) + } + let deleteEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.deleteEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + deleteEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + let idsArg = args[1] as! [String] + let keysArg = args[2] as! IntentCallEntityKeyBundle + do { + let result = try api.deleteEntitySnapshots(entityType: entityTypeArg, ids: idsArg, keys: keysArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + deleteEntitySnapshotsChannel.setMessageHandler(nil) + } + let clearEntityTypeSnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.clearEntityTypeSnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearEntityTypeSnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + do { + let result = try api.clearEntityTypeSnapshots(entityType: entityTypeArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + clearEntityTypeSnapshotsChannel.setMessageHandler(nil) + } + let listEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.listEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + listEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + do { + let result = try api.listEntitySnapshots(entityType: entityTypeArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + listEntitySnapshotsChannel.setMessageHandler(nil) + } + let searchEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.searchEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + searchEntitySnapshotsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let entityTypeArg = args[0] as! String + let queryArg = args[1] as! String + let limitArg = args[2] as! Int64 + let keysArg = args[3] as! IntentCallEntityKeyBundle + do { + let result = try api.searchEntitySnapshots(entityType: entityTypeArg, query: queryArg, limit: limitArg, keys: keysArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + searchEntitySnapshotsChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift index a95f037..02e66a7 100644 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift @@ -1,107 +1,130 @@ -import Cocoa -import FlutterMacOS +import Flutter +import UIKit -private enum IntentCallHandoffStore { - private static let pendingKey = "intentcall.pending_invocations" - - /// Current bridge semantics are at-most-once: taking pending rows clears them - /// before Dart execution reports success or failure. - static func takePendingInvocations() -> [[String: Any]] { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: pendingKey) - return pending - } -} - -/// Plugin bridge for pending native intent dispatch into Dart. +/// Plugin bridge for pending native intent dispatch and entity snapshot cache. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { - let invocations = FlutterMethodChannel( - name: "intentcall_platform/invocations", - binaryMessenger: registrar.messenger + let bridge = IntentCallPlatformBridgeHostApiImpl() + IntentCallInvocationsHostApiSetup.setUp( + binaryMessenger: registrar.messenger(), + api: bridge ) - let entities = FlutterMethodChannel( - name: "intentcall_platform/entities", - binaryMessenger: registrar.messenger + IntentCallEntitiesHostApiSetup.setUp( + binaryMessenger: registrar.messenger(), + api: bridge ) - let instance = IntentCallPlatformPlugin() - registrar.addMethodCallDelegate(instance, channel: invocations) - registrar.addMethodCallDelegate(instance, channel: entities) } } -extension IntentCallPlatformPlugin { - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "takePendingInvocations": - result(IntentCallHandoffStore.takePendingInvocations()) - case "upsertEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let snapshots = args["snapshots"] as? [[String: Any]] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.upsertSnapshots( - entityType: entityType, - snapshots: snapshots, - idKey: idKey - ) - ) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - let idKey = args["idKey"] as? String ?? "id" - result( - IntentCallNativeEntitySnapshotStore.deleteSnapshots( - entityType: entityType, - ids: ids, - idKey: idKey - ) - ) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType)) - } - case "searchEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let query = args["query"] as? String ?? "" - let limit = args["limit"] as? Int ?? 20 - let titleKey = args["titleKey"] as? String ?? "title" - let subtitleKey = args["subtitleKey"] as? String ?? "subtitle" - let keywordsKey = args["keywordsKey"] as? String ?? "keywords" - result( - IntentCallNativeEntitySnapshotStore.search( - entityType: entityType, - query: query, - titleKey: titleKey, - subtitleKey: subtitleKey, - keywordsKey: keywordsKey, - limit: limit - ) - ) +private final class IntentCallPlatformBridgeHostApiImpl: IntentCallInvocationsHostApi, + IntentCallEntitiesHostApi +{ + func takePendingInvocations() throws -> [IntentCallInvocationEnvelopeDto] { + IntentCallNativeHandoffStore.takePendingInvocations().compactMap(envelopeDto(from:)) + } + + func upsertEntitySnapshots( + entityType: String, + snapshots: [[String?: Any?]], + keys: IntentCallEntityKeyBundle + ) throws -> Int64 { + Int64( + IntentCallNativeEntitySnapshotStore.upsertSnapshots( + entityType: entityType, + snapshots: snapshotRows(from: snapshots), + idKey: keys.idKey + ) + ) + } + + func deleteEntitySnapshots( + entityType: String, + ids: [String], + keys: IntentCallEntityKeyBundle + ) throws -> Int64 { + Int64( + IntentCallNativeEntitySnapshotStore.deleteSnapshots( + entityType: entityType, + ids: ids, + idKey: keys.idKey + ) + ) + } + + func clearEntityTypeSnapshots(entityType: String) throws -> Int64 { + Int64(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) + } + + func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] { + snapshotRowsToPigeon( + IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType) + ) + } + + func searchEntitySnapshots( + entityType: String, + query: String, + limit: Int64, + keys: IntentCallEntityKeyBundle + ) throws -> [[String?: Any?]] { + snapshotRowsToPigeon( + IntentCallNativeEntitySnapshotStore.search( + entityType: entityType, + query: query, + titleKey: keys.titleKey, + subtitleKey: keys.subtitleKey, + keywordsKey: keys.keywordsKey, + limit: Int(limit) + ) + ) + } + + private func envelopeDto(from row: [String: Any]) -> IntentCallInvocationEnvelopeDto? { + guard + let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), + let qualifiedName = IntentCallNativeEntitySnapshotStore.string(row["qualifiedName"]), + let source = IntentCallNativeEntitySnapshotStore.string(row["source"]), + let createdAt = IntentCallNativeEntitySnapshotStore.string(row["createdAt"]) + else { + return nil + } + let arguments = pigeonMap(from: row["arguments"] as? [String: Any]) + return IntentCallInvocationEnvelopeDto( + id: id, + qualifiedName: qualifiedName, + arguments: arguments, + source: source, + createdAt: createdAt + ) + } + + private func pigeonMap(from row: [String: Any]?) -> [String?: Any?]? { + guard let row else { return nil } + var normalized = [String?: Any?]() + for (key, value) in row { + normalized[key] = value + } + return normalized + } + + private func snapshotRows(from rows: [[String?: Any?]]) -> [[String: Any]] { + rows.map { row in + var normalized = [String: Any]() + for (key, value) in row { + guard let key else { continue } + normalized[key] = value as Any } - default: - result(FlutterMethodNotImplemented) + return normalized } } - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) -> Void - ) { - guard let args = call.arguments as? [String: Any], - let entityType = args["entityType"] as? String else { - result(FlutterError(code: "invalid_entity_index_request", message: "Entity index calls require entityType.", details: nil)) - return + private func snapshotRowsToPigeon(_ rows: [[String: Any]]) -> [[String?: Any?]] { + rows.map { row in + var normalized = [String?: Any?]() + for (key, value) in row { + normalized[key] = value + } + return normalized } - body(args, entityType) } } diff --git a/packages/intentcall_platform/pubspec.yaml b/packages/intentcall_platform/pubspec.yaml index 0478342..7d06205 100644 --- a/packages/intentcall_platform/pubspec.yaml +++ b/packages/intentcall_platform/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: app_links: ^6.4.0 flutter: sdk: flutter + intentcall_bridge: ^0.6.0 intentcall_core: ^0.6.0 intentcall_platform_sync: ^0.6.0 intentcall_schema: ^0.6.0 diff --git a/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart b/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart new file mode 100644 index 0000000..8523999 --- /dev/null +++ b/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart @@ -0,0 +1,137 @@ +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform/src/flutter/intentcall_entity_index.dart'; +import 'package:intentcall_platform/src/flutter/intentcall_entity_key_bundle.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('Pigeon bridge contract', () { + test('invocation envelope DTO is declared in pigeon IDL', () { + final idl = _readRepoFile( + 'packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart', + ); + + expect(idl, contains('class IntentCallInvocationEnvelopeDto')); + expect(idl, contains('String id;')); + expect(idl, contains('String qualifiedName;')); + expect(idl, contains('Map? arguments;')); + expect(idl, contains('String source;')); + expect(idl, contains('String createdAt;')); + }); + + test('entity key bundle defaults match legacy channel keys', () { + final keys = intentCallDefaultEntityKeyBundle(); + + expect(keys.idKey, 'id'); + expect(keys.titleKey, 'title'); + expect(keys.subtitleKey, 'subtitle'); + expect(keys.keywordsKey, 'keywords'); + }); + + test('entity index forwards descriptor-aware key bundle', () async { + final calls = {}; + final index = IntentCallPlatformEntityIndex( + invoke: (final method, final arguments) async { + calls[method] = arguments; + return 1; + }, + ); + final descriptor = AgentEntityTypeDescriptor( + namespace: 'projects', + name: 'project', + identifierName: 'projectId', + properties: [ + AgentEntityPropertyDescriptor( + name: 'name', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.title, + isDisplay: true, + ), + AgentEntityPropertyDescriptor( + name: 'summary', + valueType: AgentEntityPropertyValueType.string, + role: AgentEntityPropertyRole.subtitle, + isSearchable: true, + ), + AgentEntityPropertyDescriptor( + name: 'tags', + valueType: AgentEntityPropertyValueType.array, + role: AgentEntityPropertyRole.keywords, + isIndexed: true, + ), + ], + ); + + await index.upsertAgentSnapshotsForType( + descriptor: descriptor, + snapshots: [ + AgentEntitySnapshot( + ref: const AgentEntityRef( + namespace: 'projects', + typeName: 'project', + identifier: 'project-1', + ), + title: 'Launch project', + properties: const { + 'name': 'Launch project', + 'summary': 'Descriptor-owned summary', + 'tags': ['launch'], + }, + ), + ], + ); + + final args = calls['upsertEntitySnapshots']! as Map; + final keys = args['keys']! as IntentCallEntityKeyBundle; + expect(keys.idKey, 'projectId'); + expect(keys.titleKey, 'name'); + expect(keys.subtitleKey, 'summary'); + expect(keys.keywordsKey, 'tags'); + }); + + test('generated host APIs expose invocation and entity surfaces', () { + final generated = _readRepoFile( + 'packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart', + ); + + expect(generated, contains('class IntentCallInvocationsHostApi')); + expect(generated, contains('takePendingInvocations()')); + expect(generated, contains('class IntentCallEntitiesHostApi')); + expect(generated, contains('upsertEntitySnapshots(')); + expect(generated, contains('deleteEntitySnapshots(')); + expect(generated, contains('clearEntityTypeSnapshots(')); + expect(generated, contains('listEntitySnapshots(')); + expect(generated, contains('searchEntitySnapshots(')); + expect( + generated, + contains( + 'dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations', + ), + ); + }); + }); +} + +String _readRepoFile(final String relativePath) { + final repoRoot = _findRepoRoot(Directory.current); + return File(p.join(repoRoot.path, relativePath)).readAsStringSync(); +} + +Directory _findRepoRoot(Directory start) { + var dir = start; + while (true) { + final pubspec = File(p.join(dir.path, 'pubspec.yaml')); + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: intentcall_workspace')) { + return dir; + } + final parent = dir.parent; + if (parent.path == dir.path) { + return start; + } + dir = parent; + } +} diff --git a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart index ff0e305..9d3ea37 100644 --- a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart @@ -3,6 +3,8 @@ library; export 'src/agent_manifest.dart'; export 'src/bootstrap/agent_web_mcp_bootstrap.dart'; export 'src/catalog/agent_registry_catalog.dart'; +export 'src/catalog/catalog_loader.dart'; +export 'src/catalog/project_utils.dart'; export 'src/emitters/android_shortcuts_xml_emitter.dart'; export 'src/emitters/apple_app_intents_testing_emitter.dart'; export 'src/emitters/apple_dart_extension_inline_emitter.dart'; @@ -14,8 +16,10 @@ export 'src/emitters/windows_protocol_emitter.dart'; export 'src/init/platform_hooks_init.dart'; export 'src/invocation/intentcall_invocation.dart'; export 'src/projection/manifest_exporter.dart'; +export 'src/projection/projection_platforms.dart'; export 'src/projection/manifest_merger.dart'; export 'src/projection/manifest_surface_index.dart'; export 'src/projection/projection_policy.dart'; export 'src/sync/platform_sync.dart'; +export 'src/templates/platform_hook_spine.dart'; export 'src/templates/platform_hook_templates.dart'; diff --git a/packages/intentcall_platform_sync/lib/src/catalog/catalog_loader.dart b/packages/intentcall_platform_sync/lib/src/catalog/catalog_loader.dart new file mode 100644 index 0000000..c1e1c56 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/catalog/catalog_loader.dart @@ -0,0 +1,283 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:path/path.dart' as p; + +import '../agent_manifest.dart'; +import '../projection/manifest_merger.dart'; +import '../projection/projection_policy.dart'; +import 'agent_registry_catalog.dart'; +import 'project_utils.dart'; + +/// Loads registry catalog rows for [ManifestMerger]. +final class CatalogLoader { + const CatalogLoader(); + + static const catalogRelativePath = 'lib/generated/agent_catalog.g.dart'; + + Future> load({ + required final String projectRoot, + }) async { + final bundle = await _loadBundle(projectRoot: projectRoot); + return bundle.entries; + } + + Future> loadEntityTypeDescriptors({ + required final String projectRoot, + }) async { + final bundle = await _loadBundle(projectRoot: projectRoot); + return bundle.entityTypeDescriptors; + } + + Future<_CatalogBundle> _loadBundle({ + required final String projectRoot, + }) async { + final root = p.normalize(p.absolute(projectRoot)); + final catalogFile = File(p.join(root, catalogRelativePath)); + if (!catalogFile.existsSync()) { + throw CatalogLoadException( + 'Missing $catalogRelativePath — run ' + '`dart run build_runner build --delete-conflicting-outputs`.', + ); + } + final fromProbe = await _loadFromGeneratedCatalog(root); + if (fromProbe != null) { + return fromProbe; + } + throw CatalogLoadException( + 'Failed to load $catalogRelativePath — run ' + '`dart run build_runner build --delete-conflicting-outputs` and ensure ' + 'the package compiles.', + ); + } + + Future<_CatalogBundle?> _loadFromGeneratedCatalog( + final String projectRoot, + ) async { + final packageName = readPackageName(projectRoot); + if (packageName == null) { + return null; + } + + final probeDir = Directory(p.join(projectRoot, '.dart_tool')) + ..createSync(recursive: true); + final probeFile = + File(p.join(probeDir.path, 'intentcall_catalog_probe.dart')) + ..writeAsStringSync(''' +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'package:$packageName/generated/agent_catalog.g.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +void main() { + final rows = >[]; + for (final row in agentCatalogEntries) { + final descriptor = row.resolveDescriptor(); + rows.add({ + 'registryKey': row.registryKey, + 'qualifiedName': descriptor.qualifiedName, + 'namespace': descriptor.namespace, + 'name': descriptor.name, + 'description': descriptor.description, + 'kind': descriptor.kind.name, + 'inputSchema': descriptor.inputSchema, + if (descriptor.resourceUri != null) 'resourceUri': descriptor.resourceUri, + if (row.projection != null) 'projection': _projectionJson(row.projection!), + }); + } + final entities = agentEntityTypeDescriptors + .map(_entityDescriptorJson) + .toList(growable: false); + print(jsonEncode({ + 'entries': rows, + 'entityTypeDescriptors': entities, + })); +} + +Map _entityDescriptorJson(final AgentEntityTypeDescriptor descriptor) { + return { + 'namespace': descriptor.namespace, + 'name': descriptor.name, + 'identifierName': descriptor.identifierName, + if (descriptor.displayName != null) 'displayName': descriptor.displayName, + 'privacy': descriptor.privacy.name, + 'deepLinkBehavior': descriptor.deepLinkBehavior.name, + 'openBehavior': descriptor.openBehavior.name, + 'properties': descriptor.properties + .map( + (final property) => { + 'name': property.name, + 'valueType': property.valueType.name, + 'description': property.description, + 'isDisplay': property.isDisplay, + 'isSearchable': property.isSearchable, + 'isIndexed': property.isIndexed, + if (property.privacy != null) 'privacy': property.privacy!.name, + if (property.role != AgentEntityPropertyRole.none) + 'role': property.role.name, + }, + ) + .toList(growable: false), + }; +} + +Map _projectionJson(final EntryProjection projection) { + return { + if (projection.dispatchMode != null) + 'dispatchMode': projection.dispatchMode!.name, + 'surfaces': { + for (final entry in projection.surfaces.entries) + entry.key.manifestKey: entry.value, + }, + }; +} +'''); + + final result = await Process.run( + 'dart', + [probeFile.path], + workingDirectory: projectRoot, + runInShell: true, + ); + if (result.exitCode != 0) { + return null; + } + final stdoutText = '${result.stdout}'.trim(); + if (stdoutText.isEmpty) { + return const _CatalogBundle( + entries: [], + entityTypeDescriptors: [], + ); + } + final decoded = jsonDecode(stdoutText); + if (decoded is List) { + return _CatalogBundle( + entries: _parseCatalogEntries(decoded), + entityTypeDescriptors: const [], + ); + } + if (decoded is! Map) { + return null; + } + final map = decoded.cast(); + final entriesRaw = map['entries']; + final entitiesRaw = map['entityTypeDescriptors']; + return _CatalogBundle( + entries: entriesRaw is List + ? _parseCatalogEntries(entriesRaw) + : const [], + entityTypeDescriptors: entitiesRaw is List + ? _parseEntityTypeDescriptors(entitiesRaw) + : const [], + ); + } + + List _parseCatalogEntries(final List decoded) => + decoded.map((final row) { + final map = (row as Map).cast(); + EntryProjection? projection; + final projectionRaw = map['projection']; + if (projectionRaw is Map) { + projection = _projectionFromJson( + projectionRaw.cast(), + ); + } + return AgentRegistryCatalogEntry( + registryKey: '${map['registryKey']}', + descriptor: AgentIntentDescriptor( + namespace: '${map['namespace']}', + name: '${map['name']}', + description: '${map['description']}', + kind: AgentIntentKind.values.byName('${map['kind']}'), + inputSchema: Map.from( + (map['inputSchema'] as Map?)?.cast() ?? + const {'type': 'object'}, + ), + resourceUri: map['resourceUri']?.toString(), + ), + projection: projection, + ); + }).toList(); + + List _parseEntityTypeDescriptors( + final List decoded, + ) => decoded.map((final row) { + final map = (row as Map).cast(); + final propertiesRaw = map['properties']; + final properties = propertiesRaw is List + ? propertiesRaw.map((final property) { + final propertyMap = (property as Map).cast(); + final privacyName = propertyMap['privacy']?.toString(); + final roleName = propertyMap['role']?.toString(); + return AgentEntityPropertyDescriptor( + name: '${propertyMap['name']}', + valueType: AgentEntityPropertyValueType.values.byName( + '${propertyMap['valueType']}', + ), + description: '${propertyMap['description'] ?? ''}', + isDisplay: propertyMap['isDisplay'] as bool? ?? false, + isSearchable: propertyMap['isSearchable'] as bool? ?? false, + isIndexed: propertyMap['isIndexed'] as bool? ?? false, + privacy: privacyName == null + ? null + : AgentEntityPrivacy.values.byName(privacyName), + role: roleName == null + ? AgentEntityPropertyRole.none + : AgentEntityPropertyRole.values.byName(roleName), + ); + }).toList() + : const []; + return AgentEntityTypeDescriptor( + namespace: '${map['namespace']}', + name: '${map['name']}', + identifierName: '${map['identifierName']}', + displayName: map['displayName']?.toString(), + properties: properties, + privacy: AgentEntityPrivacy.values.byName('${map['privacy']}'), + deepLinkBehavior: AgentEntityDeepLinkBehavior.values.byName( + '${map['deepLinkBehavior']}', + ), + openBehavior: AgentEntityOpenBehavior.values.byName( + '${map['openBehavior']}', + ), + ); + }).toList(); + + EntryProjection _projectionFromJson(final Map json) { + final dispatchName = json['dispatchMode']?.toString(); + final dispatchMode = dispatchName == null + ? null + : AgentManifestDispatchMode.values.byName(dispatchName); + final surfaces = {}; + final surfacesRaw = json['surfaces']; + if (surfacesRaw is Map) { + for (final entry in surfacesRaw.entries) { + surfaces[resolveAgentManifestSurface('${entry.key}')] = + entry.value as bool; + } + } + return EntryProjection(dispatchMode: dispatchMode, surfaces: surfaces); + } +} + +final class _CatalogBundle { + const _CatalogBundle({ + required this.entries, + required this.entityTypeDescriptors, + }); + + final List entries; + final List entityTypeDescriptors; +} + +/// Thrown when the generated catalog cannot be loaded. +final class CatalogLoadException implements Exception { + CatalogLoadException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/packages/intentcall_platform_sync/lib/src/catalog/project_utils.dart b/packages/intentcall_platform_sync/lib/src/catalog/project_utils.dart new file mode 100644 index 0000000..74c8be7 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/catalog/project_utils.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Reads the package `name` from [projectRoot]/pubspec.yaml. +String? readPackageName(final String projectRoot) { + final pubspec = File(p.join(projectRoot, 'pubspec.yaml')); + if (!pubspec.existsSync()) { + return null; + } + final match = RegExp( + r'^name:\s*(\S+)', + multiLine: true, + ).firstMatch(pubspec.readAsStringSync()); + return match?.group(1); +} diff --git a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart index 42b9f4b..22c7c96 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -179,22 +179,9 @@ final class AppleSwiftAppIntentsEmitter { } if (intentTools.isNotEmpty) { buffer - ..writeln('enum IntentCallNativeHandoffStore {') ..writeln( - ' private static let pendingKey = "intentcall.pending_invocations"', + '// IntentCallNativeHandoffStore is provided by the intentcall_platform plugin.', ) - ..writeln() - ..writeln(' static func append(_ item: [String: Any]) {') - ..writeln(' objc_sync_enter(UserDefaults.standard)') - ..writeln(' defer { objc_sync_exit(UserDefaults.standard) }') - ..writeln( - ' var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? []', - ) - ..writeln(' pending.append(item)') - ..writeln(' UserDefaults.standard.set(pending, forKey: pendingKey)') - ..writeln(' }') - ..writeln('}') - ..writeln() ..writeln('enum IntentCallNativeBridge {') ..writeln( ' private static let fallbackScheme: String? = ${_swiftOptionalString(bridgeProtocolScheme)}', diff --git a/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart index 547b52a..0c381a7 100644 --- a/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart +++ b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; import '../emitters/android_shortcuts_xml_emitter.dart'; import '../sync/platform_sync.dart'; +import '../templates/platform_hook_spine.dart'; import '../templates/platform_hook_templates.dart'; const _markerBegin = 'intentcall-platform: begin'; @@ -48,6 +49,7 @@ final class PlatformHooksInit { final bool checkOnly = false, }) async { final root = p.normalize(p.absolute(projectRoot)); + final spine = PlatformHookSpine.resolveFromProjectRoot(root); final targets = [ await _patchFile( id: 'web_index_html', @@ -58,7 +60,7 @@ final class PlatformHooksInit { await _patchFile( id: 'android_gradle', path: p.join(root, 'android', 'app', 'build.gradle.kts'), - snippet: kAndroidGradleCodegenHook.trim(), + snippet: spine.renderGradle().trim(), checkOnly: checkOnly, appendIfMissing: true, ), @@ -79,14 +81,14 @@ final class PlatformHooksInit { await _patchFile( id: 'ios_codegen_script', path: p.join(root, 'ios', 'intentcall_codegen.sh'), - snippet: kAppleXcodeCodegenRunScript.trim(), + snippet: spine.renderAppleXcode().trim(), checkOnly: checkOnly, wholeFile: true, ), await _patchFile( id: 'macos_codegen_script', path: p.join(root, 'macos', 'intentcall_codegen.sh'), - snippet: kAppleXcodeCodegenRunScript.trim(), + snippet: spine.renderAppleXcode().trim(), checkOnly: checkOnly, wholeFile: true, ), diff --git a/packages/intentcall_platform_sync/lib/src/projection/projection_platforms.dart b/packages/intentcall_platform_sync/lib/src/projection/projection_platforms.dart new file mode 100644 index 0000000..db16ec4 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/projection_platforms.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'manifest_merger.dart'; + +/// Parses comma-separated or repeated platform labels. +List parsePlatformList(final Iterable? values) { + final out = {}; + for (final value in values ?? const []) { + for (final part in value.split(',')) { + final trimmed = part.trim().toLowerCase(); + if (trimmed.isNotEmpty) { + out.add(trimmed); + } + } + } + return out.toList()..sort(); +} + +/// Resolves platform sync targets from overrides, config, or host defaults. +List resolveProjectionPlatforms({ + required final String projectRoot, + final Iterable? overridePlatforms, +}) { + final override = parsePlatformList(overridePlatforms); + if (override.isNotEmpty) { + return override; + } + + final enabled = const ManifestMerger().readEnabledPlatforms(projectRoot); + if (enabled.isNotEmpty) { + return enabled; + } + + final configFile = File(p.join(projectRoot, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return const []; + } + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return const []; + } + final host = doc['host']?.toString().trim().toLowerCase(); + return switch (host) { + 'jaspr' => const ['web'], + 'flutter' => const [ + 'web', + 'android', + 'ios', + 'macos', + 'linux', + 'windows', + ], + _ => const [], + }; +} diff --git a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_spine.dart b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_spine.dart new file mode 100644 index 0000000..da990ad --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_spine.dart @@ -0,0 +1,424 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +/// Default CLI invocation when `intentcall.yaml` omits `hooks.syncCommand`. +const kDefaultHookCliInvocation = 'dart run intentcall_cli:intentcall'; + +/// Marker delimiters for generated host hook snippets. +const kPlatformHookMarkerBegin = 'intentcall-platform: begin'; +const kPlatformHookMarkerEnd = 'intentcall-platform: end'; + +/// Legacy hook marker still accepted during migration. +const kLegacyFlutterMcpToolkitSyncMarker = 'flutter-mcp-toolkit codegen sync'; + +/// Current hook marker for [PlatformHooksInit] detection. +const kIntentcallPlatformSyncMarker = 'intentcall platform sync'; + +/// Host profile defaults for hook spine resolution (mirrors CLI [HostProfile]). +final class HookHostProfile { + const HookHostProfile({ + required this.host, + required this.defaultPlatforms, + required this.hookTemplateKeys, + }); + + final String host; + final List defaultPlatforms; + final List hookTemplateKeys; +} + +const kHookHostProfiles = { + 'flutter': HookHostProfile( + host: 'flutter', + defaultPlatforms: [ + 'web', + 'android', + 'ios', + 'macos', + 'linux', + 'windows', + ], + hookTemplateKeys: ['android', 'ios', 'macos'], + ), + 'jaspr': HookHostProfile( + host: 'jaspr', + defaultPlatforms: ['web'], + hookTemplateKeys: ['jaspr'], + ), + 'dart': HookHostProfile( + host: 'dart', + defaultPlatforms: [], + hookTemplateKeys: [], + ), + 'custom': HookHostProfile( + host: 'custom', + defaultPlatforms: [], + hookTemplateKeys: [], + ), +}; + +HookHostProfile hookHostProfileFor(final String hostName) { + final normalized = _normalizeHostName(hostName); + return kHookHostProfiles[normalized] ?? kHookHostProfiles['custom']!; +} + +/// Inputs for [PlatformHookSpine.resolve]. +final class PlatformHookSpineInput { + const PlatformHookSpineInput({ + this.host = 'flutter', + this.enabledPlatforms = const [], + this.syncCommand, + }); + + factory PlatformHookSpineInput.fromYamlMap(final YamlMap yaml) { + final host = _normalizeHostName(yaml['host']?.toString()); + final enabled = []; + final platformsRaw = yaml['platforms']; + if (platformsRaw is YamlMap) { + final enabledRaw = platformsRaw['enabled']; + if (enabledRaw is YamlList) { + for (final value in enabledRaw) { + final name = value?.toString().trim().toLowerCase(); + if (name != null && name.isNotEmpty) { + enabled.add(name); + } + } + } + } + String? syncCommand; + final hooksRaw = yaml['hooks']; + if (hooksRaw is YamlMap) { + syncCommand = _nonEmpty(hooksRaw['syncCommand']?.toString()); + } + return PlatformHookSpineInput( + host: host, + enabledPlatforms: enabled, + syncCommand: syncCommand, + ); + } + + final String host; + final List enabledPlatforms; + final String? syncCommand; +} + +/// One phase of the three-gate projection spine. +final class PlatformHookSpinePhase { + const PlatformHookSpinePhase({ + required this.id, + required this.argv, + required this.shellLine, + }); + + final String id; + final List argv; + final String shellLine; + + Map toJson() => { + 'id': id, + 'argv': argv, + 'shellLine': shellLine, + }; +} + +/// Resolved hook spine: codegen → manifest export --check → platform sync. +final class PlatformHookSpine { + const PlatformHookSpine({ + required this.host, + required this.cliInvocation, + required this.cliArgv, + required this.platformList, + required this.codegenPhase, + required this.manifestPhase, + required this.syncPhase, + required this.hookTemplateKeys, + }); + + /// Resolves spine phases and platform list from [input]. + factory PlatformHookSpine.resolve(final PlatformHookSpineInput input) { + final profile = hookHostProfileFor(input.host); + final platforms = input.enabledPlatforms.isNotEmpty + ? List.from(input.enabledPlatforms) + : List.from(profile.defaultPlatforms); + + final cliInvocation = _nonEmpty(input.syncCommand) ?? kDefaultHookCliInvocation; + final cliArgv = tokenizeShellCommand(cliInvocation); + + final codegenPhase = PlatformHookSpinePhase( + id: 'codegen', + argv: List.from(codegenArgv), + shellLine: _argvToShellLine(codegenArgv), + ); + + final manifestArgv = [...cliArgv, 'manifest', 'export', '--check']; + final manifestPhase = PlatformHookSpinePhase( + id: 'manifest', + argv: manifestArgv, + shellLine: _argvToShellLine(manifestArgv), + ); + + final syncPlatforms = _syncPlatformsForHost(profile.host, platforms); + final syncArgv = [ + ...cliArgv, + 'platform', + 'sync', + '--platform', + syncPlatforms, + ]; + final syncPhase = PlatformHookSpinePhase( + id: 'sync', + argv: syncArgv, + shellLine: _argvToShellLine(syncArgv), + ); + + return PlatformHookSpine( + host: profile.host, + cliInvocation: cliInvocation, + cliArgv: cliArgv, + platformList: platforms, + codegenPhase: codegenPhase, + manifestPhase: manifestPhase, + syncPhase: syncPhase, + hookTemplateKeys: List.from(profile.hookTemplateKeys), + ); + } + + /// Loads `intentcall.yaml` from [projectRoot] and resolves the spine. + factory PlatformHookSpine.resolveFromProjectRoot(final String projectRoot) { + final root = p.normalize(p.absolute(projectRoot)); + final configFile = File(p.join(root, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return PlatformHookSpine.resolve(const PlatformHookSpineInput()); + } + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return PlatformHookSpine.resolve(const PlatformHookSpineInput()); + } + return PlatformHookSpine.resolve(PlatformHookSpineInput.fromYamlMap(doc)); + } + + static const codegenArgv = [ + 'dart', + 'run', + 'build_runner', + 'build', + '--delete-conflicting-outputs', + ]; + + final String host; + final String cliInvocation; + final List cliArgv; + final List platformList; + final PlatformHookSpinePhase codegenPhase; + final PlatformHookSpinePhase manifestPhase; + final PlatformHookSpinePhase syncPhase; + final List hookTemplateKeys; + + /// Renders a hook snippet for [templateKey] (`android`, `ios`, `macos`, `jaspr`, `web`). + String renderTemplate(final String templateKey) { + final key = templateKey.toLowerCase(); + return switch (key) { + 'android' => renderGradle(), + 'ios' || 'macos' => renderAppleXcode(), + 'jaspr' || 'web' => renderJasprWeb(), + _ => throw ArgumentError('unknown hook template key "$templateKey"'), + }; + } + + /// Gradle `preBuild` hook for Android. + String renderGradle() { + final androidPlatforms = _platformArg( + platformList, + preferred: const ['android'], + fallback: 'android', + ); + final syncArgv = [ + ...cliArgv, + 'platform', + 'sync', + '--platform', + androidPlatforms, + ]; + return ''' +// $kPlatformHookMarkerBegin +tasks.named("preBuild").configure { + doFirst { + exec { + workingDir = rootProject.layout.projectDirectory.dir("../../").asFile + commandLine( +${_gradleCommandLine(codegenArgv)} + ) + } + exec { + workingDir = rootProject.layout.projectDirectory.dir("../../").asFile + commandLine( +${_gradleCommandLine(manifestPhase.argv)} + ) + } + exec { + workingDir = rootProject.layout.projectDirectory.dir("../../").asFile + commandLine( +${_gradleCommandLine(syncArgv)} + ) + } + } +} +// $kPlatformHookMarkerEnd +'''; + } + + /// Xcode Run Script build phase for iOS/macOS. + String renderAppleXcode() { + final applePlatforms = _platformArg( + platformList, + preferred: const ['ios', 'macos'], + fallback: 'ios,macos', + ); + final syncLine = _argvToShellLine([ + ...cliArgv, + 'platform', + 'sync', + '--platform', + applePlatforms, + ]); + return ''' +# $kPlatformHookMarkerBegin +cd "\${SRCROOT}/.." +${codegenPhase.shellLine} +${manifestPhase.shellLine} +$syncLine || exit 1 +# $kPlatformHookMarkerEnd +'''; + } + + /// Jaspr / web-only hook snippet for CI or custom build steps. + String renderJasprWeb() { + final webPlatforms = _platformArg( + platformList, + preferred: const ['web'], + fallback: 'web', + ); + final syncLine = _argvToShellLine([ + ...cliArgv, + 'platform', + 'sync', + '--platform', + webPlatforms, + ]); + return ''' +# $kPlatformHookMarkerBegin +${codegenPhase.shellLine} +${manifestPhase.shellLine} +$syncLine || exit 1 +# $kPlatformHookMarkerEnd +'''; + } + + Map toJson() => { + 'host': host, + 'cliInvocation': cliInvocation, + 'cliArgv': cliArgv, + 'platformList': platformList, + 'hookTemplateKeys': hookTemplateKeys, + 'codegenPhase': codegenPhase.toJson(), + 'manifestPhase': manifestPhase.toJson(), + 'syncPhase': syncPhase.toJson(), + }; + + String encodeJson() => '${const JsonEncoder.withIndent(' ').convert(toJson())}\n'; +} + +String _normalizeHostName(final String? value) { + final trimmed = value?.trim().toLowerCase(); + if (trimmed == null || trimmed.isEmpty) { + return 'custom'; + } + return switch (trimmed) { + 'flutter' => 'flutter', + 'jaspr' => 'jaspr', + 'dart' => 'dart', + _ => 'custom', + }; +} + +String? _nonEmpty(final String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + return trimmed; +} + +String _syncPlatformsForHost( + final String host, + final List platforms, +) { + if (host == 'jaspr') { + return _platformArg(platforms, preferred: const ['web'], fallback: 'web'); + } + final mobile = platforms + .where((final p) => {'android', 'ios', 'macos', 'web'}.contains(p)) + .toList(); + if (mobile.isEmpty) { + return platforms.join(','); + } + return mobile.join(','); +} + +String _platformArg( + final List platforms, { + required final List preferred, + required final String fallback, +}) { + final selected = preferred.where(platforms.contains).toList(); + if (selected.isEmpty) { + return fallback; + } + return selected.join(','); +} + +String _argvToShellLine(final List argv) => argv.join(' '); + +String _gradleCommandLine(final List argv) { + final lines = argv.map((final arg) => ' "$arg"'); + return lines.join(',\n'); +} + +/// Tokenizes a shell command string into argv (supports simple quotes). +List tokenizeShellCommand(final String command) { + final tokens = []; + final buffer = StringBuffer(); + String? quote; + + for (var i = 0; i < command.length; i++) { + final ch = command[i]; + if (quote != null) { + if (ch == quote) { + quote = null; + } else { + buffer.write(ch); + } + continue; + } + if (ch == '"' || ch == "'") { + quote = ch; + continue; + } + if (ch.trim().isEmpty) { + if (buffer.isNotEmpty) { + tokens.add(buffer.toString()); + buffer.clear(); + } + continue; + } + buffer.write(ch); + } + + if (buffer.isNotEmpty) { + tokens.add(buffer.toString()); + } + return tokens; +} diff --git a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart index 88593c2..8c18ee6 100644 --- a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart +++ b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart @@ -1,64 +1,24 @@ +import 'platform_hook_spine.dart'; + +export 'platform_hook_spine.dart'; + +/// Default Flutter hook spine (no project `intentcall.yaml`). +PlatformHookSpine get kDefaultFlutterHookSpine => + PlatformHookSpine.resolve(const PlatformHookSpineInput(host: 'flutter')); + +/// Default Jaspr hook spine (no project `intentcall.yaml`). +PlatformHookSpine get kDefaultJasprHookSpine => + PlatformHookSpine.resolve(const PlatformHookSpineInput(host: 'jaspr')); + /// Gradle `preBuild` hook — inject into `android/app/build.gradle.kts` once. -const kAndroidGradleCodegenHook = ''' -// intentcall-platform: begin -tasks.named("preBuild").configure { - doFirst { - exec { - workingDir = rootProject.layout.projectDirectory.dir("../../").asFile - commandLine( - "dart", - "run", - "build_runner", - "build", - "--delete-conflicting-outputs", - ) - } - exec { - workingDir = rootProject.layout.projectDirectory.dir("../../").asFile - commandLine( - "intentcall", - "manifest", - "export", - "--check", - ) - } - exec { - workingDir = rootProject.layout.projectDirectory.dir("../../").asFile - commandLine( - "intentcall", - "platform", - "sync", - "--platform", - "android", - ) - } - } -} -// intentcall-platform: end -'''; +String get kAndroidGradleCodegenHook => kDefaultFlutterHookSpine.renderGradle(); /// Xcode Run Script build phase — add to iOS/macOS target once. -const kAppleXcodeCodegenRunScript = r''' -# intentcall-platform: begin -cd "${SRCROOT}/.." -dart run build_runner build --delete-conflicting-outputs -intentcall manifest export --check -intentcall platform sync --platform ios,macos || exit 1 -# intentcall-platform: end -'''; +String get kAppleXcodeCodegenRunScript => + kDefaultFlutterHookSpine.renderAppleXcode(); /// Jaspr / web-only hook snippet for CI or custom build steps. -/// -/// Hooks call bare `intentcall` on PATH; for local development use -/// `dart run intentcall_cli:intentcall` or `dart pub global activate intentcall_cli`. -/// mcp_flutter delegation: see ADR 0019. -const kJasprWebCodegenHook = ''' -# intentcall-platform: begin -dart run build_runner build --delete-conflicting-outputs -intentcall manifest export --check -intentcall platform sync --platform web || exit 1 -# intentcall-platform: end -'''; +String get kJasprWebCodegenHook => kDefaultJasprHookSpine.renderJasprWeb(); /// Documents where hook templates live for `init intentcall-platform`. const kPlatformHookTemplatePaths = { @@ -67,9 +27,3 @@ const kPlatformHookTemplatePaths = { 'macos': 'intentcall_platform Xcode Run Script (kAppleXcodeCodegenRunScript)', 'jaspr': 'intentcall_platform web hook (kJasprWebCodegenHook)', }; - -/// Legacy hook marker still accepted during migration. -const kLegacyFlutterMcpToolkitSyncMarker = 'flutter-mcp-toolkit codegen sync'; - -/// Current hook marker for [PlatformHooksInit] detection. -const kIntentcallPlatformSyncMarker = 'intentcall platform sync'; diff --git a/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart index 6b272a7..fd54f90 100644 --- a/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart +++ b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart @@ -100,6 +100,7 @@ void main() { expect(swift, isNot(contains('IntentCallAppEntityIndexer'))); expect(swift, contains('enum IntentCallGeneratedEntityConfig')); expect(swift, isNot(contains('enum IntentCallNativeEntitySnapshotStore {'))); + expect(swift, isNot(contains('enum IntentCallNativeHandoffStore {'))); }); test('appleSpotlight adds indexing helpers', () { diff --git a/packages/intentcall_platform_sync/test/catalog_loader_test.dart b/packages/intentcall_platform_sync/test/catalog_loader_test.dart new file mode 100644 index 0000000..84ce093 --- /dev/null +++ b/packages/intentcall_platform_sync/test/catalog_loader_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +String fixtureRoot(final String name) { + final candidates = [ + p.join('packages', 'intentcall_cli', 'test', 'fixtures', name), + p.join('test', 'fixtures', name), + ]; + for (final candidate in candidates) { + final dir = Directory(candidate); + if (dir.existsSync()) { + return p.normalize(p.absolute(candidate)); + } + } + throw StateError( + 'fixture $name not found from ${Directory.current.path}', + ); +} + +void main() { + test('loads generated catalog rows from codegen_dart_project', () async { + final projectRoot = fixtureRoot('codegen_dart_project'); + final catalog = await const CatalogLoader().load(projectRoot: projectRoot); + + expect(catalog, isNotEmpty); + expect(catalog.first.qualifiedName, isNotEmpty); + }); + + test('loads entity descriptors when present', () async { + final projectRoot = fixtureRoot('codegen_dart_project'); + final descriptors = await const CatalogLoader().loadEntityTypeDescriptors( + projectRoot: projectRoot, + ); + + expect(descriptors, isA>()); + }); + + test('fails loud when catalog is missing', () async { + final temp = await Directory.systemTemp.createTemp('intentcall_catalog_'); + addTearDown(temp.deleteSync); + + expect( + () => const CatalogLoader().load(projectRoot: temp.path), + throwsA(isA()), + ); + }); +} diff --git a/packages/intentcall_platform_sync/test/native_emitters_test.dart b/packages/intentcall_platform_sync/test/native_emitters_test.dart index bb14926..d274bbc 100644 --- a/packages/intentcall_platform_sync/test/native_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/native_emitters_test.dart @@ -109,8 +109,14 @@ void main() { expect(swift, contains('arguments["currency"] = currency')); expect(swift, contains('IntentCallShortcutsProvider')); expect(swift, contains('IntentCallNativeBridge')); - expect(swift, contains('intentcall.pending_invocations')); - expect(swift, contains('objc_sync_enter(UserDefaults.standard)')); + expect(swift, contains('IntentCallNativeHandoffStore.append(item)')); + expect( + swift, + contains( + 'IntentCallNativeHandoffStore is provided by the intentcall_platform plugin', + ), + ); + expect(swift, isNot(contains('enum IntentCallNativeHandoffStore {'))); expect( swift, contains( @@ -191,6 +197,7 @@ void main() { swift, isNot(contains('enum IntentCallNativeEntitySnapshotStore {')), ); + expect(swift, isNot(contains('enum IntentCallNativeHandoffStore {'))); expect(swift, contains('static func indexAppEntities() async throws')); expect(swift, contains('CSSearchableIndex.default().indexAppEntities')); expect(swift, contains('static func deleteAppEntities(')); @@ -1169,18 +1176,7 @@ struct IntentCallShortcutsProvider: AppShortcutsProvider { } } -enum IntentCallNativeHandoffStore { - private static let pendingKey = "intentcall.pending_invocations" - - static func append(_ item: [String: Any]) { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - pending.append(item) - UserDefaults.standard.set(pending, forKey: pendingKey) - } -} - +// IntentCallNativeHandoffStore is provided by the intentcall_platform plugin. enum IntentCallNativeBridge { private static let fallbackScheme: String? = "demoapp" diff --git a/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart b/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart new file mode 100644 index 0000000..4e1b230 --- /dev/null +++ b/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('PlatformHookSpine', () { + test('default flutter spine uses dart run cli invocation', () { + final spine = PlatformHookSpine.resolve( + const PlatformHookSpineInput(host: 'flutter'), + ); + expect(spine.cliInvocation, kDefaultHookCliInvocation); + expect( + spine.manifestPhase.shellLine, + contains('dart run intentcall_cli:intentcall manifest export --check'), + ); + expect(spine.syncPhase.shellLine, contains('platform sync --platform')); + }); + + test('honors hooks.syncCommand override', () { + final spine = PlatformHookSpine.resolve( + const PlatformHookSpineInput( + host: 'flutter', + syncCommand: 'intentcall', + ), + ); + expect(spine.cliInvocation, 'intentcall'); + expect( + spine.manifestPhase.argv, + ['intentcall', 'manifest', 'export', '--check'], + ); + }); + + test('resolves platform list from yaml enabled platforms', () { + final dir = Directory.systemTemp.createTempSync('hook_spine_yaml_'); + addTearDown(() => dir.deleteSync(recursive: true)); + File(p.join(dir.path, 'intentcall.yaml')).writeAsStringSync(''' +host: flutter +platforms: + enabled: + - web + - android +hooks: + syncCommand: intentcall +'''); + final spine = PlatformHookSpine.resolveFromProjectRoot(dir.path); + expect(spine.platformList, ['web', 'android']); + expect(spine.renderGradle(), contains('"intentcall"')); + expect(spine.renderGradle(), contains('"android"')); + }); + + test('gradle template is generated from spine phases', () { + final spine = kDefaultFlutterHookSpine; + final gradle = spine.renderGradle(); + expect(gradle, contains(kPlatformHookMarkerBegin)); + expect(gradle, contains('build_runner')); + expect(gradle, contains('manifest')); + expect(gradle, contains('platform')); + expect(gradle, contains('sync')); + expect(gradle, contains(kPlatformHookMarkerEnd)); + }); + + test('apple and jaspr templates include three-gate spine', () { + final flutter = kDefaultFlutterHookSpine; + final apple = flutter.renderAppleXcode(); + expect(apple, contains('build_runner build')); + expect(apple, contains('manifest export --check')); + expect(apple, contains('platform sync --platform')); + + final jaspr = kDefaultJasprHookSpine.renderJasprWeb(); + expect(jaspr, contains('build_runner build')); + expect(jaspr, contains('platform sync --platform web')); + }); + + test('legacy template getters delegate to default spine', () { + expect(kAndroidGradleCodegenHook, kDefaultFlutterHookSpine.renderGradle()); + expect( + kAppleXcodeCodegenRunScript, + kDefaultFlutterHookSpine.renderAppleXcode(), + ); + expect(kJasprWebCodegenHook, kDefaultJasprHookSpine.renderJasprWeb()); + }); + + test('tokenizeShellCommand supports quoted segments', () { + expect( + tokenizeShellCommand('dart run intentcall_cli:intentcall'), + ['dart', 'run', 'intentcall_cli:intentcall'], + ); + expect( + tokenizeShellCommand('"dart run" intentcall'), + ['dart run', 'intentcall'], + ); + }); + }); +} diff --git a/packages/intentcall_platform_sync/test/projection_alignment_test.dart b/packages/intentcall_platform_sync/test/projection_alignment_test.dart index 2ae3a01..4a5f095 100644 --- a/packages/intentcall_platform_sync/test/projection_alignment_test.dart +++ b/packages/intentcall_platform_sync/test/projection_alignment_test.dart @@ -352,11 +352,19 @@ layout: }); group('mcp_flutter three-gate (sibling consumer)', () { + // Three-gate spine (semantics unchanged across hosts): + // 1. dart run build_runner build --delete-conflicting-outputs + // 2. intentcall manifest export --check + // 3. intentcall platform sync --platform --check + // + // mcp_flutter Jaspr recipe: `make check-contracts` → + // tool/contracts/check_intentcall_jaspr_three_gate.sh + // Flutter consumer: `tool/contracts/check_intentcall_hosted_consumer.sh` test( 'skipped in agentkit — run make check-contracts in mcp_flutter', () {}, skip: - 'L5b/L5c: mcp_flutter Jaspr + flutter_test_app gates run in sibling repo', + 'L5b/L5c: mcp_flutter Jaspr + flutter_test_app gates run in sibling repo (make check-contracts)', ); }); }); diff --git a/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart b/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart index 5a6b80c..c56ad08 100644 --- a/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart +++ b/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart @@ -117,4 +117,61 @@ void main() { returnsNormally, ); }); + + test('WebMcpJsEmitter output aligns with ManifestSurfaceIndex web.webMcp', () { + final manifest = AgentManifest.parse(''' +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "app_demo_ping", + "namespace": "app", + "name": "demo_ping", + "description": "ping", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": true}, + "web.manifestShortcuts": {"include": false}, + "web.protocolHandlers": {"include": false}, + "android.shortcuts": {"include": false}, + "apple.appShortcuts": {"include": false}, + "windows.protocolActivation": {"include": false}, + "windows.msixProtocol": {"include": false}, + "linux.schemeHandler": {"include": false} + } + }, + { + "qualifiedName": "app_demo_cart", + "namespace": "app", + "name": "demo_cart", + "description": "cart", + "kind": "tool", + "inputSchema": {"type": "object"}, + "surfaces": { + "web.webMcp": {"include": false}, + "web.manifestShortcuts": {"include": false}, + "web.protocolHandlers": {"include": false}, + "android.shortcuts": {"include": false}, + "apple.appShortcuts": {"include": false}, + "windows.protocolActivation": {"include": false}, + "windows.msixProtocol": {"include": false}, + "linux.schemeHandler": {"include": false} + } + } + ] +} +'''); + final index = ManifestSurfaceIndex.fromManifest(manifest); + final js = const WebMcpJsEmitter().emit(manifest); + + for (final entry in manifest.entries) { + if (index.includesWebMcp(entry.qualifiedName)) { + expect(js, contains(entry.qualifiedName)); + } else { + expect(js, isNot(contains(entry.qualifiedName))); + } + } + }); } diff --git a/pubspec.lock b/pubspec.lock index 120f195..27bff56 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -283,6 +299,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" html: dependency: transitive description: @@ -411,6 +435,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + pigeon: + dependency: transitive + description: + name: pigeon + sha256: "2a4bfd279fac52b115818e93f5409d07955f7b3718d303fd5f100981be4de386" + url: "https://pub.dev" + source: hosted + version: "26.3.2" plugin_platform_interface: dependency: transitive description: @@ -443,6 +475,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 96ead89..1f2f9fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,8 @@ workspace: - packages/intentcall_apple - packages/intentcall_android - packages/intentcall_platform_sync + - packages/intentcall_hooks + - packages/intentcall_bridge - packages/intentcall_platform - packages/intentcall_cli - packages/intentcall_testing diff --git a/release-please-config.json b/release-please-config.json index 70fac9d..57cfa92 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,6 +16,7 @@ "intentcall_android", "intentcall_codegen", "intentcall_platform_sync", + "intentcall_hooks", "intentcall_cli", "intentcall_platform", "intentcall_testing" @@ -63,6 +64,11 @@ "package-name": "intentcall_platform_sync", "component": "intentcall_platform_sync" }, + "packages/intentcall_hooks": { + "release-type": "dart", + "package-name": "intentcall_hooks", + "component": "intentcall_hooks" + }, "packages/intentcall_cli": { "release-type": "dart", "package-name": "intentcall_cli", diff --git a/steward.yaml b/steward.yaml index 4307b3e..a787121 100644 --- a/steward.yaml +++ b/steward.yaml @@ -235,9 +235,42 @@ actions: evidence: redaction: steward/redaction/v1 summary_fields: [exit_code, duration_ms, output_digest] + intentcall.platform-hooks-check: + kind: command + desc: Run platform hook spine init gates (ADR 0024 Phase 1). + command: + argv: [just, platform-hooks-check] + shell: false + cwd: . + effects: + fs_read: + - justfile + - packages/intentcall_platform_sync/** + - packages/intentcall_cli/** + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 200000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] intentcall.projection-pipeline-check: kind: command - desc: Run Layer 5 projection pipeline gates (merger, dense export, emitters, codegen example sync). + desc: Run Layer 5 projection pipeline gates (alignment matrix, dense export, emitters, entity export, codegen example sync). command: argv: [just, projection-pipeline-check] shell: false @@ -384,7 +417,7 @@ actions: probes: quick: profile: quick - actions: [intentcall.validate, intentcall.adapter-contract-test, intentcall.manifest-export-check, intentcall.manifest-parity, intentcall.platform-sync-check] + actions: [intentcall.validate, intentcall.adapter-contract-test, intentcall.manifest-export-check, intentcall.manifest-parity, intentcall.platform-sync-check, intentcall.projection-pipeline-check] diagnostics: cases: {} unknown_cases: diff --git a/steward/scenarios/intentcall.projection-pipeline.yaml b/steward/scenarios/intentcall.projection-pipeline.yaml index d94f78e..6142beb 100644 --- a/steward/scenarios/intentcall.projection-pipeline.yaml +++ b/steward/scenarios/intentcall.projection-pipeline.yaml @@ -15,6 +15,13 @@ steps: - run: dart test packages/intentcall_platform_sync/test/dense_manifest_test.dart - run: dart test packages/intentcall_codegen/example/test/manifest_projection_test.dart - run: dart test packages/intentcall_platform_sync/test/native_emitters_test.dart + - run: dart test packages/intentcall_platform_sync/test/projection_alignment_test.dart + - run: dart test packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart + - run: dart test packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart + - run: dart test packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart + - run: dart test packages/intentcall_platform_sync/test/platform_sync_layout_test.dart + - run: dart test packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart + - run: dart test packages/intentcall_cli/test/manifest_entity_export_test.dart - working_directory: packages/intentcall_codegen/example run: dart run ../../intentcall_cli/bin/intentcall.dart manifest export --check - working_directory: packages/intentcall_codegen/example @@ -50,6 +57,36 @@ artifacts: path: packages/intentcall_platform_sync/test/native_emitters_test.dart required: true durability: behavior + - id: apple_surface_matrix_test + kind: dart + path: packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart + required: true + durability: behavior + - id: partial_defaults_platform_scope_test + kind: dart + path: packages/intentcall_platform_sync/test/partial_defaults_platform_scope_test.dart + required: true + durability: behavior + - id: ios_shortcuts_opt_in_test + kind: dart + path: packages/intentcall_platform_sync/test/ios_shortcuts_opt_in_test.dart + required: true + durability: behavior + - id: platform_sync_layout_test + kind: dart + path: packages/intentcall_platform_sync/test/platform_sync_layout_test.dart + required: true + durability: behavior + - id: webmcp_bootstrap_surface_test + kind: dart + path: packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart + required: true + durability: behavior + - id: manifest_entity_export_test + kind: dart + path: packages/intentcall_cli/test/manifest_entity_export_test.dart + required: true + durability: behavior - id: intentcall_cli kind: dart path: packages/intentcall_cli/bin/intentcall.dart diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index 287f64d..8309cd3 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -19,6 +19,7 @@ const publishOrder = [ 'intentcall_android', 'intentcall_codegen', 'intentcall_platform_sync', + 'intentcall_hooks', 'intentcall_cli', 'intentcall_platform', 'intentcall_testing', @@ -1010,9 +1011,9 @@ Future> swiftPackageManagerFindings( '${spec.packageDir} SwiftPM source is missing IntentCallPlatformPlugin', ); } - if (!source.contains('"intentcall_platform/invocations"')) { + if (!source.contains('IntentCallInvocationsHostApiSetup.setUp')) { findings.add( - '${spec.packageDir} SwiftPM source is missing the invocation channel', + '${spec.packageDir} SwiftPM source is missing the Pigeon invocation bridge', ); } } diff --git a/tool/intentcall/bin/release_train.dart b/tool/intentcall/bin/release_train.dart index 93db0d9..b02e666 100644 --- a/tool/intentcall/bin/release_train.dart +++ b/tool/intentcall/bin/release_train.dart @@ -11,6 +11,7 @@ const publishablePackages = [ 'intentcall_android', 'intentcall_codegen', 'intentcall_platform_sync', + 'intentcall_hooks', 'intentcall_cli', 'intentcall_platform', 'intentcall_testing', From cc162e00a536e607b74dd83a8cd6c721b6917a8d Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Wed, 8 Jul 2026 23:08:19 +0300 Subject: [PATCH 14/27] feat: add intentcall_bridge package with entity open envelope support, update testing commands, and enhance documentation for new features --- .release-please-manifest.json | 1 + PUBLISHING.md | 6 +- README.md | 2 + ...art-hooks-and-pigeon-bridge-consistency.md | 14 ++- docs/evidence/hooks-native-bridge-plan.md | 41 +++++++- justfile | 2 +- packages/intentcall_bridge/CHANGELOG.md | 3 + .../intentcall_bridge/analysis_options.yaml | 2 +- .../lib/src/intentcall_platform_bridge.g.dart | 95 ++++++++++++++++++- .../pigeons/intentcall_platform_bridge.dart | 48 ++++++---- packages/intentcall_hooks/README.md | 9 +- packages/intentcall_platform/README.md | 25 ++++- .../IntentCallPlatformBridge.g.kt | 81 +++++++++++++++- .../IntentCallPlatformPlugin.kt | 3 + .../IntentCallNativeEntitySnapshotStore.swift | 10 ++ .../IntentCallPlatformBridge.g.swift | 74 ++++++++++++++- .../IntentCallPlatformPlugin.swift | 23 +++++ .../lib/intentcall_platform_flutter.dart | 1 + .../src/flutter/intentcall_flutter_host.dart | 20 ++++ .../intentcall_pending_entity_opens.dart | 27 ++++++ .../intentcall_pending_entity_opens_stub.dart | 9 ++ .../IntentCallNativeEntitySnapshotStore.swift | 10 ++ .../IntentCallPlatformBridge.g.swift | 74 ++++++++++++++- .../IntentCallPlatformPlugin.swift | 23 +++++ .../test/intentcall_flutter_host_test.dart | 25 +++++ .../test/pigeon_bridge_contract_test.dart | 18 ++++ .../lib/intentcall_platform_sync.dart | 1 + .../invocation/intentcall_entity_open.dart | 41 ++++++++ packages/intentcall_schema/README.md | 2 +- .../lib/src/resource_uri.dart | 2 +- .../test/agent_result_envelope_test.dart | 2 +- .../test/resource_uri_test.dart | 2 +- release-please-config.json | 6 ++ tool/intentcall/bin/intentcall.dart | 1 + tool/intentcall/bin/release_train.dart | 1 + .../test/publish_preflight_test.dart | 4 +- 36 files changed, 665 insertions(+), 43 deletions(-) create mode 100644 packages/intentcall_bridge/CHANGELOG.md create mode 100644 packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart create mode 100644 packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens_stub.dart create mode 100644 packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1eb5ffb..79d9aec 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -9,6 +9,7 @@ "packages/intentcall_codegen": "0.6.0", "packages/intentcall_platform_sync": "0.6.0", "packages/intentcall_hooks": "0.6.0", + "packages/intentcall_bridge": "0.6.0", "packages/intentcall_cli": "0.6.0", "packages/intentcall_platform": "0.6.0", "packages/intentcall_testing": "0.6.0" diff --git a/PUBLISHING.md b/PUBLISHING.md index 1133627..f4b7ea1 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -52,6 +52,10 @@ intentcall_webmcp-v{{version}} intentcall_apple-v{{version}} intentcall_android-v{{version}} intentcall_codegen-v{{version}} +intentcall_platform_sync-v{{version}} +intentcall_hooks-v{{version}} +intentcall_bridge-v{{version}} +intentcall_cli-v{{version}} intentcall_platform-v{{version}} intentcall_testing-v{{version}} ``` @@ -72,7 +76,7 @@ uses GitHub OIDC through `dart-lang/setup-dart`. 2. `intentcall_core` 3. `intentcall_session` 4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` -5. `intentcall_platform_sync`, `intentcall_cli` +5. `intentcall_platform_sync`, `intentcall_hooks`, `intentcall_bridge`, `intentcall_cli` 6. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) 7. `intentcall_testing` diff --git a/README.md b/README.md index 1722dec..2b63b30 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ GitHub: [Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) | `intentcall_apple` | [![pub package](https://img.shields.io/pub/v/intentcall_apple.svg?include_prereleases)](https://pub.dev/packages/intentcall_apple) [![pub points](https://img.shields.io/pub/points/intentcall_apple.svg)](https://pub.dev/packages/intentcall_apple/score) | Apple manifest projection for App Intents / Shortcuts dispatch artifacts and first typed-entity/indexing direction | | `intentcall_android` | [![pub package](https://img.shields.io/pub/v/intentcall_android.svg?include_prereleases)](https://pub.dev/packages/intentcall_android) [![pub points](https://img.shields.io/pub/points/intentcall_android.svg)](https://pub.dev/packages/intentcall_android/score) | Android manifest codegen for shortcut and deep-link artifacts | | `intentcall_platform_sync` | [![pub package](https://img.shields.io/pub/v/intentcall_platform_sync.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform_sync) | Dart-only manifest, emitters, and `PlatformSync` | +| `intentcall_hooks` | [![pub package](https://img.shields.io/pub/v/intentcall_hooks.svg?include_prereleases)](https://pub.dev/packages/intentcall_hooks) | Dart build hooks for manifest export and platform sync | +| `intentcall_bridge` | [![pub package](https://img.shields.io/pub/v/intentcall_bridge.svg?include_prereleases)](https://pub.dev/packages/intentcall_bridge) | Pigeon IDL and generated Dart bindings for the platform bridge | | `intentcall_cli` | [![pub package](https://img.shields.io/pub/v/intentcall_cli.svg?include_prereleases)](https://pub.dev/packages/intentcall_cli) | Framework-neutral CLI: manifest export, platform sync, MCP serve | | `intentcall_platform` | [![pub package](https://img.shields.io/pub/v/intentcall_platform.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform) [![pub points](https://img.shields.io/pub/points/intentcall_platform.svg)](https://pub.dev/packages/intentcall_platform/score) | Flutter plugin and native bridge (re-exports `intentcall_platform_sync`) | | `intentcall_codegen` | [![pub package](https://img.shields.io/pub/v/intentcall_codegen.svg?include_prereleases)](https://pub.dev/packages/intentcall_codegen) [![pub points](https://img.shields.io/pub/points/intentcall_codegen.svg)](https://pub.dev/packages/intentcall_codegen/score) | Optional `@AgentTool` codegen | diff --git a/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md b/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md index 78c46bf..22b118f 100644 --- a/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md +++ b/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md @@ -45,9 +45,17 @@ Gradle, Xcode, and Jaspr templates are **generated from the spine**, not hand-ma Publish `intentcall_hooks` with `hook/build.dart` that calls `ManifestExporter` and `PlatformSync` **in-process** (no subprocess `intentcall`). -- **Phase 2a:** Jaspr and plain Dart web hosts -- **Phase 2b:** Flutter hosts — only after `flutter build` hook timing is proven; Gradle/Xcode - templates may shrink to staleness checks meanwhile +- **Phase 2a:** Jaspr and plain Dart web hosts — `intentcall_hooks` is the + canonical build hook; no Gradle/Xcode involvement +- **Phase 2b (deferred gate):** Flutter hosts — Gradle `preBuild` and Xcode Run + Script templates from `PlatformHookSpine` stay until dogfood proves Dart SDK + hook ordering. **Gate:** `flutter build` must run `hook/build.dart` and + complete manifest export + platform sync **before** `xcodebuild compile` / + Android native compile for the app target. Until then, Flutter consumers keep + spine-rendered Gradle/Xcode hooks from `intentcall platform hooks init`; do not + remove those templates. After proof, templates may shrink to staleness checks; + full removal needs ADR amendment. See + [hooks-native-bridge-plan §6.3](../evidence/hooks-native-bridge-plan.md#63-flutter-native-hook-migration-deferred--phase-2b). - Hook v1 **requires** fresh `agent_catalog.g.dart`; does not spawn `build_runner` inside the hook - Extract shared `CatalogLoader` for CLI and hook parity diff --git a/docs/evidence/hooks-native-bridge-plan.md b/docs/evidence/hooks-native-bridge-plan.md index d7b8ac5..14a76f8 100644 --- a/docs/evidence/hooks-native-bridge-plan.md +++ b/docs/evidence/hooks-native-bridge-plan.md @@ -138,7 +138,44 @@ packages/intentcall_hooks/ **Order:** P2b → P2a (loader first) -**Defer:** Flutter iOS/Android Gradle/Xcode hook removal until Flutter hook timing proof. +**Defer:** Flutter iOS/Android Gradle/Xcode hook removal until Flutter hook timing proof +(see §6.3). + +### 6.3 Flutter native hook migration (deferred — Phase 2b) + +Gradle `preBuild` and Xcode Run Script hooks remain the **canonical** invocation +surface for Flutter Android/iOS/macOS until Dart SDK hook ordering is proven in +real `flutter build` pipelines. + +**Deferral criteria (Phase 2b gate):** + +Do **not** remove or shrink Gradle/Xcode templates until all of: + +1. **Ordering** — `flutter build` (apk, appbundle, ipa, macos, or equivalent) + runs `intentcall_hooks` `hook/build.dart` **before** the host native compile + phase (`xcodebuild compile` / `CompileSwift` for Apple; `compileDebugKotlin` + or release equivalent for Android). +2. **Three-gate parity** — manifest export + platform sync complete before native + Swift/Kotlin that reads `agent_manifest.json`, `IntentCallGenerated.swift`, or + other sync outputs is compiled. +3. **Incremental builds** — stale-cache and skip paths are observed (hook not + re-run on incremental rebuild, `DataAsset` invalidation gaps) and mitigations + are documented or fixed. +4. **Dogfood proof** — build log evidence from mcp_flutter or an in-repo Flutter + fixture showing hook output timestamps preceding the first native compile line + for the app target. + +**Until the gate passes:** + +- `PlatformHookSpine` continues to render Gradle/Xcode snippets via + `intentcall platform hooks init` (templates generated from spine, not + hand-maintained). +- `intentcall_hooks` ships for Jaspr and plain Dart hosts only (Phase 2a). +- Gradle/Xcode templates may shrink to staleness checks **after** timing proof; + full removal requires ADR amendment. + +**Proof artifact:** recorded build logs or CI appendices under `docs/evidence/` +demonstrating hook-before-compile ordering; optional steward scenario. --- @@ -252,7 +289,7 @@ Parent blocks phase N+1 until phase N aggregate gate passes (except P3a after P0 | Risk | Mitigation | |------|------------| -| Flutter hook timing vs Xcode compile | Keep Gradle/Xcode until dogfood proof | +| Flutter hook timing vs Xcode compile | Keep Gradle/Xcode until dogfood proof (§6.3) | | `DataAsset` not stable for Swift/XML artifacts | Hook writes project tree via `PlatformSync` (same as CLI) | | Pigeon breaks plugin consumers | Deprecation period; channel names unchanged | | mcp_flutter path deps | Land agentkit first; bump hosted versions | diff --git a/justfile b/justfile index e5bdc22..89e5d38 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,7 @@ default: # Run tests for all packages in the workspace test: - dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform_sync packages/intentcall_hooks packages/intentcall_codegen packages/intentcall_cli packages/intentcall_platform packages/intentcall_testing tool/intentcall + dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform_sync packages/intentcall_hooks packages/intentcall_bridge packages/intentcall_codegen/test packages/intentcall_cli packages/intentcall_platform packages/intentcall_testing tool/intentcall # Manifest freshness gate (build_runner catalog + export --check) manifest-export-check: diff --git a/packages/intentcall_bridge/CHANGELOG.md b/packages/intentcall_bridge/CHANGELOG.md new file mode 100644 index 0000000..1512c42 --- /dev/null +++ b/packages/intentcall_bridge/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Unreleased diff --git a/packages/intentcall_bridge/analysis_options.yaml b/packages/intentcall_bridge/analysis_options.yaml index e755406..4b5bddf 100644 --- a/packages/intentcall_bridge/analysis_options.yaml +++ b/packages/intentcall_bridge/analysis_options.yaml @@ -1 +1 @@ -include: package:xsoulspace_lints/dart.yaml +include: package:xsoulspace_lints/public_library.yaml diff --git a/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart b/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart index 82d3098..2e0e66d 100644 --- a/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart +++ b/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart @@ -158,13 +158,74 @@ class IntentCallInvocationEnvelopeDto { int get hashCode => _deepHash([runtimeType, ..._toList()]); } +/// Native entity-open envelope drained from the entity snapshot store. +class IntentCallEntityOpenEnvelopeDto { + IntentCallEntityOpenEnvelopeDto({ + required this.id, + required this.entityType, + required this.entityId, + required this.source, + required this.createdAt, + }); + + String id; + + String entityType; + + String entityId; + + String source; + + String createdAt; + + List _toList() { + return [ + id, + entityType, + entityId, + source, + createdAt, + ]; + } + + Object encode() { + return _toList(); } + + static IntentCallEntityOpenEnvelopeDto decode(Object result) { + result as List; + return IntentCallEntityOpenEnvelopeDto( + id: result[0]! as String, + entityType: result[1]! as String, + entityId: result[2]! as String, + source: result[3]! as String, + createdAt: result[4]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! IntentCallEntityOpenEnvelopeDto || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(id, other.id) && _deepEquals(entityType, other.entityType) && _deepEquals(entityId, other.entityId) && _deepEquals(source, other.source) && _deepEquals(createdAt, other.createdAt); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + /// Manifest-projected entity field keys for snapshot CRUD and search. class IntentCallEntityKeyBundle { IntentCallEntityKeyBundle({ - required this.idKey, - required this.titleKey, - required this.subtitleKey, - required this.keywordsKey, + this.idKey = 'id', + this.titleKey = 'title', + this.subtitleKey = 'subtitle', + this.keywordsKey = 'keywords', }); String idKey; @@ -225,9 +286,12 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is IntentCallInvocationEnvelopeDto) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is IntentCallEntityKeyBundle) { + } else if (value is IntentCallEntityOpenEnvelopeDto) { buffer.putUint8(130); writeValue(buffer, value.encode()); + } else if (value is IntentCallEntityKeyBundle) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -239,6 +303,8 @@ class _PigeonCodec extends StandardMessageCodec { case 129: return IntentCallInvocationEnvelopeDto.decode(readValue(buffer)!); case 130: + return IntentCallEntityOpenEnvelopeDto.decode(readValue(buffer)!); + case 131: return IntentCallEntityKeyBundle.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -386,4 +452,23 @@ class IntentCallEntitiesHostApi { ; return (pigeonVar_replyValue! as List).cast>(); } + + Future> takePendingEntityOpens() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.takePendingEntityOpens$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } } diff --git a/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart index 2e6b271..95790ad 100644 --- a/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart +++ b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart @@ -9,9 +9,7 @@ import 'package:pigeon/pigeon.dart'; swiftOptions: SwiftOptions(), kotlinOut: '../intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt', - kotlinOptions: KotlinOptions( - package: 'dev.intentcall.intentcall_platform', - ), + kotlinOptions: KotlinOptions(package: 'dev.intentcall.intentcall_platform'), ), ) /// Native invocation envelope drained from the handoff store. @@ -23,19 +21,27 @@ class IntentCallInvocationEnvelopeDto { late String createdAt; } +/// Native entity-open envelope drained from the entity snapshot store. +class IntentCallEntityOpenEnvelopeDto { + late String id; + late String entityType; + late String entityId; + late String source; + late String createdAt; +} + /// Manifest-projected entity field keys for snapshot CRUD and search. class IntentCallEntityKeyBundle { - String idKey; - String titleKey; - String subtitleKey; - String keywordsKey; - IntentCallEntityKeyBundle({ this.idKey = 'id', this.titleKey = 'title', this.subtitleKey = 'subtitle', this.keywordsKey = 'keywords', }); + String idKey; + String titleKey; + String subtitleKey; + String keywordsKey; } @HostApi() @@ -46,25 +52,27 @@ abstract class IntentCallInvocationsHostApi { @HostApi() abstract class IntentCallEntitiesHostApi { int upsertEntitySnapshots( - String entityType, - List> snapshots, - IntentCallEntityKeyBundle keys, + final String entityType, + final List> snapshots, + final IntentCallEntityKeyBundle keys, ); int deleteEntitySnapshots( - String entityType, - List ids, - IntentCallEntityKeyBundle keys, + final String entityType, + final List ids, + final IntentCallEntityKeyBundle keys, ); - int clearEntityTypeSnapshots(String entityType); + int clearEntityTypeSnapshots(final String entityType); - List> listEntitySnapshots(String entityType); + List> listEntitySnapshots(final String entityType); List> searchEntitySnapshots( - String entityType, - String query, - int limit, - IntentCallEntityKeyBundle keys, + final String entityType, + final String query, + final int limit, + final IntentCallEntityKeyBundle keys, ); + + List takePendingEntityOpens(); } diff --git a/packages/intentcall_hooks/README.md b/packages/intentcall_hooks/README.md index 2a828aa..453fbf4 100644 --- a/packages/intentcall_hooks/README.md +++ b/packages/intentcall_hooks/README.md @@ -3,13 +3,20 @@ Dart SDK build hooks for IntentCall projection: manifest export and platform sync in-process (no `intentcall` subprocess). +**Host scope (ADR 0024):** Jaspr and plain Dart web hosts use this package as the +canonical build hook (Phase 2a). **Flutter hosts still use Gradle/Xcode templates +from `PlatformHookSpine`** (`intentcall platform hooks init`) until Phase 2b +timing proof shows `flutter build` runs this hook before `xcodebuild compile` / +Android native compile. Do not remove Gradle/Xcode hooks in Flutter apps until +that gate passes. + ## Usage Add a dev dependency and configure user-defines on the consuming package: ```yaml dev_dependencies: - intentcall_hooks: ^0.6.0 + intentcall_hooks: any # dart pub add --dev intentcall_hooks hooks: user_defines: diff --git a/packages/intentcall_platform/README.md b/packages/intentcall_platform/README.md index 015d2de..806413c 100644 --- a/packages/intentcall_platform/README.md +++ b/packages/intentcall_platform/README.md @@ -270,12 +270,35 @@ Flutter MCP Toolkit consumers may delegate: flutter-mcp-toolkit codegen sync --platform web,ios,macos --project-dir ``` -### One-time hooks +### Build hooks (ADR 0024) + +| Host | Invocation surface | +|------|-------------------| +| **Flutter** (Android/iOS/macOS) | Gradle `preBuild` + Xcode Run Script from `PlatformHookSpine` — one-time init below | +| **Jaspr / plain Dart web** | `intentcall_hooks` Dart SDK `hook/build.dart` (no Gradle/Xcode) | + +Flutter native hook migration to `intentcall_hooks` is **deferred** (Phase 2b) +until `flutter build` is proven to run the Dart hook before `xcodebuild compile` +/ Android native compile. Until then, keep spine-rendered Gradle/Xcode snippets. + +**Flutter — one-time init:** ```bash intentcall platform hooks init --host flutter --project-dir ``` +Renders Gradle and Xcode Run Script blocks from `PlatformHookSpine` (not +hand-maintained strings). Re-run after `intentcall.yaml` hook config changes. + +**Jaspr / plain Dart — add dev dependency:** + +```yaml +dev_dependencies: + intentcall_hooks: ^0.6.0 +``` + +See [intentcall_hooks README](../intentcall_hooks/README.md) for `user_defines`. + ### Manifest generation Run `dart run build_runner build`, then `intentcall manifest export --check`. Do not hand-edit descriptor rows in `agent_manifest.json`. diff --git a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt index 72670d1..82531c1 100644 --- a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt +++ b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt @@ -246,6 +246,60 @@ data class IntentCallInvocationEnvelopeDto ( } } +/** + * Native entity-open envelope drained from the entity snapshot store. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class IntentCallEntityOpenEnvelopeDto ( + val id: String, + val entityType: String, + val entityId: String, + val source: String, + val createdAt: String +) + { + companion object { + fun fromList(pigeonVar_list: List): IntentCallEntityOpenEnvelopeDto { + val id = pigeonVar_list[0] as String + val entityType = pigeonVar_list[1] as String + val entityId = pigeonVar_list[2] as String + val source = pigeonVar_list[3] as String + val createdAt = pigeonVar_list[4] as String + return IntentCallEntityOpenEnvelopeDto(id, entityType, entityId, source, createdAt) + } + } + fun toList(): List { + return listOf( + id, + entityType, + entityId, + source, + createdAt, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as IntentCallEntityOpenEnvelopeDto + return IntentCallPlatformBridgePigeonUtils.deepEquals(this.id, other.id) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.entityType, other.entityType) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.entityId, other.entityId) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.source, other.source) && IntentCallPlatformBridgePigeonUtils.deepEquals(this.createdAt, other.createdAt) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.id) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.entityType) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.entityId) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.source) + result = 31 * result + IntentCallPlatformBridgePigeonUtils.deepHash(this.createdAt) + return result + } +} + /** * Manifest-projected entity field keys for snapshot CRUD and search. * @@ -304,6 +358,11 @@ private open class IntentCallPlatformBridgePigeonCodec : StandardMessageCodec() } } 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + IntentCallEntityOpenEnvelopeDto.fromList(it) + } + } + 131.toByte() -> { return (readValue(buffer) as? List)?.let { IntentCallEntityKeyBundle.fromList(it) } @@ -317,10 +376,14 @@ private open class IntentCallPlatformBridgePigeonCodec : StandardMessageCodec() stream.write(129) writeValue(stream, value.toList()) } - is IntentCallEntityKeyBundle -> { + is IntentCallEntityOpenEnvelopeDto -> { stream.write(130) writeValue(stream, value.toList()) } + is IntentCallEntityKeyBundle -> { + stream.write(131) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -364,6 +427,7 @@ interface IntentCallEntitiesHostApi { fun clearEntityTypeSnapshots(entityType: String): Long fun listEntitySnapshots(entityType: String): List> fun searchEntitySnapshots(entityType: String, query: String, limit: Long, keys: IntentCallEntityKeyBundle): List> + fun takePendingEntityOpens(): List companion object { /** The codec used by IntentCallEntitiesHostApi. */ @@ -466,6 +530,21 @@ interface IntentCallEntitiesHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.takePendingEntityOpens$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.takePendingEntityOpens()) + } catch (exception: Throwable) { + IntentCallPlatformBridgePigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt index 6d5d52b..64936d1 100644 --- a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt +++ b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt @@ -46,4 +46,7 @@ private class IntentCallPlatformBridgeStub : limit: Long, keys: IntentCallEntityKeyBundle, ): List> = emptyList() + + override fun takePendingEntityOpens(): List = + emptyList() } diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift index 712732e..5e52823 100644 --- a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift @@ -159,6 +159,16 @@ public enum IntentCallNativeEntitySnapshotStore { return openId } + /// At-most-once drain semantics: clears pending rows before Dart reports success. + public static func takePendingEntityOpens() -> [[String: Any]] { + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let pending = + UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: pendingOpenKey) + return pending + } + public static func string(_ value: Any?) -> String? { if let value = value as? String { return value } if let value = value as? CustomStringConvertible { return value.description } diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift index 2689913..6e0b602 100644 --- a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift @@ -227,6 +227,59 @@ struct IntentCallInvocationEnvelopeDto: Hashable { } } +/// Native entity-open envelope drained from the entity snapshot store. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct IntentCallEntityOpenEnvelopeDto: Hashable { + var id: String + var entityType: String + var entityId: String + var source: String + var createdAt: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallEntityOpenEnvelopeDto? { + let id = pigeonVar_list[0] as! String + let entityType = pigeonVar_list[1] as! String + let entityId = pigeonVar_list[2] as! String + let source = pigeonVar_list[3] as! String + let createdAt = pigeonVar_list[4] as! String + + return IntentCallEntityOpenEnvelopeDto( + id: id, + entityType: entityType, + entityId: entityId, + source: source, + createdAt: createdAt + ) + } + func toList() -> [Any?] { + return [ + id, + entityType, + entityId, + source, + createdAt, + ] + } + static func == (lhs: IntentCallEntityOpenEnvelopeDto, rhs: IntentCallEntityOpenEnvelopeDto) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsIntentCallPlatformBridge(lhs.id, rhs.id) && deepEqualsIntentCallPlatformBridge(lhs.entityType, rhs.entityType) && deepEqualsIntentCallPlatformBridge(lhs.entityId, rhs.entityId) && deepEqualsIntentCallPlatformBridge(lhs.source, rhs.source) && deepEqualsIntentCallPlatformBridge(lhs.createdAt, rhs.createdAt) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("IntentCallEntityOpenEnvelopeDto") + deepHashIntentCallPlatformBridge(value: id, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: entityType, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: entityId, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: source, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: createdAt, hasher: &hasher) + } +} + /// Manifest-projected entity field keys for snapshot CRUD and search. /// /// Generated class from Pigeon that represents data sent in messages. @@ -281,6 +334,8 @@ private class IntentCallPlatformBridgePigeonCodecReader: FlutterStandardReader { case 129: return IntentCallInvocationEnvelopeDto.fromList(self.readValue() as! [Any?]) case 130: + return IntentCallEntityOpenEnvelopeDto.fromList(self.readValue() as! [Any?]) + case 131: return IntentCallEntityKeyBundle.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -293,9 +348,12 @@ private class IntentCallPlatformBridgePigeonCodecWriter: FlutterStandardWriter { if let value = value as? IntentCallInvocationEnvelopeDto { super.writeByte(129) super.writeValue(value.toList()) - } else if let value = value as? IntentCallEntityKeyBundle { + } else if let value = value as? IntentCallEntityOpenEnvelopeDto { super.writeByte(130) super.writeValue(value.toList()) + } else if let value = value as? IntentCallEntityKeyBundle { + super.writeByte(131) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -349,6 +407,7 @@ protocol IntentCallEntitiesHostApi { func clearEntityTypeSnapshots(entityType: String) throws -> Int64 func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] func searchEntitySnapshots(entityType: String, query: String, limit: Int64, keys: IntentCallEntityKeyBundle) throws -> [[String?: Any?]] + func takePendingEntityOpens() throws -> [IntentCallEntityOpenEnvelopeDto] } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -439,5 +498,18 @@ class IntentCallEntitiesHostApiSetup { } else { searchEntitySnapshotsChannel.setMessageHandler(nil) } + let takePendingEntityOpensChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.takePendingEntityOpens\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + takePendingEntityOpensChannel.setMessageHandler { _, reply in + do { + let result = try api.takePendingEntityOpens() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + takePendingEntityOpensChannel.setMessageHandler(nil) + } } } diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift index 02e66a7..4df9c32 100644 --- a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift @@ -79,6 +79,10 @@ private final class IntentCallPlatformBridgeHostApiImpl: IntentCallInvocationsHo ) } + func takePendingEntityOpens() throws -> [IntentCallEntityOpenEnvelopeDto] { + IntentCallNativeEntitySnapshotStore.takePendingEntityOpens().compactMap(entityOpenDto(from:)) + } + private func envelopeDto(from row: [String: Any]) -> IntentCallInvocationEnvelopeDto? { guard let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), @@ -98,6 +102,25 @@ private final class IntentCallPlatformBridgeHostApiImpl: IntentCallInvocationsHo ) } + private func entityOpenDto(from row: [String: Any]) -> IntentCallEntityOpenEnvelopeDto? { + guard + let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), + let entityType = IntentCallNativeEntitySnapshotStore.string(row["entityType"]), + let entityId = IntentCallNativeEntitySnapshotStore.string(row["entityId"]), + let source = IntentCallNativeEntitySnapshotStore.string(row["source"]), + let createdAt = IntentCallNativeEntitySnapshotStore.string(row["createdAt"]) + else { + return nil + } + return IntentCallEntityOpenEnvelopeDto( + id: id, + entityType: entityType, + entityId: entityId, + source: source, + createdAt: createdAt + ) + } + private func pigeonMap(from row: [String: Any]?) -> [String?: Any?]? { guard let row else { return nil } var normalized = [String?: Any?]() diff --git a/packages/intentcall_platform/lib/intentcall_platform_flutter.dart b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart index 82fc722..d3c2846 100644 --- a/packages/intentcall_platform/lib/intentcall_platform_flutter.dart +++ b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart @@ -6,4 +6,5 @@ export 'src/flutter/intentcall_entity_key_bundle.dart'; export 'src/flutter/intentcall_flutter_host.dart'; export 'src/flutter/intentcall_host_events.dart'; export 'src/flutter/intentcall_invoke_link.dart'; +export 'src/flutter/intentcall_pending_entity_opens.dart'; export 'src/flutter/intentcall_pending_invocations.dart'; diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart index f08aa72..f05cf12 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart @@ -9,11 +9,15 @@ import 'intentcall_invoke_link_stub.dart' if (dart.library.ui) 'intentcall_invoke_link.dart'; import 'intentcall_lifecycle_wake_signals_stub.dart' if (dart.library.ui) 'intentcall_lifecycle_wake_signals.dart'; +import 'intentcall_pending_entity_opens_stub.dart' + if (dart.library.ui) 'intentcall_pending_entity_opens.dart'; import 'intentcall_pending_invocations_stub.dart' if (dart.library.ui) 'intentcall_pending_invocations.dart'; typedef IntentCallPendingReader = Future> Function(); +typedef IntentCallEntityOpenReader = + Future> Function(); typedef IntentCallEnvelopeCallback = void Function(IntentCallInvocationEnvelope envelope); typedef IntentCallResultCallback = @@ -24,17 +28,21 @@ typedef IntentCallErrorCallback = Object error, StackTrace stackTrace, ); +typedef IntentCallEntityOpenCallback = + void Function(IntentCallEntityOpenEnvelope envelope); final class IntentCallFlutterHost { IntentCallFlutterHost._({ required this.bridge, required this.takePendingInvocations, + required this.takePendingEntityOpens, required this.registerWebMcp, required this.webMcpSurfaceIndex, required this.onEnvelope, required this.onResult, required this.onDenied, required this.onError, + required this.onEntityOpen, required this.drainOnStart, this._wakeSignals, this._lifecycleWakeSignals, @@ -52,11 +60,13 @@ final class IntentCallFlutterHost { final bool listenForDeepLinks = false, final String? protocolScheme, final IntentCallPendingReader? takePendingInvocations, + final IntentCallEntityOpenReader? takePendingEntityOpens, final Stream? wakeSignals, final IntentCallEnvelopeCallback? onEnvelope, final IntentCallResultCallback? onResult, final IntentCallResultCallback? onDenied, final IntentCallErrorCallback? onError, + final IntentCallEntityOpenCallback? onEntityOpen, }) { final lifecycleWakeSignals = wakeSignals == null && drainOnResume ? IntentCallLifecycleWakeSignals() @@ -84,12 +94,16 @@ final class IntentCallFlutterHost { takePendingInvocations: takePendingInvocations ?? const IntentCallPendingInvocations().takePending, + takePendingEntityOpens: + takePendingEntityOpens ?? + const IntentCallPendingEntityOpens().takePending, registerWebMcp: registerWebMcp, webMcpSurfaceIndex: webMcpSurfaceIndex, onEnvelope: onEnvelope, onResult: onResult, onDenied: onDenied, onError: onError, + onEntityOpen: onEntityOpen, drainOnStart: drainOnStart, wakeSignals: wakeSignals ?? lifecycleWakeSignals?.resumeSignals, lifecycleWakeSignals: lifecycleWakeSignals, @@ -100,12 +114,14 @@ final class IntentCallFlutterHost { final IntentCallNativeBridge bridge; final IntentCallPendingReader takePendingInvocations; + final IntentCallEntityOpenReader takePendingEntityOpens; final bool registerWebMcp; final ManifestSurfaceIndex? webMcpSurfaceIndex; final IntentCallEnvelopeCallback? onEnvelope; final IntentCallResultCallback? onResult; final IntentCallResultCallback? onDenied; final IntentCallErrorCallback? onError; + final IntentCallEntityOpenCallback? onEntityOpen; final bool drainOnStart; final Stream? _wakeSignals; @@ -187,6 +203,10 @@ final class IntentCallFlutterHost { for (final envelope in pending) { results.add(await execute(envelope, trigger: trigger)); } + final pendingOpens = await takePendingEntityOpens(); + for (final open in pendingOpens) { + onEntityOpen?.call(open); + } _emit( IntentCallHostEvent( kind: IntentCallHostEventKind.drainFinished, diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart new file mode 100644 index 0000000..8085957 --- /dev/null +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart @@ -0,0 +1,27 @@ +import 'package:intentcall_bridge/intentcall_bridge.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +final class IntentCallPendingEntityOpens { + IntentCallPendingEntityOpens({ + final IntentCallEntitiesHostApi? hostApi, + }) : _hostApi = hostApi ?? IntentCallEntitiesHostApi(); + + final IntentCallEntitiesHostApi _hostApi; + + Future> takePending() async { + final rows = await _hostApi.takePendingEntityOpens(); + return rows.map(_toEnvelope).toList(growable: false); + } +} + +IntentCallEntityOpenEnvelope _toEnvelope( + final IntentCallEntityOpenEnvelopeDto dto, +) { + return IntentCallEntityOpenEnvelope( + id: dto.id, + entityType: dto.entityType, + entityId: dto.entityId, + source: dto.source, + createdAt: DateTime.tryParse(dto.createdAt), + ); +} diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens_stub.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens_stub.dart new file mode 100644 index 0000000..25cc4c8 --- /dev/null +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens_stub.dart @@ -0,0 +1,9 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +/// VM-safe fallback for host tests and non-Flutter analysis. +final class IntentCallPendingEntityOpens { + const IntentCallPendingEntityOpens(); + + Future> takePending() async => + const []; +} diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift index 712732e..5e52823 100644 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift @@ -159,6 +159,16 @@ public enum IntentCallNativeEntitySnapshotStore { return openId } + /// At-most-once drain semantics: clears pending rows before Dart reports success. + public static func takePendingEntityOpens() -> [[String: Any]] { + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + let pending = + UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: pendingOpenKey) + return pending + } + public static func string(_ value: Any?) -> String? { if let value = value as? String { return value } if let value = value as? CustomStringConvertible { return value.description } diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift index 2689913..6e0b602 100644 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift @@ -227,6 +227,59 @@ struct IntentCallInvocationEnvelopeDto: Hashable { } } +/// Native entity-open envelope drained from the entity snapshot store. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct IntentCallEntityOpenEnvelopeDto: Hashable { + var id: String + var entityType: String + var entityId: String + var source: String + var createdAt: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallEntityOpenEnvelopeDto? { + let id = pigeonVar_list[0] as! String + let entityType = pigeonVar_list[1] as! String + let entityId = pigeonVar_list[2] as! String + let source = pigeonVar_list[3] as! String + let createdAt = pigeonVar_list[4] as! String + + return IntentCallEntityOpenEnvelopeDto( + id: id, + entityType: entityType, + entityId: entityId, + source: source, + createdAt: createdAt + ) + } + func toList() -> [Any?] { + return [ + id, + entityType, + entityId, + source, + createdAt, + ] + } + static func == (lhs: IntentCallEntityOpenEnvelopeDto, rhs: IntentCallEntityOpenEnvelopeDto) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsIntentCallPlatformBridge(lhs.id, rhs.id) && deepEqualsIntentCallPlatformBridge(lhs.entityType, rhs.entityType) && deepEqualsIntentCallPlatformBridge(lhs.entityId, rhs.entityId) && deepEqualsIntentCallPlatformBridge(lhs.source, rhs.source) && deepEqualsIntentCallPlatformBridge(lhs.createdAt, rhs.createdAt) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("IntentCallEntityOpenEnvelopeDto") + deepHashIntentCallPlatformBridge(value: id, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: entityType, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: entityId, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: source, hasher: &hasher) + deepHashIntentCallPlatformBridge(value: createdAt, hasher: &hasher) + } +} + /// Manifest-projected entity field keys for snapshot CRUD and search. /// /// Generated class from Pigeon that represents data sent in messages. @@ -281,6 +334,8 @@ private class IntentCallPlatformBridgePigeonCodecReader: FlutterStandardReader { case 129: return IntentCallInvocationEnvelopeDto.fromList(self.readValue() as! [Any?]) case 130: + return IntentCallEntityOpenEnvelopeDto.fromList(self.readValue() as! [Any?]) + case 131: return IntentCallEntityKeyBundle.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -293,9 +348,12 @@ private class IntentCallPlatformBridgePigeonCodecWriter: FlutterStandardWriter { if let value = value as? IntentCallInvocationEnvelopeDto { super.writeByte(129) super.writeValue(value.toList()) - } else if let value = value as? IntentCallEntityKeyBundle { + } else if let value = value as? IntentCallEntityOpenEnvelopeDto { super.writeByte(130) super.writeValue(value.toList()) + } else if let value = value as? IntentCallEntityKeyBundle { + super.writeByte(131) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -349,6 +407,7 @@ protocol IntentCallEntitiesHostApi { func clearEntityTypeSnapshots(entityType: String) throws -> Int64 func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] func searchEntitySnapshots(entityType: String, query: String, limit: Int64, keys: IntentCallEntityKeyBundle) throws -> [[String?: Any?]] + func takePendingEntityOpens() throws -> [IntentCallEntityOpenEnvelopeDto] } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -439,5 +498,18 @@ class IntentCallEntitiesHostApiSetup { } else { searchEntitySnapshotsChannel.setMessageHandler(nil) } + let takePendingEntityOpensChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.takePendingEntityOpens\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + takePendingEntityOpensChannel.setMessageHandler { _, reply in + do { + let result = try api.takePendingEntityOpens() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + takePendingEntityOpensChannel.setMessageHandler(nil) + } } } diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift index 02e66a7..4df9c32 100644 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift @@ -79,6 +79,10 @@ private final class IntentCallPlatformBridgeHostApiImpl: IntentCallInvocationsHo ) } + func takePendingEntityOpens() throws -> [IntentCallEntityOpenEnvelopeDto] { + IntentCallNativeEntitySnapshotStore.takePendingEntityOpens().compactMap(entityOpenDto(from:)) + } + private func envelopeDto(from row: [String: Any]) -> IntentCallInvocationEnvelopeDto? { guard let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), @@ -98,6 +102,25 @@ private final class IntentCallPlatformBridgeHostApiImpl: IntentCallInvocationsHo ) } + private func entityOpenDto(from row: [String: Any]) -> IntentCallEntityOpenEnvelopeDto? { + guard + let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), + let entityType = IntentCallNativeEntitySnapshotStore.string(row["entityType"]), + let entityId = IntentCallNativeEntitySnapshotStore.string(row["entityId"]), + let source = IntentCallNativeEntitySnapshotStore.string(row["source"]), + let createdAt = IntentCallNativeEntitySnapshotStore.string(row["createdAt"]) + else { + return nil + } + return IntentCallEntityOpenEnvelopeDto( + id: id, + entityType: entityType, + entityId: entityId, + source: source, + createdAt: createdAt + ) + } + private func pigeonMap(from row: [String: Any]?) -> [String?: Any?]? { guard let row else { return nil } var normalized = [String?: Any?]() diff --git a/packages/intentcall_platform/test/intentcall_flutter_host_test.dart b/packages/intentcall_platform/test/intentcall_flutter_host_test.dart index dc9d483..99be3a6 100644 --- a/packages/intentcall_platform/test/intentcall_flutter_host_test.dart +++ b/packages/intentcall_platform/test/intentcall_flutter_host_test.dart @@ -39,6 +39,31 @@ void main() { expect(results.single.data['correlationId'], 'native-1'); }); + test('IntentCallFlutterHost drains pending native entity opens', () async { + final opens = []; + final host = IntentCallFlutterHost.bindRegistry( + registry: _registry(), + takePendingInvocations: () async => const [], + takePendingEntityOpens: () async => [ + IntentCallEntityOpenEnvelope( + id: 'open-1', + entityType: 'notes_note', + entityId: 'note-1', + source: IntentCallEntityOpenSource.nativeEntityGenerated, + ), + ], + onEntityOpen: opens.add, + ); + + await host.start(); + + expect(opens, hasLength(1)); + expect(opens.single.id, 'open-1'); + expect(opens.single.entityType, 'notes_note'); + expect(opens.single.entityId, 'note-1'); + expect(opens.single.source, IntentCallEntityOpenSource.nativeEntityGenerated); + }); + test('IntentCallFlutterHost reports denied invocations', () async { IntentCallInvocationEnvelope? deniedEnvelope; AgentResult? deniedResult; diff --git a/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart b/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart index 8523999..db358e3 100644 --- a/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart +++ b/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart @@ -92,6 +92,17 @@ void main() { expect(keys.keywordsKey, 'tags'); }); + test('entity open envelope DTO is declared in pigeon IDL', () { + final idl = _readRepoFile( + 'packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart', + ); + + expect(idl, contains('class IntentCallEntityOpenEnvelopeDto')); + expect(idl, contains('String entityType;')); + expect(idl, contains('String entityId;')); + expect(idl, contains('takePendingEntityOpens()')); + }); + test('generated host APIs expose invocation and entity surfaces', () { final generated = _readRepoFile( 'packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart', @@ -105,12 +116,19 @@ void main() { expect(generated, contains('clearEntityTypeSnapshots(')); expect(generated, contains('listEntitySnapshots(')); expect(generated, contains('searchEntitySnapshots(')); + expect(generated, contains('takePendingEntityOpens()')); expect( generated, contains( 'dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations', ), ); + expect( + generated, + contains( + 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.takePendingEntityOpens', + ), + ); }); }); } diff --git a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart index 9d3ea37..36f241f 100644 --- a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart @@ -14,6 +14,7 @@ export 'src/emitters/web_manifest_emitter.dart'; export 'src/emitters/web_mcp_js_emitter.dart'; export 'src/emitters/windows_protocol_emitter.dart'; export 'src/init/platform_hooks_init.dart'; +export 'src/invocation/intentcall_entity_open.dart'; export 'src/invocation/intentcall_invocation.dart'; export 'src/projection/manifest_exporter.dart'; export 'src/projection/projection_platforms.dart'; diff --git a/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart b/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart new file mode 100644 index 0000000..de4c8fb --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart @@ -0,0 +1,41 @@ +final class IntentCallEntityOpenSource { + const IntentCallEntityOpenSource._(); + + static const String nativeEntityGenerated = 'native.entity.generated'; +} + +final class IntentCallEntityOpenEnvelope { + IntentCallEntityOpenEnvelope({ + required this.id, + required this.entityType, + required this.entityId, + required this.source, + final DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now().toUtc(); + + factory IntentCallEntityOpenEnvelope.fromJson( + final Map json, + ) { + return IntentCallEntityOpenEnvelope( + id: '${json['id'] ?? ''}', + entityType: '${json['entityType'] ?? ''}', + entityId: '${json['entityId'] ?? ''}', + source: '${json['source'] ?? ''}', + createdAt: DateTime.tryParse('${json['createdAt'] ?? ''}'), + ); + } + + final String id; + final String entityType; + final String entityId; + final String source; + final DateTime createdAt; + + Map toJson() => { + 'id': id, + 'entityType': entityType, + 'entityId': entityId, + 'source': source, + 'createdAt': createdAt.toIso8601String(), + }; +} diff --git a/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index 2b7eb58..ec1018c 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -85,7 +85,7 @@ final result = AgentResultEnvelope.resourceEnvelope( resourceName: 'cool_runtime_snapshot', snapshot: {'phase': 'playing'}, ); -// result.data['resource_uri'] == 'demoapp://resource/spark/runtime/snapshot' +// result.data['resource_uri'] == 'demoapp://resource/cool/runtime/snapshot' ``` ### Entity snapshots (agent-visible app state) diff --git a/packages/intentcall_schema/lib/src/resource_uri.dart b/packages/intentcall_schema/lib/src/resource_uri.dart index 0fd6762..f2286e9 100644 --- a/packages/intentcall_schema/lib/src/resource_uri.dart +++ b/packages/intentcall_schema/lib/src/resource_uri.dart @@ -1,7 +1,7 @@ /// Builds `$protocolScheme://resource/...` from underscore-separated segments. /// /// Example: `cool_runtime_snapshot` with scheme `demoapp` → -/// `demoapp://resource/spark/runtime/snapshot`. +/// `demoapp://resource/cool/runtime/snapshot`. /// /// Returns `$protocolScheme://resource/unknown` when [resourceName] is empty. String resourceUri({ diff --git a/packages/intentcall_schema/test/agent_result_envelope_test.dart b/packages/intentcall_schema/test/agent_result_envelope_test.dart index ef797f0..f53bbc4 100644 --- a/packages/intentcall_schema/test/agent_result_envelope_test.dart +++ b/packages/intentcall_schema/test/agent_result_envelope_test.dart @@ -11,7 +11,7 @@ void main() { expect(result.ok, isTrue); expect( result.data['resource_uri'], - 'demoapp://resource/spark/runtime/snapshot', + 'demoapp://resource/cool/runtime/snapshot', ); }); } diff --git a/packages/intentcall_schema/test/resource_uri_test.dart b/packages/intentcall_schema/test/resource_uri_test.dart index 4cb4434..2f3a2ef 100644 --- a/packages/intentcall_schema/test/resource_uri_test.dart +++ b/packages/intentcall_schema/test/resource_uri_test.dart @@ -8,7 +8,7 @@ void main() { protocolScheme: 'demoapp', resourceName: 'cool_runtime_snapshot', ), - 'demoapp://resource/spark/runtime/snapshot', + 'demoapp://resource/cool/runtime/snapshot', ); }); diff --git a/release-please-config.json b/release-please-config.json index 57cfa92..482732d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -17,6 +17,7 @@ "intentcall_codegen", "intentcall_platform_sync", "intentcall_hooks", + "intentcall_bridge", "intentcall_cli", "intentcall_platform", "intentcall_testing" @@ -69,6 +70,11 @@ "package-name": "intentcall_hooks", "component": "intentcall_hooks" }, + "packages/intentcall_bridge": { + "release-type": "dart", + "package-name": "intentcall_bridge", + "component": "intentcall_bridge" + }, "packages/intentcall_cli": { "release-type": "dart", "package-name": "intentcall_cli", diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index 8309cd3..f5c0624 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -20,6 +20,7 @@ const publishOrder = [ 'intentcall_codegen', 'intentcall_platform_sync', 'intentcall_hooks', + 'intentcall_bridge', 'intentcall_cli', 'intentcall_platform', 'intentcall_testing', diff --git a/tool/intentcall/bin/release_train.dart b/tool/intentcall/bin/release_train.dart index b02e666..8f45867 100644 --- a/tool/intentcall/bin/release_train.dart +++ b/tool/intentcall/bin/release_train.dart @@ -12,6 +12,7 @@ const publishablePackages = [ 'intentcall_codegen', 'intentcall_platform_sync', 'intentcall_hooks', + 'intentcall_bridge', 'intentcall_cli', 'intentcall_platform', 'intentcall_testing', diff --git a/tool/intentcall/test/publish_preflight_test.dart b/tool/intentcall/test/publish_preflight_test.dart index 272fd45..1da374e 100644 --- a/tool/intentcall/test/publish_preflight_test.dart +++ b/tool/intentcall/test/publish_preflight_test.dart @@ -693,7 +693,9 @@ let package = Package( $importLine public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) {} + public static func register(with registrar: FlutterPluginRegistrar) { + IntentCallInvocationsHostApiSetup.setUp(registrar: registrar, api: nil) + } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { let channel = "intentcall_platform/invocations" From 03040ada64303cdc5e61542c70da61639bf3c99a Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Thu, 9 Jul 2026 01:24:58 +0300 Subject: [PATCH 15/27] refactor: deprecate intentcall_apple and intentcall_android packages, introduce intentcall_platform_apple and intentcall_platform_android, and update related documentation and configurations --- .github/workflows/release-pr-sync-train.yml | 2 +- .release-please-manifest.json | 4 +- PUBLISHING.md | 6 +- README.md | 6 +- docs/DESIGN_FAQ.mdx | 4 +- docs/DX_FAQ.mdx | 24 +- docs/NORTH_STAR.mdx | 6 +- .../0025-platform-subset-federated-plugins.md | 116 ++++ docs/decisions/README.md | 3 +- docs/evidence/hooks-native-bridge-plan.md | 6 +- .../platform-subset-federated-plugins-plan.md | 471 ++++++++++++++++ docs/start_here/choose_your_path.mdx | 2 +- docs/start_here/how_it_works.mdx | 6 +- justfile | 5 +- packages/intentcall_android/README.md | 2 + .../lib/src/agent_manifest_generator.dart | 3 + packages/intentcall_android/pubspec.yaml | 3 +- packages/intentcall_apple/README.md | 2 + .../lib/src/agent_manifest_generator.dart | 3 + packages/intentcall_apple/pubspec.yaml | 3 +- .../pigeons/intentcall_platform_bridge.dart | 4 +- .../lib/src/command_runner.dart | 77 ++- .../test/command_runner_test.dart | 70 +++ packages/intentcall_core/README.md | 4 +- packages/intentcall_platform/README.md | 15 +- .../ios/intentcall_platform.podspec | 18 - .../ios/intentcall_platform/Package.swift | 29 - .../IntentCallPlatformPlugin.swift | 153 ------ .../macos/intentcall_platform.podspec | 18 - .../IntentCallNativeEntitySnapshotStore.swift | 211 ------- .../IntentCallNativeHandoffStore.swift | 28 - .../IntentCallPlatformBridge.g.swift | 515 ------------------ .../intentcall_platform/PrivacyInfo.xcprivacy | 12 - packages/intentcall_platform/pubspec.yaml | 13 +- .../intentcall_platform_android/CHANGELOG.md | 7 + packages/intentcall_platform_android/LICENSE | 21 + .../intentcall_platform_android/README.md | 13 + .../analysis_options.yaml | 13 + .../android/.gitignore | 9 + .../android/build.gradle | 47 ++ .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../IntentCallPlatformBridge.g.kt | 0 .../IntentCallPlatformPlugin.kt | 0 .../lib/intentcall_platform_android.dart | 5 + .../intentcall_platform_android/pubspec.yaml | 33 ++ .../intentcall_platform_apple/CHANGELOG.md | 8 + packages/intentcall_platform_apple/LICENSE | 21 + packages/intentcall_platform_apple/README.md | 13 + .../analysis_options.yaml | 13 + .../intentcall_platform_apple}/Package.swift | 13 +- .../IntentCallNativeEntitySnapshotStore.swift | 0 .../IntentCallNativeHandoffStore.swift | 0 .../IntentCallPlatformBridge.g.swift | 0 .../IntentCallPlatformPlugin.swift | 14 +- .../PrivacyInfo.xcprivacy | 0 .../lib/intentcall_platform_apple.dart | 6 + .../intentcall_platform_apple/pubspec.yaml | 37 ++ .../lib/src/init/platform_hooks_init.dart | 128 +++-- .../test/platform_hooks_init_test.dart | 42 ++ pubspec.yaml | 2 + release-please-config.json | 24 +- tool/intentcall/bin/intentcall.dart | 231 ++++---- tool/intentcall/bin/release_train.dart | 52 +- .../test/publish_preflight_test.dart | 112 ++-- 65 files changed, 1395 insertions(+), 1317 deletions(-) create mode 100644 docs/decisions/0025-platform-subset-federated-plugins.md create mode 100644 docs/evidence/platform-subset-federated-plugins-plan.md delete mode 100644 packages/intentcall_platform/ios/intentcall_platform.podspec delete mode 100644 packages/intentcall_platform/ios/intentcall_platform/Package.swift delete mode 100644 packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift delete mode 100644 packages/intentcall_platform/macos/intentcall_platform.podspec delete mode 100644 packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift delete mode 100644 packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift delete mode 100644 packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift delete mode 100644 packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/PrivacyInfo.xcprivacy create mode 100644 packages/intentcall_platform_android/CHANGELOG.md create mode 100644 packages/intentcall_platform_android/LICENSE create mode 100644 packages/intentcall_platform_android/README.md create mode 100644 packages/intentcall_platform_android/analysis_options.yaml create mode 100644 packages/intentcall_platform_android/android/.gitignore create mode 100644 packages/intentcall_platform_android/android/build.gradle create mode 100644 packages/intentcall_platform_android/android/settings.gradle create mode 100644 packages/intentcall_platform_android/android/src/main/AndroidManifest.xml rename packages/{intentcall_platform => intentcall_platform_android}/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt (100%) rename packages/{intentcall_platform => intentcall_platform_android}/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt (100%) create mode 100644 packages/intentcall_platform_android/lib/intentcall_platform_android.dart create mode 100644 packages/intentcall_platform_android/pubspec.yaml create mode 100644 packages/intentcall_platform_apple/CHANGELOG.md create mode 100644 packages/intentcall_platform_apple/LICENSE create mode 100644 packages/intentcall_platform_apple/README.md create mode 100644 packages/intentcall_platform_apple/analysis_options.yaml rename packages/{intentcall_platform/macos/intentcall_platform => intentcall_platform_apple/darwin/intentcall_platform_apple}/Package.swift (54%) rename packages/{intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform => intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple}/IntentCallNativeEntitySnapshotStore.swift (100%) rename packages/{intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform => intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple}/IntentCallNativeHandoffStore.swift (100%) rename packages/{intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform => intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple}/IntentCallPlatformBridge.g.swift (100%) rename packages/{intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform => intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple}/IntentCallPlatformPlugin.swift (94%) rename packages/{intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform => intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple}/PrivacyInfo.xcprivacy (100%) create mode 100644 packages/intentcall_platform_apple/lib/intentcall_platform_apple.dart create mode 100644 packages/intentcall_platform_apple/pubspec.yaml diff --git a/.github/workflows/release-pr-sync-train.yml b/.github/workflows/release-pr-sync-train.yml index ca822e3..163cd44 100644 --- a/.github/workflows/release-pr-sync-train.yml +++ b/.github/workflows/release-pr-sync-train.yml @@ -39,6 +39,6 @@ jobs: fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add packages/*/pubspec.yaml packages/intentcall_platform/ios/intentcall_platform.podspec packages/intentcall_platform/macos/intentcall_platform.podspec + git add packages/*/pubspec.yaml git commit -m "chore: sync release train metadata" git push origin HEAD:${{ github.head_ref }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 79d9aec..ca05e21 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,13 +4,13 @@ "packages/intentcall_session": "0.6.0", "packages/intentcall_mcp": "0.6.0", "packages/intentcall_webmcp": "0.6.0", - "packages/intentcall_apple": "0.6.0", - "packages/intentcall_android": "0.6.0", "packages/intentcall_codegen": "0.6.0", "packages/intentcall_platform_sync": "0.6.0", "packages/intentcall_hooks": "0.6.0", "packages/intentcall_bridge": "0.6.0", "packages/intentcall_cli": "0.6.0", "packages/intentcall_platform": "0.6.0", + "packages/intentcall_platform_apple": "0.6.0", + "packages/intentcall_platform_android": "0.6.0", "packages/intentcall_testing": "0.6.0" } \ No newline at end of file diff --git a/PUBLISHING.md b/PUBLISHING.md index f4b7ea1..e98ee57 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -49,8 +49,6 @@ intentcall_core-v{{version}} intentcall_session-v{{version}} intentcall_mcp-v{{version}} intentcall_webmcp-v{{version}} -intentcall_apple-v{{version}} -intentcall_android-v{{version}} intentcall_codegen-v{{version}} intentcall_platform_sync-v{{version}} intentcall_hooks-v{{version}} @@ -75,11 +73,13 @@ uses GitHub OIDC through `dart-lang/setup-dart`. 1. `intentcall_schema` 2. `intentcall_core` 3. `intentcall_session` -4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` +4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_codegen` 5. `intentcall_platform_sync`, `intentcall_hooks`, `intentcall_bridge`, `intentcall_cli` 6. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) 7. `intentcall_testing` +Legacy `intentcall_apple` / `intentcall_android` are workspace-only (`publish_to: none`) and are not published. + ## Commands ```bash diff --git a/README.md b/README.md index 2b63b30..2a67761 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,16 @@ GitHub: [Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) | `intentcall_session` | [![pub package](https://img.shields.io/pub/v/intentcall_session.svg?include_prereleases)](https://pub.dev/packages/intentcall_session) [![pub points](https://img.shields.io/pub/points/intentcall_session.svg)](https://pub.dev/packages/intentcall_session/score) | Runtime session lifecycle, persisted session state, snapshots, and registry execution inside a session | | `intentcall_mcp` | [![pub package](https://img.shields.io/pub/v/intentcall_mcp.svg?include_prereleases)](https://pub.dev/packages/intentcall_mcp) [![pub points](https://img.shields.io/pub/points/intentcall_mcp.svg)](https://pub.dev/packages/intentcall_mcp/score) | MCP publish adapter and MCP mapping (`dart_mcp`) | | `intentcall_webmcp` | [![pub package](https://img.shields.io/pub/v/intentcall_webmcp.svg?include_prereleases)](https://pub.dev/packages/intentcall_webmcp) [![pub points](https://img.shields.io/pub/points/intentcall_webmcp.svg)](https://pub.dev/packages/intentcall_webmcp/score) | WebMCP hot-sync adapter | -| `intentcall_apple` | [![pub package](https://img.shields.io/pub/v/intentcall_apple.svg?include_prereleases)](https://pub.dev/packages/intentcall_apple) [![pub points](https://img.shields.io/pub/points/intentcall_apple.svg)](https://pub.dev/packages/intentcall_apple/score) | Apple manifest projection for App Intents / Shortcuts dispatch artifacts and first typed-entity/indexing direction | -| `intentcall_android` | [![pub package](https://img.shields.io/pub/v/intentcall_android.svg?include_prereleases)](https://pub.dev/packages/intentcall_android) [![pub points](https://img.shields.io/pub/points/intentcall_android.svg)](https://pub.dev/packages/intentcall_android/score) | Android manifest codegen for shortcut and deep-link artifacts | | `intentcall_platform_sync` | [![pub package](https://img.shields.io/pub/v/intentcall_platform_sync.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform_sync) | Dart-only manifest, emitters, and `PlatformSync` | | `intentcall_hooks` | [![pub package](https://img.shields.io/pub/v/intentcall_hooks.svg?include_prereleases)](https://pub.dev/packages/intentcall_hooks) | Dart build hooks for manifest export and platform sync | | `intentcall_bridge` | [![pub package](https://img.shields.io/pub/v/intentcall_bridge.svg?include_prereleases)](https://pub.dev/packages/intentcall_bridge) | Pigeon IDL and generated Dart bindings for the platform bridge | | `intentcall_cli` | [![pub package](https://img.shields.io/pub/v/intentcall_cli.svg?include_prereleases)](https://pub.dev/packages/intentcall_cli) | Framework-neutral CLI: manifest export, platform sync, MCP serve | -| `intentcall_platform` | [![pub package](https://img.shields.io/pub/v/intentcall_platform.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform) [![pub points](https://img.shields.io/pub/points/intentcall_platform.svg)](https://pub.dev/packages/intentcall_platform/score) | Flutter plugin and native bridge (re-exports `intentcall_platform_sync`) | +| `intentcall_platform` | [![pub package](https://img.shields.io/pub/v/intentcall_platform.svg?include_prereleases)](https://pub.dev/packages/intentcall_platform) [![pub points](https://img.shields.io/pub/points/intentcall_platform.svg)](https://pub.dev/packages/intentcall_platform/score) | Flutter runtime host / federated umbrella (re-exports `intentcall_platform_sync`) | | `intentcall_codegen` | [![pub package](https://img.shields.io/pub/v/intentcall_codegen.svg?include_prereleases)](https://pub.dev/packages/intentcall_codegen) [![pub points](https://img.shields.io/pub/points/intentcall_codegen.svg)](https://pub.dev/packages/intentcall_codegen/score) | Optional `@AgentTool` codegen | | `intentcall_testing` | [![pub package](https://img.shields.io/pub/v/intentcall_testing.svg?include_prereleases)](https://pub.dev/packages/intentcall_testing) [![pub points](https://img.shields.io/pub/points/intentcall_testing.svg)](https://pub.dev/packages/intentcall_testing/score) | Contract / invoke test helpers | +Deprecated workspace-only (not published): `intentcall_apple`, `intentcall_android` — use `intentcall_platform_sync` instead. + `intentcall_gemma` is example-only and intentionally unpublished. `intentcall_cli` is the workspace maintainer tool and is not part of the hosted package train. diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index 643686a..66f2638 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -62,8 +62,8 @@ A: `AgentRegistry`, `AgentCallEntry`, and `RegisteredAgentIntent` are the curren **Q: Why are additive actions, typed entities, and indexing lifecycle not a new core runtime?** A: They are a projection layer over the existing source concepts. Actions remain registry entries. Typed entities are app-owned Dart snapshots with stable ids, display fields, and search/index fields. Indexing or donation is a lifecycle that copies those snapshots into a platform projection cache. Keeping this additive avoids turning `intentcall_core` into an Apple-shaped runtime while still letting Apple be the first concrete typed-entity/indexing projection. See [ADR 0018](decisions/0018-additive-actions-typed-entities-indexing-lifecycle.md). -**Q: Why are there separate `intentcall_apple` / `intentcall_android` / `intentcall_gemma` packages instead of one `intentcall_native`?** -A: Native surface adapters differ sharply in their platform SDKs and entitlement requirements. A single `intentcall_native` would force all three sets of platform SDKs into every build. Platform-specific packages let Flutter tree-shaker and pubspec `platforms` keys exclude irrelevant targets cleanly. +**Q: Why were there separate `intentcall_apple` / `intentcall_android` packages, and what replaced them?** +A: Those packages were sparse-manifest generators for platform-specific SDKs. They are **deprecated** (`publish_to: none`); canonical projection is `intentcall_platform_sync` (`PlatformSync` / `ManifestExporter`). Runtime native code will split into federated Flutter plugins per [ADR 0025](decisions/0025-platform-subset-federated-plugins.md). `intentcall_gemma` remains a separate surface adapter. ## Runtime sessions diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 335e49a..7f027e0 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -504,6 +504,21 @@ them — see [ADR 0020](decisions/0020-platform-scoped-manifest-surfaces.md). default manifest surface families at export time (not just platform sync targets). Explicit `defaults.surfaces` in yaml overrides platform-scoped defaults. +**Platform subset author matrix** ([ADR 0025](decisions/0025-platform-subset-federated-plugins.md)): + +| App profile | `platforms.enabled` | `pubspec` deps | Hooks | +|-------------|---------------------|----------------|-------| +| iOS + Android mobile | `[android, ios]` | `intentcall_platform` (+ codegen stack) | Gradle + Xcode from spine | +| macOS + Windows desktop | `[macos, windows]` | `intentcall_platform` | CI sync for windows/linux | +| Android + Huawei stores | `[android]` | `intentcall_platform` | Gradle only | +| Web Jaspr | `[web]` | `intentcall_hooks` as a dev dep; no Flutter platform plugin | Dart SDK hook | +| iOS-only | `[ios]` | `intentcall_platform` (federated; android impl not compiled into the app binary) | Xcode only | + +Apps list the `intentcall_platform` umbrella only. Endorsed federated impls +(`intentcall_platform_apple`, `intentcall_platform_android`) come in via +`default_package`. Huawei/HyperOS use the `android` token — OEM packaging is an +app concern. Do not depend on deprecated `intentcall_apple` / `intentcall_android`. + --- ## 🧪 Testing @@ -647,9 +662,12 @@ supported claim yet. 1. `intentcall_schema` 2. `intentcall_core` 3. `intentcall_session` -4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` (parallel) -5. `intentcall_platform` -6. `intentcall_testing` +4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_codegen` (parallel) +5. `intentcall_platform_sync`, `intentcall_hooks`, `intentcall_bridge`, `intentcall_cli` +6. `intentcall_platform` +7. `intentcall_testing` + +Legacy `intentcall_apple` / `intentcall_android` are deprecated workspace-only packages — use `intentcall_platform_sync` emitters instead. **Q: How do I publish?** diff --git a/docs/NORTH_STAR.mdx b/docs/NORTH_STAR.mdx index cd91d51..2929f51 100644 --- a/docs/NORTH_STAR.mdx +++ b/docs/NORTH_STAR.mdx @@ -50,10 +50,12 @@ Support tiers are explicit on purpose. Apple App Intents are currently parameter | `intentcall_session` — runtime session lifecycle, persistence, snapshots | Runtime discovery and inspection inside concrete apps → **mcp_flutter / mcp_toolkit** | | `intentcall_mcp` — MCP publish adapter and MCP mapping only | Embedding / RAG / LLM backends | | `intentcall_webmcp` — WebMCP hot-sync adapter | UI rendering, visual harness reconstruction | -| `intentcall_platform` — native/web emitters + Flutter plugin | Any production app serving end users | +| `intentcall_platform_sync` — native/web emitters, `PlatformSync`, `ManifestExporter` | Any production app serving end users | +| `intentcall_platform` — Flutter runtime host / federated umbrella (not emitters) | | | `intentcall_codegen` — optional `@AgentTool` code generation | | | `intentcall_testing` — contract / invoke test helpers | | -| `intentcall_gemma` / `intentcall_apple` / `intentcall_android` — surface adapters | | +| `intentcall_gemma` — surface adapter | | +| ~~`intentcall_apple` / `intentcall_android`~~ — deprecated legacy generators; use `intentcall_platform_sync` | | **Do not own:** harness tooling (CLI, inspector UI), skill governance, LLM prompt engineering, or any product that wraps IntentCall for end users. diff --git a/docs/decisions/0025-platform-subset-federated-plugins.md b/docs/decisions/0025-platform-subset-federated-plugins.md new file mode 100644 index 0000000..ba54964 --- /dev/null +++ b/docs/decisions/0025-platform-subset-federated-plugins.md @@ -0,0 +1,116 @@ +# 0025. Platform Subset and Federated Flutter Plugins + +Date: 2026-07-08 + +## Status + +Accepted + +## Context + +IntentCall supports many projection targets (`web`, `android`, `ios`, `macos`, +`linux`, `windows`) but consuming apps ship different subsets. Legacy packages +`intentcall_apple` and `intentcall_android` implement an obsolete sparse-manifest +path superseded by ADR 0019/0022 unified projection in `intentcall_platform_sync`. + +The monolithic `intentcall_platform` Flutter plugin bundles Android, iOS, and +macOS native implementations. Apps that target only one mobile platform still +depend on a plugin that declares all platforms. + +Flutter documents [federated +plugins](https://docs.flutter.dev/packages-and-plugins/developing-packages) and +[Swift Package Manager for plugin +authors](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors) +as the modern integration model. As of Flutter 3.44, SPM is the primary strategy +for iOS/macOS native dependencies. The CocoaPods registry becomes read-only on +2026-12-02. + +IntentCall will drop CocoaPods for Apple-owned plugin packages and standardize on +SPM with `sharedDarwinSource` for iOS + macOS. + +Implementation plan: +[platform-subset-federated-plugins-plan.md](../evidence/platform-subset-federated-plugins-plan.md). + +## Decision + +### 1. Platform subset contract + +- `intentcall.yaml` → `platforms.enabled` is the **authoritative** platform + contract for manifest surface defaults, `platform sync`, and hook spine + resolution. +- Authors with `host: flutter` or `host: jaspr` must set `platforms.enabled` + explicitly for non-default combinations; validation will warn then error on + empty lists. +- Huawei/HyperOS and similar OEM Android variants use the `android` sync token; + OEM store packaging remains an app concern. + +### 2. Projection stays unified + +- `intentcall_platform_sync` remains the single Dart-only projection package for + all emitters and `PlatformSync`. +- Per-platform pub packages for emitters are deferred until a platform requires + non-Dart build toolchain dependencies (e.g. future HarmonyOS ArkTS). + +### 3. Federated Flutter runtime plugins + +Split runtime native code into endorsed federated packages: + +| Package | Role | +|---------|------| +| `intentcall_platform` | App-facing umbrella with `default_package` map and Dart host API | +| `intentcall_platform_apple` | iOS + macOS native (Pigeon + Swift), SPM under `darwin/` | +| `intentcall_platform_android` | Android native (Pigeon + Kotlin) | +| `intentcall_bridge` | Shared Pigeon IDL and generated bindings | + +**Hardcut:** no separate `intentcall_platform_interface` package — the umbrella +owns the Dart host surface; apple + android are the endorsed impl packages. + +### 4. Apple: SPM-only + +- Native Apple code lives under SPM layout in the apple impl package: + `intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/...` + with `Package.swift`. +- Use `sharedDarwinSource: true` in the umbrella plugin pubspec for ios + macos. +- No `*.podspec` for IntentCall-owned Apple plugin packages (SPM-only). +- Minimum consumer Flutter: 3.44+ with Swift Package Manager enabled. + +### 5. Legacy package sunset + +- Deprecate `intentcall_apple` and `intentcall_android` manifest generators. +- Remove from release train after one deprecation cycle. +- Do not port new features to legacy generators. + +## Consequences + +Good: + +- Config (`platforms.enabled`) scopes projection surfaces and hook spine targets; + federation scopes per-target native compile. Config does **not** remove + transitive pub dependencies — unused platform packages may still appear in the + dependency graph until consumers depend on a federated subset. +- Flutter-aligned federated plugin model; domain experts can extend per platform. +- Single Darwin Swift tree under `intentcall_platform_apple/darwin/`; SPM-only + (no CocoaPods dual path). +- No separate interface package — fewer packages, umbrella remains the only + app-facing dependency for most authors. +- Projection pipeline unchanged — three-gate spine preserved. + +Tradeoffs: + +- More packages in the release train (umbrella + 2 endorsed impl packages). +- SPM-only is a breaking change for consumers on legacy CocoaPods-only workflows. +- Migration requires coordinated `mcp_flutter` and docs updates. + +## Non-goals + +- Per-platform projection pub packages without toolchain justification +- Pigeon for App Intents Swift emitters +- HarmonyOS package until artifact format diverges from Android +- Live OS semantic proof in agentkit CI + +## Related + +- [0022-projection-pipeline-alignment.md](0022-projection-pipeline-alignment.md) +- [0024-dart-hooks-and-pigeon-bridge-consistency.md](0024-dart-hooks-and-pigeon-bridge-consistency.md) +- [platform-subset-federated-plugins-plan.md](../evidence/platform-subset-federated-plugins-plan.md) +- [hooks-native-bridge-plan.md](../evidence/hooks-native-bridge-plan.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0769e04..ab080ac 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,7 +3,7 @@ Architecture Decision Records (ADRs) for IntentCall. Format: [MADR](https://adr.github.io/madr/) — see any existing ADR for the template. -Next ADR number: **0025** +Next ADR number: **0026** --- @@ -26,6 +26,7 @@ Next ADR number: **0025** | [0022](0022-projection-pipeline-alignment.md) | accepted | Projection Pipeline Alignment — Dense Export and Apple Sub-Channels | 2026-07-07 | | [0023](0023-entity-three-slot-projection.md) | accepted | Entity Three-Slot Projection and Property Roles | 2026-07-07 | | [0024](0024-dart-hooks-and-pigeon-bridge-consistency.md) | accepted | Dart Hooks and Pigeon Bridge Consistency | 2026-07-08 | +| [0025](0025-platform-subset-federated-plugins.md) | accepted | Platform Subset and Federated Flutter Plugins | 2026-07-08 | --- diff --git a/docs/evidence/hooks-native-bridge-plan.md b/docs/evidence/hooks-native-bridge-plan.md index 14a76f8..19d254e 100644 --- a/docs/evidence/hooks-native-bridge-plan.md +++ b/docs/evidence/hooks-native-bridge-plan.md @@ -2,11 +2,13 @@ **Status:** Active implementation plan **Date:** 2026-07-08 -**Supersedes:** [projection-pipeline-spec.md](projection-pipeline-spec.md) (retired) +**Superseded by:** [projection-pipeline-spec.md](projection-pipeline-spec.md) (retired) +**Supersedes:** — +**See also:** [platform-subset-federated-plugins-plan.md](platform-subset-federated-plugins-plan.md) **ADR:** [0024 — Dart Hooks and Pigeon Bridge Consistency](../decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md) **Disposition:** `promote_to_artifact` -**Related ADRs:** [0015](../decisions/0015-dart-first-native-bridge.md), [0016](../decisions/0016-dispatch-mode-handoff-contract.md), [0017](../decisions/0017-apple-inline-runtime-tracks.md), [0019](../decisions/0019-framework-neutral-intentcall-cli.md), [0022](../decisions/0022-projection-pipeline-alignment.md), [0023](../decisions/0023-entity-three-slot-projection.md) +**Related ADRs:** [0015](../decisions/0015-dart-first-native-bridge.md), [0016](../decisions/0016-dispatch-mode-handoff-contract.md), [0017](../decisions/0017-apple-inline-runtime-tracks.md), [0019](../decisions/0019-framework-neutral-intentcall-cli.md), [0022](../decisions/0022-projection-pipeline-alignment.md), [0023](../decisions/0023-entity-three-slot-projection.md), [0025](../decisions/0025-platform-subset-federated-plugins.md) (federated plugins — see [platform-subset-federated-plugins-plan.md](platform-subset-federated-plugins-plan.md)) --- diff --git a/docs/evidence/platform-subset-federated-plugins-plan.md b/docs/evidence/platform-subset-federated-plugins-plan.md new file mode 100644 index 0000000..bcbcbbc --- /dev/null +++ b/docs/evidence/platform-subset-federated-plugins-plan.md @@ -0,0 +1,471 @@ +# Platform Subset + Federated Plugins Implementation Plan + +**Status:** Implemented (compressed A/B/C) — ready for `delete_or_retire` after this PR +**Date:** 2026-07-08 +**ADR target:** [0025 — Platform Subset and Federated Flutter Plugins](../decisions/0025-platform-subset-federated-plugins.md) (Accepted) +**Builds on:** [hooks-native-bridge-plan.md](hooks-native-bridge-plan.md), [ADR 0024](../decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md) +**Disposition:** `delete_or_retire` after this PR (durable knowledge already in ADR 0025 + DX_FAQ) + +**Hardcuts (landed vs early draft below):** + +- No separate `intentcall_platform_interface` package — host API lives in the umbrella +- Darwin native code lives in `intentcall_platform_apple` (`darwin/…`), not the umbrella +- SPM-only for Apple (no CocoaPods / podspecs) +- Phase A+B+C landed (subset enforcement, legacy sunset, federated apple+android + umbrella) + +**Flutter references (authoritative):** + +- [Developing packages & plugins](https://docs.flutter.dev/packages-and-plugins/developing-packages) — federated plugins, `default_package`, `sharedDarwinSource`, `implements` +- [Swift Package Manager for plugin authors](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors) — `Package.swift` layout, Pigeon `swiftOut` paths, `FlutterFramework` dependency +- [Dart SDK hooks](https://dart.dev/tools/hooks) — build-time orchestration (see hooks-native-bridge-plan) + +**User constraint:** Drop CocoaPods completely for IntentCall Apple plugins. SPM is the sole native integration path for iOS/macOS. + +--- + +## 1. Problem statement + +IntentCall must support **many platforms** (web, android, ios, macos, linux, windows, future HarmonyOS/Huawei APK variants) while apps ship **different subsets**: + +- Mobile-only: `android` + `ios` +- Desktop: `macos` + `windows` +- Android + Huawei: still `android` token (OEM packaging is app concern) +- Web-only: Jaspr / plain Dart + +**Current pain:** + +| Issue | Impact | +|-------|--------| +| `intentcall_apple` / `intentcall_android` legacy packages | Wrong mental model; parallel manifest generators (deprecated ADR 0022) | +| `intentcall_platform` bundles all Flutter native impls | iOS-only apps still compile android/ios/macos plugin code | +| Duplicate iOS + macOS Swift trees | Drift risk (`ios/.../Sources` vs `macos/.../Sources`) | +| CocoaPods + SPM dual maintenance | Podspecs + Package.swift; user wants SPM-only | +| Empty `platforms.enabled` on Flutter | Defaults to all six sync targets + broad manifest surfaces | +| `PlatformHooksInit` ignores enabled list | Patches Gradle/Xcode even when platform disabled | + +**Non-goals:** + +- Split `intentcall_platform_sync` emitters into per-platform pub packages (unless a platform needs non-Dart toolchain deps) +- Pigeon for App Intents Swift (manifest emitters remain correct tool) +- Live OS proof (Siri, Spotlight UX) in agentkit CI +- HarmonyOS NEXT package until artifact format diverges from Android + +--- + +## 2. Target architecture + +### 2.1 Three layers (unchanged truth model) + +```text +Layer 1 — Truth: intentcall_schema + intentcall_core + intentcall_codegen +Layer 2 — Projection: intentcall_platform_sync + intentcall_cli + intentcall_hooks +Layer 3 — Runtime: federated Flutter plugins + intentcall_bridge (Pigeon) +Layer 4 — Adapters: intentcall_mcp | intentcall_webmcp +``` + +### 2.2 Platform opt-in contract + +**Authoritative knob:** `intentcall.yaml` → `platforms.enabled` + +```yaml +host: flutter +protocolScheme: myapp +platforms: + enabled: [android, ios] # REQUIRED for non-default combos +``` + +Drives: + +1. Manifest surface defaults (ADR 0020) +2. `intentcall platform sync --platform` default list +3. `PlatformHookSpine` template platform list +4. `PlatformHooksInit` patch targets (after Phase 2) +5. Federated plugin `default_package` endorsement (after Phase 4) + +**Sync tokens today:** `web`, `android`, `ios`, `macos`, `linux`, `windows` +**No `huawei` token** — use `android`; document OEM caveats in DX_FAQ. + +### 2.3 Federated Flutter plugin topology (Flutter docs pattern) + +Per [federated plugins](https://docs.flutter.dev/packages-and-plugins/developing-packages#federated-plugins): + +```text +intentcall_platform # app-facing umbrella (endorsed default_package map) +intentcall_platform_interface # platform interface (Dart API + Pigeon contracts) +intentcall_platform_apple # ios + macos via sharedDarwinSource +intentcall_platform_android # Kotlin + Pigeon +intentcall_bridge # Pigeon generated code (shared by apple + android impls) +``` + +**App-facing `pubspec.yaml` (endorsed — automatic):** + +```yaml +dependencies: + intentcall_platform: ^0.7.0 +``` + +**Non-endorsed override (advanced):** + +```yaml +dependencies: + intentcall_platform: ^0.7.0 + # Omit apple impl on Android-only CI nodes if needed: + # intentcall_platform_apple: ^0.7.0 +``` + +**Umbrella `pubspec.yaml` plugin map (target):** + +```yaml +flutter: + plugin: + platforms: + android: + default_package: intentcall_platform_android + ios: + default_package: intentcall_platform_apple + sharedDarwinSource: true + macos: + default_package: intentcall_platform_apple + sharedDarwinSource: true +``` + +Projection (`intentcall_platform_sync`) stays **one package** — emitters are pure Dart; subset is config-gated. + +### 2.4 Apple native: SPM-only, shared Darwin + +Per [SPM for plugin authors](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors): + +| Rule | IntentCall application | +|------|------------------------| +| Layout | `darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/` | +| `Package.swift` | `FlutterFramework` path dep; library name `intentcall-platform-apple` (underscores → hyphens) | +| Pigeon `swiftOut` | `darwin/intentcall_platform_apple/Sources/.../IntentCallPlatformBridge.g.swift` | +| Privacy | `PrivacyInfo.xcprivacy` in Sources; `.process(...)` in Package.swift | +| CocoaPods | **Remove** `*.podspec` after SPM CI proof | +| `sharedDarwinSource: true` | Single Swift tree for ios + macos in umbrella pubspec | + +**Minimum Flutter SDK:** `>=3.24.0` (already in `intentcall_platform`); document `>=3.44.0` for SPM-default consumers. + +### 2.5 Legacy package sunset + +| Package | Action | +|---------|--------| +| `intentcall_apple` | `@Deprecated` + `publish_to: none` + removed from release train (this cycle) | +| `intentcall_android` | Same | + +Canonical path: `agent_catalog.g.dart` → `ManifestExporter` → `PlatformSync` emitters. + +--- + +## 3. Acceptance criteria + +| # | Criterion | Phase | +|---|-----------|-------| +| S1 | ADR 0025 accepted; NORTH_STAR / choose_your_path updated | P0 | +| S2 | `intentcall validate` warns/errors when `host:flutter` and `platforms.enabled` empty | P1 | +| S3 | `PlatformHooksInit` only patches targets in `platforms.enabled` | P1 | +| S4 | CI templates for `windows`/`linux` from `PlatformHookSpine` | P1 | +| S5 | `intentcall_apple` / `intentcall_android` deprecated + removed from docs router | P2 | +| S6 | `intentcall_platform_interface` published with stable Dart host API | P3 | +| S7 | `intentcall_platform_apple` + `intentcall_platform_android` federated impls | P3 | +| S8 | Umbrella `intentcall_platform` endorses `default_package` per platform | P3 | +| S9 | Single `darwin/` Swift source; no duplicate ios/macos trees | P4 | +| S10 | No `*.podspec` in intentcall Apple plugins; SPM-only publish preflight | P4 | +| S11 | Pigeon outputs land under federated package paths | P4 | +| S12 | `mcp_flutter` `make check-contracts` green with federated layout | P5 | +| S13 | `just test && just analyze` green | P5 | + +**Claim ceiling:** artifact + static CI + federated plugin compile proof. +**Non-claims:** App Store discovery, Huawei HMS behavior, Windows App Actions live proof. + +--- + +## 4. Phase 0 — ADR + charter sync + +**Goal:** Record decision before structural package moves. + +| Lane | Agent | Write set | Gate | +|------|-------|-----------|------| +| **P0-adr** | ADR author | `docs/decisions/0025-platform-subset-federated-plugins.md`, update `docs/decisions/README.md` | Review | +| **P0-docs** | Doc author | `NORTH_STAR.mdx`, `choose_your_path.mdx`, `DX_FAQ.mdx` platform subset section; link this plan | `intentcall validate` | + +**ADR 0025 must state:** + +1. Projection stays in `intentcall_platform_sync`; subset via `platforms.enabled` +2. Runtime splits into federated Flutter plugins +3. Apple: SPM-only; CocoaPods dropped for IntentCall-owned plugins +4. `intentcall_apple` / `intentcall_android` deprecated +5. Huawei = `android` token until HarmonyOS artifact diverges + +**Merge order:** P0-adr → P0-docs + +--- + +## 5. Phase 1 — Platform subset enforcement + +**Goal:** Make `platforms.enabled` real before package splits. + +| Lane | Agent | Write set | Forbidden | Gate | +|------|-------|-----------|-----------|------| +| **P1a-validate** | CLI validator | `intentcall_config.dart`, `intentcall validate` warning/error for empty enabled on flutter/jaspr | Federated packages | `command_runner_test` | +| **P1b-hooks-init** | Hooks engineer | `platform_hooks_init.dart` — patch only enabled platforms | Package moves | `platform_hooks_init_test` | +| **P1c-spine-ci** | Spine engineer | `platform_hook_spine.dart` — `renderCiSnippet()` for windows/linux; docs in plan appendix | — | `platform_hook_templates_test` | +| **P1d-fixtures** | Fixture author | Update CLI fixtures with explicit `platforms.enabled`; register-intents skill | — | `just adr-gates` | + +**Aggregate gate:** + +```bash +just platform-hooks-check +dart run tool/intentcall/bin/intentcall.dart validate +``` + +--- + +## 6. Phase 2 — Legacy package sunset + +**Goal:** Stop authors from installing wrong packages. + +| Lane | Agent | Write set | Gate | +|------|-------|-----------|------| +| **P2a-deprecate** | Package maintainer | `@Deprecated` on `generateAppleAgentManifest`, `generateAndroidAgentManifest`; README banners | Package unit tests | +| **P2b-train** | Release engineer | Remove from `release_train.dart` / release-please OR mark `publish_to: none` + changelog | `release_train check` | +| **P2c-docs** | Doc sweep | Remove apple/android from package tables; migration snippet to three-gate | `docs-check` | + +**Do not delete code until one release cycle after deprecation notice.** + +--- + +## 7. Phase 3 — Federated plugin scaffold + +**Goal:** Flutter-correct package separation per [developing-packages](https://docs.flutter.dev/packages-and-plugins/developing-packages). + +### 7.1 Package creation commands + +```bash +# Interface (Dart only) +flutter create --template=package --org dev.intentcall intentcall_platform_interface + +# Apple federated impl (ios + macos, sharedDarwinSource later in umbrella) +flutter create --template=plugin --org dev.intentcall \ + --platforms=ios,macos -i swift intentcall_platform_apple + +# Android federated impl +flutter create --template=plugin --org dev.intentcall \ + --platforms=android -a kotlin intentcall_platform_android +``` + +Then **move** from current `intentcall_platform`: + +| From | To | +|------|-----| +| `lib/intentcall_platform_flutter.dart` + `lib/src/flutter/*` | `intentcall_platform_interface` (API) + keep thin exports in umbrella | +| iOS/macOS Swift plugin + stores | `intentcall_platform_apple` | +| Android Kotlin plugin | `intentcall_platform_android` | +| Pigeon IDL | Stay `intentcall_bridge`; impl packages depend on it | + +### 7.2 Parallel lanes + +| Lane | Agent | Scope | Gate | +|------|-------|-------|------| +| **P3a-interface** | Interface author | `intentcall_platform_interface` — `IntentCallFlutterHost`, exports from sync/bridge types | Interface tests | +| **P3a-apple** | Apple impl | `intentcall_platform_apple` — move Swift, wire Pigeon HostApi | `pigeon_bridge_contract_test` (relocated) | +| **P3b-android** | Android impl | `intentcall_platform_android` — real Kotlin Pigeon impl (replace stub) | Android compile in example | +| **P3c-umbrella** | Umbrella author | `intentcall_platform` pubspec `default_package` + deps; re-export interface | `flutter_host_test` | +| **P3d-codegen** | Pigeon paths | Update `pigeons/intentcall_platform_bridge.dart` swiftOut/kotlinOut to federated paths | `just pigeon-codegen-check` | + +**Merge order:** P3a-interface → P3a-apple ∥ P3b-android → P3c-umbrella → P3d-codegen + +**Umbrella stays the only package most apps list.** Interface + impl packages are endorsed dependencies. + +--- + +## 8. Phase 4 — SPM-only + shared Darwin consolidation + +**Goal:** One Apple native tree; drop CocoaPods per user directive. + +### 8.1 Target directory layout (`intentcall_platform_apple`) + +```text +intentcall_platform_apple/ + pubspec.yaml # implements: intentcall_platform (interface) + darwin/ + intentcall_platform_apple/ + Package.swift # .iOS("13.0"), .macOS("10.14") + Sources/intentcall_platform_apple/ + IntentCallPlatformPlugin.swift + IntentCallNativeHandoffStore.swift + IntentCallNativeEntitySnapshotStore.swift + IntentCallPlatformBridge.g.swift + PrivacyInfo.xcprivacy + android/ ... # empty — apple package is darwin-only +``` + +**Umbrella pubspec** (ios + macos): + +```yaml +flutter: + plugin: + platforms: + ios: + pluginClass: IntentCallPlatformPlugin + sharedDarwinSource: true + default_package: intentcall_platform_apple + macos: + pluginClass: IntentCallPlatformPlugin + sharedDarwinSource: true + default_package: intentcall_platform_apple +``` + +Per Flutter docs, enable `sharedDarwinSource: true` and use **`darwin/`** folder instead of separate `ios/` + `macos/` native folders in the **impl** package. + +### 8.2 CocoaPods removal checklist + +| Step | Action | +|------|--------| +| 1 | Delete `ios/intentcall_platform.podspec`, `macos/intentcall_platform.podspec` from apple impl | +| 2 | Remove podspec version checks from `release_train.dart` / publish preflight **or** gate on SPM-only packages | +| 3 | Update `swiftPackageManagerFindings` in `tool/intentcall` — require `Package.swift` + Pigeon bridge in federated paths | +| 4 | Update `PlatformSync` / Xcode sync to target SPM layout only | +| 5 | Document minimum Flutter 3.44 + `flutter config --enable-swift-package-manager` for consumers | +| 6 | Add `.gitignore` entries: `.build/`, `.swiftpm/` | + +### 8.3 Parallel lanes + +| Lane | Agent | Scope | Gate | +|------|-------|-------|------| +| **P4a-darwin** | Swift consolidation | Merge ios/macos Sources → `darwin/`; delete duplicates | `swiftPackageManagerFindings` empty on fixture | +| **P4b-spm** | SPM hygiene | Package.swift, PrivacyInfo, FlutterFramework dep | `flutter build ios --config-only` on example | +| **P4c-pod-drop** | Remove podspecs | Delete podspecs; update publish preflight tests | `publish_preflight_test` | +| **P4d-pigeon** | Regenerate Pigeon | `swiftOut` → `darwin/.../IntentCallPlatformBridge.g.swift` | `just pigeon-codegen-check` | + +**Aggregate gate:** + +```bash +just pigeon-codegen-check +dart test tool/intentcall/test/publish_preflight_test.dart +# Manual: flutter build ios --no-codesign --config-only in mcp_flutter/flutter_test_app +``` + +--- + +## 9. Phase 5 — Harness + consumer proof + +| Lane | Agent | Scope | Gate | +|------|-------|-------|------| +| **P5a-harness** | Steward | New scenario `intentcall.federated-platform` or extend adapter-contract | steward benchmark | +| **P5b-mcp** | Sibling consumer | Update `mcp_flutter` path deps for new packages; regenerate artifacts | `make check-contracts` | +| **P5c-docs** | DX | Author matrix: platform combo → `enabled` → deps → hooks | `docs-check` | + +**Extend `projection_alignment_test.dart`:** federated plugin compile + SPM layout rows. + +--- + +## 10. Execution timeline + +```text +P0 (ADR) ─────────────────────────────────────────► +P1 (subset) ──────► [P1a ∥ P1b ∥ P1c ∥ P1d] +P2 (sunset) ──────► [P2a ∥ P2b ∥ P2c] (after P0) +P3 (federated) ──────► [P3a → P3b∥P3c → P3d] +P4 (SPM/darwin) ──────► [P4a → P4b∥P4c∥P4d] (after P3 apple scaffold) +P5 (harness) ──────► [P5a ∥ P5b ∥ P5c] +``` + +**Hard dependencies:** + +- P3 requires P0 ADR +- P4 requires P3 apple package exists +- P5 requires P3 + P4 minimum + +**P1 and P2 can run in parallel after P0.** + +--- + +## 11. Master subagent batch contract + +```markdown +| Lane | Phase | Role | Write set | Forbidden | Native gate | +|------|-------|------|-----------|-----------|-------------| +| P0-adr | 0 | ADR 0025 | docs/decisions/0025-* | code moves | review | +| P0-docs | 0 | Charter sync | NORTH_STAR, choose_your_path, DX_FAQ | emitters | validate | +| P1a-validate | 1 | platforms.enabled gate | intentcall_cli config/validate | federated pkgs | command_runner_test | +| P1b-hooks-init | 1 | Platform-aware hooks init | platform_hooks_init | package moves | platform_hooks_init_test | +| P1c-spine-ci | 1 | CI snippets for desktop | platform_hook_spine | — | platform_hook_templates_test | +| P1d-fixtures | 1 | Fixture enabled lists | fixtures, register-intents skill | — | adr-gates | +| P2a-deprecate | 2 | Legacy deprecations | intentcall_apple/android | delete yet | generator tests | +| P2b-train | 2 | Release train | release_train, release-please | — | release_train check | +| P2c-docs | 2 | Doc sunset | README, package tables | — | docs-check | +| P3a-interface | 3 | Platform interface | intentcall_platform_interface | SPM moves | interface tests | +| P3a-apple | 3 | Apple federated impl | intentcall_platform_apple | drop podspec early | pigeon tests | +| P3b-android | 3 | Android federated impl | intentcall_platform_android | — | android compile | +| P3c-umbrella | 3 | Endorsed umbrella | intentcall_platform pubspec | — | flutter_host_test | +| P3d-pigeon | 3 | Pigeon path update | intentcall_bridge pigeons | — | pigeon-codegen-check | +| P4a-darwin | 4 | sharedDarwinSource | darwin/ tree | podspec | SPM findings | +| P4b-spm | 4 | Package.swift hygiene | Package.swift, PrivacyInfo | — | flutter build config-only | +| P4c-pod-drop | 4 | Remove CocoaPods | delete podspecs, preflight | — | publish_preflight_test | +| P4d-pigeon | 4 | Regenerate swift out | pigeon + swift | — | pigeon-codegen-check | +| P5a-harness | 5 | Steward scenario | steward.yaml, justfile | — | steward benchmark | +| P5b-mcp | 5 | mcp_flutter consumer | ../mcp_flutter | agentkit core | check-contracts | +| P5c-docs | 5 | Author matrix | DX_FAQ platform table | — | docs-check | +``` + +Parent blocks phase N+1 until phase N aggregate gate passes. + +--- + +## 12. Author cheat sheet (target state) + +| App profile | `platforms.enabled` | `pubspec` deps | Hooks | +|-------------|---------------------|----------------|-------| +| iOS + Android mobile | `[android, ios]` | `intentcall_platform` (+ codegen stack) | Gradle + Xcode from spine | +| macOS + Windows desktop | `[macos, windows]` | `intentcall_platform` | CI sync for windows/linux | +| Android + Huawei stores | `[android]` | `intentcall_platform` | Gradle only | +| Web Jaspr | `[web]` | `intentcall_hooks` dev; no platform plugin | Dart SDK hook | +| iOS-only (future) | `[ios]` | `intentcall_platform` (android impl not compiled into app binary via federated split) | Xcode only | + +--- + +## 13. Risk register + +| Risk | Mitigation | +|------|------------| +| SPM-only breaks older Flutter apps | Document min Flutter 3.44; pre-1.0 train allows breaking change | +| Federated split breaks `mcp_flutter` | Path deps + contract scripts in P5 | +| Duplicate Swift during migration | P4 deletes ios/macos copies only after darwin compiles | +| Manifest surface drift when narrowing enabled | Regenerate fixtures in same PR as P1 | +| HarmonyOS premature package | Stay on `android` emitter until ArkTS format exists | +| CocoaPods removal vs Flutter docs | User explicit: drop pods; Flutter 3.44 SPM primary; pods registry read-only Dec 2026 | + +--- + +## 14. Relationship to hooks-native-bridge-plan + +| hooks plan phase | Status | This plan | +|----------------|--------|-----------| +| P0–P4 hooks/bridge/harness | Implemented | Builds on; do not regress | +| Flutter Gradle/Xcode deferral (ADR 0024 §6.3) | Active | Unchanged until federated + SPM stable | +| `intentcall_platform` monolith | Open | **Resolved by P3–P4 here** | + +--- + +## 15. Suggested first execution prompt + +```text +Execute Phase 0 + Phase 1 of docs/evidence/platform-subset-federated-plugins-plan.md: + +1. Draft and accept ADR 0025 (platform subset + federated plugins + SPM-only). +2. Implement platforms.enabled validation for host:flutter (warn → error). +3. Make PlatformHooksInit respect platforms.enabled. +4. Add CI hook snippets for windows/linux in PlatformHookSpine. +5. Update fixtures and register-intents skill. + +Do NOT create federated packages or delete podspecs yet. +Gates: just platform-hooks-check, intentcall validate, just adr-gates. +Use subagents per batch contract §11. +``` + +--- + +## 16. MoE audit origin + +Synthesized from platform package strategy discussion (2026-07-08), Flutter developing-packages + SPM plugin author docs, and prior hooks-native-bridge MoE. Generational Architecture Skeptic lens: split runtime (federated), not projection; sunset `intentcall_apple`/`android`; `platforms.enabled` is the product-facing contract. diff --git a/docs/start_here/choose_your_path.mdx b/docs/start_here/choose_your_path.mdx index d6b5bc2..ee7862c 100644 --- a/docs/start_here/choose_your_path.mdx +++ b/docs/start_here/choose_your_path.mdx @@ -8,7 +8,7 @@ Use this page when you know what you want to build, but not which IntentCall pac | Publish MCP tools/resources | `intentcall_mcp`, plus `intentcall_core` and `intentcall_schema` | Read registry entries, publish existing entries, listen to registry events, route calls back to `registry.invoke(...)`. | Add or extend `verifyNativeAdapterContract(...)`, then run `steward benchmark --scenario intentcall.adapter-contract --json`. | [DX FAQ](/DX_FAQ#writing-a-new-adapter) | | Browser WebMCP registration | `intentcall_webmcp`, `intentcall_platform` when using the Dart-first bootstrap | Register WebMCP tools from Dart and treat older `navigator.modelContext` behavior as compatibility only. | WebMCP emitter/bootstrap tests plus `steward probe --json --profile quick`; live browser-host interoperability needs separate proof. | [Platform support](/start_here/platform_support) | | Runtime sessions for tools or CLIs | `intentcall_session`, `intentcall_core` | Implement `IntentSessionConnector`, compose `IntentSessionManager` and `IntentSessionExecutor`. | Package tests around connector selection, persistence, and executor behavior. | [DX FAQ](/DX_FAQ#runtime-sessions) | -| Native/web artifacts and fallback invoke routes | `intentcall_platform_sync`, `intentcall_cli`, optional `intentcall_platform` (Flutter bridge) | Run `build_runner`, `intentcall manifest export --check`, then `intentcall platform sync --check`. | Emitter tests, fixture parity, platform sync check, adapter contract tests. | [Platform support](/start_here/platform_support) | +| Native/web artifacts and fallback invoke routes | `intentcall_platform_sync`, `intentcall_cli`, optional `intentcall_platform` (federated Flutter umbrella only) | Run `build_runner`, `intentcall manifest export --check`, then `intentcall platform sync --check`. Depend on the umbrella — not deprecated `intentcall_apple` / `intentcall_android`. | Emitter tests, fixture parity, platform sync check, adapter contract tests. | [Platform support](/start_here/platform_support) | | Optional typed registration helpers | `intentcall_codegen`, `build_runner`, `intentcall_core`, `intentcall_schema` | Annotate stable top-level functions with `@AgentTool`; keep handwritten entries first-class. | Generator fixture tests and normal package tests for generated registrations. | [DX FAQ](/DX_FAQ#codegen-agenttool) | | Adapter contract proof | `intentcall_testing` | Use `verifyNativeAdapterContract(...)` in adapter tests. | `steward benchmark --scenario intentcall.adapter-contract --json`. | [intentcall_testing README](https://github.com/Arenukvern/intentcall/blob/main/packages/intentcall_testing/README.md) | diff --git a/docs/start_here/how_it_works.mdx b/docs/start_here/how_it_works.mdx index 2b8c0ca..c3b1f14 100644 --- a/docs/start_here/how_it_works.mdx +++ b/docs/start_here/how_it_works.mdx @@ -13,11 +13,13 @@ flowchart LR registry --> entitySnapshot["Dart-owned snapshots\nentities + index records"] core --> mcp["intentcall_mcp\nMCP tools/resources"] core --> webmcp["intentcall_webmcp\nDart-first WebMCP registration"] - core --> platform["intentcall_platform\nnative/web artifacts + invocation envelopes"] + core --> platformSync["intentcall_platform_sync\nnative/web emitters + PlatformSync"] + core --> platform["intentcall_platform\nFlutter runtime host / federated umbrella"] core --> codegen["intentcall_codegen\noptional @AgentTool helpers"] - entitySnapshot --> platform + entitySnapshot --> platformSync mcp --> callers["Agents, CLIs, assistants, app hosts"] webmcp --> callers + platformSync --> callers platform --> callers session --> callers toolkit["mcp_flutter / mcp_toolkit\nproduct harness for app authors"] -. consumes .-> core diff --git a/justfile b/justfile index 89e5d38..4e69dc8 100644 --- a/justfile +++ b/justfile @@ -121,7 +121,7 @@ check-path-deps: check-release-train: dart tool/intentcall/bin/release_train.dart check -# Synchronize release train versions, internal floors, and native podspecs +# Synchronize release train versions and internal floors sync-release-train: dart run tool/intentcall/bin/intentcall.dart sync-release-train @@ -156,8 +156,7 @@ adapter-contract-test: # Regenerate and verify Pigeon bridge outputs are committed (Phase 3 gate) pigeon-codegen-check: cd packages/intentcall_bridge && dart run pigeon --input pigeons/intentcall_platform_bridge.dart - cp packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift - git diff --exit-code packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt + git diff --exit-code packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt # List custom agent skills defined in this repository list-skills: diff --git a/packages/intentcall_android/README.md b/packages/intentcall_android/README.md index d609076..fc92bee 100644 --- a/packages/intentcall_android/README.md +++ b/packages/intentcall_android/README.md @@ -1,3 +1,5 @@ +> ⚠️ **DEPRECATED** — Use [`intentcall_platform_sync`](https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync) `PlatformSync` / `ManifestExporter` instead. This package is workspace-only (`publish_to: none`) and will be removed after one deprecation cycle. See [ADR 0025](../../docs/decisions/0025-platform-subset-federated-plugins.md). + > ⚠️ **Pre-release train** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). diff --git a/packages/intentcall_android/lib/src/agent_manifest_generator.dart b/packages/intentcall_android/lib/src/agent_manifest_generator.dart index 09e3707..2f7bdae 100644 --- a/packages/intentcall_android/lib/src/agent_manifest_generator.dart +++ b/packages/intentcall_android/lib/src/agent_manifest_generator.dart @@ -3,6 +3,9 @@ import 'dart:convert'; import 'package:intentcall_core/intentcall_core.dart'; /// Builds `agent_manifest.json` for Android App Actions / shortcuts (Phase 3). +@Deprecated( + 'Use intentcall_platform_sync PlatformSync / ManifestExporter', +) String generateAndroidAgentManifest( final Iterable descriptors, { final String? protocolScheme, diff --git a/packages/intentcall_android/pubspec.yaml b/packages/intentcall_android/pubspec.yaml index 1518cd3..720faf1 100644 --- a/packages/intentcall_android/pubspec.yaml +++ b/packages/intentcall_android/pubspec.yaml @@ -1,6 +1,7 @@ name: intentcall_android -description: PRE-RELEASE — Android shortcuts XML manifest generator from intentcall agent manifest JSON. +description: DEPRECATED — Use intentcall_platform_sync. Legacy Android shortcuts XML manifest generator. version: 0.6.0 +publish_to: none license: MIT repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_android issue_tracker: https://github.com/Arenukvern/intentcall/issues diff --git a/packages/intentcall_apple/README.md b/packages/intentcall_apple/README.md index 5dd3152..a2bd345 100644 --- a/packages/intentcall_apple/README.md +++ b/packages/intentcall_apple/README.md @@ -1,3 +1,5 @@ +> ⚠️ **DEPRECATED** — Use [`intentcall_platform_sync`](https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync) `PlatformSync` / `ManifestExporter` instead. This package is workspace-only (`publish_to: none`) and will be removed after one deprecation cycle. See [ADR 0025](../../docs/decisions/0025-platform-subset-federated-plugins.md). + > ⚠️ **Pre-release train** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). diff --git a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart b/packages/intentcall_apple/lib/src/agent_manifest_generator.dart index 38ff57f..4b12134 100644 --- a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart +++ b/packages/intentcall_apple/lib/src/agent_manifest_generator.dart @@ -3,6 +3,9 @@ import 'dart:convert'; import 'package:intentcall_core/intentcall_core.dart'; /// Builds `agent_manifest.json` for App Intents / Shortcuts codegen (Phase 3). +@Deprecated( + 'Use intentcall_platform_sync PlatformSync / ManifestExporter', +) String generateAppleAgentManifest( final Iterable descriptors, { final Iterable entityTypeDescriptors = const [], diff --git a/packages/intentcall_apple/pubspec.yaml b/packages/intentcall_apple/pubspec.yaml index 17598c5..c749f2e 100644 --- a/packages/intentcall_apple/pubspec.yaml +++ b/packages/intentcall_apple/pubspec.yaml @@ -1,6 +1,7 @@ name: intentcall_apple -description: PRE-RELEASE — Apple App Intents manifest generator from intentcall agent manifest JSON. +description: DEPRECATED — Use intentcall_platform_sync. Legacy Apple App Intents manifest generator. version: 0.6.0 +publish_to: none license: MIT repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_apple issue_tracker: https://github.com/Arenukvern/intentcall/issues diff --git a/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart index 95790ad..a1b646e 100644 --- a/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart +++ b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart @@ -5,10 +5,10 @@ import 'package:pigeon/pigeon.dart'; dartOut: 'lib/src/intentcall_platform_bridge.g.dart', dartPackageName: 'intentcall_bridge', swiftOut: - '../intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift', + '../intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift', swiftOptions: SwiftOptions(), kotlinOut: - '../intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt', + '../intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt', kotlinOptions: KotlinOptions(package: 'dev.intentcall.intentcall_platform'), ), ) diff --git a/packages/intentcall_cli/lib/src/command_runner.dart b/packages/intentcall_cli/lib/src/command_runner.dart index 0e9085c..37ceffa 100644 --- a/packages/intentcall_cli/lib/src/command_runner.dart +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -133,15 +133,24 @@ final class _DoctorCommand extends Command { final class _ConfigCommand extends Command { _ConfigCommand() { addSubcommand(_ConfigShowCommand()); + addSubcommand(_ConfigValidateCommand()); } @override String get name => 'config'; @override - String get description => 'Show intentcall.yaml host wiring.'; + String get description => 'Show or validate intentcall.yaml host wiring.'; } +bool _requiresExplicitPlatforms(final IntentCallHost host) => + host == IntentCallHost.flutter || host == IntentCallHost.jaspr; + +String _emptyPlatformsRemediation(final IntentCallHost host) => + 'host: ${host.name} requires platforms.enabled (e.g. ' + 'platforms.enabled: [android, ios]). ' + 'Empty lists fall back at sync/hooks time but fail config validate.'; + final class _ConfigShowCommand extends Command { @override String get name => 'show'; @@ -159,8 +168,14 @@ final class _ConfigShowCommand extends Command { printUsageError('intentcall.yaml not found under $projectRoot'); return inputMissingExitCode(); } + final emptyEnabled = + _requiresExplicitPlatforms(config.host) && + config.platforms.enabled.isEmpty; final enriched = Map.from(config.toJson()) ..['resolvedPlatforms'] = resolveEnabledPlatforms(config); + if (emptyEnabled) { + stderr.writeln('WARN: ${_emptyPlatformsRemediation(config.host)}'); + } if (results['json'] as bool? ?? false) { printJson(enriched); } else { @@ -180,6 +195,66 @@ final class _ConfigShowCommand extends Command { } } +final class _ConfigValidateCommand extends Command { + @override + String get name => 'validate'; + + @override + String get description => + 'Validate intentcall.yaml host wiring (platforms.enabled contract).'; + + @override + int run() => _runValidate(argResults!); + + int _runValidate(final ArgResults results) { + final projectRoot = _ProjectDirOption.read(results); + final config = loadIntentCallConfig(projectRoot); + if (config == null) { + printUsageError('intentcall.yaml not found under $projectRoot'); + return inputMissingExitCode(); + } + + if (_requiresExplicitPlatforms(config.host) && + config.platforms.enabled.isEmpty) { + final message = _emptyPlatformsRemediation(config.host); + if (results['json'] as bool? ?? false) { + printJson({ + 'ok': false, + 'path': config.sourcePath, + 'host': config.host.name, + 'error': message, + }); + } else { + printUsageError(message); + } + return dataErrorExitCode(); + } + + if (results['json'] as bool? ?? false) { + printJson({ + 'ok': true, + 'path': config.sourcePath, + 'host': config.host.name, + 'enabledPlatforms': config.platforms.enabled, + }); + } else { + stdout.writeln( + 'OK: intentcall.yaml valid' + '${config.sourcePath == null ? '' : ' (${config.sourcePath})'}', + ); + } + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser.addFlag('json', negatable: false); + return parser; + } +} + final class _ManifestCommand extends Command { _ManifestCommand() { addSubcommand(_ManifestValidateCommand()); diff --git a/packages/intentcall_cli/test/command_runner_test.dart b/packages/intentcall_cli/test/command_runner_test.dart index adc1b69..098a2e8 100644 --- a/packages/intentcall_cli/test/command_runner_test.dart +++ b/packages/intentcall_cli/test/command_runner_test.dart @@ -32,6 +32,70 @@ void main() { expect(exitCode, 0); }); + test('config validate fails for flutter with empty platforms.enabled', + () async { + final root = _tempConfigProject( + 'host: flutter\nplatforms:\n enabled: []\n', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final exitCode = await IntentCallCommandRunner().run([ + 'config', + 'validate', + '--project-dir', + root.path, + ]); + expect(exitCode, 65); + }); + + test('config validate passes for flutter with explicit platforms.enabled', + () async { + final root = _tempConfigProject( + 'host: flutter\n' + 'platforms:\n' + ' enabled:\n' + ' - android\n' + ' - ios\n', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final exitCode = await IntentCallCommandRunner().run([ + 'config', + 'validate', + '--project-dir', + root.path, + ]); + expect(exitCode, 0); + }); + + test('config validate fails for jaspr with empty platforms.enabled', + () async { + final root = _tempConfigProject( + 'host: jaspr\nplatforms:\n enabled: []\n', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final exitCode = await IntentCallCommandRunner().run([ + 'config', + 'validate', + '--project-dir', + root.path, + ]); + expect(exitCode, 65); + }); + + test('config validate passes for dart with empty platforms.enabled', + () async { + final root = _tempConfigProject( + 'host: dart\nplatforms:\n enabled: []\n', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final exitCode = await IntentCallCommandRunner().run([ + 'config', + 'validate', + '--project-dir', + root.path, + ]); + expect(exitCode, 0); + }); + test('manifest validate accepts fixture manifest', () async { final runner = IntentCallCommandRunner(); final exitCode = await runner.run([ @@ -161,3 +225,9 @@ Directory _fixtureRoot(final String name) { } throw StateError('Missing fixture directory for $name'); } + +Directory _tempConfigProject(final String yaml) { + final root = Directory.systemTemp.createTempSync('intentcall_config_validate_'); + File(p.join(root.path, 'intentcall.yaml')).writeAsStringSync(yaml); + return root; +} diff --git a/packages/intentcall_core/README.md b/packages/intentcall_core/README.md index 2603b16..579c1b1 100644 --- a/packages/intentcall_core/README.md +++ b/packages/intentcall_core/README.md @@ -100,7 +100,9 @@ Use this import for `MigrateAgentEntriesMigrator`, core registration value objects for compatibility - `intentcall_webmcp` — WebMCP `modelContext` publish adapter - `intentcall_gemma` — on-device Gemma function-calling adapter -- `intentcall_apple` / `intentcall_android` — `agent_manifest.json` codegen +- `intentcall_platform_sync` — canonical manifest projection / emitters +- `intentcall_apple` / `intentcall_android` — **deprecated** legacy generators + (`publish_to: none`; use `intentcall_platform_sync`) - `intentcall_testing` — registry contract helpers Canonical design docs: [North Star](../../docs/NORTH_STAR.mdx), [Design FAQ](../../docs/DESIGN_FAQ.mdx), and [DX FAQ](../../docs/DX_FAQ.mdx). diff --git a/packages/intentcall_platform/README.md b/packages/intentcall_platform/README.md index 806413c..816300a 100644 --- a/packages/intentcall_platform/README.md +++ b/packages/intentcall_platform/README.md @@ -33,11 +33,16 @@ that app-owned scheme. That is an artifact/project-sync/configuration claim: successful Xcode builds, signing, installation, Apple system discovery, and live invocation need proof in the consuming app. -Swift Package Manager support is declared for the iOS/macOS Flutter plugin under -`ios/intentcall_platform/Package.swift` and -`macos/intentcall_platform/Package.swift`. CocoaPods remains supported through -the existing podspecs so current Flutter projects can use either native package -integration path. +This package is the **federated Flutter umbrella**: apps depend on +`intentcall_platform` only. Endorsed native impls are +`intentcall_platform_apple` and `intentcall_platform_android` via +`default_package`. + +Apple native integration is **SPM-only** (no CocoaPods / podspecs). The shared +Darwin tree lives under +`packages/intentcall_platform_apple/darwin/intentcall_platform_apple/` +(`Package.swift`, `sharedDarwinSource` for iOS + macOS). Android native code +lives in `packages/intentcall_platform_android/`. ## Invocation policy diff --git a/packages/intentcall_platform/ios/intentcall_platform.podspec b/packages/intentcall_platform/ios/intentcall_platform.podspec deleted file mode 100644 index 0437e28..0000000 --- a/packages/intentcall_platform/ios/intentcall_platform.podspec +++ /dev/null @@ -1,18 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'intentcall_platform' - s.version = '0.6.0' - s.summary = 'Platform bridge for IntentCall pending native invocations.' - s.description = <<-DESC -Platform bridge for dispatching generated native invocation envelopes into Dart. - DESC - s.homepage = 'https://github.com/Arenukvern/intentcall' - s.license = { :file => '../LICENSE' } - s.author = { 'Arenukvern' => 'intentcall@example.invalid' } - s.source = { :path => '.' } - s.source_files = 'intentcall_platform/Sources/**/*' - s.dependency 'Flutter' - - s.platform = :ios, '13.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.swift_version = '5.0' -end diff --git a/packages/intentcall_platform/ios/intentcall_platform/Package.swift b/packages/intentcall_platform/ios/intentcall_platform/Package.swift deleted file mode 100644 index fe95454..0000000 --- a/packages/intentcall_platform/ios/intentcall_platform/Package.swift +++ /dev/null @@ -1,29 +0,0 @@ -// swift-tools-version: 5.9 - -import PackageDescription - -let package = Package( - name: "intentcall_platform", - platforms: [ - .iOS("13.0") - ], - products: [ - .library(name: "intentcall-platform", targets: ["intentcall_platform"]) - ], - dependencies: [ - .package(name: "FlutterFramework", path: "../FlutterFramework") - ], - targets: [ - .target( - name: "intentcall_platform", - dependencies: [ - .product(name: "FlutterFramework", package: "FlutterFramework") - ], - resources: [ - // The plugin does not currently use required-reason APIs or - // collect data. Keep the manifest ready for future changes. - // .process("PrivacyInfo.xcprivacy"), - ] - ) - ] -) diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift deleted file mode 100644 index 4df9c32..0000000 --- a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Flutter -import UIKit - -/// Plugin bridge for pending native intent dispatch and entity snapshot cache. -public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let bridge = IntentCallPlatformBridgeHostApiImpl() - IntentCallInvocationsHostApiSetup.setUp( - binaryMessenger: registrar.messenger(), - api: bridge - ) - IntentCallEntitiesHostApiSetup.setUp( - binaryMessenger: registrar.messenger(), - api: bridge - ) - } -} - -private final class IntentCallPlatformBridgeHostApiImpl: IntentCallInvocationsHostApi, - IntentCallEntitiesHostApi -{ - func takePendingInvocations() throws -> [IntentCallInvocationEnvelopeDto] { - IntentCallNativeHandoffStore.takePendingInvocations().compactMap(envelopeDto(from:)) - } - - func upsertEntitySnapshots( - entityType: String, - snapshots: [[String?: Any?]], - keys: IntentCallEntityKeyBundle - ) throws -> Int64 { - Int64( - IntentCallNativeEntitySnapshotStore.upsertSnapshots( - entityType: entityType, - snapshots: snapshotRows(from: snapshots), - idKey: keys.idKey - ) - ) - } - - func deleteEntitySnapshots( - entityType: String, - ids: [String], - keys: IntentCallEntityKeyBundle - ) throws -> Int64 { - Int64( - IntentCallNativeEntitySnapshotStore.deleteSnapshots( - entityType: entityType, - ids: ids, - idKey: keys.idKey - ) - ) - } - - func clearEntityTypeSnapshots(entityType: String) throws -> Int64 { - Int64(IntentCallNativeEntitySnapshotStore.clearSnapshots(entityType: entityType)) - } - - func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] { - snapshotRowsToPigeon( - IntentCallNativeEntitySnapshotStore.snapshots(entityType: entityType) - ) - } - - func searchEntitySnapshots( - entityType: String, - query: String, - limit: Int64, - keys: IntentCallEntityKeyBundle - ) throws -> [[String?: Any?]] { - snapshotRowsToPigeon( - IntentCallNativeEntitySnapshotStore.search( - entityType: entityType, - query: query, - titleKey: keys.titleKey, - subtitleKey: keys.subtitleKey, - keywordsKey: keys.keywordsKey, - limit: Int(limit) - ) - ) - } - - func takePendingEntityOpens() throws -> [IntentCallEntityOpenEnvelopeDto] { - IntentCallNativeEntitySnapshotStore.takePendingEntityOpens().compactMap(entityOpenDto(from:)) - } - - private func envelopeDto(from row: [String: Any]) -> IntentCallInvocationEnvelopeDto? { - guard - let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), - let qualifiedName = IntentCallNativeEntitySnapshotStore.string(row["qualifiedName"]), - let source = IntentCallNativeEntitySnapshotStore.string(row["source"]), - let createdAt = IntentCallNativeEntitySnapshotStore.string(row["createdAt"]) - else { - return nil - } - let arguments = pigeonMap(from: row["arguments"] as? [String: Any]) - return IntentCallInvocationEnvelopeDto( - id: id, - qualifiedName: qualifiedName, - arguments: arguments, - source: source, - createdAt: createdAt - ) - } - - private func entityOpenDto(from row: [String: Any]) -> IntentCallEntityOpenEnvelopeDto? { - guard - let id = IntentCallNativeEntitySnapshotStore.string(row["id"]), - let entityType = IntentCallNativeEntitySnapshotStore.string(row["entityType"]), - let entityId = IntentCallNativeEntitySnapshotStore.string(row["entityId"]), - let source = IntentCallNativeEntitySnapshotStore.string(row["source"]), - let createdAt = IntentCallNativeEntitySnapshotStore.string(row["createdAt"]) - else { - return nil - } - return IntentCallEntityOpenEnvelopeDto( - id: id, - entityType: entityType, - entityId: entityId, - source: source, - createdAt: createdAt - ) - } - - private func pigeonMap(from row: [String: Any]?) -> [String?: Any?]? { - guard let row else { return nil } - var normalized = [String?: Any?]() - for (key, value) in row { - normalized[key] = value - } - return normalized - } - - private func snapshotRows(from rows: [[String?: Any?]]) -> [[String: Any]] { - rows.map { row in - var normalized = [String: Any]() - for (key, value) in row { - guard let key else { continue } - normalized[key] = value as Any - } - return normalized - } - } - - private func snapshotRowsToPigeon(_ rows: [[String: Any]]) -> [[String?: Any?]] { - rows.map { row in - var normalized = [String?: Any?]() - for (key, value) in row { - normalized[key] = value - } - return normalized - } - } -} diff --git a/packages/intentcall_platform/macos/intentcall_platform.podspec b/packages/intentcall_platform/macos/intentcall_platform.podspec deleted file mode 100644 index 797c731..0000000 --- a/packages/intentcall_platform/macos/intentcall_platform.podspec +++ /dev/null @@ -1,18 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'intentcall_platform' - s.version = '0.6.0' - s.summary = 'Platform bridge for IntentCall pending native invocations.' - s.description = <<-DESC -Platform bridge for dispatching generated native invocation envelopes into Dart. - DESC - s.homepage = 'https://github.com/Arenukvern/intentcall' - s.license = { :file => '../LICENSE' } - s.author = { 'Arenukvern' => 'intentcall@example.invalid' } - s.source = { :path => '.' } - s.source_files = 'intentcall_platform/Sources/**/*' - s.dependency 'FlutterMacOS' - - s.platform = :osx, '10.14' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.swift_version = '5.0' -end diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift deleted file mode 100644 index 5e52823..0000000 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift +++ /dev/null @@ -1,211 +0,0 @@ -import Foundation -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - -/// Shared native cache for Dart-projected entity snapshots. -/// -/// Generated App Intents entity/query code and the Flutter plugin bridge both -/// read and write through this store. -public enum IntentCallNativeEntitySnapshotStore { - public static let snapshotsDidChangeNotification = Notification.Name( - "intentcall.entitySnapshotsDidChange" - ) - - public static var fallbackScheme: String? - - private static let snapshotsKeyPrefix = "intentcall.entity_snapshots." - private static let pendingOpenKey = "intentcall.pending_entity_opens" - - @discardableResult - public static func upsertSnapshots( - entityType: String, - snapshots: [[String: Any]], - idKey: String = "id" - ) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var existing = - UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - var byId = Dictionary( - uniqueKeysWithValues: existing.compactMap { snapshot -> (String, [String: Any])? in - guard let id = string(snapshot[idKey]) else { return nil } - return (id, snapshot) - } - ) - for snapshot in snapshots { - guard let id = string(snapshot[idKey]) else { continue } - byId[id] = snapshot - } - existing = Array(byId.values) - UserDefaults.standard.set(existing, forKey: snapshotsKey(entityType: type)) - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - return snapshots.count - } - - @discardableResult - public static func deleteSnapshots( - entityType: String, - ids: [String], - idKey: String = "id" - ) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - let deleted = Set(ids) - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let existing = - UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - let kept = existing.filter { snapshot in - guard let id = string(snapshot[idKey]) else { return true } - return !deleted.contains(id) - } - UserDefaults.standard.set(kept, forKey: snapshotsKey(entityType: type)) - let removed = existing.count - kept.count - if removed > 0 { - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - } - return removed - } - - @discardableResult - public static func clearSnapshots(entityType: String) -> Int { - guard let type = validatedEntityType(entityType) else { return 0 } - let existing = snapshots(entityType: type).count - UserDefaults.standard.removeObject(forKey: snapshotsKey(entityType: type)) - if existing > 0 { - NotificationCenter.default.post(name: snapshotsDidChangeNotification, object: nil) - } - return existing - } - - public static func snapshots(entityType: String) -> [[String: Any]] { - guard let type = validatedEntityType(entityType) else { return [] } - return UserDefaults.standard.array(forKey: snapshotsKey(entityType: type)) - as? [[String: Any]] ?? [] - } - - public static func entities( - entityType: String, - identifiers: [String], - idKey: String, - limit: Int? - ) -> [[String: Any]] { - let wanted = Set(identifiers) - let matches = snapshots(entityType: entityType).filter { snapshot in - guard let id = string(snapshot[idKey]) else { return false } - return wanted.contains(id) - } - return applyingLimit(matches, limit: limit) - } - - public static func suggested(entityType: String, limit: Int) -> [[String: Any]] { - applyingLimit(snapshots(entityType: entityType), limit: limit) - } - - public static func search( - entityType: String, - query: String, - titleKey: String, - subtitleKey: String, - keywordsKey: String, - limit: Int - ) -> [[String: Any]] { - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let all = snapshots(entityType: entityType) - guard !needle.isEmpty else { - return suggested(entityType: entityType, limit: limit) - } - let matches = all.filter { snapshot in - let fields = - [string(snapshot[titleKey]), string(snapshot[subtitleKey])].compactMap { $0 } - + strings(snapshot[keywordsKey]) - return fields.contains { $0.lowercased().contains(needle) } - } - return applyingLimit(matches, limit: limit) - } - - public static func recordOpen(entityType: String, id: String) async -> String { - let type = validatedEntityType(entityType) ?? entityType - let openId = UUID().uuidString - let item: [String: Any] = [ - "id": openId, - "entityType": type, - "entityId": id, - "source": "native.entity.generated", - "createdAt": ISO8601DateFormatter().string(from: Date()), - ] - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var pending = - UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] - pending.append(item) - UserDefaults.standard.set(pending, forKey: pendingOpenKey) - guard let scheme = fallbackScheme else { return openId } - let encodedEntityType = encodedPathComponent(type) - let encodedId = encodedPathComponent(id) - guard let url = URL(string: "\(scheme)://entity/\(encodedEntityType)/\(encodedId)") else { - return openId - } - #if canImport(UIKit) - await UIApplication.shared.open(url) - #elseif canImport(AppKit) - NSWorkspace.shared.open(url) - #endif - return openId - } - - /// At-most-once drain semantics: clears pending rows before Dart reports success. - public static func takePendingEntityOpens() -> [[String: Any]] { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let pending = - UserDefaults.standard.array(forKey: pendingOpenKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: pendingOpenKey) - return pending - } - - public static func string(_ value: Any?) -> String? { - if let value = value as? String { return value } - if let value = value as? CustomStringConvertible { return value.description } - return nil - } - - public static func strings(_ value: Any?) -> [String] { - if let values = value as? [String] { return values } - if let values = value as? [Any] { return values.compactMap { string($0) } } - if let value = string(value) { return [value] } - return [] - } - - private static func snapshotsKey(entityType: String) -> String { - snapshotsKeyPrefix + entityType - } - - private static func applyingLimit(_ rows: [[String: Any]], limit: Int?) -> [[String: Any]] { - guard let limit else { return rows } - return Array(rows.prefix(max(0, limit))) - } - - private static func encodedPathComponent(_ value: String) -> String { - var allowedPath = CharacterSet.alphanumerics - allowedPath.insert(charactersIn: "_-.~") - return value.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? value - } - - private static func validatedEntityType(_ value: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let range = NSRange(location: 0, length: trimmed.utf16.count) - guard - let regex = try? NSRegularExpression(pattern: "^[a-z][a-z0-9_]*_[a-z][a-z0-9_]*$"), - regex.firstMatch(in: trimmed, options: [], range: range) != nil - else { - return nil - } - return trimmed - } -} diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift deleted file mode 100644 index 96b0a6e..0000000 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -/// Shared native queue for App Intents / deep-link handoff into Dart. -/// -/// Generated App Intents code appends via [append]; the Flutter plugin drains -/// via [takePendingInvocations] through the Pigeon bridge. -public enum IntentCallNativeHandoffStore { - private static let pendingKey = "intentcall.pending_invocations" - - public static func append(_ item: [String: Any]) { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - var pending = - UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - pending.append(item) - UserDefaults.standard.set(pending, forKey: pendingKey) - } - - /// At-most-once drain semantics: clears pending rows before Dart reports success. - public static func takePendingInvocations() -> [[String: Any]] { - objc_sync_enter(UserDefaults.standard) - defer { objc_sync_exit(UserDefaults.standard) } - let pending = - UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: pendingKey) - return pending - } -} diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift deleted file mode 100644 index 6e0b602..0000000 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift +++ /dev/null @@ -1,515 +0,0 @@ -// Autogenerated from Pigeon (v26.3.2), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -import Foundation - -#if os(iOS) - import Flutter -#elseif os(macOS) - import FlutterMacOS -#else - #error("Unsupported platform.") -#endif - -/// Error class for passing custom error details to Dart side. -final class PigeonError: Error { - let code: String - let message: String? - let details: Sendable? - - init(code: String, message: String?, details: Sendable?) { - self.code = code - self.message = message - self.details = details - } - - var localizedDescription: String { - return - "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" - } -} - -private func wrapResult(_ result: Any?) -> [Any?] { - return [result] -} - -private func wrapError(_ error: Any) -> [Any?] { - if let pigeonError = error as? PigeonError { - return [ - pigeonError.code, - pigeonError.message, - pigeonError.details, - ] - } - if let flutterError = error as? FlutterError { - return [ - flutterError.code, - flutterError.message, - flutterError.details, - ] - } - return [ - "\(error)", - "\(Swift.type(of: error))", - "Stacktrace: \(Thread.callStackSymbols)", - ] -} - -private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil -} - -private func nilOrValue(_ value: Any?) -> T? { - if value is NSNull { return nil } - return value as! T? -} - -private func doubleEqualsIntentCallPlatformBridge(_ lhs: Double, _ rhs: Double) -> Bool { - return (lhs.isNaN && rhs.isNaN) || lhs == rhs -} - -private func doubleHashIntentCallPlatformBridge(_ value: Double, _ hasher: inout Hasher) { - if value.isNaN { - hasher.combine(0x7FF8000000000000) - } else { - // Normalize -0.0 to 0.0 - hasher.combine(value == 0 ? 0 : value) - } -} - -func deepEqualsIntentCallPlatformBridge(_ lhs: Any?, _ rhs: Any?) -> Bool { - let cleanLhs = nilOrValue(lhs) as Any? - let cleanRhs = nilOrValue(rhs) as Any? - switch (cleanLhs, cleanRhs) { - case (nil, nil): - return true - - case (nil, _), (_, nil): - return false - - case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: - return true - - case is (Void, Void): - return true - - case (let lhsArray, let rhsArray) as ([Any?], [Any?]): - guard lhsArray.count == rhsArray.count else { return false } - for (index, element) in lhsArray.enumerated() { - if !deepEqualsIntentCallPlatformBridge(element, rhsArray[index]) { - return false - } - } - return true - - case (let lhsArray, let rhsArray) as ([Double], [Double]): - guard lhsArray.count == rhsArray.count else { return false } - for (index, element) in lhsArray.enumerated() { - if !doubleEqualsIntentCallPlatformBridge(element, rhsArray[index]) { - return false - } - } - return true - - case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): - guard lhsDictionary.count == rhsDictionary.count else { return false } - for (lhsKey, lhsValue) in lhsDictionary { - var found = false - for (rhsKey, rhsValue) in rhsDictionary { - if deepEqualsIntentCallPlatformBridge(lhsKey, rhsKey) { - if deepEqualsIntentCallPlatformBridge(lhsValue, rhsValue) { - found = true - break - } else { - return false - } - } - } - if !found { return false } - } - return true - - case (let lhs as Double, let rhs as Double): - return doubleEqualsIntentCallPlatformBridge(lhs, rhs) - - case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): - return lhsHashable == rhsHashable - - default: - return false - } -} - -func deepHashIntentCallPlatformBridge(value: Any?, hasher: inout Hasher) { - let cleanValue = nilOrValue(value) as Any? - if let cleanValue = cleanValue { - if let doubleValue = cleanValue as? Double { - doubleHashIntentCallPlatformBridge(doubleValue, &hasher) - } else if let valueList = cleanValue as? [Any?] { - for item in valueList { - deepHashIntentCallPlatformBridge(value: item, hasher: &hasher) - } - } else if let valueList = cleanValue as? [Double] { - for item in valueList { - doubleHashIntentCallPlatformBridge(item, &hasher) - } - } else if let valueDict = cleanValue as? [AnyHashable: Any?] { - var result = 0 - for (key, value) in valueDict { - var entryKeyHasher = Hasher() - deepHashIntentCallPlatformBridge(value: key, hasher: &entryKeyHasher) - var entryValueHasher = Hasher() - deepHashIntentCallPlatformBridge(value: value, hasher: &entryValueHasher) - result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) - } - hasher.combine(result) - } else if let hashableValue = cleanValue as? AnyHashable { - hasher.combine(hashableValue) - } else { - hasher.combine(String(describing: cleanValue)) - } - } else { - hasher.combine(0) - } -} - - -/// Native invocation envelope drained from the handoff store. -/// -/// Generated class from Pigeon that represents data sent in messages. -struct IntentCallInvocationEnvelopeDto: Hashable { - var id: String - var qualifiedName: String - var arguments: [String?: Any?]? = nil - var source: String - var createdAt: String - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallInvocationEnvelopeDto? { - let id = pigeonVar_list[0] as! String - let qualifiedName = pigeonVar_list[1] as! String - let arguments: [String?: Any?]? = nilOrValue(pigeonVar_list[2]) - let source = pigeonVar_list[3] as! String - let createdAt = pigeonVar_list[4] as! String - - return IntentCallInvocationEnvelopeDto( - id: id, - qualifiedName: qualifiedName, - arguments: arguments, - source: source, - createdAt: createdAt - ) - } - func toList() -> [Any?] { - return [ - id, - qualifiedName, - arguments, - source, - createdAt, - ] - } - static func == (lhs: IntentCallInvocationEnvelopeDto, rhs: IntentCallInvocationEnvelopeDto) -> Bool { - if Swift.type(of: lhs) != Swift.type(of: rhs) { - return false - } - return deepEqualsIntentCallPlatformBridge(lhs.id, rhs.id) && deepEqualsIntentCallPlatformBridge(lhs.qualifiedName, rhs.qualifiedName) && deepEqualsIntentCallPlatformBridge(lhs.arguments, rhs.arguments) && deepEqualsIntentCallPlatformBridge(lhs.source, rhs.source) && deepEqualsIntentCallPlatformBridge(lhs.createdAt, rhs.createdAt) - } - - func hash(into hasher: inout Hasher) { - hasher.combine("IntentCallInvocationEnvelopeDto") - deepHashIntentCallPlatformBridge(value: id, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: qualifiedName, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: arguments, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: source, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: createdAt, hasher: &hasher) - } -} - -/// Native entity-open envelope drained from the entity snapshot store. -/// -/// Generated class from Pigeon that represents data sent in messages. -struct IntentCallEntityOpenEnvelopeDto: Hashable { - var id: String - var entityType: String - var entityId: String - var source: String - var createdAt: String - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallEntityOpenEnvelopeDto? { - let id = pigeonVar_list[0] as! String - let entityType = pigeonVar_list[1] as! String - let entityId = pigeonVar_list[2] as! String - let source = pigeonVar_list[3] as! String - let createdAt = pigeonVar_list[4] as! String - - return IntentCallEntityOpenEnvelopeDto( - id: id, - entityType: entityType, - entityId: entityId, - source: source, - createdAt: createdAt - ) - } - func toList() -> [Any?] { - return [ - id, - entityType, - entityId, - source, - createdAt, - ] - } - static func == (lhs: IntentCallEntityOpenEnvelopeDto, rhs: IntentCallEntityOpenEnvelopeDto) -> Bool { - if Swift.type(of: lhs) != Swift.type(of: rhs) { - return false - } - return deepEqualsIntentCallPlatformBridge(lhs.id, rhs.id) && deepEqualsIntentCallPlatformBridge(lhs.entityType, rhs.entityType) && deepEqualsIntentCallPlatformBridge(lhs.entityId, rhs.entityId) && deepEqualsIntentCallPlatformBridge(lhs.source, rhs.source) && deepEqualsIntentCallPlatformBridge(lhs.createdAt, rhs.createdAt) - } - - func hash(into hasher: inout Hasher) { - hasher.combine("IntentCallEntityOpenEnvelopeDto") - deepHashIntentCallPlatformBridge(value: id, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: entityType, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: entityId, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: source, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: createdAt, hasher: &hasher) - } -} - -/// Manifest-projected entity field keys for snapshot CRUD and search. -/// -/// Generated class from Pigeon that represents data sent in messages. -struct IntentCallEntityKeyBundle: Hashable { - var idKey: String - var titleKey: String - var subtitleKey: String - var keywordsKey: String - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> IntentCallEntityKeyBundle? { - let idKey = pigeonVar_list[0] as! String - let titleKey = pigeonVar_list[1] as! String - let subtitleKey = pigeonVar_list[2] as! String - let keywordsKey = pigeonVar_list[3] as! String - - return IntentCallEntityKeyBundle( - idKey: idKey, - titleKey: titleKey, - subtitleKey: subtitleKey, - keywordsKey: keywordsKey - ) - } - func toList() -> [Any?] { - return [ - idKey, - titleKey, - subtitleKey, - keywordsKey, - ] - } - static func == (lhs: IntentCallEntityKeyBundle, rhs: IntentCallEntityKeyBundle) -> Bool { - if Swift.type(of: lhs) != Swift.type(of: rhs) { - return false - } - return deepEqualsIntentCallPlatformBridge(lhs.idKey, rhs.idKey) && deepEqualsIntentCallPlatformBridge(lhs.titleKey, rhs.titleKey) && deepEqualsIntentCallPlatformBridge(lhs.subtitleKey, rhs.subtitleKey) && deepEqualsIntentCallPlatformBridge(lhs.keywordsKey, rhs.keywordsKey) - } - - func hash(into hasher: inout Hasher) { - hasher.combine("IntentCallEntityKeyBundle") - deepHashIntentCallPlatformBridge(value: idKey, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: titleKey, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: subtitleKey, hasher: &hasher) - deepHashIntentCallPlatformBridge(value: keywordsKey, hasher: &hasher) - } -} - -private class IntentCallPlatformBridgePigeonCodecReader: FlutterStandardReader { - override func readValue(ofType type: UInt8) -> Any? { - switch type { - case 129: - return IntentCallInvocationEnvelopeDto.fromList(self.readValue() as! [Any?]) - case 130: - return IntentCallEntityOpenEnvelopeDto.fromList(self.readValue() as! [Any?]) - case 131: - return IntentCallEntityKeyBundle.fromList(self.readValue() as! [Any?]) - default: - return super.readValue(ofType: type) - } - } -} - -private class IntentCallPlatformBridgePigeonCodecWriter: FlutterStandardWriter { - override func writeValue(_ value: Any) { - if let value = value as? IntentCallInvocationEnvelopeDto { - super.writeByte(129) - super.writeValue(value.toList()) - } else if let value = value as? IntentCallEntityOpenEnvelopeDto { - super.writeByte(130) - super.writeValue(value.toList()) - } else if let value = value as? IntentCallEntityKeyBundle { - super.writeByte(131) - super.writeValue(value.toList()) - } else { - super.writeValue(value) - } - } -} - -private class IntentCallPlatformBridgePigeonCodecReaderWriter: FlutterStandardReaderWriter { - override func reader(with data: Data) -> FlutterStandardReader { - return IntentCallPlatformBridgePigeonCodecReader(data: data) - } - - override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return IntentCallPlatformBridgePigeonCodecWriter(data: data) - } -} - -class IntentCallPlatformBridgePigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = IntentCallPlatformBridgePigeonCodec(readerWriter: IntentCallPlatformBridgePigeonCodecReaderWriter()) -} - -/// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol IntentCallInvocationsHostApi { - func takePendingInvocations() throws -> [IntentCallInvocationEnvelopeDto] -} - -/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class IntentCallInvocationsHostApiSetup { - static var codec: FlutterStandardMessageCodec { IntentCallPlatformBridgePigeonCodec.shared } - /// Sets up an instance of `IntentCallInvocationsHostApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: IntentCallInvocationsHostApi?, messageChannelSuffix: String = "") { - let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let takePendingInvocationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - takePendingInvocationsChannel.setMessageHandler { _, reply in - do { - let result = try api.takePendingInvocations() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - takePendingInvocationsChannel.setMessageHandler(nil) - } - } -} -/// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol IntentCallEntitiesHostApi { - func upsertEntitySnapshots(entityType: String, snapshots: [[String?: Any?]], keys: IntentCallEntityKeyBundle) throws -> Int64 - func deleteEntitySnapshots(entityType: String, ids: [String], keys: IntentCallEntityKeyBundle) throws -> Int64 - func clearEntityTypeSnapshots(entityType: String) throws -> Int64 - func listEntitySnapshots(entityType: String) throws -> [[String?: Any?]] - func searchEntitySnapshots(entityType: String, query: String, limit: Int64, keys: IntentCallEntityKeyBundle) throws -> [[String?: Any?]] - func takePendingEntityOpens() throws -> [IntentCallEntityOpenEnvelopeDto] -} - -/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class IntentCallEntitiesHostApiSetup { - static var codec: FlutterStandardMessageCodec { IntentCallPlatformBridgePigeonCodec.shared } - /// Sets up an instance of `IntentCallEntitiesHostApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: IntentCallEntitiesHostApi?, messageChannelSuffix: String = "") { - let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let upsertEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.upsertEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - upsertEntitySnapshotsChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let entityTypeArg = args[0] as! String - let snapshotsArg = args[1] as! [[String?: Any?]] - let keysArg = args[2] as! IntentCallEntityKeyBundle - do { - let result = try api.upsertEntitySnapshots(entityType: entityTypeArg, snapshots: snapshotsArg, keys: keysArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - upsertEntitySnapshotsChannel.setMessageHandler(nil) - } - let deleteEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.deleteEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - deleteEntitySnapshotsChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let entityTypeArg = args[0] as! String - let idsArg = args[1] as! [String] - let keysArg = args[2] as! IntentCallEntityKeyBundle - do { - let result = try api.deleteEntitySnapshots(entityType: entityTypeArg, ids: idsArg, keys: keysArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - deleteEntitySnapshotsChannel.setMessageHandler(nil) - } - let clearEntityTypeSnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.clearEntityTypeSnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - clearEntityTypeSnapshotsChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let entityTypeArg = args[0] as! String - do { - let result = try api.clearEntityTypeSnapshots(entityType: entityTypeArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - clearEntityTypeSnapshotsChannel.setMessageHandler(nil) - } - let listEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.listEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - listEntitySnapshotsChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let entityTypeArg = args[0] as! String - do { - let result = try api.listEntitySnapshots(entityType: entityTypeArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - listEntitySnapshotsChannel.setMessageHandler(nil) - } - let searchEntitySnapshotsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.searchEntitySnapshots\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - searchEntitySnapshotsChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let entityTypeArg = args[0] as! String - let queryArg = args[1] as! String - let limitArg = args[2] as! Int64 - let keysArg = args[3] as! IntentCallEntityKeyBundle - do { - let result = try api.searchEntitySnapshots(entityType: entityTypeArg, query: queryArg, limit: limitArg, keys: keysArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - searchEntitySnapshotsChannel.setMessageHandler(nil) - } - let takePendingEntityOpensChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.takePendingEntityOpens\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - takePendingEntityOpensChannel.setMessageHandler { _, reply in - do { - let result = try api.takePendingEntityOpens() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - takePendingEntityOpensChannel.setMessageHandler(nil) - } - } -} diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/PrivacyInfo.xcprivacy b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/PrivacyInfo.xcprivacy deleted file mode 100644 index 918d80b..0000000 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,12 +0,0 @@ - - - - - NSPrivacyTrackingDomains - - NSPrivacyCollectedDataTypes - - NSPrivacyTracking - - - diff --git a/packages/intentcall_platform/pubspec.yaml b/packages/intentcall_platform/pubspec.yaml index 7d06205..22e0370 100644 --- a/packages/intentcall_platform/pubspec.yaml +++ b/packages/intentcall_platform/pubspec.yaml @@ -1,5 +1,7 @@ name: intentcall_platform -description: "PRE-RELEASE — Platform emitters and sync for intentcall: web manifests, WebMCP JS, native handoff, and Apple App Intents scaffolds." +description: >- + PRE-RELEASE — Federated Flutter runtime umbrella for IntentCall: Dart host + APIs, WebMCP bootstrap, and endorsed native platform implementations. version: 0.6.0 license: MIT repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform @@ -21,6 +23,8 @@ dependencies: sdk: flutter intentcall_bridge: ^0.6.0 intentcall_core: ^0.6.0 + intentcall_platform_android: ^0.6.0 + intentcall_platform_apple: ^0.6.0 intentcall_platform_sync: ^0.6.0 intentcall_schema: ^0.6.0 meta: ^1.18.0 @@ -31,12 +35,11 @@ flutter: plugin: platforms: android: - package: dev.intentcall.intentcall_platform - pluginClass: IntentCallPlatformPlugin + default_package: intentcall_platform_android ios: - pluginClass: IntentCallPlatformPlugin + default_package: intentcall_platform_apple macos: - pluginClass: IntentCallPlatformPlugin + default_package: intentcall_platform_apple dev_dependencies: lints: ^6.1.0 diff --git a/packages/intentcall_platform_android/CHANGELOG.md b/packages/intentcall_platform_android/CHANGELOG.md new file mode 100644 index 0000000..7080268 --- /dev/null +++ b/packages/intentcall_platform_android/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +### Features + +- Federated Android implementation package for `intentcall_platform`. diff --git a/packages/intentcall_platform_android/LICENSE b/packages/intentcall_platform_android/LICENSE new file mode 100644 index 0000000..ec57a7f --- /dev/null +++ b/packages/intentcall_platform_android/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anton Malofeev (Arenukvern) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/intentcall_platform_android/README.md b/packages/intentcall_platform_android/README.md new file mode 100644 index 0000000..6d2501e --- /dev/null +++ b/packages/intentcall_platform_android/README.md @@ -0,0 +1,13 @@ +> ⚠️ **Pre-release train** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). + +# intentcall_platform_android + +Federated Android implementation for +[`intentcall_platform`](https://pub.dev/packages/intentcall_platform). + +Kotlin package remains `dev.intentcall.intentcall_platform` to match the +Pigeon-generated bridge. Dart API stays in the umbrella package — this package +is native-only. + +App authors depend on `intentcall_platform`; this package is endorsed and +resolved automatically. diff --git a/packages/intentcall_platform_android/analysis_options.yaml b/packages/intentcall_platform_android/analysis_options.yaml new file mode 100644 index 0000000..1c3da69 --- /dev/null +++ b/packages/intentcall_platform_android/analysis_options.yaml @@ -0,0 +1,13 @@ +# Flutter plugin — app-level lint set. +include: package:xsoulspace_lints/library.yaml + +analyzer: + language: + strict-casts: true + errors: + always_use_package_imports: ignore + lines_longer_than_80_chars: ignore + public_member_api_docs: ignore + unnecessary_library_directive: ignore + exclude: + - "**/*.g.dart" diff --git a/packages/intentcall_platform_android/android/.gitignore b/packages/intentcall_platform_android/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/packages/intentcall_platform_android/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/packages/intentcall_platform_android/android/build.gradle b/packages/intentcall_platform_android/android/build.gradle new file mode 100644 index 0000000..4298399 --- /dev/null +++ b/packages/intentcall_platform_android/android/build.gradle @@ -0,0 +1,47 @@ +group = 'dev.intentcall.intentcall_platform' +version = '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.9.22' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'dev.intentcall.intentcall_platform' + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdk = 21 + } +} diff --git a/packages/intentcall_platform_android/android/settings.gradle b/packages/intentcall_platform_android/android/settings.gradle new file mode 100644 index 0000000..2612238 --- /dev/null +++ b/packages/intentcall_platform_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'intentcall_platform_android' diff --git a/packages/intentcall_platform_android/android/src/main/AndroidManifest.xml b/packages/intentcall_platform_android/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..74c7927 --- /dev/null +++ b/packages/intentcall_platform_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt b/packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt similarity index 100% rename from packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt rename to packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt diff --git a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt b/packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt similarity index 100% rename from packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt rename to packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt diff --git a/packages/intentcall_platform_android/lib/intentcall_platform_android.dart b/packages/intentcall_platform_android/lib/intentcall_platform_android.dart new file mode 100644 index 0000000..4be945c --- /dev/null +++ b/packages/intentcall_platform_android/lib/intentcall_platform_android.dart @@ -0,0 +1,5 @@ +/// Native-only Android federated implementation for `intentcall_platform`. +/// +/// Dart API lives in the umbrella `intentcall_platform` package. This library +/// exists so the package has a valid Dart entrypoint for pub. +library; diff --git a/packages/intentcall_platform_android/pubspec.yaml b/packages/intentcall_platform_android/pubspec.yaml new file mode 100644 index 0000000..7b06865 --- /dev/null +++ b/packages/intentcall_platform_android/pubspec.yaml @@ -0,0 +1,33 @@ +name: intentcall_platform_android +description: PRE-RELEASE — Android federated implementation for intentcall_platform. +version: 0.6.0 +license: MIT +repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_android +issue_tracker: https://github.com/Arenukvern/intentcall/issues +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_android +topics: + - mcp + - flutter + - android + +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.24.0" +resolution: workspace + +dependencies: + flutter: + sdk: flutter + intentcall_bridge: ^0.6.0 + +flutter: + plugin: + implements: intentcall_platform + platforms: + android: + package: dev.intentcall.intentcall_platform + pluginClass: IntentCallPlatformPlugin + +dev_dependencies: + lints: ^6.1.0 + xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_platform_apple/CHANGELOG.md b/packages/intentcall_platform_apple/CHANGELOG.md new file mode 100644 index 0000000..5e94994 --- /dev/null +++ b/packages/intentcall_platform_apple/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## Unreleased + +### Features + +- Federated Apple (iOS/macOS) implementation package with shared Darwin SPM + sources for `intentcall_platform`. diff --git a/packages/intentcall_platform_apple/LICENSE b/packages/intentcall_platform_apple/LICENSE new file mode 100644 index 0000000..ec57a7f --- /dev/null +++ b/packages/intentcall_platform_apple/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anton Malofeev (Arenukvern) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/intentcall_platform_apple/README.md b/packages/intentcall_platform_apple/README.md new file mode 100644 index 0000000..d3821ef --- /dev/null +++ b/packages/intentcall_platform_apple/README.md @@ -0,0 +1,13 @@ +> ⚠️ **Pre-release train** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). + +# intentcall_platform_apple + +Federated Apple (iOS/macOS) implementation for +[`intentcall_platform`](https://pub.dev/packages/intentcall_platform). + +Uses `sharedDarwinSource` with SPM under +`darwin/intentcall_platform_apple/`. Dart API stays in the umbrella package — +this package is native-only. + +App authors depend on `intentcall_platform`; this package is endorsed and +resolved automatically. diff --git a/packages/intentcall_platform_apple/analysis_options.yaml b/packages/intentcall_platform_apple/analysis_options.yaml new file mode 100644 index 0000000..1c3da69 --- /dev/null +++ b/packages/intentcall_platform_apple/analysis_options.yaml @@ -0,0 +1,13 @@ +# Flutter plugin — app-level lint set. +include: package:xsoulspace_lints/library.yaml + +analyzer: + language: + strict-casts: true + errors: + always_use_package_imports: ignore + lines_longer_than_80_chars: ignore + public_member_api_docs: ignore + unnecessary_library_directive: ignore + exclude: + - "**/*.g.dart" diff --git a/packages/intentcall_platform/macos/intentcall_platform/Package.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Package.swift similarity index 54% rename from packages/intentcall_platform/macos/intentcall_platform/Package.swift rename to packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Package.swift index ea09280..b5fc698 100644 --- a/packages/intentcall_platform/macos/intentcall_platform/Package.swift +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Package.swift @@ -3,26 +3,25 @@ import PackageDescription let package = Package( - name: "intentcall_platform", + name: "intentcall_platform_apple", platforms: [ - .macOS("10.14") + .iOS("13.0"), + .macOS("10.14"), ], products: [ - .library(name: "intentcall-platform", targets: ["intentcall_platform"]) + .library(name: "intentcall-platform-apple", targets: ["intentcall_platform_apple"]) ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework") ], targets: [ .target( - name: "intentcall_platform", + name: "intentcall_platform_apple", dependencies: [ .product(name: "FlutterFramework", package: "FlutterFramework") ], resources: [ - // The plugin does not currently collect data. Keep the - // manifest ready for future changes. - // .process("PrivacyInfo.xcprivacy"), + .process("PrivacyInfo.xcprivacy"), ] ) ] diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeEntitySnapshotStore.swift similarity index 100% rename from packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeEntitySnapshotStore.swift rename to packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeEntitySnapshotStore.swift diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeHandoffStore.swift similarity index 100% rename from packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallNativeHandoffStore.swift rename to packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeHandoffStore.swift diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift similarity index 100% rename from packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformBridge.g.swift rename to packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift diff --git a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift similarity index 94% rename from packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift rename to packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift index 4df9c32..16f32ad 100644 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift @@ -1,16 +1,24 @@ +#if os(macOS) +import FlutterMacOS +#else import Flutter -import UIKit +#endif /// Plugin bridge for pending native intent dispatch and entity snapshot cache. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let bridge = IntentCallPlatformBridgeHostApiImpl() +#if os(macOS) + let binaryMessenger = registrar.messenger +#else + let binaryMessenger = registrar.messenger() +#endif IntentCallInvocationsHostApiSetup.setUp( - binaryMessenger: registrar.messenger(), + binaryMessenger: binaryMessenger, api: bridge ) IntentCallEntitiesHostApiSetup.setUp( - binaryMessenger: registrar.messenger(), + binaryMessenger: binaryMessenger, api: bridge ) } diff --git a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/PrivacyInfo.xcprivacy b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/PrivacyInfo.xcprivacy similarity index 100% rename from packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/PrivacyInfo.xcprivacy rename to packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/PrivacyInfo.xcprivacy diff --git a/packages/intentcall_platform_apple/lib/intentcall_platform_apple.dart b/packages/intentcall_platform_apple/lib/intentcall_platform_apple.dart new file mode 100644 index 0000000..cc570ef --- /dev/null +++ b/packages/intentcall_platform_apple/lib/intentcall_platform_apple.dart @@ -0,0 +1,6 @@ +/// Native-only Apple (iOS/macOS) federated implementation for +/// `intentcall_platform`. +/// +/// Dart API lives in the umbrella `intentcall_platform` package. This library +/// exists so the package has a valid Dart entrypoint for pub. +library; diff --git a/packages/intentcall_platform_apple/pubspec.yaml b/packages/intentcall_platform_apple/pubspec.yaml new file mode 100644 index 0000000..6553496 --- /dev/null +++ b/packages/intentcall_platform_apple/pubspec.yaml @@ -0,0 +1,37 @@ +name: intentcall_platform_apple +description: PRE-RELEASE — Apple (iOS/macOS) federated implementation for intentcall_platform. +version: 0.6.0 +license: MIT +repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_apple +issue_tracker: https://github.com/Arenukvern/intentcall/issues +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_apple +topics: + - mcp + - flutter + - ios + - macos + +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.24.0" +resolution: workspace + +dependencies: + flutter: + sdk: flutter + intentcall_bridge: ^0.6.0 + +flutter: + plugin: + implements: intentcall_platform + platforms: + ios: + pluginClass: IntentCallPlatformPlugin + sharedDarwinSource: true + macos: + pluginClass: IntentCallPlatformPlugin + sharedDarwinSource: true + +dev_dependencies: + lints: ^6.1.0 + xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart index 0c381a7..f0435ce 100644 --- a/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart +++ b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart @@ -50,57 +50,85 @@ final class PlatformHooksInit { }) async { final root = p.normalize(p.absolute(projectRoot)); final spine = PlatformHookSpine.resolveFromProjectRoot(root); - final targets = [ - await _patchFile( - id: 'web_index_html', - path: p.join(root, 'web', 'index.html'), - snippet: kIntentCallWebIndexSnippet.trim(), - checkOnly: checkOnly, - ), - await _patchFile( - id: 'android_gradle', - path: p.join(root, 'android', 'app', 'build.gradle.kts'), - snippet: spine.renderGradle().trim(), - checkOnly: checkOnly, - appendIfMissing: true, - ), - await _patchFile( - id: 'android_manifest', - path: p.join( - root, - 'android', - 'app', - 'src', - 'main', - 'AndroidManifest.xml', + final enabled = spine.platformList.toSet(); + // Host template keys (android/ios/macos/…) ∩ enabled, plus web when enabled. + final patchable = enabled.intersection(spine.hookTemplateKeys.toSet()); + if (enabled.contains('web')) { + patchable.add('web'); + } + + final targets = []; + if (patchable.contains('web')) { + targets.add( + await _patchFile( + id: 'web_index_html', + path: p.join(root, 'web', 'index.html'), + snippet: kIntentCallWebIndexSnippet.trim(), + checkOnly: checkOnly, + ), + ); + } + if (patchable.contains('android')) { + targets.add( + await _patchFile( + id: 'android_gradle', + path: p.join(root, 'android', 'app', 'build.gradle.kts'), + snippet: spine.renderGradle().trim(), + checkOnly: checkOnly, + appendIfMissing: true, + ), + ); + targets.add( + await _patchFile( + id: 'android_manifest', + path: p.join( + root, + 'android', + 'app', + 'src', + 'main', + 'AndroidManifest.xml', + ), + snippet: kAndroidShortcutsManifestSnippet.trim(), + checkOnly: checkOnly, + insertBefore: '', + ), + ); + } + if (patchable.contains('ios')) { + targets.add( + await _patchFile( + id: 'ios_codegen_script', + path: p.join(root, 'ios', 'intentcall_codegen.sh'), + snippet: spine.renderAppleXcode().trim(), + checkOnly: checkOnly, + wholeFile: true, ), - snippet: kAndroidShortcutsManifestSnippet.trim(), - checkOnly: checkOnly, - insertBefore: '', - ), - await _patchFile( - id: 'ios_codegen_script', - path: p.join(root, 'ios', 'intentcall_codegen.sh'), - snippet: spine.renderAppleXcode().trim(), - checkOnly: checkOnly, - wholeFile: true, - ), - await _patchFile( - id: 'macos_codegen_script', - path: p.join(root, 'macos', 'intentcall_codegen.sh'), - snippet: spine.renderAppleXcode().trim(), - checkOnly: checkOnly, - wholeFile: true, - ), - _checkXcodeRunScript( - id: 'ios_xcode_run_script', - path: p.join(root, 'ios', 'Runner.xcodeproj', 'project.pbxproj'), - ), - _checkXcodeRunScript( - id: 'macos_xcode_run_script', - path: p.join(root, 'macos', 'Runner.xcodeproj', 'project.pbxproj'), - ), - ]; + ); + targets.add( + _checkXcodeRunScript( + id: 'ios_xcode_run_script', + path: p.join(root, 'ios', 'Runner.xcodeproj', 'project.pbxproj'), + ), + ); + } + if (patchable.contains('macos')) { + targets.add( + await _patchFile( + id: 'macos_codegen_script', + path: p.join(root, 'macos', 'intentcall_codegen.sh'), + snippet: spine.renderAppleXcode().trim(), + checkOnly: checkOnly, + wholeFile: true, + ), + ); + targets.add( + _checkXcodeRunScript( + id: 'macos_xcode_run_script', + path: p.join(root, 'macos', 'Runner.xcodeproj', 'project.pbxproj'), + ), + ); + } return PlatformHooksInitReport( projectRoot: root, diff --git a/packages/intentcall_platform_sync/test/platform_hooks_init_test.dart b/packages/intentcall_platform_sync/test/platform_hooks_init_test.dart index 2718bf3..2425cae 100644 --- a/packages/intentcall_platform_sync/test/platform_hooks_init_test.dart +++ b/packages/intentcall_platform_sync/test/platform_hooks_init_test.dart @@ -43,8 +43,21 @@ void main() { tearDown(() => tmp.deleteSync(recursive: true)); test('apply then check passes for core hooks', () async { + // No intentcall.yaml → flutter host defaults (web/android/ios/macos/…). const init = PlatformHooksInit(); final applied = await init.run(projectRoot: tmp.path); + expect( + applied.targets.map((final t) => t.id).toSet(), + containsAll({ + 'web_index_html', + 'android_gradle', + 'android_manifest', + 'ios_codegen_script', + 'ios_xcode_run_script', + 'macos_codegen_script', + 'macos_xcode_run_script', + }), + ); expect( applied.targets.firstWhere((final t) => t.id == 'web_index_html').ok, isTrue, @@ -53,4 +66,33 @@ void main() { final checked = await init.run(projectRoot: tmp.path, checkOnly: true); expect(checked.ok, isTrue); }); + + test('only patches platforms in platforms.enabled', () async { + File(p.join(tmp.path, 'intentcall.yaml')).writeAsStringSync(''' +host: flutter +platforms: + enabled: + - android +'''); + + const init = PlatformHooksInit(); + final applied = await init.run(projectRoot: tmp.path); + final ids = applied.targets.map((final t) => t.id).toSet(); + + expect(ids, containsAll({'android_gradle', 'android_manifest'})); + expect( + ids.intersection({ + 'web_index_html', + 'ios_codegen_script', + 'ios_xcode_run_script', + 'macos_codegen_script', + 'macos_xcode_run_script', + }), + isEmpty, + ); + expect( + applied.targets.every((final t) => t.ok), + isTrue, + ); + }); } diff --git a/pubspec.yaml b/pubspec.yaml index 1f2f9fe..4f9ddf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,8 @@ workspace: - packages/intentcall_hooks - packages/intentcall_bridge - packages/intentcall_platform + - packages/intentcall_platform_android + - packages/intentcall_platform_apple - packages/intentcall_cli - packages/intentcall_testing - packages/intentcall_session diff --git a/release-please-config.json b/release-please-config.json index 482732d..77b8753 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,14 +12,14 @@ "intentcall_session", "intentcall_mcp", "intentcall_webmcp", - "intentcall_apple", - "intentcall_android", "intentcall_codegen", "intentcall_platform_sync", "intentcall_hooks", "intentcall_bridge", "intentcall_cli", "intentcall_platform", + "intentcall_platform_apple", + "intentcall_platform_android", "intentcall_testing" ] } @@ -45,16 +45,6 @@ "package-name": "intentcall_webmcp", "component": "intentcall_webmcp" }, - "packages/intentcall_apple": { - "release-type": "dart", - "package-name": "intentcall_apple", - "component": "intentcall_apple" - }, - "packages/intentcall_android": { - "release-type": "dart", - "package-name": "intentcall_android", - "component": "intentcall_android" - }, "packages/intentcall_codegen": { "release-type": "dart", "package-name": "intentcall_codegen", @@ -85,6 +75,16 @@ "package-name": "intentcall_platform", "component": "intentcall_platform" }, + "packages/intentcall_platform_apple": { + "release-type": "dart", + "package-name": "intentcall_platform_apple", + "component": "intentcall_platform_apple" + }, + "packages/intentcall_platform_android": { + "release-type": "dart", + "package-name": "intentcall_platform_android", + "component": "intentcall_platform_android" + }, "packages/intentcall_testing": { "release-type": "dart", "package-name": "intentcall_testing", diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index f5c0624..2477cd0 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -15,17 +15,23 @@ const publishOrder = [ 'intentcall_session', 'intentcall_mcp', 'intentcall_webmcp', - 'intentcall_apple', - 'intentcall_android', 'intentcall_codegen', 'intentcall_platform_sync', 'intentcall_hooks', 'intentcall_bridge', 'intentcall_cli', 'intentcall_platform', + 'intentcall_platform_apple', + 'intentcall_platform_android', 'intentcall_testing', ]; +const flutterPublishPackages = { + 'intentcall_platform', + 'intentcall_platform_apple', + 'intentcall_platform_android', +}; + void main(List arguments) async { final parser = ArgParser() ..addCommand('doctor') @@ -309,10 +315,10 @@ void printUsage(ArgParser parser) { ' validate Validate path dependencies and version consistency.', ); print( - ' check-release-train Verify train versions, internal floors, and podspecs.', + ' check-release-train Verify train versions and internal floors.', ); print( - ' sync-release-train Rewrite train versions, internal floors, and podspecs.', + ' sync-release-train Rewrite train versions and internal floors.', ); print( ' check-path-deps Scan workspace for invalid path dependencies.', @@ -828,10 +834,7 @@ Future runValidate(Directory repoRoot) async { print('OK: All packages are synchronized at version $synchronizedVersion.'); // 3. Check native package metadata for Flutter plugin hygiene - final nativePackageCode = await runNativePackageHygieneCheck( - repoRoot, - version: synchronizedVersion, - ); + final nativePackageCode = await runNativePackageHygieneCheck(repoRoot); if (nativePackageCode != 0) { return nativePackageCode; } @@ -897,32 +900,41 @@ Future runValidate(Directory repoRoot) async { return 0; } -Future runNativePackageHygieneCheck( - Directory repoRoot, { - required String version, -}) async { +Future runNativePackageHygieneCheck(Directory repoRoot) async { print('\nChecking native package hygiene...'); - final packageRoot = Directory( - p.join(repoRoot.path, 'packages', 'intentcall_platform'), - ); final mismatches = []; - for (final relativePath in [ - p.join('ios', 'intentcall_platform.podspec'), - p.join('macos', 'intentcall_platform.podspec'), - ]) { - final file = File(p.join(packageRoot.path, relativePath)); - final content = await file.readAsString(); - final actual = podspecVersion(content); - if (actual == null) { - mismatches.add('$relativePath is missing s.version'); - continue; - } - if (actual != version) { - mismatches.add('$relativePath has s.version $actual, expected $version'); - } + + final applePackageRoot = Directory( + p.join(repoRoot.path, 'packages', 'intentcall_platform_apple'), + ); + if (!applePackageRoot.existsSync()) { + mismatches.add('packages/intentcall_platform_apple is missing'); + } else { + mismatches.addAll(await swiftPackageManagerFindings(applePackageRoot)); } - mismatches.addAll(await swiftPackageManagerFindings(packageRoot)); + mismatches.addAll(await federatedPlatformPodspecFindings(repoRoot)); + + final androidPlugin = File( + p.join( + repoRoot.path, + 'packages', + 'intentcall_platform_android', + 'android', + 'src', + 'main', + 'kotlin', + 'dev', + 'intentcall', + 'intentcall_platform', + 'IntentCallPlatformPlugin.kt', + ), + ); + if (!androidPlugin.existsSync()) { + mismatches.add( + 'packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt is missing', + ); + } if (mismatches.isNotEmpty) { stderr.writeln('FAIL: Native package hygiene drift detected.'); @@ -932,100 +944,117 @@ Future runNativePackageHygieneCheck( return 1; } print( - 'OK: native podspec versions and SwiftPM package layout are synchronized.', + 'OK: federated Apple SPM layout and Android plugin sources are synchronized.', ); return 0; } -String? podspecVersion(final String content) { - final match = RegExp( - r"^\s*s\.version\s*=\s*'([^']+)'", - multiLine: true, - ).firstMatch(content); - return match?.group(1); +Future> federatedPlatformPodspecFindings( + final Directory repoRoot, +) async { + final findings = []; + final packagesDir = Directory(p.join(repoRoot.path, 'packages')); + if (!packagesDir.existsSync()) { + return findings; + } + for (final entity in packagesDir.listSync()) { + if (entity is! Directory) { + continue; + } + final name = p.basename(entity.path); + if (!name.startsWith('intentcall_platform')) { + continue; + } + for (final file in entity.listSync(recursive: true)) { + if (file is! File) { + continue; + } + if (p.extension(file.path) != '.podspec') { + continue; + } + findings.add( + '${p.relative(file.path, from: repoRoot.path)} must not exist (SPM-only hardcut)', + ); + } + } + return findings; } Future> swiftPackageManagerFindings( final Directory packageRoot, ) async { final findings = []; - final specs = <({String platform, String version, String packageDir})>[ - (platform: 'iOS', version: '13.0', packageDir: 'ios'), - (platform: 'macOS', version: '10.14', packageDir: 'macos'), - ]; + final spmRoot = Directory( + p.join(packageRoot.path, 'darwin', 'intentcall_platform_apple'), + ); + final packageFile = File(p.join(spmRoot.path, 'Package.swift')); + final sourcesDir = p.join( + spmRoot.path, + 'Sources', + 'intentcall_platform_apple', + ); + final sourceFile = File( + p.join(sourcesDir, 'IntentCallPlatformPlugin.swift'), + ); + final privacyFile = File(p.join(sourcesDir, 'PrivacyInfo.xcprivacy')); + final bridgeFile = File( + p.join(sourcesDir, 'IntentCallPlatformBridge.g.swift'), + ); - for (final spec in specs) { - final spmRoot = Directory( - p.join(packageRoot.path, spec.packageDir, 'intentcall_platform'), - ); - final packageFile = File(p.join(spmRoot.path, 'Package.swift')); - final sourceFile = File( - p.join( - spmRoot.path, - 'Sources', - 'intentcall_platform', - 'IntentCallPlatformPlugin.swift', - ), - ); - final privacyFile = File( - p.join( - spmRoot.path, - 'Sources', - 'intentcall_platform', - 'PrivacyInfo.xcprivacy', - ), + if (!packageFile.existsSync()) { + findings.add( + 'darwin/intentcall_platform_apple/Package.swift is missing', ); + return findings; + } - if (!packageFile.existsSync()) { + final content = await packageFile.readAsString(); + final requiredSnippets = [ + 'name: "intentcall_platform_apple"', + '.library(name: "intentcall-platform-apple", targets: ["intentcall_platform_apple"])', + '.package(name: "FlutterFramework", path: "../FlutterFramework")', + '.product(name: "FlutterFramework", package: "FlutterFramework")', + '.iOS("13.0")', + '.macOS("10.14")', + ]; + for (final snippet in requiredSnippets) { + if (!content.contains(snippet)) { findings.add( - '${spec.packageDir}/intentcall_platform/Package.swift is missing', + 'darwin/intentcall_platform_apple/Package.swift is missing `$snippet`', ); - continue; - } - final content = await packageFile.readAsString(); - final platformDeclaration = spec.platform == 'iOS' - ? '.iOS("${spec.version}")' - : '.macOS("${spec.version}")'; - final requiredSnippets = [ - 'name: "intentcall_platform"', - '.library(name: "intentcall-platform", targets: ["intentcall_platform"])', - '.package(name: "FlutterFramework", path: "../FlutterFramework")', - '.product(name: "FlutterFramework", package: "FlutterFramework")', - platformDeclaration, - ]; - for (final snippet in requiredSnippets) { - if (!content.contains(snippet)) { - findings.add( - '${spec.packageDir}/intentcall_platform/Package.swift is missing `$snippet`', - ); - } } + } - if (!sourceFile.existsSync()) { + if (!sourceFile.existsSync()) { + findings.add( + 'darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift is missing', + ); + } else { + final source = await sourceFile.readAsString(); + if (!source.contains('public class IntentCallPlatformPlugin')) { findings.add( - '${spec.packageDir}/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift is missing', + 'darwin SwiftPM source is missing IntentCallPlatformPlugin', ); - } else { - final source = await sourceFile.readAsString(); - if (!source.contains('public class IntentCallPlatformPlugin')) { - findings.add( - '${spec.packageDir} SwiftPM source is missing IntentCallPlatformPlugin', - ); - } - if (!source.contains('IntentCallInvocationsHostApiSetup.setUp')) { - findings.add( - '${spec.packageDir} SwiftPM source is missing the Pigeon invocation bridge', - ); - } } - - if (!privacyFile.existsSync()) { + if (!source.contains('IntentCallInvocationsHostApiSetup.setUp')) { findings.add( - '${spec.packageDir}/intentcall_platform/Sources/intentcall_platform/PrivacyInfo.xcprivacy is missing', + 'darwin SwiftPM source is missing the Pigeon invocation bridge', ); } } + if (!privacyFile.existsSync()) { + findings.add( + 'darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/PrivacyInfo.xcprivacy is missing', + ); + } + + if (!bridgeFile.existsSync()) { + findings.add( + 'darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift is missing', + ); + } + return findings; } @@ -1514,7 +1543,7 @@ Future runPublishAll( final dirPath = p.join(repoRoot.path, 'packages', pkg); print('\n== Publishing package: $pkg =='); - final isPlatform = pkg == 'intentcall_platform'; + final isPlatform = flutterPublishPackages.contains(pkg); final exec = isPlatform ? 'flutter' : 'dart'; final args = buildPublishArgs( dryRun: dryRun, @@ -1579,7 +1608,7 @@ Future runPublishTag( } final packageDir = p.join(repoRoot.path, 'packages', release.package); - final isPlatform = release.package == 'intentcall_platform'; + final isPlatform = flutterPublishPackages.contains(release.package); final exec = isPlatform ? 'flutter' : 'dart'; if (dryRun) { diff --git a/tool/intentcall/bin/release_train.dart b/tool/intentcall/bin/release_train.dart index 8f45867..02743d0 100644 --- a/tool/intentcall/bin/release_train.dart +++ b/tool/intentcall/bin/release_train.dart @@ -7,18 +7,22 @@ const publishablePackages = [ 'intentcall_session', 'intentcall_mcp', 'intentcall_webmcp', - 'intentcall_apple', - 'intentcall_android', 'intentcall_codegen', 'intentcall_platform_sync', 'intentcall_hooks', 'intentcall_bridge', 'intentcall_cli', 'intentcall_platform', + 'intentcall_platform_apple', + 'intentcall_platform_android', 'intentcall_testing', ]; -const workspaceOnlyPackages = ['intentcall_gemma']; +const workspaceOnlyPackages = [ + 'intentcall_gemma', + 'intentcall_apple', + 'intentcall_android', +]; const allInternalPackages = [...publishablePackages, ...workspaceOnlyPackages]; @@ -176,17 +180,6 @@ Future> releaseTrainFindings( } } - for (final relativePath in [ - 'packages/intentcall_platform/ios/intentcall_platform.podspec', - 'packages/intentcall_platform/macos/intentcall_platform.podspec', - ]) { - final file = File(joinPath([repoRoot.path, relativePath])); - final actual = podspecVersion(await file.readAsString()); - if (actual != version) { - findings.add('$relativePath has s.version $actual, expected $version'); - } - } - return findings; } @@ -225,21 +218,6 @@ Future> syncReleaseTrainMetadata( } } - for (final relative in [ - 'packages/intentcall_platform/ios/intentcall_platform.podspec', - 'packages/intentcall_platform/macos/intentcall_platform.podspec', - ]) { - final file = File(joinPath([repoRoot.path, relative])); - final original = await file.readAsString(); - final updated = replacePodspecVersion(original, version); - if (updated != original) { - edits.add('$relative s.version -> $version'); - if (write) { - await file.writeAsString(updated); - } - } - } - return edits; } @@ -271,13 +249,6 @@ String replaceInternalDependencyFloors( return updated; } -String replacePodspecVersion(String content, String version) { - return content.replaceFirstMapped( - RegExp(r"^(\s*s\.version\s*=\s*)'[^']+'", multiLine: true), - (match) => "${match.group(1)}'$version'", - ); -} - String? pubspecVersion(String content) { return RegExp( r'^version:\s*([^\s]+)', @@ -292,13 +263,6 @@ String? dependencyFloor(String content, String dependency) { ).firstMatch(content)?.group(1); } -String? podspecVersion(String content) { - return RegExp( - r"^\s*s\.version\s*=\s*'([^']+)'", - multiLine: true, - ).firstMatch(content)?.group(1); -} - Future readPubspec(Directory repoRoot, String packageName) { return pubspecFile(repoRoot, packageName).readAsString(); } @@ -340,7 +304,7 @@ int _usage() { ); stderr.writeln(' check Verify release train metadata.'); stderr.writeln( - ' sync [--version X] Rewrite train versions/floors/podspecs.', + ' sync [--version X] Rewrite train versions and internal floors.', ); stderr.writeln(' sync --check Report the edits sync would make.'); return 64; diff --git a/tool/intentcall/test/publish_preflight_test.dart b/tool/intentcall/test/publish_preflight_test.dart index 1da374e..0f51ef6 100644 --- a/tool/intentcall/test/publish_preflight_test.dart +++ b/tool/intentcall/test/publish_preflight_test.dart @@ -167,33 +167,13 @@ INTENTCALL_ROOT="\$(pwd)/../agentkit" make check-intentcall-integration expect(findings, isEmpty); }); - test('reads CocoaPods podspec versions', () { - expect( - intentcall_cli.podspecVersion( - "Pod::Spec.new do |s|\n s.version = '0.3.0'\nend\n", - ), - '0.3.0', - ); - }); - test('validates Swift Package Manager plugin layout', () async { final packageRoot = await Directory.systemTemp.createTemp( - 'intentcall_platform_spm_', + 'intentcall_platform_apple_spm_', ); addTearDown(() => packageRoot.deleteSync(recursive: true)); - await _writeSwiftPackageFixture( - packageRoot, - packageDir: 'ios', - platform: '.iOS("13.0")', - importLine: 'import Flutter', - ); - await _writeSwiftPackageFixture( - packageRoot, - packageDir: 'macos', - platform: '.macOS("10.14")', - importLine: 'import FlutterMacOS', - ); + await _writeSwiftPackageFixture(packageRoot); expect( await intentcall_cli.swiftPackageManagerFindings(packageRoot), @@ -203,10 +183,10 @@ INTENTCALL_ROOT="\$(pwd)/../agentkit" make check-intentcall-integration File( p.join( packageRoot.path, - 'macos', - 'intentcall_platform', + 'darwin', + 'intentcall_platform_apple', 'Sources', - 'intentcall_platform', + 'intentcall_platform_apple', 'IntentCallPlatformPlugin.swift', ), ).deleteSync(); @@ -214,7 +194,7 @@ INTENTCALL_ROOT="\$(pwd)/../agentkit" make check-intentcall-integration expect( await intentcall_cli.swiftPackageManagerFindings(packageRoot), contains( - 'macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift is missing', + 'darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift is missing', ), ); }); @@ -400,7 +380,7 @@ INTENTCALL_ROOT="\$(pwd)/../agentkit" make check-intentcall-integration }); group('release train sync', () { - test('checks and synchronizes dependency floors and podspecs', () async { + test('checks and synchronizes dependency floors', () async { final repo = await _createReleaseTrainFixture(); addTearDown(() => repo.deleteSync(recursive: true)); @@ -422,18 +402,6 @@ INTENTCALL_ROOT="\$(pwd)/../agentkit" make check-intentcall-integration ).readAsStringSync(), contains(' intentcall_testing: ^0.6.0'), ); - expect( - File( - p.join( - repo.path, - 'packages', - 'intentcall_platform', - 'ios', - 'intentcall_platform.podspec', - ), - ).readAsStringSync(), - contains("s.version = '0.6.0'"), - ); }); test('check-only sync reports stale metadata without writing', () async { @@ -558,12 +526,14 @@ Future _createReleaseTrainFixture() async { "packages/intentcall_session": "0.6.0", "packages/intentcall_mcp": "0.6.0", "packages/intentcall_webmcp": "0.6.0", - "packages/intentcall_apple": "0.6.0", - "packages/intentcall_android": "0.6.0", "packages/intentcall_codegen": "0.6.0", "packages/intentcall_platform_sync": "0.6.0", + "packages/intentcall_hooks": "0.6.0", + "packages/intentcall_bridge": "0.6.0", "packages/intentcall_cli": "0.6.0", "packages/intentcall_platform": "0.6.0", + "packages/intentcall_platform_apple": "0.6.0", + "packages/intentcall_platform_android": "0.6.0", "packages/intentcall_testing": "0.6.0" } '''); @@ -578,11 +548,11 @@ Future _createReleaseTrainFixture() async { ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', 'intentcall_webmcp' => ' intentcall_core: ^0.5.0\n intentcall_testing: ^0.5.0\n', - 'intentcall_apple' => ' intentcall_core: ^0.5.0\n', - 'intentcall_android' => ' intentcall_core: ^0.5.0\n', 'intentcall_codegen' => ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', - 'intentcall_platform' => + 'intentcall_platform' || + 'intentcall_platform_apple' || + 'intentcall_platform_android' => ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', 'intentcall_testing' => ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', @@ -591,6 +561,16 @@ Future _createReleaseTrainFixture() async { _writePubspec(repo, packageName, version: '0.6.0', dependencies: deps); } + for (final packageName in ['intentcall_apple', 'intentcall_android']) { + _writePubspec( + repo, + packageName, + version: '0.6.0', + publishToNone: true, + dependencies: ' intentcall_core: ^0.5.0\n', + ); + } + _writePubspec( repo, 'intentcall_gemma', @@ -602,24 +582,6 @@ Future _createReleaseTrainFixture() async { ' intentcall_testing: ^0.5.0\n', ); - for (final platform in ['ios', 'macos']) { - final podspec = File( - p.join( - repo.path, - 'packages', - 'intentcall_platform', - platform, - 'intentcall_platform.podspec', - ), - )..createSync(recursive: true); - podspec.writeAsStringSync(''' -Pod::Spec.new do |s| - s.name = 'intentcall_platform' - s.version = '0.5.0' -end -'''); - } - return repo; } @@ -648,17 +610,12 @@ Future _runGit(Directory repo, List args) async { } } -Future _writeSwiftPackageFixture( - Directory packageRoot, { - required String packageDir, - required String platform, - required String importLine, -}) async { +Future _writeSwiftPackageFixture(Directory packageRoot) async { final spmRoot = Directory( - p.join(packageRoot.path, packageDir, 'intentcall_platform'), + p.join(packageRoot.path, 'darwin', 'intentcall_platform_apple'), ); final sourceDir = Directory( - p.join(spmRoot.path, 'Sources', 'intentcall_platform'), + p.join(spmRoot.path, 'Sources', 'intentcall_platform_apple'), )..createSync(recursive: true); File(p.join(spmRoot.path, 'Package.swift')).writeAsStringSync(''' @@ -666,19 +623,20 @@ Future _writeSwiftPackageFixture( import PackageDescription let package = Package( - name: "intentcall_platform", + name: "intentcall_platform_apple", platforms: [ - $platform + .iOS("13.0"), + .macOS("10.14") ], products: [ - .library(name: "intentcall-platform", targets: ["intentcall_platform"]) + .library(name: "intentcall-platform-apple", targets: ["intentcall_platform_apple"]) ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework") ], targets: [ .target( - name: "intentcall_platform", + name: "intentcall_platform_apple", dependencies: [ .product(name: "FlutterFramework", package: "FlutterFramework") ] @@ -690,7 +648,7 @@ let package = Package( File( p.join(sourceDir.path, 'IntentCallPlatformPlugin.swift'), ).writeAsStringSync(''' -$importLine +import Flutter public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { @@ -708,4 +666,8 @@ public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { '''); + + File( + p.join(sourceDir.path, 'IntentCallPlatformBridge.g.swift'), + ).writeAsStringSync('// Generated pigeon bridge fixture\n'); } From b0297d8398656e45d2d136f4be8baa02774b370f Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Thu, 9 Jul 2026 01:29:43 +0300 Subject: [PATCH 16/27] refactor: remove deprecated intentcall_apple and intentcall_android packages, update workspace configurations, and enhance documentation for new platform implementations --- PUBLISHING.md | 5 +- README.md | 2 - docs/DESIGN_FAQ.mdx | 4 +- docs/DX_FAQ.mdx | 6 +- docs/NORTH_STAR.mdx | 2 +- .../0025-platform-subset-federated-plugins.md | 14 ++- .../platform-subset-federated-plugins-plan.md | 5 +- docs/start_here/choose_your_path.mdx | 2 +- justfile | 2 +- packages/intentcall_android/CHANGELOG.md | 57 ---------- packages/intentcall_android/LICENSE | 21 ---- packages/intentcall_android/README.md | 45 -------- .../intentcall_android/analysis_options.yaml | 1 - .../lib/intentcall_android.dart | 1 - .../lib/src/agent_manifest_generator.dart | 51 --------- packages/intentcall_android/pubspec.yaml | 26 ----- .../test/agent_manifest_generator_test.dart | 23 ---- packages/intentcall_apple/CHANGELOG.md | 66 ------------ packages/intentcall_apple/LICENSE | 21 ---- packages/intentcall_apple/README.md | 51 --------- .../intentcall_apple/analysis_options.yaml | 1 - .../lib/intentcall_apple.dart | 1 - .../lib/src/agent_manifest_generator.dart | 88 --------------- packages/intentcall_apple/pubspec.yaml | 26 ----- .../test/agent_manifest_generator_test.dart | 100 ------------------ packages/intentcall_core/README.md | 3 +- .../apple_swift_app_intents_emitter.dart | 88 +++++++-------- pubspec.yaml | 2 - tool/intentcall/bin/release_train.dart | 6 +- .../test/publish_preflight_test.dart | 10 -- 30 files changed, 69 insertions(+), 661 deletions(-) delete mode 100644 packages/intentcall_android/CHANGELOG.md delete mode 100644 packages/intentcall_android/LICENSE delete mode 100644 packages/intentcall_android/README.md delete mode 100644 packages/intentcall_android/analysis_options.yaml delete mode 100644 packages/intentcall_android/lib/intentcall_android.dart delete mode 100644 packages/intentcall_android/lib/src/agent_manifest_generator.dart delete mode 100644 packages/intentcall_android/pubspec.yaml delete mode 100644 packages/intentcall_android/test/agent_manifest_generator_test.dart delete mode 100644 packages/intentcall_apple/CHANGELOG.md delete mode 100644 packages/intentcall_apple/LICENSE delete mode 100644 packages/intentcall_apple/README.md delete mode 100644 packages/intentcall_apple/analysis_options.yaml delete mode 100644 packages/intentcall_apple/lib/intentcall_apple.dart delete mode 100644 packages/intentcall_apple/lib/src/agent_manifest_generator.dart delete mode 100644 packages/intentcall_apple/pubspec.yaml delete mode 100644 packages/intentcall_apple/test/agent_manifest_generator_test.dart diff --git a/PUBLISHING.md b/PUBLISHING.md index e98ee57..f4795f2 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -76,9 +76,8 @@ uses GitHub OIDC through `dart-lang/setup-dart`. 4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_codegen` 5. `intentcall_platform_sync`, `intentcall_hooks`, `intentcall_bridge`, `intentcall_cli` 6. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) -7. `intentcall_testing` - -Legacy `intentcall_apple` / `intentcall_android` are workspace-only (`publish_to: none`) and are not published. +7. `intentcall_platform_apple`, `intentcall_platform_android` (federated impls) +8. `intentcall_testing` ## Commands diff --git a/README.md b/README.md index 2a67761..ea83a40 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,6 @@ GitHub: [Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) | `intentcall_codegen` | [![pub package](https://img.shields.io/pub/v/intentcall_codegen.svg?include_prereleases)](https://pub.dev/packages/intentcall_codegen) [![pub points](https://img.shields.io/pub/points/intentcall_codegen.svg)](https://pub.dev/packages/intentcall_codegen/score) | Optional `@AgentTool` codegen | | `intentcall_testing` | [![pub package](https://img.shields.io/pub/v/intentcall_testing.svg?include_prereleases)](https://pub.dev/packages/intentcall_testing) [![pub points](https://img.shields.io/pub/points/intentcall_testing.svg)](https://pub.dev/packages/intentcall_testing/score) | Contract / invoke test helpers | -Deprecated workspace-only (not published): `intentcall_apple`, `intentcall_android` — use `intentcall_platform_sync` instead. - `intentcall_gemma` is example-only and intentionally unpublished. `intentcall_cli` is the workspace maintainer tool and is not part of the hosted package train. diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index 66f2638..928c878 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -62,8 +62,8 @@ A: `AgentRegistry`, `AgentCallEntry`, and `RegisteredAgentIntent` are the curren **Q: Why are additive actions, typed entities, and indexing lifecycle not a new core runtime?** A: They are a projection layer over the existing source concepts. Actions remain registry entries. Typed entities are app-owned Dart snapshots with stable ids, display fields, and search/index fields. Indexing or donation is a lifecycle that copies those snapshots into a platform projection cache. Keeping this additive avoids turning `intentcall_core` into an Apple-shaped runtime while still letting Apple be the first concrete typed-entity/indexing projection. See [ADR 0018](decisions/0018-additive-actions-typed-entities-indexing-lifecycle.md). -**Q: Why were there separate `intentcall_apple` / `intentcall_android` packages, and what replaced them?** -A: Those packages were sparse-manifest generators for platform-specific SDKs. They are **deprecated** (`publish_to: none`); canonical projection is `intentcall_platform_sync` (`PlatformSync` / `ManifestExporter`). Runtime native code will split into federated Flutter plugins per [ADR 0025](decisions/0025-platform-subset-federated-plugins.md). `intentcall_gemma` remains a separate surface adapter. +**Q: Why were `intentcall_apple` / `intentcall_android` removed, and what replaced them?** +A: Those packages were sparse-manifest generators. They were **deleted** (zero consumers, pre-1.0). Canonical projection is `intentcall_platform_sync` (`PlatformSync` / `ManifestExporter`). Runtime native code lives in federated Flutter plugins `intentcall_platform_apple` / `intentcall_platform_android` under the `intentcall_platform` umbrella ([ADR 0025](decisions/0025-platform-subset-federated-plugins.md)). `intentcall_gemma` remains a separate surface adapter. ## Runtime sessions diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 7f027e0..7da7e59 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -517,7 +517,7 @@ Explicit `defaults.surfaces` in yaml overrides platform-scoped defaults. Apps list the `intentcall_platform` umbrella only. Endorsed federated impls (`intentcall_platform_apple`, `intentcall_platform_android`) come in via `default_package`. Huawei/HyperOS use the `android` token — OEM packaging is an -app concern. Do not depend on deprecated `intentcall_apple` / `intentcall_android`. +app concern. --- @@ -664,11 +664,9 @@ supported claim yet. 3. `intentcall_session` 4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_codegen` (parallel) 5. `intentcall_platform_sync`, `intentcall_hooks`, `intentcall_bridge`, `intentcall_cli` -6. `intentcall_platform` +6. `intentcall_platform`, `intentcall_platform_apple`, `intentcall_platform_android` 7. `intentcall_testing` -Legacy `intentcall_apple` / `intentcall_android` are deprecated workspace-only packages — use `intentcall_platform_sync` emitters instead. - **Q: How do I publish?** See [PUBLISHING.md](https://github.com/Arenukvern/intentcall/blob/main/PUBLISHING.md) for full instructions. In short: diff --git a/docs/NORTH_STAR.mdx b/docs/NORTH_STAR.mdx index 2929f51..d36bbaa 100644 --- a/docs/NORTH_STAR.mdx +++ b/docs/NORTH_STAR.mdx @@ -55,7 +55,7 @@ Support tiers are explicit on purpose. Apple App Intents are currently parameter | `intentcall_codegen` — optional `@AgentTool` code generation | | | `intentcall_testing` — contract / invoke test helpers | | | `intentcall_gemma` — surface adapter | | -| ~~`intentcall_apple` / `intentcall_android`~~ — deprecated legacy generators; use `intentcall_platform_sync` | | +| `intentcall_platform_apple` / `intentcall_platform_android` — federated native impls (endorsed by umbrella) | | **Do not own:** harness tooling (CLI, inspector UI), skill governance, LLM prompt engineering, or any product that wraps IntentCall for end users. diff --git a/docs/decisions/0025-platform-subset-federated-plugins.md b/docs/decisions/0025-platform-subset-federated-plugins.md index ba54964..8a728ee 100644 --- a/docs/decisions/0025-platform-subset-federated-plugins.md +++ b/docs/decisions/0025-platform-subset-federated-plugins.md @@ -10,8 +10,10 @@ Accepted IntentCall supports many projection targets (`web`, `android`, `ios`, `macos`, `linux`, `windows`) but consuming apps ship different subsets. Legacy packages -`intentcall_apple` and `intentcall_android` implement an obsolete sparse-manifest -path superseded by ADR 0019/0022 unified projection in `intentcall_platform_sync`. +`intentcall_apple` and `intentcall_android` implemented an obsolete sparse-manifest +path superseded by ADR 0019/0022 unified projection in `intentcall_platform_sync` +and were **deleted** from the workspace (hardcut; not renamed into +`intentcall_platform_*`). The monolithic `intentcall_platform` Flutter plugin bundles Android, iOS, and macOS native implementations. Apps that target only one mobile platform still @@ -76,9 +78,11 @@ owns the Dart host surface; apple + android are the endorsed impl packages. ### 5. Legacy package sunset -- Deprecate `intentcall_apple` and `intentcall_android` manifest generators. -- Remove from release train after one deprecation cycle. -- Do not port new features to legacy generators. +- Delete `intentcall_apple` and `intentcall_android` from the workspace (hardcut: + zero pub consumers; do not rename into `intentcall_platform_*`). +- Projection remains in `intentcall_platform_sync`; runtime remains federated + under `intentcall_platform` + endorsed impl packages. +- Do not resurrect sparse-manifest generators. ## Consequences diff --git a/docs/evidence/platform-subset-federated-plugins-plan.md b/docs/evidence/platform-subset-federated-plugins-plan.md index bcbcbbc..e71826d 100644 --- a/docs/evidence/platform-subset-federated-plugins-plan.md +++ b/docs/evidence/platform-subset-federated-plugins-plan.md @@ -1,6 +1,6 @@ # Platform Subset + Federated Plugins Implementation Plan -**Status:** Implemented (compressed A/B/C) — ready for `delete_or_retire` after this PR +**Status:** Implemented (compressed A/B/C) + legacy hard-delete — ready for `delete_or_retire` after this PR **Date:** 2026-07-08 **ADR target:** [0025 — Platform Subset and Federated Flutter Plugins](../decisions/0025-platform-subset-federated-plugins.md) (Accepted) **Builds on:** [hooks-native-bridge-plan.md](hooks-native-bridge-plan.md), [ADR 0024](../decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md) @@ -11,7 +11,8 @@ - No separate `intentcall_platform_interface` package — host API lives in the umbrella - Darwin native code lives in `intentcall_platform_apple` (`darwin/…`), not the umbrella - SPM-only for Apple (no CocoaPods / podspecs) -- Phase A+B+C landed (subset enforcement, legacy sunset, federated apple+android + umbrella) +- Phase A+B+C landed (subset enforcement, federated apple+android + umbrella) +- **`intentcall_apple` / `intentcall_android` hard-deleted** from workspace (not renamed into `platform_*`) **Flutter references (authoritative):** diff --git a/docs/start_here/choose_your_path.mdx b/docs/start_here/choose_your_path.mdx index ee7862c..1a6dfaa 100644 --- a/docs/start_here/choose_your_path.mdx +++ b/docs/start_here/choose_your_path.mdx @@ -8,7 +8,7 @@ Use this page when you know what you want to build, but not which IntentCall pac | Publish MCP tools/resources | `intentcall_mcp`, plus `intentcall_core` and `intentcall_schema` | Read registry entries, publish existing entries, listen to registry events, route calls back to `registry.invoke(...)`. | Add or extend `verifyNativeAdapterContract(...)`, then run `steward benchmark --scenario intentcall.adapter-contract --json`. | [DX FAQ](/DX_FAQ#writing-a-new-adapter) | | Browser WebMCP registration | `intentcall_webmcp`, `intentcall_platform` when using the Dart-first bootstrap | Register WebMCP tools from Dart and treat older `navigator.modelContext` behavior as compatibility only. | WebMCP emitter/bootstrap tests plus `steward probe --json --profile quick`; live browser-host interoperability needs separate proof. | [Platform support](/start_here/platform_support) | | Runtime sessions for tools or CLIs | `intentcall_session`, `intentcall_core` | Implement `IntentSessionConnector`, compose `IntentSessionManager` and `IntentSessionExecutor`. | Package tests around connector selection, persistence, and executor behavior. | [DX FAQ](/DX_FAQ#runtime-sessions) | -| Native/web artifacts and fallback invoke routes | `intentcall_platform_sync`, `intentcall_cli`, optional `intentcall_platform` (federated Flutter umbrella only) | Run `build_runner`, `intentcall manifest export --check`, then `intentcall platform sync --check`. Depend on the umbrella — not deprecated `intentcall_apple` / `intentcall_android`. | Emitter tests, fixture parity, platform sync check, adapter contract tests. | [Platform support](/start_here/platform_support) | +| Native/web artifacts and fallback invoke routes | `intentcall_platform_sync`, `intentcall_cli`, optional `intentcall_platform` (federated Flutter umbrella) | Run `build_runner`, `intentcall manifest export --check`, then `intentcall platform sync --check`. Depend on the umbrella only. | Emitter tests, fixture parity, platform sync check, adapter contract tests. | [Platform support](/start_here/platform_support) | | Optional typed registration helpers | `intentcall_codegen`, `build_runner`, `intentcall_core`, `intentcall_schema` | Annotate stable top-level functions with `@AgentTool`; keep handwritten entries first-class. | Generator fixture tests and normal package tests for generated registrations. | [DX FAQ](/DX_FAQ#codegen-agenttool) | | Adapter contract proof | `intentcall_testing` | Use `verifyNativeAdapterContract(...)` in adapter tests. | `steward benchmark --scenario intentcall.adapter-contract --json`. | [intentcall_testing README](https://github.com/Arenukvern/intentcall/blob/main/packages/intentcall_testing/README.md) | diff --git a/justfile b/justfile index 4e69dc8..03d7096 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,7 @@ default: # Run tests for all packages in the workspace test: - dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform_sync packages/intentcall_hooks packages/intentcall_bridge packages/intentcall_codegen/test packages/intentcall_cli packages/intentcall_platform packages/intentcall_testing tool/intentcall + dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_platform_sync packages/intentcall_hooks packages/intentcall_bridge packages/intentcall_codegen/test packages/intentcall_cli packages/intentcall_platform packages/intentcall_platform_apple packages/intentcall_platform_android packages/intentcall_testing tool/intentcall # Manifest freshness gate (build_runner catalog + export --check) manifest-export-check: diff --git a/packages/intentcall_android/CHANGELOG.md b/packages/intentcall_android/CHANGELOG.md deleted file mode 100644 index f6ff397..0000000 --- a/packages/intentcall_android/CHANGELOG.md +++ /dev/null @@ -1,57 +0,0 @@ -# Changelog - -## [0.6.0](https://github.com/Arenukvern/intentcall/compare/intentcall_android-v0.5.0...intentcall_android-v0.6.0) (2026-06-29) - - -### Miscellaneous Chores - -* **intentcall_android:** Synchronize intentcall package train versions - -## [0.5.0](https://github.com/Arenukvern/intentcall/compare/intentcall_android-v0.4.0...intentcall_android-v0.5.0) (2026-06-29) - - -### Miscellaneous Chores - -* **intentcall_android:** Synchronize intentcall package train versions - -## [0.4.0](https://github.com/Arenukvern/intentcall/compare/intentcall_android-v0.3.1...intentcall_android-v0.4.0) (2026-06-28) - - -### Features - -* **intentcall_platform:** add Apple inline runtime proof scaffolds ([a09f403](https://github.com/Arenukvern/intentcall/commit/a09f40326233e04e28901e2d06c7649b039a54d8)) -* **intentcall_platform:** add Apple inline runtime proof scaffolds ([f9a6221](https://github.com/Arenukvern/intentcall/commit/f9a6221a0e1ff49a87dc670d6a0dbb805931522b)) - -## [0.3.1](https://github.com/Arenukvern/intentcall/compare/intentcall_android-v0.3.0...intentcall_android-v0.3.1) (2026-06-27) - - -### Miscellaneous Chores - -* **intentcall_android:** Synchronize intentcall package train versions - -## [0.3.0](https://github.com/Arenukvern/intentcall/compare/intentcall_android-v0.2.1...intentcall_android-v0.3.0) (2026-06-26) - - -### Features - -* add Dart-first native invocation surfaces ([4d5eaae](https://github.com/Arenukvern/intentcall/commit/4d5eaae19f31e2c5acba6f40280111766710c396)) - -## [0.2.1](https://github.com/Arenukvern/intentcall/compare/intentcall_android-v0.2.0...intentcall_android-v0.2.1) (2026-06-23) - - -### Miscellaneous Chores - -* **intentcall_android:** Synchronize intentcall package train versions - -## [0.2.0](https://github.com/Arenukvern/intentcall/compare/intentcall_android-v0.1.0...intentcall_android-v0.2.0) (2026-06-22) - - -### Miscellaneous Chores - -* **intentcall_android:** Synchronize intentcall package train versions - -## 0.1.0 - -- First pre-release of Android manifest helpers for IntentCall. -- Includes dynamic-shortcut-oriented manifest generation from IntentCall - descriptors. diff --git a/packages/intentcall_android/LICENSE b/packages/intentcall_android/LICENSE deleted file mode 100644 index ec57a7f..0000000 --- a/packages/intentcall_android/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Anton Malofeev (Arenukvern) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/intentcall_android/README.md b/packages/intentcall_android/README.md deleted file mode 100644 index fc92bee..0000000 --- a/packages/intentcall_android/README.md +++ /dev/null @@ -1,45 +0,0 @@ -> ⚠️ **DEPRECATED** — Use [`intentcall_platform_sync`](https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync) `PlatformSync` / `ManifestExporter` instead. This package is workspace-only (`publish_to: none`) and will be removed after one deprecation cycle. See [ADR 0025](../../docs/decisions/0025-platform-subset-federated-plugins.md). - -> ⚠️ **Pre-release train** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). - - -# intentcall_android - -[![pub package](https://img.shields.io/pub/v/intentcall_android.svg?include_prereleases)](https://pub.dev/packages/intentcall_android) -[![pub points](https://img.shields.io/pub/points/intentcall_android.svg)](https://pub.dev/packages/intentcall_android/score) -[![repository](https://img.shields.io/badge/repo-intentcall-blue)](https://github.com/Arenukvern/intentcall) - -Android manifest codegen for IntentCall shortcut and deep-link artifacts. - -Current Android support is shortcut/deep-link dispatch into Dart. Android -AppFunctions and fuller App Actions capability generation remain roadmap work. - -## Author workflow - -1. **Author tools** — hand-written `AgentCallEntry` or optional `@AgentTool` codegen (`intentcall_codegen`). -2. **Collect descriptors** — `entry.toRegistration().descriptor` or registry snapshot. -3. **Generate manifest** — `generateAndroidAgentManifest(descriptors)` → `agent_manifest.json`. -4. **Platform snippet** — map manifest shortcuts to `shortcuts.xml` / deep-link routing. - -```dart -import 'package:intentcall_android/intentcall_android.dart'; - -final json = generateAndroidAgentManifest([ - entry.toRegistration().descriptor, -]); -// write to android/app/src/main/res/values/agent_manifest.json -``` - -Example XML-oriented snippet derived from manifest: - -```xml - - - -``` - -Input: `agent_manifest.json` (`platform: android`, `shortcuts[]`). -Output: JSON manifest + documented shortcuts XML / deep-link mapping. Android -AppFunctions and fuller App Actions capability generation remain roadmap work. - -See `test/agent_manifest_generator_test.dart`. diff --git a/packages/intentcall_android/analysis_options.yaml b/packages/intentcall_android/analysis_options.yaml deleted file mode 100644 index f04c6cf..0000000 --- a/packages/intentcall_android/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options.yaml diff --git a/packages/intentcall_android/lib/intentcall_android.dart b/packages/intentcall_android/lib/intentcall_android.dart deleted file mode 100644 index 9ab470b..0000000 --- a/packages/intentcall_android/lib/intentcall_android.dart +++ /dev/null @@ -1 +0,0 @@ -export 'src/agent_manifest_generator.dart'; diff --git a/packages/intentcall_android/lib/src/agent_manifest_generator.dart b/packages/intentcall_android/lib/src/agent_manifest_generator.dart deleted file mode 100644 index 2f7bdae..0000000 --- a/packages/intentcall_android/lib/src/agent_manifest_generator.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:convert'; - -import 'package:intentcall_core/intentcall_core.dart'; - -/// Builds `agent_manifest.json` for Android App Actions / shortcuts (Phase 3). -@Deprecated( - 'Use intentcall_platform_sync PlatformSync / ManifestExporter', -) -String generateAndroidAgentManifest( - final Iterable descriptors, { - final String? protocolScheme, -}) { - final shortcuts = >[]; - for (final descriptor in descriptors) { - final resolvedResourceUri = _manifestResourceUri( - descriptor, - protocolScheme, - ); - shortcuts.add({ - 'qualifiedName': descriptor.qualifiedName, - 'namespace': descriptor.namespace, - 'name': descriptor.name, - 'description': descriptor.description, - 'kind': descriptor.kind.name, - 'resourceUri': ?resolvedResourceUri, - 'inputSchema': descriptor.inputSchema, - }); - } - return const JsonEncoder.withIndent(' ').convert({ - 'version': 1, - 'platform': 'android', - 'shortcuts': shortcuts, - }); -} - -String? _manifestResourceUri( - final AgentIntentDescriptor descriptor, - final String? protocolScheme, -) { - if (descriptor.resourceUri != null) { - return descriptor.resourceUri; - } - if (descriptor.kind != AgentIntentKind.resource) { - return null; - } - final scheme = protocolScheme?.trim() ?? ''; - if (scheme.isEmpty) { - return null; - } - return descriptor.effectiveResourceUri(scheme); -} diff --git a/packages/intentcall_android/pubspec.yaml b/packages/intentcall_android/pubspec.yaml deleted file mode 100644 index 720faf1..0000000 --- a/packages/intentcall_android/pubspec.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: intentcall_android -description: DEPRECATED — Use intentcall_platform_sync. Legacy Android shortcuts XML manifest generator. -version: 0.6.0 -publish_to: none -license: MIT -repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_android -issue_tracker: https://github.com/Arenukvern/intentcall/issues -homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_android -topics: - - mcp - - android - - agents - -environment: - sdk: ">=3.12.0 <4.0.0" -resolution: workspace - -dependencies: - intentcall_core: ^0.6.0 - meta: ^1.17.0 - path: ^1.9.1 - -dev_dependencies: - lints: ^6.1.0 - test: ^1.31.1 - xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_android/test/agent_manifest_generator_test.dart b/packages/intentcall_android/test/agent_manifest_generator_test.dart deleted file mode 100644 index fbcc9a7..0000000 --- a/packages/intentcall_android/test/agent_manifest_generator_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:convert'; - -import 'package:intentcall_android/intentcall_android.dart'; -import 'package:intentcall_core/intentcall_core.dart'; -import 'package:test/test.dart'; - -void main() { - test('generateAndroidAgentManifest lists shortcuts', () { - final json = generateAndroidAgentManifest([ - AgentIntentDescriptor( - namespace: 'app', - name: 'cart_total', - description: 'cart', - kind: AgentIntentKind.tool, - inputSchema: const {'type': 'object'}, - ), - ]); - - final map = jsonDecode(json) as Map; - expect(map['platform'], 'android'); - expect(map['shortcuts']! as List, hasLength(1)); - }); -} diff --git a/packages/intentcall_apple/CHANGELOG.md b/packages/intentcall_apple/CHANGELOG.md deleted file mode 100644 index 95a5af8..0000000 --- a/packages/intentcall_apple/CHANGELOG.md +++ /dev/null @@ -1,66 +0,0 @@ -# Changelog - -## Unreleased - -### Features - -- Allow Apple manifest generation to include neutral typed entity descriptors - and snapshot field metadata for downstream App Intents projection. - -## [0.6.0](https://github.com/Arenukvern/intentcall/compare/intentcall_apple-v0.5.0...intentcall_apple-v0.6.0) (2026-06-29) - - -### Miscellaneous Chores - -* **intentcall_apple:** Synchronize intentcall package train versions - -## [0.5.0](https://github.com/Arenukvern/intentcall/compare/intentcall_apple-v0.4.0...intentcall_apple-v0.5.0) (2026-06-29) - - -### Features - -* add release-ready typed entity projections ([b2119b1](https://github.com/Arenukvern/intentcall/commit/b2119b14a1e157129ead9cf18e795bdde1ea2cd3)) -* add release-ready typed entity projections ([f7b9546](https://github.com/Arenukvern/intentcall/commit/f7b9546d291f7206c3be0ea71302144de6b836eb)) - -## [0.4.0](https://github.com/Arenukvern/intentcall/compare/intentcall_apple-v0.3.1...intentcall_apple-v0.4.0) (2026-06-28) - - -### Features - -* **intentcall_platform:** add Apple inline runtime proof scaffolds ([a09f403](https://github.com/Arenukvern/intentcall/commit/a09f40326233e04e28901e2d06c7649b039a54d8)) -* **intentcall_platform:** add Apple inline runtime proof scaffolds ([f9a6221](https://github.com/Arenukvern/intentcall/commit/f9a6221a0e1ff49a87dc670d6a0dbb805931522b)) - -## [0.3.1](https://github.com/Arenukvern/intentcall/compare/intentcall_apple-v0.3.0...intentcall_apple-v0.3.1) (2026-06-27) - - -### Bug Fixes - -* **intentcall_platform:** add SwiftPM support ([81d2ccf](https://github.com/Arenukvern/intentcall/commit/81d2ccf37d2726b026b2ab5b5b09e1fd3bebdace)) -* **intentcall_platform:** add SwiftPM support ([7c709f0](https://github.com/Arenukvern/intentcall/commit/7c709f01f461b864b578fd4680682d6e0a18e5c9)) - -## [0.3.0](https://github.com/Arenukvern/intentcall/compare/intentcall_apple-v0.2.1...intentcall_apple-v0.3.0) (2026-06-26) - - -### Features - -* add Dart-first native invocation surfaces ([4d5eaae](https://github.com/Arenukvern/intentcall/commit/4d5eaae19f31e2c5acba6f40280111766710c396)) - -## [0.2.1](https://github.com/Arenukvern/intentcall/compare/intentcall_apple-v0.2.0...intentcall_apple-v0.2.1) (2026-06-23) - - -### Miscellaneous Chores - -* **intentcall_apple:** Synchronize intentcall package train versions - -## [0.2.0](https://github.com/Arenukvern/intentcall/compare/intentcall_apple-v0.1.0...intentcall_apple-v0.2.0) (2026-06-22) - - -### Miscellaneous Chores - -* **intentcall_apple:** Synchronize intentcall package train versions - -## 0.1.0 - -- First pre-release of Apple manifest helpers for IntentCall. -- Includes App Intents / Shortcuts-oriented manifest generation from - IntentCall descriptors. diff --git a/packages/intentcall_apple/LICENSE b/packages/intentcall_apple/LICENSE deleted file mode 100644 index ec57a7f..0000000 --- a/packages/intentcall_apple/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Anton Malofeev (Arenukvern) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/intentcall_apple/README.md b/packages/intentcall_apple/README.md deleted file mode 100644 index a2bd345..0000000 --- a/packages/intentcall_apple/README.md +++ /dev/null @@ -1,51 +0,0 @@ -> ⚠️ **DEPRECATED** — Use [`intentcall_platform_sync`](https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync) `PlatformSync` / `ManifestExporter` instead. This package is workspace-only (`publish_to: none`) and will be removed after one deprecation cycle. See [ADR 0025](../../docs/decisions/0025-platform-subset-federated-plugins.md). - -> ⚠️ **Pre-release train** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). - - -# intentcall_apple - -[![pub package](https://img.shields.io/pub/v/intentcall_apple.svg?include_prereleases)](https://pub.dev/packages/intentcall_apple) -[![pub points](https://img.shields.io/pub/points/intentcall_apple.svg)](https://pub.dev/packages/intentcall_apple/score) -[![repository](https://img.shields.io/badge/repo-intentcall-blue)](https://github.com/Arenukvern/intentcall) - -Apple platform manifest projection for IntentCall. - -Current generated App Intents collect supported primitive parameters, enqueue a -pending invocation envelope, and open or wake the Flutter app for Dart registry -execution. `nativeInline` can call app-owned Swift code in the main app target. -`dartExtensionInline` is experimental scaffold-only in `intentcall_platform`; -it does not yet prove Dart business logic inside an App Intent extension. - -This package owns Apple manifest JSON projection only. Flutter project sync and -generated Swift AppIntent artifacts live in `intentcall_platform`, so there is -one implementation of Flutter-native Apple wrapper generation. - -## Author workflow - -1. **Author tools** — hand-written `AgentCallEntry` or optional `@AgentTool` codegen (`intentcall_codegen`). -2. **Collect descriptors** — `entry.toRegistration().descriptor` or registry snapshot. -3. **Generate manifest** — `generateAppleAgentManifest(descriptors)` → `agent_manifest.json`. -4. **Platform wrapper** — let `intentcall_platform` generate Shortcuts / App Intents metadata that dispatches to Dart. - -```dart -import 'package:intentcall_apple/intentcall_apple.dart'; - -final json = generateAppleAgentManifest([ - entry.toRegistration().descriptor, -]); -// write to ios/Runner/agent_manifest.json -``` - -Example Swift-oriented snippet derived from manifest (hand-off to Xcode codegen): - -```swift -// agent_manifest.json → App Intents (illustrative) -// Intent: app_demo_ping — "Returns pong for a message" -// Parameters: message (String, required) -``` - -Input: `agent_manifest.json` (`platform: apple`, `intents[]`). -Output: JSON manifest for the Flutter project sync layer. - -See `test/agent_manifest_generator_test.dart`. diff --git a/packages/intentcall_apple/analysis_options.yaml b/packages/intentcall_apple/analysis_options.yaml deleted file mode 100644 index f04c6cf..0000000 --- a/packages/intentcall_apple/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options.yaml diff --git a/packages/intentcall_apple/lib/intentcall_apple.dart b/packages/intentcall_apple/lib/intentcall_apple.dart deleted file mode 100644 index 9ab470b..0000000 --- a/packages/intentcall_apple/lib/intentcall_apple.dart +++ /dev/null @@ -1 +0,0 @@ -export 'src/agent_manifest_generator.dart'; diff --git a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart b/packages/intentcall_apple/lib/src/agent_manifest_generator.dart deleted file mode 100644 index 4b12134..0000000 --- a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:convert'; - -import 'package:intentcall_core/intentcall_core.dart'; - -/// Builds `agent_manifest.json` for App Intents / Shortcuts codegen (Phase 3). -@Deprecated( - 'Use intentcall_platform_sync PlatformSync / ManifestExporter', -) -String generateAppleAgentManifest( - final Iterable descriptors, { - final Iterable entityTypeDescriptors = const [], - final Iterable> entityTypes = const [], - final String? protocolScheme, -}) { - final intents = >[]; - for (final descriptor in descriptors) { - final resolvedResourceUri = _manifestResourceUri( - descriptor, - protocolScheme, - ); - intents.add({ - 'qualifiedName': descriptor.qualifiedName, - 'namespace': descriptor.namespace, - 'name': descriptor.name, - 'description': descriptor.description, - 'kind': descriptor.kind.name, - 'resourceUri': ?resolvedResourceUri, - if (descriptor.mimeType != null) 'mimeType': descriptor.mimeType, - 'inputSchema': descriptor.inputSchema, - }); - } - final entities = >[ - ...entityTypeDescriptors.map(_entityTypeDescriptorManifest), - ...entityTypes.map(Map.from), - ]; - return const JsonEncoder.withIndent(' ').convert({ - 'version': 1, - 'platform': 'apple', - 'intents': intents, - if (entities.isNotEmpty) 'entityTypes': entities, - }); -} - -String? _manifestResourceUri( - final AgentIntentDescriptor descriptor, - final String? protocolScheme, -) { - if (descriptor.resourceUri != null) { - return descriptor.resourceUri; - } - if (descriptor.kind != AgentIntentKind.resource) { - return null; - } - final scheme = protocolScheme?.trim() ?? ''; - if (scheme.isEmpty) { - return null; - } - return descriptor.effectiveResourceUri(scheme); -} - -Map _entityTypeDescriptorManifest( - final AgentEntityTypeDescriptor descriptor, -) { - final keys = AgentEntitySnapshotKeys.fromDescriptor(descriptor); - return { - 'qualifiedName': descriptor.qualifiedName, - 'namespace': descriptor.namespace, - 'name': descriptor.name, - 'displayName': descriptor.displayName ?? _humanizeName(descriptor.name), - 'idKey': keys.idKey, - 'titleKey': keys.titleKey, - 'subtitleKey': keys.subtitleKey, - 'keywordsKey': keys.keywordsKey, - 'snapshotSchema': agentEntitySnapshotSchema(descriptor), - }; -} - -String _humanizeName(final String name) { - final parts = name - .split(RegExp(r'[_\s-]+')) - .where((final part) => part.trim().isNotEmpty); - if (parts.isEmpty) { - return name; - } - return parts - .map((final part) => '${part[0].toUpperCase()}${part.substring(1)}') - .join(' '); -} diff --git a/packages/intentcall_apple/pubspec.yaml b/packages/intentcall_apple/pubspec.yaml deleted file mode 100644 index c749f2e..0000000 --- a/packages/intentcall_apple/pubspec.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: intentcall_apple -description: DEPRECATED — Use intentcall_platform_sync. Legacy Apple App Intents manifest generator. -version: 0.6.0 -publish_to: none -license: MIT -repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_apple -issue_tracker: https://github.com/Arenukvern/intentcall/issues -homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_apple -topics: - - mcp - - ios - - macos - -environment: - sdk: ">=3.12.0 <4.0.0" -resolution: workspace - -dependencies: - intentcall_core: ^0.6.0 - meta: ^1.17.0 - path: ^1.9.1 - -dev_dependencies: - lints: ^6.1.0 - test: ^1.31.1 - xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_apple/test/agent_manifest_generator_test.dart b/packages/intentcall_apple/test/agent_manifest_generator_test.dart deleted file mode 100644 index 21004a9..0000000 --- a/packages/intentcall_apple/test/agent_manifest_generator_test.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:convert'; - -import 'package:intentcall_apple/intentcall_apple.dart'; -import 'package:intentcall_core/intentcall_core.dart'; -import 'package:test/test.dart'; - -void main() { - test('generateAppleAgentManifest includes tool and resource intents', () { - final json = generateAppleAgentManifest([ - AgentIntentDescriptor( - namespace: 'fmt', - name: 'wait_for', - description: 'wait', - kind: AgentIntentKind.tool, - inputSchema: const {'type': 'object'}, - ), - AgentIntentDescriptor( - namespace: 'app', - name: 'diagnostics', - description: 'diag', - kind: AgentIntentKind.resource, - inputSchema: const {'type': 'object'}, - mimeType: 'application/json', - ), - ], protocolScheme: 'demoapp'); - - final map = jsonDecode(json) as Map; - expect(map['platform'], 'apple'); - final intents = map['intents']! as List; - expect(intents, hasLength(2)); - expect( - (intents[1] as Map)['resourceUri'], - 'demoapp://resource/diagnostics', - ); - }); - - test('generateAppleAgentManifest includes raw entityTypes section', () { - final json = generateAppleAgentManifest( - [], - entityTypes: [ - { - 'qualifiedName': 'app_project', - 'namespace': 'app', - 'name': 'project', - 'displayName': 'Project', - 'titleKey': 'name', - }, - ], - ); - - final map = jsonDecode(json) as Map; - expect(map['platform'], 'apple'); - final entityTypes = map['entityTypes']! as List; - expect(entityTypes, hasLength(1)); - expect((entityTypes.first as Map)['titleKey'], 'name'); - }); - - test('generateAppleAgentManifest projects core entity descriptors', () { - final json = generateAppleAgentManifest( - [], - entityTypeDescriptors: [ - AgentEntityTypeDescriptor( - namespace: 'app', - name: 'project', - identifierName: 'project_id', - displayName: 'Project', - properties: [ - AgentEntityPropertyDescriptor( - name: 'name', - valueType: AgentEntityPropertyValueType.string, - isDisplay: true, - isSearchable: true, - isIndexed: true, - ), - AgentEntityPropertyDescriptor( - name: 'summary', - valueType: AgentEntityPropertyValueType.string, - isSearchable: true, - ), - AgentEntityPropertyDescriptor( - name: 'tags', - valueType: AgentEntityPropertyValueType.array, - isSearchable: true, - ), - ], - ), - ], - ); - - final map = jsonDecode(json) as Map; - final entityTypes = map['entityTypes']! as List; - final entityType = entityTypes.first as Map; - expect(entityType['qualifiedName'], 'app_project'); - expect(entityType['idKey'], 'project_id'); - expect(entityType['titleKey'], 'name'); - expect(entityType['subtitleKey'], 'summary'); - expect(entityType['keywordsKey'], 'tags'); - expect((entityType['snapshotSchema']! as Map)['required'], ['project_id']); - }); -} diff --git a/packages/intentcall_core/README.md b/packages/intentcall_core/README.md index 579c1b1..85e70aa 100644 --- a/packages/intentcall_core/README.md +++ b/packages/intentcall_core/README.md @@ -101,8 +101,7 @@ Use this import for `MigrateAgentEntriesMigrator`, - `intentcall_webmcp` — WebMCP `modelContext` publish adapter - `intentcall_gemma` — on-device Gemma function-calling adapter - `intentcall_platform_sync` — canonical manifest projection / emitters -- `intentcall_apple` / `intentcall_android` — **deprecated** legacy generators - (`publish_to: none`; use `intentcall_platform_sync`) +- `intentcall_platform` — Flutter runtime umbrella (endorses `intentcall_platform_apple` / `intentcall_platform_android`) - `intentcall_testing` — registry contract helpers Canonical design docs: [North Star](../../docs/NORTH_STAR.mdx), [Design FAQ](../../docs/DESIGN_FAQ.mdx), and [DX FAQ](../../docs/DX_FAQ.mdx). diff --git a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart index 22c7c96..f74a300 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -3,8 +3,8 @@ import 'emitter_utils.dart'; /// Emits Swift App Intents + shortcuts provider for iOS and macOS. /// -/// Logic mirrors [generateAppleAgentManifest] in `intentcall_apple`; this emitter -/// turns manifest rows into compile-time Swift for `Runner/Generated/`. +/// Turns manifest rows into compile-time Swift for `Runner/Generated/`. +/// (Supersedes the removed `intentcall_apple` sparse-manifest generator.) final class AppleSwiftAppIntentsEmitter { const AppleSwiftAppIntentsEmitter({this.fallbackProtocolScheme}); @@ -143,7 +143,9 @@ final class AppleSwiftAppIntentsEmitter { for (final entityType in manifest.entityTypes) { if (emitEntities) { - buffer.write(_swiftEntityType(entityType, includeSpotlight: emitSpotlight)); + buffer.write( + _swiftEntityType(entityType, includeSpotlight: emitSpotlight), + ); } } @@ -179,44 +181,44 @@ final class AppleSwiftAppIntentsEmitter { } if (intentTools.isNotEmpty) { buffer - ..writeln( - '// IntentCallNativeHandoffStore is provided by the intentcall_platform plugin.', - ) - ..writeln('enum IntentCallNativeBridge {') - ..writeln( - ' private static let fallbackScheme: String? = ${_swiftOptionalString(bridgeProtocolScheme)}', - ) - ..writeln() - ..writeln( - ' static func enqueue(qualifiedName: String, arguments: [String: Any], openApp: Bool) async -> String {', - ) - ..writeln(' let invocationId = UUID().uuidString') - ..writeln(' let item: [String: Any] = [') - ..writeln(' "id": invocationId,') - ..writeln(' "qualifiedName": qualifiedName,') - ..writeln(' "arguments": arguments,') - ..writeln(' "source": "native.generated",') - ..writeln( - ' "createdAt": ISO8601DateFormatter().string(from: Date())', - ) - ..writeln(' ]') - ..writeln(' IntentCallNativeHandoffStore.append(item)') - ..writeln(' var allowedPath = CharacterSet.alphanumerics') - ..writeln(' allowedPath.insert(charactersIn: "_-.~")') - ..writeln( - ' let encodedName = qualifiedName.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? qualifiedName', - ) - ..writeln( - r' guard openApp, let scheme = fallbackScheme, let url = URL(string: "\(scheme)://invoke/\(encodedName)") else { return invocationId }', - ) - ..writeln(' #if canImport(UIKit)') - ..writeln(' await UIApplication.shared.open(url)') - ..writeln(' #elseif canImport(AppKit)') - ..writeln(' NSWorkspace.shared.open(url)') - ..writeln(' #endif') - ..writeln(' return invocationId') - ..writeln(' }') - ..writeln('}'); + ..writeln( + '// IntentCallNativeHandoffStore is provided by the intentcall_platform plugin.', + ) + ..writeln('enum IntentCallNativeBridge {') + ..writeln( + ' private static let fallbackScheme: String? = ${_swiftOptionalString(bridgeProtocolScheme)}', + ) + ..writeln() + ..writeln( + ' static func enqueue(qualifiedName: String, arguments: [String: Any], openApp: Bool) async -> String {', + ) + ..writeln(' let invocationId = UUID().uuidString') + ..writeln(' let item: [String: Any] = [') + ..writeln(' "id": invocationId,') + ..writeln(' "qualifiedName": qualifiedName,') + ..writeln(' "arguments": arguments,') + ..writeln(' "source": "native.generated",') + ..writeln( + ' "createdAt": ISO8601DateFormatter().string(from: Date())', + ) + ..writeln(' ]') + ..writeln(' IntentCallNativeHandoffStore.append(item)') + ..writeln(' var allowedPath = CharacterSet.alphanumerics') + ..writeln(' allowedPath.insert(charactersIn: "_-.~")') + ..writeln( + ' let encodedName = qualifiedName.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? qualifiedName', + ) + ..writeln( + r' guard openApp, let scheme = fallbackScheme, let url = URL(string: "\(scheme)://invoke/\(encodedName)") else { return invocationId }', + ) + ..writeln(' #if canImport(UIKit)') + ..writeln(' await UIApplication.shared.open(url)') + ..writeln(' #elseif canImport(AppKit)') + ..writeln(' NSWorkspace.shared.open(url)') + ..writeln(' #endif') + ..writeln(' return invocationId') + ..writeln(' }') + ..writeln('}'); } return buffer.toString(); @@ -528,7 +530,9 @@ String _swiftEntitySnapshotSupport( ..writeln(' queue: nil') ..writeln(' ) { _ in') ..writeln(' Task {') - ..writeln(' try? await IntentCallAppEntityIndexer.indexAppEntities()') + ..writeln( + ' try? await IntentCallAppEntityIndexer.indexAppEntities()', + ) ..writeln(' }') ..writeln(' }') ..writeln(' }') diff --git a/pubspec.yaml b/pubspec.yaml index 4f9ddf1..d66c2c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,8 +12,6 @@ workspace: - packages/intentcall_codegen - packages/intentcall_webmcp - packages/intentcall_gemma - - packages/intentcall_apple - - packages/intentcall_android - packages/intentcall_platform_sync - packages/intentcall_hooks - packages/intentcall_bridge diff --git a/tool/intentcall/bin/release_train.dart b/tool/intentcall/bin/release_train.dart index 02743d0..f07291f 100644 --- a/tool/intentcall/bin/release_train.dart +++ b/tool/intentcall/bin/release_train.dart @@ -18,11 +18,7 @@ const publishablePackages = [ 'intentcall_testing', ]; -const workspaceOnlyPackages = [ - 'intentcall_gemma', - 'intentcall_apple', - 'intentcall_android', -]; +const workspaceOnlyPackages = ['intentcall_gemma']; const allInternalPackages = [...publishablePackages, ...workspaceOnlyPackages]; diff --git a/tool/intentcall/test/publish_preflight_test.dart b/tool/intentcall/test/publish_preflight_test.dart index 0f51ef6..09b4b05 100644 --- a/tool/intentcall/test/publish_preflight_test.dart +++ b/tool/intentcall/test/publish_preflight_test.dart @@ -561,16 +561,6 @@ Future _createReleaseTrainFixture() async { _writePubspec(repo, packageName, version: '0.6.0', dependencies: deps); } - for (final packageName in ['intentcall_apple', 'intentcall_android']) { - _writePubspec( - repo, - packageName, - version: '0.6.0', - publishToNone: true, - dependencies: ' intentcall_core: ^0.5.0\n', - ); - } - _writePubspec( repo, 'intentcall_gemma', From 2110e087bede87c949f01885d1d091ec41b8dd9a Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Thu, 9 Jul 2026 01:42:25 +0300 Subject: [PATCH 17/27] feat: add audience routing documentation and enhance navigation links across various documentation files --- AGENTS.md | 1 + docs.json | 4 + docs/DX_FAQ.mdx | 112 +++++++---- docs/NORTH_STAR.mdx | 1 + docs/index.mdx | 1 + docs/start_here/audiences.mdx | 286 +++++++++++++++++++++++++++ docs/start_here/choose_your_path.mdx | 17 +- docs/start_here/docs_map.mdx | 2 + docs/start_here/how_it_works.mdx | 29 +-- docs/start_here/platform_support.mdx | 17 ++ 10 files changed, 416 insertions(+), 54 deletions(-) create mode 100644 docs/start_here/audiences.mdx diff --git a/AGENTS.md b/AGENTS.md index 55c55c6..f01badc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ npx skills add arenukvern/skill_steward | Question | Go to | |---|---| +| Which audience / setup lane? | [docs/start_here/audiences.mdx](docs/start_here/audiences.mdx) | | Where is the full doc map? | [docs/start_here/docs_map.mdx](docs/start_here/docs_map.mdx) | | What does this repo own? | [docs/NORTH_STAR.mdx](docs/NORTH_STAR.mdx) | | Why is it built this way? | [docs/DESIGN_FAQ.mdx](docs/DESIGN_FAQ.mdx) | diff --git a/docs.json b/docs.json index 3b79a7d..67647b9 100644 --- a/docs.json +++ b/docs.json @@ -34,6 +34,10 @@ "title": "Overview", "href": "/" }, + { + "title": "Who is this for?", + "href": "/start_here/audiences" + }, { "title": "How it works", "href": "/start_here/how_it_works" diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 7da7e59..eee5996 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -521,57 +521,43 @@ app concern. --- -## 🧪 Testing - -**Q: How do I test an adapter against the contract?** - -Add `intentcall_testing` as a `dev_dependency` and call `verifyNativeAdapterContract(...)` from a package test. For example: - -```dart -test('McpPublishAdapter satisfies the shared native contract', () async { - await verifyNativeAdapterContract( - attach: (registry, transport) async { - final adapter = McpPublishAdapter(registry: registry, transport: transport); - await adapter.attach(); - return adapter.detach; - }, - ); -}); -``` +## Flutter in-app host {#flutter-in-app-host} -See `packages/intentcall_mcp/test/mcp_adapter_contract_test.dart` for the current reference shape. +**Q: How do I wire `IntentCallFlutterHost` in a Flutter app?** -**Q: How do I test session lifecycle without Flutter or MCP?** +Audience overview: [Who is this for? §1](/start_here/audiences#1-in-app--internal-flutter-host). -Use an in-memory fake connector and a temporary `StateStore` path. This proves persistence, lock handling, and session executor behavior without importing adapter internals. +1. Depend on `intentcall_platform` (umbrella) + `intentcall_core` — not deleted + `intentcall_apple` / `intentcall_android`. +2. Add `intentcall.yaml` with `host: flutter`, `protocolScheme`, and explicit + `platforms.enabled` (required — `intentcall config validate` errors if empty). +3. Register tools once; bind the host: ```dart -final manager = IntentSessionManager( - connector: FakeConnector(endpoint: 'runtime://test'), - stateStore: StateStore(path: '${tempDir.path}/state.json'), +final host = IntentCallFlutterHost.bindRegistry( + registry: registry, + policy: productionPolicy, // never ship debugAllowAll in release + registerWebMcp: kIsWeb, + listenForDeepLinks: !kIsWeb, + protocolScheme: 'myapp', ); +await host.start(); ``` -**Q: How do I run consumer regression checks against mcp_flutter?** - -```bash -# from the mcp_flutter repo root: -make check-intentcall-integration -# (defaults INTENTCALL_ROOT=../agentkit when cloned as siblings) -``` +4. Run hooks init + three-gate spine (`manifest export --check`, + `platform sync --check`) or the mcp_flutter `init intentcall-platform` / + `codegen sync` wrappers. -**Q: What should a Flutter host check after moving to the current hosted IntentCall train?** +**Q: What should a Flutter host check after moving to the current IntentCall train?** -Use hosted `intentcall_*` packages from the same train, then bind the app -registry once through `IntentCallFlutterHost.bindRegistry(...)`. Pass an -explicit production policy, enable `registerWebMcp` only for pages that expose a -compatible WebMCP host, enable `listenForDeepLinks` only with an app-owned -`protocolScheme`, and drain on startup plus foreground/resume for open-app -native handoff. +Use the same train for all `intentcall_*` packages. Pass an explicit production +policy, enable `registerWebMcp` only for pages that expose a compatible WebMCP +host, enable `listenForDeepLinks` only with an app-owned `protocolScheme`, and +drain on startup plus foreground/resume for open-app native handoff. For iOS and macOS open-app App Intents, the readiness checklist is: -1. `agent_manifest.json` has the app-owned `protocolScheme`. +1. `agent_manifest.json` / export has the app-owned `protocolScheme`. 2. Apple entries that should appear in Shortcuts use `"dispatchMode": "openApp"` and curated `"surfaces": { "apple.appShortcuts": { "include": true } }`. 3. `intentcall platform sync --platform ios,macos --check` passes, proving generated Swift, @@ -638,6 +624,56 @@ fixture does not check in `web/canvaskit/`. In Flutter CLI terms, launch with `--no-web-resources-cdn` rather than pointing at a missing local CanvasKit override. +**mcp_flutter dogfood:** web and macOS have the strongest runtime lanes today; +iOS shares Apple emitters and is proven at sync/scaffold level until signed +AppIntentsTesting runs. See [Platform support — mcp_flutter dogfood](/start_here/platform_support#mcp_flutter-dogfood-sibling-consumer). + +--- + +## 🧪 Testing + +**Q: How do I test an adapter against the contract?** + +Add `intentcall_testing` as a `dev_dependency` and call `verifyNativeAdapterContract(...)` from a package test. For example: + +```dart +test('McpPublishAdapter satisfies the shared native contract', () async { + await verifyNativeAdapterContract( + attach: (registry, transport) async { + final adapter = McpPublishAdapter(registry: registry, transport: transport); + await adapter.attach(); + return adapter.detach; + }, + ); +}); +``` + +See `packages/intentcall_mcp/test/mcp_adapter_contract_test.dart` for the current reference shape. + +**Q: How do I test session lifecycle without Flutter or MCP?** + +Use an in-memory fake connector and a temporary `StateStore` path. This proves persistence, lock handling, and session executor behavior without importing adapter internals. + +```dart +final manager = IntentSessionManager( + connector: FakeConnector(endpoint: 'runtime://test'), + stateStore: StateStore(path: '${tempDir.path}/state.json'), +); +``` + +**Q: How do I run consumer regression checks against mcp_flutter?** + +```bash +# from the mcp_flutter repo root: +make check-contracts +make dogfood-eval-static +# web / macOS runtime dogfood: +make web-showcase +make showcase +make macos-validate-runtime +# (defaults INTENTCALL_ROOT=../agentkit when cloned as siblings) +``` + **Q: Which app actions should become Apple App Shortcuts?** Publish product verbs, not bridge diagnostics. A Flutter showcase can expose diff --git a/docs/NORTH_STAR.mdx b/docs/NORTH_STAR.mdx index d36bbaa..3f6b1bf 100644 --- a/docs/NORTH_STAR.mdx +++ b/docs/NORTH_STAR.mdx @@ -112,6 +112,7 @@ All packages are on the **pre-1.0 train** — experimental. APIs may change with ## Key docs - [AGENTS.md](https://github.com/Arenukvern/intentcall/blob/main/AGENTS.md) — agent map and navigation pointers +- [Who is this for?](/start_here/audiences) — in-app, external MCP agents, OS surfaces, mcp_flutter, Gemma - [How it works](/start_here/how_it_works) — registry, adapters, invocation flow, and neighboring systems - [Choose your path](/start_here/choose_your_path) — package choices by task - [Platform support](/start_here/platform_support) — evidence levels, trust model, and roadmap non-claims diff --git a/docs/index.mdx b/docs/index.mdx index 497fcff..6ce9dde 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -10,6 +10,7 @@ | I want to… | Go to | |------------|--------| +| Pick my audience (in-app, MCP agents, OS, mcp_flutter, Gemma) | [Who is this for?](/start_here/audiences) | | Understand the moving parts | [How it works](/start_here/how_it_works) | | Pick the right package path | [Choose your path](/start_here/choose_your_path) | | Check platform evidence and non-claims | [Platform support](/start_here/platform_support) | diff --git a/docs/start_here/audiences.mdx b/docs/start_here/audiences.mdx new file mode 100644 index 0000000..1fe8fdd --- /dev/null +++ b/docs/start_here/audiences.mdx @@ -0,0 +1,286 @@ +# Who is this for? + +IntentCall is **platform infrastructure**: one `AgentRegistry`, many projections. +Most Flutter app authors start in [mcp_flutter](https://github.com/Arenukvern/mcp_flutter) +(the product harness), then depend on IntentCall packages for registry, MCP, +WebMCP, and OS projection. This page routes by **audience**, not by package name. + +```mermaid +flowchart TB + subgraph who["Who are you?"] + A1["In-app / internal\nFlutter host + registry"] + A2["External coding agents\nCursor, Claude, MCP clients"] + A3["Platform / OS-wide\nSiri, Shortcuts, deep links"] + A4["Sibling harness\nmcp_flutter / mcp_toolkit"] + A5["On-device LLM adapter\nintentcall_gemma example"] + end + + subgraph start["Start here"] + MF["mcp_flutter"] + IC["IntentCall this repo"] + end + + A1 --> MF + A1 --> IC + A2 --> MF + A2 --> IC + A3 --> IC + A4 --> MF + A5 --> IC +``` + +## Audience map + +| You are… | Start | Key packages | Prove with | Wrong move | +|---|---|---|---|---| +| **In-app Flutter author** — register intents, bind host, drain envelopes | [mcp_flutter](https://github.com/Arenukvern/mcp_flutter) → then this page §1 | `intentcall_core`, `intentcall_platform` (umbrella), optional `intentcall_codegen` | App runs + host start; web/macOS dogfood in mcp_flutter | Depending on deleted `intentcall_apple` / `intentcall_android` | +| **External coding-agent consumer** — Cursor/Claude call your app over MCP | mcp_flutter MCP attach **or** `intentcall_mcp` | `intentcall_mcp`, `intentcall_session` | MCP contract + live client tools/call | Treating Steward repo probes as app MCP setup | +| **Platform / OS integrator** — App Intents, shortcuts, schemes | [Platform support](/start_here/platform_support) | `intentcall_platform_sync`, `intentcall_cli`, `intentcall_bridge` | `intentcall platform sync --check`; evidence labels | Claiming Siri/Spotlight from artifact tests alone | +| **Harness maintainer / app author using mcp_toolkit** | mcp_flutter README | `mcp_toolkit`, `flutter-mcp-toolkit` CLI → delegates to `intentcall_cli` | `make check-contracts`, dogfood targets | Forking platform contracts inside the harness | +| **On-device LLM / custom surface adapter author** | [Choose your path](/start_here/choose_your_path) + write-adapter skill | `intentcall_gemma` (**example-only**), `intentcall_testing` | `verifyNativeAdapterContract` + adapter-contract benchmark | Shipping Gemma as a production SDK | + +## One registry, four consumer lanes + +```mermaid +flowchart LR + REG["AgentRegistry\ntruth once"] + + REG --> INAPP["In-app\nIntentCallFlutterHost\nWebMCP + deep links + native drain"] + REG --> MCP["External agents\nintentcall_mcp\ntools/call → invoke"] + REG --> OS["OS surfaces\nplatform_sync emitters\nApp Intents / shortcuts / schemes"] + REG --> GEMMA["On-device LLM\nintentcall_gemma\nfunction-calling adapter"] + + INAPP --> USER["End user / page"] + MCP --> IDE["Cursor / Claude / CLI"] + OS --> ASSIST["Siri / Shortcuts / launchers"] + GEMMA --> LOCAL["Local model runtime"] +``` + +Dart handlers stay the source of truth. Adapters publish metadata and route +invocations; they do not redefine business logic. + +--- + +## 1. In-app / internal (Flutter host) + +**Goal:** The running app owns the registry, binds a host, and accepts +invocations from WebMCP, deep links, and native open-app envelopes. + +### Setup + +1. Depend on the **umbrella only** for runtime: + +```yaml +dependencies: + intentcall_core: ^0.6.0 + intentcall_platform: ^0.6.0 # endorses platform_apple + platform_android +dev_dependencies: + intentcall_cli: ^0.6.0 + intentcall_codegen: ^0.6.0 # optional +``` + +2. Add `intentcall.yaml` (required for `host: flutter` / `jaspr` — empty + `platforms.enabled` fails `intentcall config validate`): + +```yaml +host: flutter +protocolScheme: myapp +platforms: + enabled: [android, ios, macos, web] # subset you actually ship +``` + +3. Register tools once (`AgentCallEntry` / optional `@AgentTool`). +4. Bind the host at startup: + +```dart +final host = IntentCallFlutterHost.bindRegistry( + registry: registry, + policy: const IntentCallAuthorizationPolicy( + allowedSources: { + IntentCallInvocationSource.webMcpDart, + IntentCallInvocationSource.nativeGenerated, + IntentCallInvocationSource.deepLink, + }, + ), + registerWebMcp: kIsWeb, + listenForDeepLinks: !kIsWeb, + protocolScheme: 'myapp', +); +await host.start(); +``` + +5. One-time hooks + sync: + +```bash +# via IntentCall CLI +dart run intentcall_cli:intentcall platform hooks init --host flutter +dart run intentcall_cli:intentcall config validate +dart run intentcall_cli:intentcall manifest export --check +dart run intentcall_cli:intentcall platform sync --check + +# or via mcp_flutter wrapper (same spine) +dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart init intentcall-platform \ + --project-dir . +dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart codegen sync \ + --platform web,ios,macos --project-dir . +``` + +**Federated note:** apps do **not** depend on `intentcall_platform_apple` / +`intentcall_platform_android` directly unless overriding endorsed packages. +Projection stays in `intentcall_platform_sync` (pulled transitively / via CLI). + +Deep checklist: [DX FAQ — Flutter in-app host](/DX_FAQ#flutter-in-app-host). + +--- + +## 2. External coding agents (MCP clients) + +**Goal:** An IDE or CLI agent discovers tools and calls `registry.invoke` through +MCP. The agent never imports IntentCall packages — it speaks MCP. + +```mermaid +sequenceDiagram + participant Agent as Cursor / Claude / MCP client + participant MCP as intentcall_mcp or mcp_flutter attach + participant Reg as AgentRegistry + participant Handler as Dart handler + + Agent->>MCP: tools/list / tools/call + MCP->>Reg: invoke(qualifiedName, args) + Reg->>Handler: business logic + Handler-->>Reg: AgentResult + Reg-->>MCP: AgentResult + MCP-->>Agent: MCP tool result +``` + +### Two happy paths + +| Path | When | How | +|---|---|---| +| **A — Flutter app + mcp_flutter** | App is running; agent attaches to VM / toolkit | Use mcp_flutter MCP server + `mcp_toolkit` bootstrap; registry lives in the app | +| **B — Headless / library host** | No Flutter UI | Attach `McpPublishAdapter` (`intentcall_mcp`) to a registry; serve over your MCP transport | + +**Not this lane:** Skill Steward `steward probe` / repo actions — those govern +*this repository*, they do not expose your product app to Cursor. + +Prove with: `verifyNativeAdapterContract` + live client `tools/call`. See +[Choose your path — Publish MCP](/start_here/choose_your_path) and +[DX FAQ — Writing a new adapter](/DX_FAQ#writing-a-new-adapter). + +--- + +## 3. Platform / OS-wide agents + +**Goal:** OS assistants and launchers see projected metadata (App Intents, +shortcuts, protocol handlers) and route back into the app. + +Read [Platform support](/start_here/platform_support) **before** claiming live +Siri/Shortcuts/Spotlight behavior. Artifact + sync proof ≠ live OS proof. + +| Surface | Package / tool | Typical proof | +|---|---|---| +| Apple App Intents / Shortcuts | `intentcall_platform_sync` emitters + `intentcall_platform_apple` | `platform sync --check`; AppIntentsTesting / signed app for runtime | +| Android shortcuts / deep links | emitters + `intentcall_platform_android` | Manifest/XML drift checks | +| Web / PWA / WebMCP | emitters + `intentcall_webmcp` / host `registerWebMcp` | Emitter tests + browser verify | +| Windows / Linux protocol | emitters | Artifact `--check` only | + +Subset contract: `platforms.enabled` scopes projection defaults and hook +targets. Federation scopes per-target native compile. Config does **not** remove +transitive pub deps. + +--- + +## 4. Sibling harness — mcp_flutter + +**Goal:** Product DX for Flutter authors (CLI, VM discovery, inspector, dogfood +app). IntentCall owns contracts; mcp_flutter consumes them. + +```mermaid +flowchart LR + subgraph harness["mcp_flutter"] + TK["mcp_toolkit"] + FCLI["flutter-mcp-toolkit"] + APP["flutter_test_app"] + end + + subgraph platform["IntentCall"] + CORE["intentcall_core"] + UMB["intentcall_platform"] + SYNC["intentcall_platform_sync"] + CLI["intentcall_cli"] + end + + TK --> CORE + TK --> UMB + FCLI --> CLI + CLI --> SYNC + APP --> TK + APP --> UMB +``` + +### Maintainer vs external author + +| Role | Dependency style | Gate | +|---|---|---| +| **mcp_flutter maintainers** (sibling dogfood) | Path deps to `../agentkit/packages/intentcall_*` | `make check-contracts`, `make dogfood-eval-static` | +| **External app authors** | Hosted `intentcall_*: ^0.6.0` from pub.dev | App tests + `intentcall platform sync --check` | + +### Dogfood proof in `flutter_test_app` (sibling checkout) + +Clone as siblings: `mcp_flutter` next to `agentkit` (`INTENTCALL_ROOT=../agentkit`). + +| Platform | What is proven today | How | +|---|---|---| +| **Web** | Strong — WebMCP JS + host bind + verify | `make web-showcase`, `flutter-mcp-toolkit webmcp verify` | +| **macOS** | Runtime — generated Swift + host drain + validate | `make showcase`, `make macos-validate-runtime` | +| **iOS** | Scaffold + codegen drift (same Apple emitters as macOS) | Xcode hooks + `codegen sync --check`; **live** App Intents needs signed `xcodebuild test` / AppIntentsTesting | +| Android / Linux / Windows | Codegen / protocol artifacts | `codegen sync --check` | + +**Claim ceiling for “migration complete”:** sibling path deps resolve the +federated umbrella; web + macOS dogfood targets pass; iOS shares Apple +projection with macOS and passes sync `--check`. That is **not** a claim of +App Store discovery or unsigned-device Siri UX. + +Migration checklist for mcp_flutter: + +1. Depend on `intentcall_platform` (not deleted `intentcall_apple` / `_android`). +2. Add `intentcall.yaml` with explicit `platforms.enabled` (test app should gain this). +3. `init intentcall-platform` + `codegen sync` for web,ios,macos (and others you ship). +4. Keep `IntentCallFlutterHost.bindRegistry` in app bootstrap. +5. Run `make check-contracts` and platform dogfood targets above. + +Consumer guide in the harness repo: +[mcp_flutter/docs/intentcall/README.md](https://github.com/Arenukvern/mcp_flutter/blob/main/docs/intentcall/README.md). + +--- + +## 5. Gemma and other surface adapters + +**`intentcall_gemma`** is an **example-only** on-device function-calling adapter +(`publish_to: none`). Pattern: thin surface over `AgentRegistry` — same contract +as MCP, different transport. + +Use it to learn adapter shape; do **not** treat it as a production LLM SDK. +Ship new surfaces with `intentcall_testing` + +`steward benchmark --scenario intentcall.adapter-contract --json`. + +Skill: [write-adapter](https://github.com/Arenukvern/intentcall/tree/main/skills/write-adapter). + +--- + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Artifact green ⇒ Siri/Shortcuts work | Read evidence labels on [Platform support](/start_here/platform_support) | +| Steward probe = app MCP for Cursor | Use mcp_flutter attach or `intentcall_mcp` | +| Depend on `intentcall_apple` / `_android` | Deleted; use `intentcall_platform` + `platform_sync` | +| Empty `platforms.enabled` on flutter/jaspr | `intentcall config validate` (exit 65) | +| Implement harness features in IntentCall | Keep discovery/inspector in mcp_flutter | + +## Next + +- Architecture → [How it works](/start_here/how_it_works) +- Packages by task → [Choose your path](/start_here/choose_your_path) +- Evidence / non-claims → [Platform support](/start_here/platform_support) +- Procedures → [DX FAQ](/DX_FAQ) diff --git a/docs/start_here/choose_your_path.mdx b/docs/start_here/choose_your_path.mdx index 1a6dfaa..0cafd71 100644 --- a/docs/start_here/choose_your_path.mdx +++ b/docs/start_here/choose_your_path.mdx @@ -2,15 +2,19 @@ Use this page when you know what you want to build, but not which IntentCall package or validation lane owns it. +**Prefer audience routing first?** See [Who is this for?](/start_here/audiences) (in-app, external MCP agents, OS surfaces, mcp_flutter, Gemma). + | Goal | Packages/imports | First move | Validate with | Next doc | |---|---|---|---|---| | Pure Dart callable registry | `intentcall_core`, `intentcall_schema` | Create `InMemoryAgentRegistry`, register `AgentCallEntry.tool(...)`, invoke by qualified name. | `dart test ` and `steward probe --json --profile quick` in this repo when changing IntentCall itself. | [How it works](/start_here/how_it_works) | -| Publish MCP tools/resources | `intentcall_mcp`, plus `intentcall_core` and `intentcall_schema` | Read registry entries, publish existing entries, listen to registry events, route calls back to `registry.invoke(...)`. | Add or extend `verifyNativeAdapterContract(...)`, then run `steward benchmark --scenario intentcall.adapter-contract --json`. | [DX FAQ](/DX_FAQ#writing-a-new-adapter) | -| Browser WebMCP registration | `intentcall_webmcp`, `intentcall_platform` when using the Dart-first bootstrap | Register WebMCP tools from Dart and treat older `navigator.modelContext` behavior as compatibility only. | WebMCP emitter/bootstrap tests plus `steward probe --json --profile quick`; live browser-host interoperability needs separate proof. | [Platform support](/start_here/platform_support) | +| Flutter in-app host (WebMCP, deep links, native drain) | `intentcall_platform`, `intentcall_core` | Add `intentcall.yaml` with `platforms.enabled`; `IntentCallFlutterHost.bindRegistry(...)`; hooks init + platform sync. | `intentcall config validate`; app start; mcp_flutter web/macOS dogfood when using the harness. | [Audiences §1](/start_here/audiences#1-in-app--internal-flutter-host) | +| Publish MCP tools for Cursor / Claude / CLI agents | `intentcall_mcp`, plus `intentcall_core` and `intentcall_schema` | Attach `McpPublishAdapter` (or use mcp_flutter attach to a running app). | `verifyNativeAdapterContract(...)`, then live client `tools/call`. | [Audiences §2](/start_here/audiences#2-external-coding-agents-mcp-clients) | +| Browser WebMCP registration | `intentcall_webmcp`, `intentcall_platform` when using the Dart-first bootstrap | Register WebMCP tools from Dart; treat older `navigator.modelContext` as compatibility only. | WebMCP emitter/bootstrap tests; `webmcp verify` in mcp_flutter for live page proof. | [Platform support](/start_here/platform_support) | +| OS projection (App Intents, shortcuts, schemes) | `intentcall_platform_sync`, `intentcall_cli`, federated `intentcall_platform` | `manifest export --check` then `platform sync --check` for enabled platforms. | Emitter tests, fixture parity, evidence labels — not live Siri by default. | [Platform support](/start_here/platform_support) | | Runtime sessions for tools or CLIs | `intentcall_session`, `intentcall_core` | Implement `IntentSessionConnector`, compose `IntentSessionManager` and `IntentSessionExecutor`. | Package tests around connector selection, persistence, and executor behavior. | [DX FAQ](/DX_FAQ#runtime-sessions) | -| Native/web artifacts and fallback invoke routes | `intentcall_platform_sync`, `intentcall_cli`, optional `intentcall_platform` (federated Flutter umbrella) | Run `build_runner`, `intentcall manifest export --check`, then `intentcall platform sync --check`. Depend on the umbrella only. | Emitter tests, fixture parity, platform sync check, adapter contract tests. | [Platform support](/start_here/platform_support) | | Optional typed registration helpers | `intentcall_codegen`, `build_runner`, `intentcall_core`, `intentcall_schema` | Annotate stable top-level functions with `@AgentTool`; keep handwritten entries first-class. | Generator fixture tests and normal package tests for generated registrations. | [DX FAQ](/DX_FAQ#codegen-agenttool) | -| Adapter contract proof | `intentcall_testing` | Use `verifyNativeAdapterContract(...)` in adapter tests. | `steward benchmark --scenario intentcall.adapter-contract --json`. | [intentcall_testing README](https://github.com/Arenukvern/intentcall/blob/main/packages/intentcall_testing/README.md) | +| Adapter contract proof / custom surface | `intentcall_testing`; example: `intentcall_gemma` | Use `verifyNativeAdapterContract(...)`; treat Gemma as example-only. | `steward benchmark --scenario intentcall.adapter-contract --json`. | [Audiences §5](/start_here/audiences#5-gemma-and-other-surface-adapters) | +| Flutter product harness (CLI, inspector, dogfood) | Stay in [mcp_flutter](https://github.com/Arenukvern/mcp_flutter) | `mcp_toolkit` + `flutter-mcp-toolkit`; path or hosted IntentCall deps per role. | `make check-contracts`, `make web-showcase`, `make showcase` / `macos-validate-runtime`. | [Audiences §4](/start_here/audiences#4-sibling-harness--mcp_flutter) | ## Library, CLI, Server, and Adapter Paths @@ -20,5 +24,8 @@ IntentCall is the direct platform layer when you are building a library, CLI, se 2. Let adapters publish metadata and translate transport calls. 3. Invoke by the stored registry key from `AgentRegistry.listEntries()`. 4. Keep runtime discovery, product policy, and UI-specific behavior in the host app or tool. +5. Use `intentcall_cli` for framework-neutral manifest export and platform sync. + +Do **not** depend on deleted packages `intentcall_apple` / `intentcall_android`. Runtime = `intentcall_platform`; projection = `intentcall_platform_sync`. -Flutter app authors who want the packaged product harness should start with [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). +Flutter app authors who want the packaged product harness should start with [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). Audience map: [Who is this for?](/start_here/audiences). diff --git a/docs/start_here/docs_map.mdx b/docs/start_here/docs_map.mdx index 5ea0e88..7b6287b 100644 --- a/docs/start_here/docs_map.mdx +++ b/docs/start_here/docs_map.mdx @@ -8,6 +8,7 @@ Index for the IntentCall documentation site and repository. | I want to… | Go to | |------------|--------| +| Know which audience / setup lane I am in | [Who is this for?](/start_here/audiences) | | Understand the moving parts | [How it works](/start_here/how_it_works) | | Pick the right package path | [Choose your path](/start_here/choose_your_path) | | Check platform evidence and non-claims | [Platform support](/start_here/platform_support) | @@ -24,6 +25,7 @@ Index for the IntentCall documentation site and repository. ## Published site (`docs/`) - [Overview](/) +- [Who is this for?](/start_here/audiences) - [How it works](/start_here/how_it_works) - [Choose your path](/start_here/choose_your_path) - [Platform support](/start_here/platform_support) diff --git a/docs/start_here/how_it_works.mdx b/docs/start_here/how_it_works.mdx index c3b1f14..af8ec16 100644 --- a/docs/start_here/how_it_works.mdx +++ b/docs/start_here/how_it_works.mdx @@ -4,6 +4,8 @@ IntentCall gives a Dart or Flutter program one source of truth for agent-callabl The key idea is simple: adapters publish and route calls, but Dart remains the home of application behavior. +**Not sure which lane you are in?** Start with [Who is this for?](/start_here/audiences) (in-app host, external MCP agents, OS surfaces, mcp_flutter, Gemma). + ```mermaid flowchart LR author["App or package author"] --> registry["AgentRegistry\nAgentCallEntry + RegisteredAgentIntent"] @@ -14,16 +16,20 @@ flowchart LR core --> mcp["intentcall_mcp\nMCP tools/resources"] core --> webmcp["intentcall_webmcp\nDart-first WebMCP registration"] core --> platformSync["intentcall_platform_sync\nnative/web emitters + PlatformSync"] - core --> platform["intentcall_platform\nFlutter runtime host / federated umbrella"] + core --> platform["intentcall_platform\nFlutter host + federated apple/android"] + core --> gemma["intentcall_gemma\nexample on-device adapter"] core --> codegen["intentcall_codegen\noptional @AgentTool helpers"] entitySnapshot --> platformSync - mcp --> callers["Agents, CLIs, assistants, app hosts"] - webmcp --> callers - platformSync --> callers - platform --> callers - session --> callers - toolkit["mcp_flutter / mcp_toolkit\nproduct harness for app authors"] -. consumes .-> core - steward["Skill Steward\nrepo governance + validation"] -. verifies .-> registry + mcp --> external["External coding agents\nCursor / Claude / MCP clients"] + webmcp --> inapp["In-app / browser\nWebMCP + Flutter host"] + platform --> inapp + platformSync --> os["OS-wide assistants\nApp Intents / shortcuts / schemes"] + platform --> os + gemma --> local["Local model runtime"] + session --> external + toolkit["mcp_flutter / mcp_toolkit\nproduct harness"] -. consumes .-> core + toolkit -. dogfoods .-> platform + steward["Skill Steward\nrepo governance"] -. verifies .-> registry ``` ## One Minimal Intent @@ -112,7 +118,8 @@ need a signed consuming app or AppIntentsTesting proof where applicable. | Surface | What it is for | |---|---| | IntentCall | Registry, wire contracts, adapters, platform artifact emitters, session primitives, and adapter contract tests. | -| mcp_flutter / mcp_toolkit | Product harness for Flutter app authors: CLI, runtime discovery, Flutter VM integration, inspection, and app-side bootstrap. | -| Skill Steward | Repository governance, declared actions, probes, benchmarks, and agent workflow discipline. | +| mcp_flutter / mcp_toolkit | Product harness for Flutter app authors: CLI, runtime discovery, Flutter VM integration, inspection, and app-side bootstrap. Dogfoods IntentCall via `flutter_test_app` (web + macOS runtime; iOS sync/scaffold). | +| Skill Steward | Repository governance, declared actions, probes, benchmarks, and agent workflow discipline — **not** your product MCP for Cursor. | +| intentcall_gemma | Example-only on-device function-calling adapter (`publish_to: none`). | -For implementation routes, continue to [Choose Your Path](/start_here/choose_your_path). +Audience routing: [Who is this for?](/start_here/audiences). For implementation routes, continue to [Choose Your Path](/start_here/choose_your_path). diff --git a/docs/start_here/platform_support.mdx b/docs/start_here/platform_support.mdx index c1d7ab4..ef37121 100644 --- a/docs/start_here/platform_support.mdx +++ b/docs/start_here/platform_support.mdx @@ -146,3 +146,20 @@ DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer \ ## Claim Rule Use “current” only for behavior covered by repo tests, generated artifacts, documented sync helpers, configuration checks, or local SDK typechecks. Use “experiment” for Apple `dartExtensionInline` scaffolds until a fixture app proves extension target membership, FlutterEngine boot, plugin allowlisting, App Group/shared storage or IPC, timeout/memory limits, and live OS invocation through AppIntentsTesting or Shortcuts. Use “roadmap” or “target” for Android AppFunctions, richer Android App Actions, Windows App Actions / Agent Launchers, AAIF alignment, native background Dart execution, and real OS assistant/launcher discovery. Apple Info.plist URL-scheme sync proves configuration drift only; live Shortcuts/App Intents proof still belongs to a signed consuming app. Generated entity/indexing artifacts and native cache storage prove projection shape only; live Spotlight, Siri, Shortcuts, donation, indexing, and product proof require a signed consuming app or AppIntentsTesting where applicable. + +## mcp_flutter dogfood (sibling consumer) + +The early consumer harness is [mcp_flutter](https://github.com/Arenukvern/mcp_flutter) +(`flutter_test_app`). Clone as a sibling of this repo. Audience and setup: +[Who is this for? §4](/start_here/audiences#4-sibling-harness--mcp_flutter). + +| Platform | Evidence in dogfood today | Command (from mcp_flutter) | +|---|---|---| +| Web | Strong — WebMCP + `IntentCallFlutterHost` | `make web-showcase` (+ `webmcp verify`) | +| macOS | Runtime — generated Swift + host drain | `make showcase`, `make macos-validate-runtime` | +| iOS | Scaffold + sync drift (shared Apple emitters) | `codegen sync --check`; live App Intents needs signed UI tests | +| Contracts | Sibling path-dep + three-gate spine | `make check-contracts`, `make dogfood-eval-static` | + +**Honest “integration works” claim for migration:** federated `intentcall_platform` +resolves; web and macOS dogfood targets pass; iOS shares Apple projection and +passes sync `--check`. That is **not** App Store / unsigned Siri product proof. From e1db853a12fc59605d1a343c9fa906a23bc9efaa Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Thu, 9 Jul 2026 02:32:06 +0300 Subject: [PATCH 18/27] refactor: simplify constructor and improve formatting in IntentCallPendingInvocations class --- .../src/flutter/intentcall_flutter_host.dart | 6 ++++-- .../intentcall_pending_invocations.dart | 21 ++++++++----------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart index f05cf12..dbeeb1f 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart @@ -93,10 +93,12 @@ final class IntentCallFlutterHost { ), takePendingInvocations: takePendingInvocations ?? - const IntentCallPendingInvocations().takePending, + // ignore: prefer_const_constructors + IntentCallPendingInvocations().takePending, takePendingEntityOpens: takePendingEntityOpens ?? - const IntentCallPendingEntityOpens().takePending, + // ignore: prefer_const_constructors + IntentCallPendingEntityOpens().takePending, registerWebMcp: registerWebMcp, webMcpSurfaceIndex: webMcpSurfaceIndex, onEnvelope: onEnvelope, diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart index 21e0ddc..4fc3e70 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart @@ -2,9 +2,8 @@ import 'package:intentcall_bridge/intentcall_bridge.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; final class IntentCallPendingInvocations { - IntentCallPendingInvocations({ - final IntentCallInvocationsHostApi? hostApi, - }) : _hostApi = hostApi ?? IntentCallInvocationsHostApi(); + IntentCallPendingInvocations({final IntentCallInvocationsHostApi? hostApi}) + : _hostApi = hostApi ?? IntentCallInvocationsHostApi(); final IntentCallInvocationsHostApi _hostApi; @@ -16,12 +15,10 @@ final class IntentCallPendingInvocations { IntentCallInvocationEnvelope _toEnvelope( final IntentCallInvocationEnvelopeDto dto, -) { - return IntentCallInvocationEnvelope( - id: dto.id, - qualifiedName: dto.qualifiedName, - arguments: Map.from(dto.arguments ?? const {}), - source: dto.source, - createdAt: DateTime.tryParse(dto.createdAt), - ); -} +) => IntentCallInvocationEnvelope( + id: dto.id, + qualifiedName: dto.qualifiedName, + arguments: Map.from(dto.arguments ?? const {}), + source: dto.source, + createdAt: DateTime.tryParse(dto.createdAt), +); From 9f222422bebf7fa3b01f80741ce0961a130fd278 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Thu, 9 Jul 2026 04:25:57 +0300 Subject: [PATCH 19/27] feat: add apple-runner-compile-check command and update Swift imports for intentcall_platform_apple in various test files --- justfile | 6 ++ .../apple_swift_app_intents_emitter.dart | 5 +- .../test/apple_surface_matrix_test.dart | 5 ++ .../test/mcp_flutter_apple_sync_test.dart | 1 + .../test/native_emitters_test.dart | 6 +- .../test/projection_alignment_test.dart | 2 + tool/intentcall/check_apple_runner_compile.sh | 62 +++++++++++++++++++ 7 files changed, 83 insertions(+), 4 deletions(-) create mode 100755 tool/intentcall/check_apple_runner_compile.sh diff --git a/justfile b/justfile index 03d7096..7a17e17 100644 --- a/justfile +++ b/justfile @@ -62,6 +62,12 @@ platform-sync-check: mcp-flutter-apple-sync-check: dart test packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart +# Compile-proof: Runner Generated Swift builds against intentcall_platform_apple. +# Requires sibling mcp_flutter, Flutter, and Xcode — skips gracefully when absent +# (CI on ubuntu has no Xcode/mcp_flutter). Not wired into projection-pipeline-check. +apple-runner-compile-check: + bash tool/intentcall/check_apple_runner_compile.sh + # Manifest/registry parity gate manifest-parity: dart test packages/intentcall_cli/test/manifest_registry_parity_test.dart diff --git a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart index f74a300..88698be 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -32,7 +32,8 @@ final class AppleSwiftAppIntentsEmitter { final buffer = StringBuffer() ..writeln('// Generated by intentcall_platform — do not edit by hand.') ..writeln('import AppIntents') - ..writeln('import Foundation'); + ..writeln('import Foundation') + ..writeln('import intentcall_platform_apple'); if (emitSpotlight) { buffer.writeln('import CoreSpotlight'); } @@ -182,7 +183,7 @@ final class AppleSwiftAppIntentsEmitter { if (intentTools.isNotEmpty) { buffer ..writeln( - '// IntentCallNativeHandoffStore is provided by the intentcall_platform plugin.', + '// IntentCallNativeHandoffStore is provided by the intentcall_platform_apple federated plugin.', ) ..writeln('enum IntentCallNativeBridge {') ..writeln( diff --git a/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart index fd54f90..4a6ed61 100644 --- a/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart +++ b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart @@ -46,6 +46,7 @@ void main() { _appleManifest(toolSurfaces: _appleAppIntentsOnly), ); + expect(swift, contains('import intentcall_platform_apple')); expect(swift, contains('struct AppPingIntent: AppIntent')); expect(swift, isNot(contains('AppShortcut(intent: AppPingIntent()'))); expect(swift, contains('static var appShortcuts: [AppShortcut] {')); @@ -57,6 +58,7 @@ void main() { _appleManifest(toolSurfaces: _appleShortcutsOnly), ); + expect(swift, contains('import intentcall_platform_apple')); expect(swift, isNot(contains('struct AppPingIntent: AppIntent'))); expect(swift, isNot(contains('AppShortcut(intent: AppPingIntent()'))); expect(swift, contains('return []')); @@ -72,6 +74,7 @@ void main() { ), ); + expect(swift, contains('import intentcall_platform_apple')); expect(swift, contains('struct AppPingIntent: AppIntent')); expect(swift, contains('AppShortcut(intent: AppPingIntent(), phrases:')); }); @@ -94,6 +97,7 @@ void main() { ), ); + expect(swift, contains('import intentcall_platform_apple')); expect(swift, contains('struct AppProjectEntity: AppEntity {')); expect(swift, isNot(contains('IndexedEntity'))); expect(swift, isNot(contains('import CoreSpotlight'))); @@ -119,6 +123,7 @@ void main() { ), ); + expect(swift, contains('import intentcall_platform_apple')); expect( swift, contains('struct AppProjectEntity: AppEntity, IndexedEntity'), diff --git a/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart b/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart index deec124..80a70f5 100644 --- a/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart +++ b/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart @@ -58,6 +58,7 @@ void main() { final swift = File(swiftPath); expect(swift.existsSync(), isTrue, reason: swiftPath); final source = swift.readAsStringSync(); + expect(source, contains('import intentcall_platform_apple')); expect(source, contains('struct AppSetGreetingIntent: AppIntent')); expect( source, diff --git a/packages/intentcall_platform_sync/test/native_emitters_test.dart b/packages/intentcall_platform_sync/test/native_emitters_test.dart index d274bbc..f892786 100644 --- a/packages/intentcall_platform_sync/test/native_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/native_emitters_test.dart @@ -108,12 +108,13 @@ void main() { expect(swift, contains('var includeTax: Bool?')); expect(swift, contains('arguments["currency"] = currency')); expect(swift, contains('IntentCallShortcutsProvider')); + expect(swift, contains('import intentcall_platform_apple')); expect(swift, contains('IntentCallNativeBridge')); expect(swift, contains('IntentCallNativeHandoffStore.append(item)')); expect( swift, contains( - 'IntentCallNativeHandoffStore is provided by the intentcall_platform plugin', + 'IntentCallNativeHandoffStore is provided by the intentcall_platform_apple federated plugin', ), ); expect(swift, isNot(contains('enum IntentCallNativeHandoffStore {'))); @@ -1139,6 +1140,7 @@ const _goldenAppleSwift = r''' // Generated by intentcall_platform — do not edit by hand. import AppIntents import Foundation +import intentcall_platform_apple #if canImport(UIKit) import UIKit #elseif canImport(AppKit) @@ -1176,7 +1178,7 @@ struct IntentCallShortcutsProvider: AppShortcutsProvider { } } -// IntentCallNativeHandoffStore is provided by the intentcall_platform plugin. +// IntentCallNativeHandoffStore is provided by the intentcall_platform_apple federated plugin. enum IntentCallNativeBridge { private static let fallbackScheme: String? = "demoapp" diff --git a/packages/intentcall_platform_sync/test/projection_alignment_test.dart b/packages/intentcall_platform_sync/test/projection_alignment_test.dart index 4a5f095..36d55ec 100644 --- a/packages/intentcall_platform_sync/test/projection_alignment_test.dart +++ b/packages/intentcall_platform_sync/test/projection_alignment_test.dart @@ -278,6 +278,7 @@ layout: }), ); + expect(swift, contains('import intentcall_platform_apple')); expect(swift, contains('struct AppPingIntent: AppIntent')); expect(swift, isNot(contains('AppShortcut(intent: AppPingIntent()'))); }); @@ -338,6 +339,7 @@ layout: }), ); + expect(swift, contains('import intentcall_platform_apple')); expect(swift, contains('enum IntentCallGeneratedEntityConfig')); expect( swift, diff --git a/tool/intentcall/check_apple_runner_compile.sh b/tool/intentcall/check_apple_runner_compile.sh new file mode 100755 index 0000000..cdff4ba --- /dev/null +++ b/tool/intentcall/check_apple_runner_compile.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Compile-proof gate: Runner Generated Swift must build against intentcall_platform_apple. +# Requires sibling mcp_flutter/flutter_test_app, Flutter, and a full Xcode toolchain. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGENTKIT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +resolve_test_app_root() { + local env_root="${MCP_FLUTTER_ROOT:-}" + if [[ -n "$env_root" ]]; then + local candidate + candidate="$(cd "$env_root/flutter_test_app" 2>/dev/null && pwd || true)" + if [[ -n "$candidate" && -f "$candidate/web/agent_manifest.json" ]]; then + echo "$candidate" + return 0 + fi + fi + + local sibling + sibling="$(cd "$AGENTKIT_ROOT/../mcp_flutter" 2>/dev/null && pwd || true)" + if [[ -n "$sibling" ]]; then + local candidate + candidate="$sibling/flutter_test_app" + if [[ -f "$candidate/web/agent_manifest.json" ]]; then + echo "$(cd "$candidate" && pwd)" + return 0 + fi + fi + + return 1 +} + +skip() { + echo "SKIP: $*" + exit 0 +} + +if ! TEST_APP_ROOT="$(resolve_test_app_root)"; then + skip "mcp_flutter sibling not found — clone ../mcp_flutter or set MCP_FLUTTER_ROOT" +fi + +if ! command -v flutter >/dev/null 2>&1; then + skip "flutter not found in PATH" +fi + +if ! command -v xcodebuild >/dev/null 2>&1 || ! xcodebuild -version >/dev/null 2>&1; then + skip "Xcode toolchain not available" +fi + +echo "Apple runner compile check: $TEST_APP_ROOT" + +cd "$AGENTKIT_ROOT" +dart run intentcall_cli:intentcall platform sync \ + --project-dir "$TEST_APP_ROOT" \ + --platform ios,macos + +cd "$TEST_APP_ROOT" +flutter pub get +flutter build macos --config-only + +echo "OK: Runner Swift compiles against intentcall_platform_apple (macos --config-only)" From 2c8464aee49c526d5770a286b0d93c0f4eb1d269 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Thu, 9 Jul 2026 04:29:52 +0300 Subject: [PATCH 20/27] feat: implement IntentCallNativeBridge for native handoff and update enqueue method to include fallback protocol scheme --- justfile | 6 +- .../IntentCallNativeBridge.swift | 44 +++++++++++++ .../apple_swift_app_intents_emitter.dart | 47 ++------------ .../test/mcp_flutter_apple_sync_test.dart | 1 + .../test/native_emitters_test.dart | 58 ++++------------- tool/intentcall/check_apple_runner_compile.sh | 62 ------------------- 6 files changed, 63 insertions(+), 155 deletions(-) create mode 100644 packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeBridge.swift delete mode 100755 tool/intentcall/check_apple_runner_compile.sh diff --git a/justfile b/justfile index 7a17e17..9bc0290 100644 --- a/justfile +++ b/justfile @@ -63,10 +63,10 @@ mcp-flutter-apple-sync-check: dart test packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart # Compile-proof: Runner Generated Swift builds against intentcall_platform_apple. -# Requires sibling mcp_flutter, Flutter, and Xcode — skips gracefully when absent -# (CI on ubuntu has no Xcode/mcp_flutter). Not wired into projection-pipeline-check. +# Canonical script lives in mcp_flutter (dogfood consumer). Requires sibling +# mcp_flutter, Flutter, and Xcode — skips gracefully when absent. apple-runner-compile-check: - bash tool/intentcall/check_apple_runner_compile.sh + bash ../mcp_flutter/tool/contracts/check_apple_runner_compile.sh # Manifest/registry parity gate manifest-parity: diff --git a/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeBridge.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeBridge.swift new file mode 100644 index 0000000..61ff816 --- /dev/null +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeBridge.swift @@ -0,0 +1,44 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Native handoff facade for generated App Intents in the consuming app Runner target. +/// +/// Generated `IntentCallGenerated.swift` imports `intentcall_platform_apple` and +/// calls [enqueue] instead of duplicating queue + deep-link logic per app. +public enum IntentCallNativeBridge { + public static func enqueue( + qualifiedName: String, + arguments: [String: Any], + openApp: Bool, + fallbackProtocolScheme: String? = nil + ) async -> String { + let invocationId = UUID().uuidString + let item: [String: Any] = [ + "id": invocationId, + "qualifiedName": qualifiedName, + "arguments": arguments, + "source": "native.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + IntentCallNativeHandoffStore.append(item) + var allowedPath = CharacterSet.alphanumerics + allowedPath.insert(charactersIn: "_-.~") + let encodedName = + qualifiedName.addingPercentEncoding(withAllowedCharacters: allowedPath) + ?? qualifiedName + guard openApp, + let scheme = fallbackProtocolScheme, + let url = URL(string: "\(scheme)://invoke/\(encodedName)") + else { return invocationId } + #if canImport(UIKit) + await UIApplication.shared.open(url) + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + return invocationId + } +} diff --git a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart index 88698be..8f8c046 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -88,9 +88,12 @@ final class AppleSwiftAppIntentsEmitter { buffer.writeln(' $line'); } if (inlineRuntime == null) { + final schemeArgument = opensApp && bridgeProtocolScheme != null + ? ', fallbackProtocolScheme: ${_swiftOptionalString(bridgeProtocolScheme)}' + : ''; buffer ..writeln( - ' let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "${escapeSwiftString(tool.qualifiedName)}", arguments: arguments, openApp: $opensApp)', + ' let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "${escapeSwiftString(tool.qualifiedName)}", arguments: arguments, openApp: $opensApp$schemeArgument)', ) ..writeln( r' return .result(dialog: IntentDialog("Queued invocation \(invocationId) for app dispatch."))', @@ -180,48 +183,6 @@ final class AppleSwiftAppIntentsEmitter { ), ); } - if (intentTools.isNotEmpty) { - buffer - ..writeln( - '// IntentCallNativeHandoffStore is provided by the intentcall_platform_apple federated plugin.', - ) - ..writeln('enum IntentCallNativeBridge {') - ..writeln( - ' private static let fallbackScheme: String? = ${_swiftOptionalString(bridgeProtocolScheme)}', - ) - ..writeln() - ..writeln( - ' static func enqueue(qualifiedName: String, arguments: [String: Any], openApp: Bool) async -> String {', - ) - ..writeln(' let invocationId = UUID().uuidString') - ..writeln(' let item: [String: Any] = [') - ..writeln(' "id": invocationId,') - ..writeln(' "qualifiedName": qualifiedName,') - ..writeln(' "arguments": arguments,') - ..writeln(' "source": "native.generated",') - ..writeln( - ' "createdAt": ISO8601DateFormatter().string(from: Date())', - ) - ..writeln(' ]') - ..writeln(' IntentCallNativeHandoffStore.append(item)') - ..writeln(' var allowedPath = CharacterSet.alphanumerics') - ..writeln(' allowedPath.insert(charactersIn: "_-.~")') - ..writeln( - ' let encodedName = qualifiedName.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? qualifiedName', - ) - ..writeln( - r' guard openApp, let scheme = fallbackScheme, let url = URL(string: "\(scheme)://invoke/\(encodedName)") else { return invocationId }', - ) - ..writeln(' #if canImport(UIKit)') - ..writeln(' await UIApplication.shared.open(url)') - ..writeln(' #elseif canImport(AppKit)') - ..writeln(' NSWorkspace.shared.open(url)') - ..writeln(' #endif') - ..writeln(' return invocationId') - ..writeln(' }') - ..writeln('}'); - } - return buffer.toString(); } } diff --git a/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart b/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart index 80a70f5..daa672d 100644 --- a/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart +++ b/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart @@ -59,6 +59,7 @@ void main() { expect(swift.existsSync(), isTrue, reason: swiftPath); final source = swift.readAsStringSync(); expect(source, contains('import intentcall_platform_apple')); + expect(source, isNot(contains('enum IntentCallNativeBridge {'))); expect(source, contains('struct AppSetGreetingIntent: AppIntent')); expect( source, diff --git a/packages/intentcall_platform_sync/test/native_emitters_test.dart b/packages/intentcall_platform_sync/test/native_emitters_test.dart index f892786..353db0f 100644 --- a/packages/intentcall_platform_sync/test/native_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/native_emitters_test.dart @@ -110,12 +110,10 @@ void main() { expect(swift, contains('IntentCallShortcutsProvider')); expect(swift, contains('import intentcall_platform_apple')); expect(swift, contains('IntentCallNativeBridge')); - expect(swift, contains('IntentCallNativeHandoffStore.append(item)')); + expect(swift, isNot(contains('enum IntentCallNativeBridge {'))); expect( swift, - contains( - 'IntentCallNativeHandoffStore is provided by the intentcall_platform_apple federated plugin', - ), + isNot(contains('IntentCallNativeHandoffStore.append(item)')), ); expect(swift, isNot(contains('enum IntentCallNativeHandoffStore {'))); expect( @@ -128,14 +126,10 @@ void main() { expect( swift, contains( - 'IntentCallNativeBridge.enqueue(qualifiedName: "app_cart_total", arguments: arguments, openApp: true)', + 'IntentCallNativeBridge.enqueue(qualifiedName: "app_cart_total", arguments: arguments, openApp: true, fallbackProtocolScheme: "demoapp")', ), ); expect(swift, contains('IntentDialog("Queued invocation')); - expect( - swift, - contains('private static let fallbackScheme: String? = "demoapp"'), - ); expect(swift, contains('demoapp')); }); @@ -246,8 +240,11 @@ void main() { expect(swift, contains('static var openAppWhenRun: Bool = true')); expect( swift, - contains('private static let fallbackScheme: String? = nil'), + contains( + 'IntentCallNativeBridge.enqueue(qualifiedName: "app_ping", arguments: arguments, openApp: true)', + ), ); + expect(swift, isNot(contains('fallbackProtocolScheme:'))); expect(swift, isNot(contains('demoapp://invoke/'))); }); @@ -284,11 +281,8 @@ void main() { 'IntentCallNativeBridge.enqueue(qualifiedName: "app_ping", arguments: arguments, openApp: false)', ), ); - expect( - swift, - contains('private static let fallbackScheme: String? = nil'), - ); - expect(swift, contains('guard openApp, let scheme = fallbackScheme')); + expect(swift, isNot(contains('fallbackProtocolScheme:'))); + expect(swift, isNot(contains('enum IntentCallNativeBridge {'))); }); test('nativeInline emits handler invocation without app wake', () { @@ -344,10 +338,7 @@ void main() { 'return .result(dialog: IntentDialog(stringLiteral: inlineResult.dialog))', ), ); - expect( - swift, - contains('private static let fallbackScheme: String? = nil'), - ); + expect(swift, isNot(contains('enum IntentCallNativeBridge {'))); expect( swift, isNot(contains('app_inline", arguments: arguments, openApp')), @@ -1166,7 +1157,7 @@ struct AppCartTotalIntent: AppIntent { var arguments: [String: Any] = [:] arguments["currency"] = currency if let value = includeTax { arguments["includeTax"] = value } - let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "app_cart_total", arguments: arguments, openApp: true) + let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "app_cart_total", arguments: arguments, openApp: true, fallbackProtocolScheme: "demoapp") return .result(dialog: IntentDialog("Queued invocation \(invocationId) for app dispatch.")) } } @@ -1176,33 +1167,6 @@ struct IntentCallShortcutsProvider: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut(intent: AppCartTotalIntent(), phrases: ["\(.applicationName) Cart Total"]) } -} - -// IntentCallNativeHandoffStore is provided by the intentcall_platform_apple federated plugin. -enum IntentCallNativeBridge { - private static let fallbackScheme: String? = "demoapp" - - static func enqueue(qualifiedName: String, arguments: [String: Any], openApp: Bool) async -> String { - let invocationId = UUID().uuidString - let item: [String: Any] = [ - "id": invocationId, - "qualifiedName": qualifiedName, - "arguments": arguments, - "source": "native.generated", - "createdAt": ISO8601DateFormatter().string(from: Date()) - ] - IntentCallNativeHandoffStore.append(item) - var allowedPath = CharacterSet.alphanumerics - allowedPath.insert(charactersIn: "_-.~") - let encodedName = qualifiedName.addingPercentEncoding(withAllowedCharacters: allowedPath) ?? qualifiedName - guard openApp, let scheme = fallbackScheme, let url = URL(string: "\(scheme)://invoke/\(encodedName)") else { return invocationId } - #if canImport(UIKit) - await UIApplication.shared.open(url) - #elseif canImport(AppKit) - NSWorkspace.shared.open(url) - #endif - return invocationId - } }'''; const _goldenLinuxDesktop = ''' diff --git a/tool/intentcall/check_apple_runner_compile.sh b/tool/intentcall/check_apple_runner_compile.sh deleted file mode 100755 index cdff4ba..0000000 --- a/tool/intentcall/check_apple_runner_compile.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Compile-proof gate: Runner Generated Swift must build against intentcall_platform_apple. -# Requires sibling mcp_flutter/flutter_test_app, Flutter, and a full Xcode toolchain. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -AGENTKIT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -resolve_test_app_root() { - local env_root="${MCP_FLUTTER_ROOT:-}" - if [[ -n "$env_root" ]]; then - local candidate - candidate="$(cd "$env_root/flutter_test_app" 2>/dev/null && pwd || true)" - if [[ -n "$candidate" && -f "$candidate/web/agent_manifest.json" ]]; then - echo "$candidate" - return 0 - fi - fi - - local sibling - sibling="$(cd "$AGENTKIT_ROOT/../mcp_flutter" 2>/dev/null && pwd || true)" - if [[ -n "$sibling" ]]; then - local candidate - candidate="$sibling/flutter_test_app" - if [[ -f "$candidate/web/agent_manifest.json" ]]; then - echo "$(cd "$candidate" && pwd)" - return 0 - fi - fi - - return 1 -} - -skip() { - echo "SKIP: $*" - exit 0 -} - -if ! TEST_APP_ROOT="$(resolve_test_app_root)"; then - skip "mcp_flutter sibling not found — clone ../mcp_flutter or set MCP_FLUTTER_ROOT" -fi - -if ! command -v flutter >/dev/null 2>&1; then - skip "flutter not found in PATH" -fi - -if ! command -v xcodebuild >/dev/null 2>&1 || ! xcodebuild -version >/dev/null 2>&1; then - skip "Xcode toolchain not available" -fi - -echo "Apple runner compile check: $TEST_APP_ROOT" - -cd "$AGENTKIT_ROOT" -dart run intentcall_cli:intentcall platform sync \ - --project-dir "$TEST_APP_ROOT" \ - --platform ios,macos - -cd "$TEST_APP_ROOT" -flutter pub get -flutter build macos --config-only - -echo "OK: Runner Swift compiles against intentcall_platform_apple (macos --config-only)" From 5bd11e1dcaf80bdf826e69770f444fc311f1a858 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Thu, 9 Jul 2026 04:36:05 +0300 Subject: [PATCH 21/27] docs: update documentation to include details on Apple compile checks and clarify intentcall_platform_apple integration across various files --- AGENTS.md | 1 + CONTRIBUTING.md | 9 +++++++++ docs/DESIGN_FAQ.mdx | 5 ++++- docs/DX_FAQ.mdx | 3 +++ .../0025-platform-subset-federated-plugins.md | 5 ++++- docs/start_here/audiences.mdx | 9 ++++++--- docs/start_here/platform_support.mdx | 15 +++++++++------ 7 files changed, 36 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f01badc..d5d8824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,3 +62,4 @@ When writing code, documentation, or planning features: 3. Run `steward probe --json --profile quick` for the safe first pass. 4. Run `steward benchmark --scenario intentcall.adapter-contract --json` for the first dogfood loop. 5. The repository uses standardized agent skills under `.agents/skills/` and distributable skills under `skills/`; skills remain installed separately from hook/plugin wiring. +6. Apple generated Swift compile proof runs in the mcp_flutter dogfood repo (`tool/contracts/check_apple_runner_compile.sh`, wired into `make check-contracts`). From agentkit with a sibling checkout: `just apple-runner-compile-check`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3adc20..4d4983c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,15 @@ pnpm install # once just docs-check ``` +If you changed Apple projection (`intentcall_platform_sync` emitters or +`intentcall_platform_apple` Swift facades), also run the dogfood compile gate +with sibling [mcp_flutter](https://github.com/Arenukvern/mcp_flutter) checked +out next to this repo: + +```bash +just apple-runner-compile-check # delegates to mcp_flutter/tool/contracts/check_apple_runner_compile.sh +``` + Agent/operator preflight starts with the declared Steward surface: ```bash diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index 928c878..deaad26 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -63,7 +63,10 @@ A: `AgentRegistry`, `AgentCallEntry`, and `RegisteredAgentIntent` are the curren A: They are a projection layer over the existing source concepts. Actions remain registry entries. Typed entities are app-owned Dart snapshots with stable ids, display fields, and search/index fields. Indexing or donation is a lifecycle that copies those snapshots into a platform projection cache. Keeping this additive avoids turning `intentcall_core` into an Apple-shaped runtime while still letting Apple be the first concrete typed-entity/indexing projection. See [ADR 0018](decisions/0018-additive-actions-typed-entities-indexing-lifecycle.md). **Q: Why were `intentcall_apple` / `intentcall_android` removed, and what replaced them?** -A: Those packages were sparse-manifest generators. They were **deleted** (zero consumers, pre-1.0). Canonical projection is `intentcall_platform_sync` (`PlatformSync` / `ManifestExporter`). Runtime native code lives in federated Flutter plugins `intentcall_platform_apple` / `intentcall_platform_android` under the `intentcall_platform` umbrella ([ADR 0025](decisions/0025-platform-subset-federated-plugins.md)). `intentcall_gemma` remains a separate surface adapter. +A: Those packages were sparse-manifest generators. They were **deleted** (zero consumers, pre-1.0). Canonical projection is `intentcall_platform_sync` (`PlatformSync` / `ManifestExporter`). Runtime native code lives in federated Flutter plugins `intentcall_platform_apple` / `intentcall_platform_android` under the `intentcall_platform` umbrella ([ADR 0025](decisions/0025-platform-subset-federated-plugins.md)). On Apple, generated `AppIntent` structs compile in the app `Runner` target but import `intentcall_platform_apple` for handoff (`IntentCallNativeBridge.enqueue`) and snapshot stores — do not duplicate that logic in emitted Swift. `intentcall_gemma` remains a separate surface adapter. + +**Q: Why are there two `IntentCallNativeBridge` types?** +A: Different languages, same handoff story. Dart `IntentCallNativeBridge.bindRegistry(...)` in `intentcall_platform_sync` authorizes envelopes into the registry after the app wakes. Swift `IntentCallNativeBridge.enqueue(...)` in `intentcall_platform_apple` queues native App Intent invocations and optionally opens the app-owned `protocolScheme` URL. Generated Runner Swift calls the Swift facade only. ## Runtime sessions diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index eee5996..3d936eb 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -562,6 +562,9 @@ For iOS and macOS open-app App Intents, the readiness checklist is: and curated `"surfaces": { "apple.appShortcuts": { "include": true } }`. 3. `intentcall platform sync --platform ios,macos --check` passes, proving generated Swift, Runner target membership, and `Runner/Info.plist` `CFBundleURLTypes` drift. + For module visibility (generated Runner code vs federated plugin), also run + the mcp_flutter compile gate: `bash tool/contracts/check_apple_runner_compile.sh` + (or `just apple-runner-compile-check` from agentkit when `../mcp_flutter` exists). 4. The app is signed and installed before claiming manual Shortcuts or Spotlight product proof. 5. Automated Apple App Intents regression proof uses AppIntentsTesting where the diff --git a/docs/decisions/0025-platform-subset-federated-plugins.md b/docs/decisions/0025-platform-subset-federated-plugins.md index 8a728ee..f57dfeb 100644 --- a/docs/decisions/0025-platform-subset-federated-plugins.md +++ b/docs/decisions/0025-platform-subset-federated-plugins.md @@ -60,7 +60,7 @@ Split runtime native code into endorsed federated packages: | Package | Role | |---------|------| | `intentcall_platform` | App-facing umbrella with `default_package` map and Dart host API | -| `intentcall_platform_apple` | iOS + macOS native (Pigeon + Swift), SPM under `darwin/` | +| `intentcall_platform_apple` | iOS + macOS native (Pigeon + Swift), SPM under `darwin/`; public Swift facades `IntentCallNativeBridge`, `IntentCallNativeHandoffStore`, `IntentCallNativeEntitySnapshotStore` | | `intentcall_platform_android` | Android native (Pigeon + Kotlin) | | `intentcall_bridge` | Shared Pigeon IDL and generated bindings | @@ -95,6 +95,9 @@ Good: - Flutter-aligned federated plugin model; domain experts can extend per platform. - Single Darwin Swift tree under `intentcall_platform_apple/darwin/`; SPM-only (no CocoaPods dual path). +- Apple cross-target contract: `intentcall_platform_sync` emits `AppIntent` + structs into `Runner/Generated/`; generated Swift imports + `intentcall_platform_apple` and calls plugin facades (no per-app bridge enum). - No separate interface package — fewer packages, umbrella remains the only app-facing dependency for most authors. - Projection pipeline unchanged — three-gate spine preserved. diff --git a/docs/start_here/audiences.mdx b/docs/start_here/audiences.mdx index 1fe8fdd..3a0671a 100644 --- a/docs/start_here/audiences.mdx +++ b/docs/start_here/audiences.mdx @@ -128,6 +128,9 @@ dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart codegen sync \ **Federated note:** apps do **not** depend on `intentcall_platform_apple` / `intentcall_platform_android` directly unless overriding endorsed packages. Projection stays in `intentcall_platform_sync` (pulled transitively / via CLI). +Generated Apple `AppIntent` structs land in the app `Runner` target; they +`import intentcall_platform_apple` and call `IntentCallNativeBridge.enqueue` — +handoff queue and deep-link open logic live in the plugin, not duplicated per app. Deep checklist: [DX FAQ — Flutter in-app host](/DX_FAQ#flutter-in-app-host). @@ -179,7 +182,7 @@ Siri/Shortcuts/Spotlight behavior. Artifact + sync proof ≠ live OS proof. | Surface | Package / tool | Typical proof | |---|---|---| -| Apple App Intents / Shortcuts | `intentcall_platform_sync` emitters + `intentcall_platform_apple` | `platform sync --check`; AppIntentsTesting / signed app for runtime | +| Apple App Intents / Shortcuts | `intentcall_platform_sync` emitters → `Runner/Generated/`; runtime facade + stores in `intentcall_platform_apple` | `platform sync --check` (drift); `check_apple_runner_compile.sh` in mcp_flutter (compile); AppIntentsTesting / signed app for runtime | | Android shortcuts / deep links | emitters + `intentcall_platform_android` | Manifest/XML drift checks | | Web / PWA / WebMCP | emitters + `intentcall_webmcp` / host `registerWebMcp` | Emitter tests + browser verify | | Windows / Linux protocol | emitters | Artifact `--check` only | @@ -222,7 +225,7 @@ flowchart LR | Role | Dependency style | Gate | |---|---|---| -| **mcp_flutter maintainers** (sibling dogfood) | Path deps to `../agentkit/packages/intentcall_*` | `make check-contracts`, `make dogfood-eval-static` | +| **mcp_flutter maintainers** (sibling dogfood) | Path deps to `../agentkit/packages/intentcall_*` | `make check-contracts` (includes Apple compile gate), `make dogfood-eval-static` | | **External app authors** | Hosted `intentcall_*: ^0.6.0` from pub.dev | App tests + `intentcall platform sync --check` | ### Dogfood proof in `flutter_test_app` (sibling checkout) @@ -232,7 +235,7 @@ Clone as siblings: `mcp_flutter` next to `agentkit` (`INTENTCALL_ROOT=../agentki | Platform | What is proven today | How | |---|---|---| | **Web** | Strong — WebMCP JS + host bind + verify | `make web-showcase`, `flutter-mcp-toolkit webmcp verify` | -| **macOS** | Runtime — generated Swift + host drain + validate | `make showcase`, `make macos-validate-runtime` | +| **macOS** | Runtime — plugin facade compile + host drain + validate | `bash tool/contracts/check_apple_runner_compile.sh`, `make showcase`, `make macos-validate-runtime` | | **iOS** | Scaffold + codegen drift (same Apple emitters as macOS) | Xcode hooks + `codegen sync --check`; **live** App Intents needs signed `xcodebuild test` / AppIntentsTesting | | Android / Linux / Windows | Codegen / protocol artifacts | `codegen sync --check` | diff --git a/docs/start_here/platform_support.mdx b/docs/start_here/platform_support.mdx index ef37121..cf70287 100644 --- a/docs/start_here/platform_support.mdx +++ b/docs/start_here/platform_support.mdx @@ -21,11 +21,11 @@ flowchart TB |---|---|---|---|---|---| | MCP | `McpPublishAdapter` maps registry tools/resources to `dart_mcp`. | Dart `AgentRegistry` handler. | Shared adapter contract tests. | Transport host policy plus registry validation. | Not an LLM backend, RAG system, or Flutter runtime inspector. | | WebMCP | Dart-first in-page registration and JS emitter/bootstrap helpers. | Dart registry when bound; optional network fallback only when configured. | Emitter/bootstrap tests. | Deny unavailable runtime by default; network fallback is opt-in. | Live browser-host interoperability needs separate proof. | -| Apple App Intents / Shortcuts | Generated parameter wrappers and artifacts; explicit `nativeInline` can call app-owned Swift handlers; Apple inline runtimes can generate primitive typed App Intents returns; additive typed app entity and indexing/donation scaffolds may project Dart-owned snapshots through a durable native cache; `dartExtensionInline` has an experimental extension scaffold; Apple 27+ AppIntentsTesting UI-test scaffolds can be generated for live proof; `PlatformSync` patches/checks Runner target membership and `CFBundleURLTypes` for app-owned fallback schemes. | `openApp` launches or wakes app and dispatches envelope to Dart; `nativeInline` completes in generated Swift/native handler; entity query/indexing code reads native projection cache because Flutter may be cold; scaffolded `dartExtensionInline` is not wired by default. | Artifact/project-sync/configuration tests, emitter tests, typed return generation tests, native cache behavior tests, Dart extension runtime bridge tests, AppIntentsTesting scaffold tests, and local SDK typechecks. | `IntentCallAuthorizationPolicy` for Dart handoff and Dart extension bridge; native inline handlers must enforce app-owned permission policy; native entity caches are projection caches, not product databases. | No stable claim of automatic app-extension target generation, proven app-extension-hosted Dart, app signing, Shortcuts/Spotlight/Siri discovery, accepted donation/indexing, or completed live OS invocation in a real app. | +| Apple App Intents / Shortcuts | Generated `AppIntent` structs in `Runner/Generated/IntentCallGenerated.swift`; handoff facade (`IntentCallNativeBridge.enqueue`), handoff queue, and entity snapshot stores live in the federated `intentcall_platform_apple` plugin (generated Runner Swift imports that module — no duplicated bridge enum); explicit `nativeInline` can call app-owned Swift handlers; Apple inline runtimes can generate primitive typed App Intents returns; additive typed app entity and indexing/donation scaffolds may project Dart-owned snapshots through a durable native cache; `dartExtensionInline` has an experimental extension scaffold; Apple 27+ AppIntentsTesting UI-test scaffolds can be generated for live proof; `PlatformSync` patches/checks Runner target membership and `CFBundleURLTypes` for app-owned fallback schemes. | `openApp` enqueues via plugin facade and may open app-owned `protocolScheme` URL; Dart host drains `IntentCallNativeHandoffStore` on startup/resume; `nativeInline` completes in generated Swift/native handler; entity query/indexing code reads native projection cache because Flutter may be cold; scaffolded `dartExtensionInline` is not wired by default. | Artifact/project-sync/configuration tests, emitter tests, federated plugin compile proof (`flutter build macos --config-only` in mcp_flutter dogfood), typed return generation tests, native cache behavior tests, Dart extension runtime bridge tests, AppIntentsTesting scaffold tests, and local SDK typechecks. | `IntentCallAuthorizationPolicy` for Dart handoff and Dart extension bridge; native inline handlers must enforce app-owned permission policy; native entity caches are projection caches, not product databases. | No stable claim of automatic app-extension target generation, proven app-extension-hosted Dart, app signing, Shortcuts/Spotlight/Siri discovery, accepted donation/indexing, or completed live OS invocation in a real app. | | Android shortcuts / deep links | Manifest and shortcut/deep-link metadata. | App receives route and dispatches to Dart. | Manifest generator tests. | Treat plain deep links as untrusted unless generated wrapper or app allowlist marks source trusted. | Android AppFunctions and fuller App Actions capability generation are roadmap. | | Windows protocol activation | Protocol activation artifacts. | App route dispatches to Dart when wired by host. | Artifact-level support. | Fallback routes are untrusted by default. | Windows App Actions / Agent Launchers are roadmap. | | Linux `x-scheme-handler` | Desktop protocol handler artifacts for the app-owned scheme. | App route dispatches to Dart when wired by host. | Artifact-level support. | Fallback routes are untrusted by default. | No Linux OS-level native intent API claim. | -| Native/WebMCP bridge | `IntentCallInvocationEnvelope` and authorization policy. | Dart registry through `IntentCallNativeBridge.bindRegistry(...)`. | Platform bridge tests. | Deny-by-default in compiled builds; `debugAllowAll()` is local dogfood only. | Host apps own product-specific permissions and UX. | +| Native/WebMCP bridge | `IntentCallInvocationEnvelope` and authorization policy. | Dart registry through `IntentCallNativeBridge.bindRegistry(...)` in `intentcall_platform_sync` (distinct from the Swift `IntentCallNativeBridge.enqueue` facade in `intentcall_platform_apple`). | Platform bridge tests. | Deny-by-default in compiled builds; `debugAllowAll()` is local dogfood only. | Host apps own product-specific permissions and UX. | ## Dispatch Mode Claims @@ -77,8 +77,11 @@ AppIntentsTesting proof where that API covers the scenario. Keep Apple evidence labels separate: -- Generated Swift compile proof: generated App Intents code compiles against the - active SDK, but this is not runtime behavior. +- Generated Swift compile proof: generated `Runner/Generated/IntentCallGenerated.swift` + compiles against `import intentcall_platform_apple` and calls the plugin's + `IntentCallNativeBridge.enqueue` facade (handoff queue + optional deep-link open). + `platform sync --check` alone is text drift only — use the mcp_flutter dogfood + compile gate below for module-visibility regressions. - AppIntentsTesting runtime proof: the primary automated regression lane for Apple App Intents behavior, including actions, supported entity queries, and Spotlight query paths where Apple's testing API covers the scenario. @@ -156,9 +159,9 @@ The early consumer harness is [mcp_flutter](https://github.com/Arenukvern/mcp_fl | Platform | Evidence in dogfood today | Command (from mcp_flutter) | |---|---|---| | Web | Strong — WebMCP + `IntentCallFlutterHost` | `make web-showcase` (+ `webmcp verify`) | -| macOS | Runtime — generated Swift + host drain | `make showcase`, `make macos-validate-runtime` | +| macOS | Runtime — generated Swift + plugin facade compile + host drain | `bash tool/contracts/check_apple_runner_compile.sh`, `make showcase`, `make macos-validate-runtime` | | iOS | Scaffold + sync drift (shared Apple emitters) | `codegen sync --check`; live App Intents needs signed UI tests | -| Contracts | Sibling path-dep + three-gate spine | `make check-contracts`, `make dogfood-eval-static` | +| Contracts | Sibling path-dep + three-gate spine + Apple compile gate | `make check-contracts`, `make dogfood-eval-static` | **Honest “integration works” claim for migration:** federated `intentcall_platform` resolves; web and macOS dogfood targets pass; iOS shares Apple projection and From 83c8e7aa63f39b9c4babb0532d0f98fba7ae8d42 Mon Sep 17 00:00:00 2001 From: arenukvern Date: Mon, 3 Aug 2026 02:37:05 +0300 Subject: [PATCH 22/27] docs: readme --- packages/intentcall_bridge/README.md | 0 packages/intentcall_cli/LICENSE | 21 +++++++++++++++++++++ packages/intentcall_cli/README.md | 0 3 files changed, 21 insertions(+) create mode 100644 packages/intentcall_bridge/README.md create mode 100644 packages/intentcall_cli/LICENSE create mode 100644 packages/intentcall_cli/README.md diff --git a/packages/intentcall_bridge/README.md b/packages/intentcall_bridge/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/intentcall_cli/LICENSE b/packages/intentcall_cli/LICENSE new file mode 100644 index 0000000..ec57a7f --- /dev/null +++ b/packages/intentcall_cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anton Malofeev (Arenukvern) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/intentcall_cli/README.md b/packages/intentcall_cli/README.md new file mode 100644 index 0000000..e69de29 From ee053c426bc220a1f35574f2b952c526af8086e8 Mon Sep 17 00:00:00 2001 From: arenukvern Date: Mon, 3 Aug 2026 03:30:41 +0300 Subject: [PATCH 23/27] feat: add from_json_to_json and is_dart_empty_or_not dependencies, refactor JSON handling in schema and session management --- .../intentcall_codegen/example/pubspec.lock | 16 +++ .../src/flutter/intentcall_entity_index.dart | 12 +-- .../intentcall_entity_index_channel.dart | 32 +++--- .../intentcall_pending_entity_opens.dart | 21 ++-- packages/intentcall_platform/pubspec.yaml | 1 + .../lib/intentcall_platform_sync.dart | 2 +- .../invocation/intentcall_entity_open.dart | 16 ++- .../templates/platform_hook_templates.dart | 2 +- .../lib/src/agent_entity_model.dart | 98 +++---------------- .../lib/src/agent_wire_args.dart | 35 ++----- .../src/client_resource_input_schemas.dart | 24 +---- .../intentcall_schema/lib/src/json_utils.dart | 98 +++++++++++++++++++ .../lib/src/schema_coercion.dart | 35 ++----- packages/intentcall_schema/pubspec.yaml | 3 + .../lib/src/json_helpers.dart | 19 ++-- .../lib/src/session_manager.dart | 4 +- .../lib/src/snapshot_store.dart | 12 +-- .../lib/src/state_lock_manager.dart | 17 +--- .../lib/src/state_store.dart | 23 ++--- packages/intentcall_session/pubspec.yaml | 1 + 20 files changed, 212 insertions(+), 259 deletions(-) create mode 100644 packages/intentcall_schema/lib/src/json_utils.dart diff --git a/packages/intentcall_codegen/example/pubspec.lock b/packages/intentcall_codegen/example/pubspec.lock index 09fc372..a3a94c0 100644 --- a/packages/intentcall_codegen/example/pubspec.lock +++ b/packages/intentcall_codegen/example/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + from_json_to_json: + dependency: transitive + description: + name: from_json_to_json + sha256: a29219df65cd20b1b17be8141ee51b3103d1cd95b192b7512139f3ae44775f49 + url: "https://pub.dev" + source: hosted + version: "0.5.0" frontend_server_client: dependency: transitive description: @@ -237,6 +245,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + is_dart_empty_or_not: + dependency: transitive + description: + name: is_dart_empty_or_not + sha256: "1454632c2b961175d4c2807310713cbcbd054a51e63c545dc86c3eb9b2b061b0" + url: "https://pub.dev" + source: hosted + version: "0.4.0" json_annotation: dependency: transitive description: diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart index 2fb7801..16f86e9 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart @@ -1,3 +1,4 @@ +import 'package:from_json_to_json/from_json_to_json.dart'; import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; @@ -165,15 +166,8 @@ Map _snapshotRow(final Map snapshot) { return {...snapshot, 'id': id}; } -int _intResult(final Object? result, {required final int fallback}) { - if (result is int) { - return result; - } - if (result is num) { - return result.toInt(); - } - return fallback; -} +int _intResult(final Object? result, {required final int fallback}) => + jsonDecodeNullableInt(result) ?? fallback; List> _snapshotRows(final Object? result) { if (result is! List) { diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart index 5257e46..fcb9749 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index_channel.dart @@ -16,9 +16,7 @@ Future defaultIntentCallPlatformEntityInvoke( case 'upsertEntitySnapshots': final snapshots = (args['snapshots'] as List?) - ?.map( - (final row) => Map.from(row as Map), - ) + ?.map((final row) => Map.from(row as Map)) .toList(growable: false) ?? const >[]; return _entitiesHostApi.upsertEntitySnapshots( @@ -28,18 +26,16 @@ Future defaultIntentCallPlatformEntityInvoke( ); case 'deleteEntitySnapshots': final ids = - (args['ids'] as List?)?.map((final id) => '$id').toList( - growable: false, - ) ?? + (args['ids'] as List?) + ?.map((final id) => '$id') + .toList(growable: false) ?? const []; return _entitiesHostApi.deleteEntitySnapshots(entityType, ids, keys); case 'clearEntityTypeSnapshots': return _entitiesHostApi.clearEntityTypeSnapshots(entityType); case 'listEntitySnapshots': final rows = await _entitiesHostApi.listEntitySnapshots(entityType); - return rows - .map((final row) => Map.from(row)) - .toList(growable: false); + return rows.map(Map.from).toList(growable: false); case 'searchEntitySnapshots': final query = '${args['query'] ?? ''}'; final limit = args['limit'] as int? ?? 20; @@ -49,9 +45,7 @@ Future defaultIntentCallPlatformEntityInvoke( limit, keys, ); - return rows - .map((final row) => Map.from(row)) - .toList(growable: false); + return rows.map(Map.from).toList(growable: false); default: throw UnsupportedError('Unknown entity bridge method: $method'); } @@ -75,11 +69,9 @@ IntentCallEntityKeyBundle _readKeyBundle(final Map args) { bridge.IntentCallEntityKeyBundle _toBridgeKeyBundle( final IntentCallEntityKeyBundle keys, -) { - return bridge.IntentCallEntityKeyBundle( - idKey: keys.idKey, - titleKey: keys.titleKey, - subtitleKey: keys.subtitleKey, - keywordsKey: keys.keywordsKey, - ); -} +) => bridge.IntentCallEntityKeyBundle( + idKey: keys.idKey, + titleKey: keys.titleKey, + subtitleKey: keys.subtitleKey, + keywordsKey: keys.keywordsKey, +); diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart index 8085957..923cb1b 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart @@ -2,9 +2,8 @@ import 'package:intentcall_bridge/intentcall_bridge.dart'; import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; final class IntentCallPendingEntityOpens { - IntentCallPendingEntityOpens({ - final IntentCallEntitiesHostApi? hostApi, - }) : _hostApi = hostApi ?? IntentCallEntitiesHostApi(); + IntentCallPendingEntityOpens({final IntentCallEntitiesHostApi? hostApi}) + : _hostApi = hostApi ?? IntentCallEntitiesHostApi(); final IntentCallEntitiesHostApi _hostApi; @@ -16,12 +15,10 @@ final class IntentCallPendingEntityOpens { IntentCallEntityOpenEnvelope _toEnvelope( final IntentCallEntityOpenEnvelopeDto dto, -) { - return IntentCallEntityOpenEnvelope( - id: dto.id, - entityType: dto.entityType, - entityId: dto.entityId, - source: dto.source, - createdAt: DateTime.tryParse(dto.createdAt), - ); -} +) => IntentCallEntityOpenEnvelope( + id: dto.id, + entityType: dto.entityType, + entityId: dto.entityId, + source: dto.source, + createdAt: DateTime.tryParse(dto.createdAt), +); diff --git a/packages/intentcall_platform/pubspec.yaml b/packages/intentcall_platform/pubspec.yaml index 22e0370..b2add07 100644 --- a/packages/intentcall_platform/pubspec.yaml +++ b/packages/intentcall_platform/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: app_links: ^6.4.0 flutter: sdk: flutter + from_json_to_json: ^0.5.0 intentcall_bridge: ^0.6.0 intentcall_core: ^0.6.0 intentcall_platform_android: ^0.6.0 diff --git a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart index 36f241f..a6e53c4 100644 --- a/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart @@ -17,9 +17,9 @@ export 'src/init/platform_hooks_init.dart'; export 'src/invocation/intentcall_entity_open.dart'; export 'src/invocation/intentcall_invocation.dart'; export 'src/projection/manifest_exporter.dart'; -export 'src/projection/projection_platforms.dart'; export 'src/projection/manifest_merger.dart'; export 'src/projection/manifest_surface_index.dart'; +export 'src/projection/projection_platforms.dart'; export 'src/projection/projection_policy.dart'; export 'src/sync/platform_sync.dart'; export 'src/templates/platform_hook_spine.dart'; diff --git a/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart b/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart index de4c8fb..b78f4a9 100644 --- a/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart +++ b/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart @@ -15,15 +15,13 @@ final class IntentCallEntityOpenEnvelope { factory IntentCallEntityOpenEnvelope.fromJson( final Map json, - ) { - return IntentCallEntityOpenEnvelope( - id: '${json['id'] ?? ''}', - entityType: '${json['entityType'] ?? ''}', - entityId: '${json['entityId'] ?? ''}', - source: '${json['source'] ?? ''}', - createdAt: DateTime.tryParse('${json['createdAt'] ?? ''}'), - ); - } + ) => IntentCallEntityOpenEnvelope( + id: '${json['id'] ?? ''}', + entityType: '${json['entityType'] ?? ''}', + entityId: '${json['entityId'] ?? ''}', + source: '${json['source'] ?? ''}', + createdAt: DateTime.tryParse('${json['createdAt'] ?? ''}'), + ); final String id; final String entityType; diff --git a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart index 8c18ee6..e174d70 100644 --- a/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart +++ b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart @@ -4,7 +4,7 @@ export 'platform_hook_spine.dart'; /// Default Flutter hook spine (no project `intentcall.yaml`). PlatformHookSpine get kDefaultFlutterHookSpine => - PlatformHookSpine.resolve(const PlatformHookSpineInput(host: 'flutter')); + PlatformHookSpine.resolve(const PlatformHookSpineInput()); /// Default Jaspr hook spine (no project `intentcall.yaml`). PlatformHookSpine get kDefaultJasprHookSpine => diff --git a/packages/intentcall_schema/lib/src/agent_entity_model.dart b/packages/intentcall_schema/lib/src/agent_entity_model.dart index 729a1ee..f3cdb2a 100644 --- a/packages/intentcall_schema/lib/src/agent_entity_model.dart +++ b/packages/intentcall_schema/lib/src/agent_entity_model.dart @@ -1,5 +1,8 @@ +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'json_utils.dart'; + /// Stable identity for an indexable app object exposed to agents. /// /// The triple `(namespace, typeName, identifier)` must be unique within an app. @@ -108,7 +111,7 @@ final class AgentEntitySnapshot { return trimmed; }), ), - properties = _jsonObject(properties); + properties = jsonDecodeNullableStringKeyMap(properties); /// Parses [json] produced by [toJson]. /// @@ -129,7 +132,9 @@ final class AgentEntitySnapshot { } return AgentEntitySnapshot( ref: AgentEntityRef.fromJson(Map.from(rawRef)), - properties: _jsonObject(Map.from(rawProperties)), + properties: jsonDecodeNullableStringKeyMap( + Map.from(rawProperties), + ), title: _optionalString(json, 'title'), subtitle: _optionalString(json, 'subtitle'), keywords: _optionalStringList(json, 'keywords'), @@ -280,14 +285,8 @@ List _optionalStringList( } bool? _optionalBool(final Map json, final String key) { - final value = json[key]; - if (value == null) { - return null; - } - if (value is bool) { - return value; - } - throw ArgumentError.value(value, key, 'Expected a boolean.'); + final rawValue = json[key]; + return jsonDecodeNullableThrowableBool(rawValue); } DateTime? _optionalDateTime(final Map json, final String key) { @@ -296,82 +295,13 @@ DateTime? _optionalDateTime(final Map json, final String key) { return null; } if (value is String) { - return DateTime.parse(value).toUtc(); + return DateTime.tryParse(value)?.toUtc(); } throw ArgumentError.value(value, key, 'Expected an ISO-8601 string.'); } -Map _jsonObject(final Map value) => - Map.unmodifiable( - value.map((final key, final value) => MapEntry(key, _jsonValue(value))), - ); +bool _jsonEquals(final Object? left, final Object? right) => + const DeepCollectionEquality().equals(left, right); -Object? _jsonValue(final Object? value) { - if (value == null || value is String || value is bool || value is int) { - return value; - } - if (value is double) { - if (!value.isFinite) { - throw ArgumentError.value(value, 'value', 'Expected a finite number.'); - } - return value; - } - if (value is List) { - return List.unmodifiable(value.map(_jsonValue)); - } - if (value is Map) { - return Map.unmodifiable( - value.map((final key, final value) { - if (key is! String) { - throw ArgumentError.value(key, 'key', 'Expected a string key.'); - } - return MapEntry(key, _jsonValue(value)); - }), - ); - } - throw ArgumentError.value(value, 'value', 'Expected a JSON-safe value.'); -} - -bool _jsonEquals(final Object? left, final Object? right) { - if (identical(left, right)) { - return true; - } - if (left is Map && right is Map) { - if (left.length != right.length) { - return false; - } - for (final entry in left.entries) { - if (!right.containsKey(entry.key) || - !_jsonEquals(entry.value, right[entry.key])) { - return false; - } - } - return true; - } - if (left is List && right is List) { - if (left.length != right.length) { - return false; - } - for (var i = 0; i < left.length; i += 1) { - if (!_jsonEquals(left[i], right[i])) { - return false; - } - } - return true; - } - return left == right; -} - -int _jsonHash(final Object? value) { - if (value is Map) { - return Object.hashAllUnordered( - value.entries.map( - (final entry) => Object.hash(entry.key, _jsonHash(entry.value)), - ), - ); - } - if (value is List) { - return Object.hashAll(value.map(_jsonHash)); - } - return value.hashCode; -} +int _jsonHash(final Object? value) => + const DeepCollectionEquality().hash(value); diff --git a/packages/intentcall_schema/lib/src/agent_wire_args.dart b/packages/intentcall_schema/lib/src/agent_wire_args.dart index c67b36e..51d851e 100644 --- a/packages/intentcall_schema/lib/src/agent_wire_args.dart +++ b/packages/intentcall_schema/lib/src/agent_wire_args.dart @@ -1,6 +1,7 @@ -import 'dart:convert'; +import 'package:from_json_to_json/from_json_to_json.dart'; import 'agent_result.dart'; +import 'json_utils.dart'; /// Typed view over a VM service extension wire map (`Map`). /// @@ -16,6 +17,7 @@ import 'agent_result.dart'; extension type const AgentWireArgs(AgentWireMap _raw) { /// Returns a trimmed non-empty string, or `null` if missing or blank. String? string(final String key) { + // TODO: migrate to from_json_to_json jsonDecodeNullableString final value = _raw[key]; if (value == null) { return null; @@ -27,42 +29,21 @@ extension type const AgentWireArgs(AgentWireMap _raw) { /// Parses common wire boolean literals: `1`/`0`, `true`/`false`, `yes`/`no`. /// /// Returns `null` when the key is absent or the value is not recognized. - bool? bool_(final String key) { - final normalized = string(key)?.toLowerCase(); - if (normalized == null) { - return null; - } - if (normalized == '1' || normalized == 'true' || normalized == 'yes') { - return true; - } - if (normalized == '0' || normalized == 'false' || normalized == 'no') { - return false; - } - return null; - } + bool? bool_(final String key) => jsonDecodeNullableBool(string(key)); /// Parses an integer from the string value, or `null` if missing or invalid. - int? int_(final String key) => int.tryParse(_raw[key]?.trim() ?? ''); + int? int_(final String key) => jsonDecodeNullableInt(_raw[key]?.trim()); /// Parses a double from the string value, or `null` if missing or invalid. - double? double_(final String key) => double.tryParse(_raw[key]?.trim() ?? ''); + double? double_(final String key) => + jsonDecodeNullableDouble(_raw[key]?.trim()); /// Decodes a JSON object from the string value at [key]. /// /// Returns `null` when the key is absent or the decoded value is not a map. Map? jsonObject(final String key) { final raw = string(key); - if (raw == null) { - return null; - } - final decoded = jsonDecode(raw); - if (decoded is Map) { - return decoded; - } - if (decoded is Map) { - return Map.from(decoded); - } - return null; + return jsonDecodeNullableMapAs(raw); } /// Copies the underlying map into [AgentArguments] (values remain strings). diff --git a/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart b/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart index 19fc7c8..a4e135d 100644 --- a/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart +++ b/packages/intentcall_schema/lib/src/client_resource_input_schemas.dart @@ -2,6 +2,7 @@ // Licensed under the MIT License. import 'agent_result.dart'; +import 'json_utils.dart'; /// Extracts an [InputSchema] from a dynamic resource registration map. /// @@ -20,7 +21,7 @@ InputSchema inputSchemaFromDynamicRegistrationMap( if (raw is! Map) { throw ArgumentError('Resource registration inputSchema must be a Map'); } - return _deepCopySchemaMap(Map.from(raw)); + return deepCopySchemaMap(Map.from(raw)); } /// Default JSON Schema for reading a dynamic client resource by URI. @@ -68,24 +69,3 @@ InputSchema clientResourceTemplateReadInputSchema({ 'properties': properties, }; } - -InputSchema _deepCopySchemaMap(final Map raw) => raw.map( - (final key, final value) => - MapEntry(key.toString(), _normalizeSchemaValue(value)), -); - -Object? _normalizeSchemaValue(final Object? value) { - if (value is Map) { - return _deepCopySchemaMap(Map.from(value)); - } - if (value is Iterable && value is! String) { - return value - .map( - (final item) => item is Map - ? _deepCopySchemaMap(Map.from(item)) - : item, - ) - .toList(); - } - return value; -} diff --git a/packages/intentcall_schema/lib/src/json_utils.dart b/packages/intentcall_schema/lib/src/json_utils.dart new file mode 100644 index 0000000..8b97361 --- /dev/null +++ b/packages/intentcall_schema/lib/src/json_utils.dart @@ -0,0 +1,98 @@ +import 'package:from_json_to_json/from_json_to_json.dart'; + +import 'agent_result.dart'; + +@Deprecated('migrate cases to from_json_to_json') +// ignore: avoid_annotating_with_dynamic +bool? jsonDecodeNullableThrowableBool(final dynamic value) => switch (value) { + final String v => jsonDecodeNullableBool(v), + final bool v => v, + null => null, + _ => throw ArgumentError.value(value, null, 'Expected a boolean'), +}; + +@Deprecated('migrate cases to from_json_to_json') +// ignore: avoid_annotating_with_dynamic +bool? jsonDecodeNullableBool(final dynamic value) { + final normalized = jsonDecodeNullableString(value)?.toLowerCase(); + // TODO(arenukvern): add jsonDecodeNullableBool for from_json_to_json + if (normalized == null || normalized.isEmpty) { + return null; + } + // TODO(arenukvern): add case for from_json_to_json + if (normalized == 'yes') { + return true; + } + // TODO(arenukvern): add case for from_json_to_json + if (normalized == 'no') { + return false; + } + return jsonDecodeBool(normalized); +} + +@Deprecated('migrate cases to from_json_to_json') +// ignore: avoid_annotating_with_dynamic +String? jsonDecodeNullableString(final dynamic value) => switch (value) { + final String value => value, + _ => null, +}; + +@Deprecated('migrate cases to from_json_to_json') +// ignore: avoid_annotating_with_dynamic +Map? jsonDecodeNullableMapAs(final dynamic json) => + jsonDecodeNullableMap(json)?.cast(); + +/// validates every key is String, and checs map values +/// normalization? +Map jsonDecodeNullableStringKeyMap( + final Map value, +) => Map.unmodifiable( + value.map((final key, final value) => MapEntry(key, _jsonValue(value))), +); + +Object? _jsonValue(final Object? value) { + if (value == null || value is String || value is bool || value is int) { + return value; + } + if (value is double) { + if (!value.isFinite) { + throw ArgumentError.value(value, 'value', 'Expected a finite number.'); + } + return value; + } + if (value is List) { + return List.unmodifiable(value.map(_jsonValue)); + } + if (value is Map) { + return Map.unmodifiable( + value.map((final key, final value) { + if (key is! String) { + throw ArgumentError.value(key, 'key', 'Expected a string key.'); + } + return MapEntry(key, _jsonValue(value)); + }), + ); + } + throw ArgumentError.value(value, 'value', 'Expected a JSON-safe value.'); +} + +InputSchema deepCopySchemaMap(final Map raw) => raw.map( + (final key, final value) => + MapEntry(key.toString(), _normalizeSchemaValue(value)), +); + +Object? _normalizeSchemaValue(final Object? value) { + if (value is Map) { + return deepCopySchemaMap(Map.from(value)); + } + if (value is Iterable && value is! String) { + return value + .map( + (final item) => item is Map + ? deepCopySchemaMap(Map.from(item)) + : item, + ) + .toList(); + } + return value; +} diff --git a/packages/intentcall_schema/lib/src/schema_coercion.dart b/packages/intentcall_schema/lib/src/schema_coercion.dart index 5b5c830..e21bf1f 100644 --- a/packages/intentcall_schema/lib/src/schema_coercion.dart +++ b/packages/intentcall_schema/lib/src/schema_coercion.dart @@ -1,6 +1,9 @@ import 'dart:convert'; +import 'package:from_json_to_json/from_json_to_json.dart'; + import 'agent_result.dart'; +import 'json_utils.dart'; /// Coerces wire [arguments] to types expected by [validateAgainstSchema]. /// @@ -64,10 +67,10 @@ Object? _coercePropertyValue( } return switch (type) { 'string' => value, - 'integer' => int.tryParse(trimmed) ?? value, + 'integer' => jsonDecodeNullableInt(trimmed) ?? value, 'number' => num.tryParse(trimmed) ?? value, - 'boolean' => _parseWireBool(trimmed) ?? value, - 'object' => _parseWireJsonMap(trimmed) ?? value, + 'boolean' => jsonDecodeNullableBool(trimmed) ?? value, + 'object' => jsonDecodeNullableMapAs(trimmed) ?? value, 'array' => _parseWireJsonList(trimmed, propertySchema) ?? value, _ => value, }; @@ -126,7 +129,8 @@ Map _coerceOpenObjectMap(final Map value) { Object? _coerceOpenObjectEntryValue(final Object? value) { if (value is String) { final trimmed = value.trim(); - if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + // TODO(arenukvern): add trim case for jsonDecodeString + if (verifyMapDecodability(trimmed)) { final parsed = _parseWireJsonMap(trimmed); if (parsed != null) { return _coerceOpenObjectMap(parsed); @@ -166,27 +170,8 @@ List? _parseWireJsonList( return _coerceArrayValue(arraySchema, decoded); } -Map? _parseWireJsonMap(final String trimmed) { - final decoded = jsonDecode(trimmed); - if (decoded is Map) { - return decoded; - } - if (decoded is Map) { - return Map.from(decoded); - } - return null; -} - -bool? _parseWireBool(final String normalized) { - final lower = normalized.toLowerCase(); - if (lower == '1' || lower == 'true' || lower == 'yes') { - return true; - } - if (lower == '0' || lower == 'false' || lower == 'no') { - return false; - } - return null; -} +Map? _parseWireJsonMap(final String trimmed) => + jsonDecodeNullableMapAs(json); Map> _propertySchemas( final Map schema, diff --git a/packages/intentcall_schema/pubspec.yaml b/packages/intentcall_schema/pubspec.yaml index aa56de8..0327614 100644 --- a/packages/intentcall_schema/pubspec.yaml +++ b/packages/intentcall_schema/pubspec.yaml @@ -17,6 +17,9 @@ environment: resolution: workspace dependencies: + collection: ^1.19.1 + from_json_to_json: ^0.5.0 + is_dart_empty_or_not: ^0.4.0 meta: ^1.17.0 dev_dependencies: diff --git a/packages/intentcall_session/lib/src/json_helpers.dart b/packages/intentcall_session/lib/src/json_helpers.dart index f5e4e0a..78733bc 100644 --- a/packages/intentcall_session/lib/src/json_helpers.dart +++ b/packages/intentcall_session/lib/src/json_helpers.dart @@ -3,16 +3,13 @@ import 'package:from_json_to_json/from_json_to_json.dart'; -Map jsonObjectOrEmpty(final Object? value) { - if (value is Map) { - return value; - } - if (value is Map) { - return value.cast(); - } - try { - return Map.from(jsonDecodeMap(value)); - } on Exception { - return const {}; +Map jsonDecodeObjectOrEmpty(final Object? value) => + jsonDecodeMapAs(value); + +// TODO(arenukvern): migrate to from_json_to_json +String? jsonDecodeNullableString(final Object? value) { + if (value == null) { + return null; } + return jsonDecodeString(value); } diff --git a/packages/intentcall_session/lib/src/session_manager.dart b/packages/intentcall_session/lib/src/session_manager.dart index 208e6f8..19f199c 100644 --- a/packages/intentcall_session/lib/src/session_manager.dart +++ b/packages/intentcall_session/lib/src/session_manager.dart @@ -335,12 +335,12 @@ final class IntentSessionManager { Map _detailsMap(final Object? value) { if (value is Map) { - return jsonObjectOrEmpty(value); + return jsonDecodeObjectOrEmpty(value); } if (value == null) { return const {}; } - final decoded = jsonObjectOrEmpty(value); + final decoded = jsonDecodeObjectOrEmpty(value); if (decoded.isNotEmpty) { return decoded; } diff --git a/packages/intentcall_session/lib/src/snapshot_store.dart b/packages/intentcall_session/lib/src/snapshot_store.dart index 15f7b8b..8b83b3c 100644 --- a/packages/intentcall_session/lib/src/snapshot_store.dart +++ b/packages/intentcall_session/lib/src/snapshot_store.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:io' as io; +import 'package:collection/collection.dart'; import 'package:from_json_to_json/from_json_to_json.dart'; import 'json_helpers.dart'; @@ -76,7 +77,7 @@ final class IntentSnapshotStore { if (!verifyMapDecodability(raw.trim())) { continue; } - final json = jsonObjectOrEmpty(raw); + final json = jsonDecodeObjectOrEmpty(raw); snapshots.add({ 'id': jsonDecodeString(json['id']), 'createdAt': json['createdAt'], @@ -195,11 +196,6 @@ final class IntentSnapshotStore { out.add({'path': path, 'type': 'changed', 'before': left, 'after': right}); } - static bool _jsonEquals(final Object? left, final Object? right) { - try { - return jsonEncode(left) == jsonEncode(right); - } on Object { - return left == right; - } - } + static bool _jsonEquals(final Object? left, final Object? right) => + const DeepCollectionEquality().equals(left, right); } diff --git a/packages/intentcall_session/lib/src/state_lock_manager.dart b/packages/intentcall_session/lib/src/state_lock_manager.dart index 7040b5b..2874a2f 100644 --- a/packages/intentcall_session/lib/src/state_lock_manager.dart +++ b/packages/intentcall_session/lib/src/state_lock_manager.dart @@ -8,6 +8,8 @@ import 'dart:math'; import 'package:meta/meta.dart'; +import 'json_helpers.dart'; + @immutable final class LockAcquisition { const LockAcquisition({ @@ -133,7 +135,7 @@ final class StateLockManager { try { final raw = lockFile.readAsStringSync(); - final decoded = _decodeMap(raw); + final decoded = jsonDecodeObjectOrEmpty(raw); final token = decoded['token']?.toString(); if (token != acquisition.token) { @@ -149,7 +151,7 @@ final class StateLockManager { _recoverStaleLockIfNeeded(final io.File lockFile) async { try { final raw = lockFile.readAsStringSync(); - final owner = _decodeMap(raw); + final owner = jsonDecodeObjectOrEmpty(raw); final createdAtRaw = owner['createdAt']?.toString(); final createdAt = createdAtRaw == null ? null @@ -171,17 +173,6 @@ final class StateLockManager { } } - Map _decodeMap(final String raw) { - final decoded = jsonDecode(raw); - if (decoded is Map) { - return decoded; - } - if (decoded is Map) { - return decoded.cast(); - } - return const {}; - } - String _nextToken() { final rand = Random(); final suffix = rand.nextInt(1 << 32).toRadixString(16).padLeft(8, '0'); diff --git a/packages/intentcall_session/lib/src/state_store.dart b/packages/intentcall_session/lib/src/state_store.dart index be758ef..9ca82f5 100644 --- a/packages/intentcall_session/lib/src/state_store.dart +++ b/packages/intentcall_session/lib/src/state_store.dart @@ -35,9 +35,9 @@ final class SessionState { DateTime.tryParse(jsonDecodeString(json['lastUsedAt']))?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), mode: mode.isEmpty ? 'auto' : mode, - host: _optionalJsonString(json['host']), + host: jsonDecodeNullableString(json['host']), port: jsonDecodeNullableInt(json['port']), - uri: _optionalJsonString(json['uri']), + uri: jsonDecodeNullableString(json['uri']), ); } @@ -84,21 +84,21 @@ final class PersistedState { }); factory PersistedState.fromJson(final Map json) { - final rawSessions = jsonObjectOrEmpty(json['sessions']); + final rawSessions = jsonDecodeObjectOrEmpty(json['sessions']); final sessions = {}; for (final entry in rawSessions.entries) { final key = jsonDecodeString(entry.key); final value = entry.value; if (value is Map || verifyMapDecodability(value)) { - sessions[key] = SessionState.fromJson(jsonObjectOrEmpty(value)); + sessions[key] = SessionState.fromJson(jsonDecodeObjectOrEmpty(value)); } } return PersistedState( schemaVersion: jsonDecodeNullableInt(json['schemaVersion']) ?? 1, - activeSessionId: _optionalJsonString(json['activeSessionId']), - stickyEndpoint: _optionalJsonString(json['stickyEndpoint']), - lastMode: _optionalJsonString(json['lastMode']), + activeSessionId: jsonDecodeNullableString(json['activeSessionId']), + stickyEndpoint: jsonDecodeNullableString(json['stickyEndpoint']), + lastMode: jsonDecodeNullableString(json['lastMode']), sessions: sessions, ); } @@ -180,7 +180,7 @@ final class StateStore { return const PersistedState(); } - return PersistedState.fromJson(jsonObjectOrEmpty(raw)); + return PersistedState.fromJson(jsonDecodeObjectOrEmpty(raw)); } on Exception { return const PersistedState(); } @@ -196,10 +196,3 @@ final class StateStore { } } } - -String? _optionalJsonString(final Object? value) { - if (value == null) { - return null; - } - return jsonDecodeString(value); -} diff --git a/packages/intentcall_session/pubspec.yaml b/packages/intentcall_session/pubspec.yaml index b445e45..406f6c2 100644 --- a/packages/intentcall_session/pubspec.yaml +++ b/packages/intentcall_session/pubspec.yaml @@ -15,6 +15,7 @@ environment: resolution: workspace dependencies: + collection: ^1.19.1 from_json_to_json: ^0.5.0 intentcall_core: ^0.6.0 intentcall_schema: ^0.6.0 From 7c29a403102079c0b68c9d809eccd7e9eb67c182 Mon Sep 17 00:00:00 2001 From: arenukvern Date: Mon, 3 Aug 2026 03:46:55 +0300 Subject: [PATCH 24/27] fix: gitignore --- packages/intentcall_platform_apple/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/intentcall_platform_apple/.gitignore diff --git a/packages/intentcall_platform_apple/.gitignore b/packages/intentcall_platform_apple/.gitignore new file mode 100644 index 0000000..12a96bd --- /dev/null +++ b/packages/intentcall_platform_apple/.gitignore @@ -0,0 +1 @@ +**/.build \ No newline at end of file From ac52964bf7590dce08b8e3f4607d6ea4f9c3616c Mon Sep 17 00:00:00 2001 From: arenukvern Date: Mon, 3 Aug 2026 05:16:12 +0300 Subject: [PATCH 25/27] chore: update pubspec.lock and generated JavaScript files to include new dependencies and adjust schema handling for lists --- .../0023-entity-three-slot-projection.md | 8 +- docs/packages/intentcall_schema.mdx | 4 +- .../web/intentcall_webmcp.generated.js | 445 +++++++------ .../web/intentcall_webmcp.generated.js | 445 +++++++------ .../web/intentcall_webmcp.generated.js | 445 +++++++------ .../intentcall_codegen/example/pubspec.lock | 72 +++ .../web/intentcall_webmcp.generated.js | 595 +++++++++--------- .../generators/agent_catalog_generator.dart | 4 +- .../lib/src/authoring/agent_call_entry.dart | 6 +- .../agent_entity_property_descriptor.dart | 2 +- .../entity/agent_entity_property_role.dart | 13 +- .../entity/agent_entity_snapshot_keys.dart | 52 +- .../agent_entity_snapshot_projection.dart | 3 +- .../entity/agent_entity_snapshot_schema.dart | 2 +- .../lib/src/migrate_agent_entries.dart | 55 +- .../test/agent_entity_snapshot_keys_test.dart | 4 +- ...agent_entity_snapshot_projection_test.dart | 2 +- .../test/migrate_agent_entries_test.dart | 10 +- .../test/intentcall_entity_index_test.dart | 2 +- .../test/pigeon_bridge_contract_test.dart | 4 +- .../IntentCallNativeEntitySnapshotStore.swift | 9 +- .../lib/src/agent_manifest.dart | 2 +- .../lib/src/emitters/web_mcp_js_emitter.dart | 14 +- .../test/web_emitters_test.dart | 4 +- packages/intentcall_schema/README.md | 4 +- .../lib/src/agent_entity_model.dart | 2 +- .../lib/src/agent_wire_args.dart | 6 +- .../intentcall_schema/lib/src/json_utils.dart | 8 + .../lib/src/schema_coercion.dart | 14 +- .../lib/src/schema_validator.dart | 8 +- packages/intentcall_schema/pubspec.yaml | 1 + .../test/schema_validator_test.dart | 4 +- pubspec.lock | 64 ++ skills/register-intents/SKILL.md | 6 +- 34 files changed, 1286 insertions(+), 1033 deletions(-) diff --git a/docs/decisions/0023-entity-three-slot-projection.md b/docs/decisions/0023-entity-three-slot-projection.md index 142bdb8..e9b69f0 100644 --- a/docs/decisions/0023-entity-three-slot-projection.md +++ b/docs/decisions/0023-entity-three-slot-projection.md @@ -19,7 +19,7 @@ native discovery UIs expose a **fixed display surface**: |----------|--------------|----------------|---------------| | Apple `AppEntity` | `title` | `subtitle` | `keywords: [String]` | | Apple `DisplayRepresentation` | title | subtitle | — | -| IntentCall native cache search | `titleKey` field | `subtitleKey` field | `keywordsKey` array | +| IntentCall native cache search | `titleKey` field | `subtitleKey` field | `keywordsKey` list | Apple is the first concrete emitter (`apple.entities`, `apple.spotlight`). Android and Windows entity lanes are not implemented yet, but they will map to the same @@ -44,7 +44,7 @@ snapshot key slots plus the identifier: | `idKey` | Stable entity identifier in cache rows | `id` | | `titleKey` | Primary display string | `title` | | `subtitleKey` | Secondary display string | `subtitle` | -| `keywordsKey` | Search token array | `keywords` | +| `keywordsKey` | Search token list | `keywords` | Emitters and native stores read these keys from the manifest. They do **not** project arbitrary `displayProperties` lists — only one property name per slot. @@ -66,7 +66,7 @@ time. Validation (codegen and `AgentEntitySnapshotKeys.fromDescriptor`): - At most one property per role (`title`, `subtitle`, `keywords`). -- `keywords` role requires `valueType: array`. +- `keywords` role requires `valueType: list`. - Entity-level override names must match a declared property. ### 3. Canonical key resolution @@ -78,7 +78,7 @@ Validation (codegen and `AgentEntitySnapshotKeys.fromDescriptor`): 2. Heuristic fallback for backward compatibility: - `titleKey` ← first `isDisplay` property, else `'title'` - `subtitleKey` ← second `isDisplay`, else first `isSearchable` ≠ title, else `'subtitle'` - - `keywordsKey` ← first `isSearchable` array property, else `'keywords'` + - `keywordsKey` ← first `isSearchable` list property, else `'keywords'` Consumers must call this API — not reimplement heuristics: diff --git a/docs/packages/intentcall_schema.mdx b/docs/packages/intentcall_schema.mdx index ffdcdf8..ad72bad 100644 --- a/docs/packages/intentcall_schema.mdx +++ b/docs/packages/intentcall_schema.mdx @@ -120,11 +120,11 @@ final json = snapshot.toJson(); // round-trips via AgentEntitySnapshot.fromJson | Root `type: object` | Yes | | `required`, `properties` | Yes | | `additionalProperties: false` | Yes | -| Property types: `string`, `integer`, `number`, `boolean`, `object`, `array` | Yes | +| Property types: `string`, `integer`, `number`, `boolean`, `object`, `list` | Yes | | `enum` on strings | Yes | | `minimum` / `maximum` on numbers | Yes | | Array `items` when each item is `type: object` with `required` / `properties` | Yes | -| `pattern`, `format`, `oneOf`, nested object property validation (except array items) | No | +| `pattern`, `format`, `oneOf`, nested object property validation (except list items) | No | | Type coercion | Use `coerceArgumentsForSchema` first | Properties without a `type` are skipped. Unknown keys are allowed unless `additionalProperties` is `false`. diff --git a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js index ac15bc7..dd611d1 100644 --- a/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js @@ -1,218 +1,247 @@ // Generated by intentcall_platform — do not edit by hand. (function intentcallWebMcpBootstrap(global) { - 'use strict'; - var doc = global.document; - var nav = global.navigator; - var modelContext = - doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' - ? doc.modelContext - : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' - ? nav.modelContext - : null; - if (!modelContext) { - return; - } - var fallbackEnabled = false; - var invokePath = "/agent/invoke"; - var tools = [ - { - "name": "app_demo_ping", - "description": "Returns pong for a message", - "inputSchema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Message to echo" - } - }, - "required": [ - "message" - ] - } - } -]; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && + doc.modelContext && + typeof doc.modelContext.registerTool === "function" + ? doc.modelContext + : nav && + nav.modelContext && + typeof nav.modelContext.registerTool === "function" + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + name: "app_demo_ping", + description: "Returns pong for a message", + inputSchema: { + type: "object", + properties: { + message: { + type: "string", + description: "Message to echo", + }, + }, + required: ["message"], + }, + }, + ]; - function validationError(message) { - return { ok: false, code: 'validation_error', message: message }; - } + function validationError(message) { + return { ok: false, code: "validation_error", message: message }; + } - function validateNumericBounds(path, schema, value) { - if (schema.minimum != null && value < schema.minimum) { - return validationError(path + ' must be at least ' + schema.minimum + '.'); - } - if (schema.maximum != null && value > schema.maximum) { - return validationError(path + ' must be at most ' + schema.maximum + '.'); - } - return null; - } + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError( + path + " must be at least " + schema.minimum + ".", + ); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + " must be at most " + schema.maximum + "."); + } + return null; + } - function validateValue(path, schema, value) { - var type = schema.type; - if (!type) return null; - switch (type) { - case 'string': - if (typeof value !== 'string') return validationError(path + ' must be a string.'); - return null; - case 'integer': - if (typeof value !== 'number' || value % 1 !== 0) { - return validationError(path + ' must be an integer.'); - } - return validateNumericBounds(path, schema, value); - case 'number': - if (typeof value !== 'number') return validationError(path + ' must be a number.'); - return validateNumericBounds(path, schema, value); - case 'boolean': - if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); - return null; - case 'object': - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return validationError(path + ' must be an object.'); - } - return null; - case 'array': - if (!Array.isArray(value)) return validationError(path + ' must be an array.'); - var arrayPath = path; - if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && - arrayPath.charAt(arrayPath.length - 1) === '"') { - arrayPath = arrayPath.slice(1, -1); - } - return validateArrayItems(arrayPath, schema, value); - default: - return null; - } - } + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case "string": + if (typeof value !== "string") + return validationError(path + " must be a string."); + return null; + case "integer": + if (typeof value !== "number" || value % 1 !== 0) { + return validationError(path + " must be an integer."); + } + return validateNumericBounds(path, schema, value); + case "number": + if (typeof value !== "number") + return validationError(path + " must be a number."); + return validateNumericBounds(path, schema, value); + case "boolean": + if (typeof value !== "boolean") + return validationError(path + " must be a boolean."); + return null; + case "object": + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) + ) { + return validationError(path + " must be an object."); + } + return null; + case "list": { + if (!Array.isArray(value)) + return validationError(path + " must be an list."); + var listPath = path; + if ( + listPath.length >= 2 && + listPath.charAt(0) === '"' && + listPath.charAt(listPath.length - 1) === '"' + ) { + listPath = listPath.slice(1, -1); + } + return validateArrayItems(listPath, schema, value); + } + default: + return null; + } + } - function validateObjectProperties(pathPrefix, schema, args) { - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Unknown property "' + key + '"' + at + '.'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var name = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, name)) { - var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Missing required property "' + name + '"' + atReq + '.'); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; - var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); - if (propErr) return propErr; - } - return null; - } + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError('Unknown property "' + key + '"' + at + "."); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.hasOwn(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError( + 'Missing required property "' + name + '"' + atReq + ".", + ); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + "." + prop : prop; + var propErr = validateValue( + '"' + childPath + '"', + properties[prop], + args[prop], + ); + if (propErr) return propErr; + } + return null; + } - function validateArrayItems(path, schema, value) { - var items = schema.items; - if (!items || typeof items !== 'object' || Array.isArray(items)) return null; - if (items.type !== 'object') return null; - var itemProperties = items.properties || {}; - var itemRequired = items.required; - var hasRequired = itemRequired && itemRequired.length; - var hasProps = false; - for (var pk in itemProperties) { - if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { - hasProps = true; - break; - } - } - if (!hasProps && !hasRequired) return null; - for (var i = 0; i < value.length; i += 1) { - var element = value[i]; - var elementPath = path + '[' + i + ']'; - if (typeof element !== 'object' || element === null || Array.isArray(element)) { - return validationError('"' + elementPath + '" must be an object.'); - } - var objErr = validateObjectProperties(elementPath, items, element); - if (objErr) return objErr; - } - return null; - } + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== "object" || Array.isArray(items)) + return null; + if (items.type !== "object") return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.hasOwn(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + "[" + i + "]"; + if ( + typeof element !== "object" || + element === null || + Array.isArray(element) + ) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } - function validateInput(schema, args) { - if (!schema || schema.type !== 'object') return null; - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - return validationError('Unknown property "' + key + '".'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var reqKey = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, reqKey) || - args[reqKey] === undefined || args[reqKey] === null) { - return validationError('Missing required property: ' + reqKey); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var val = args[prop]; - if (val === undefined || val === null) continue; - var propErr = validateValue('"' + prop + '"', properties[prop], val); - if (propErr) return propErr; - } - return null; - } + function validateInput(schema, args) { + if (!schema || schema.type !== "object") return null; + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if ( + !Object.hasOwn(args, reqKey) || + args[reqKey] === undefined || + args[reqKey] === null + ) { + return validationError("Missing required property: " + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } - function fetchInvoke(name, args) { - if (!fallbackEnabled) { - return Promise.resolve({ - ok: false, - code: 'runtime_unavailable', - message: 'No Dart WebMCP runtime registered for ' + name + '.', - }); - } - return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(args || {}), - }).then(function (response) { - return response.json(); - }); - } + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: "runtime_unavailable", + message: "No Dart WebMCP runtime registered for " + name + ".", + }); + } + return global + .fetch(invokePath + "?name=" + encodeURIComponent(name), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args || {}), + }) + .then((response) => response.json()); + } - tools.forEach(function (tool) { - try { - modelContext.registerTool({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - execute: function (args) { - var err = validateInput(tool.inputSchema, args); - if (err) return Promise.resolve(err); - var dart = global.__intentcallWebMcpDartExecute; - if (typeof dart === 'function') { - return Promise.resolve(dart(tool.name, args || {})).then(function (result) { - if (result != null) return result; - return fetchInvoke(tool.name, args); - }); - } - return fetchInvoke(tool.name, args); - }, - }); - } catch (e) { - // Hot restart / Dart bootstrap may have registered the same name. - } - }); -})(typeof globalThis !== 'undefined' ? globalThis : window); + tools.forEach((tool) => { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: (args) => { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === "function") { + return Promise.resolve(dart(tool.name, args || {})).then( + (result) => { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }, + ); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== "undefined" ? globalThis : window); diff --git a/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js index 4f78863..04fd9c3 100644 --- a/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js @@ -1,218 +1,247 @@ // Generated by intentcall_platform — do not edit by hand. (function intentcallWebMcpBootstrap(global) { - 'use strict'; - var doc = global.document; - var nav = global.navigator; - var modelContext = - doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' - ? doc.modelContext - : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' - ? nav.modelContext - : null; - if (!modelContext) { - return; - } - var fallbackEnabled = false; - var invokePath = "/agent/invoke"; - var tools = [ - { - "name": "app_cart_total", - "description": "Return cart total", - "inputSchema": { - "type": "object", - "properties": { - "currency": { - "type": "string", - "description": "Currency code" - } - }, - "required": [ - "currency" - ] - } - } -]; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && + doc.modelContext && + typeof doc.modelContext.registerTool === "function" + ? doc.modelContext + : nav && + nav.modelContext && + typeof nav.modelContext.registerTool === "function" + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + name: "app_cart_total", + description: "Return cart total", + inputSchema: { + type: "object", + properties: { + currency: { + type: "string", + description: "Currency code", + }, + }, + required: ["currency"], + }, + }, + ]; - function validationError(message) { - return { ok: false, code: 'validation_error', message: message }; - } + function validationError(message) { + return { ok: false, code: "validation_error", message: message }; + } - function validateNumericBounds(path, schema, value) { - if (schema.minimum != null && value < schema.minimum) { - return validationError(path + ' must be at least ' + schema.minimum + '.'); - } - if (schema.maximum != null && value > schema.maximum) { - return validationError(path + ' must be at most ' + schema.maximum + '.'); - } - return null; - } + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError( + path + " must be at least " + schema.minimum + ".", + ); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + " must be at most " + schema.maximum + "."); + } + return null; + } - function validateValue(path, schema, value) { - var type = schema.type; - if (!type) return null; - switch (type) { - case 'string': - if (typeof value !== 'string') return validationError(path + ' must be a string.'); - return null; - case 'integer': - if (typeof value !== 'number' || value % 1 !== 0) { - return validationError(path + ' must be an integer.'); - } - return validateNumericBounds(path, schema, value); - case 'number': - if (typeof value !== 'number') return validationError(path + ' must be a number.'); - return validateNumericBounds(path, schema, value); - case 'boolean': - if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); - return null; - case 'object': - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return validationError(path + ' must be an object.'); - } - return null; - case 'array': - if (!Array.isArray(value)) return validationError(path + ' must be an array.'); - var arrayPath = path; - if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && - arrayPath.charAt(arrayPath.length - 1) === '"') { - arrayPath = arrayPath.slice(1, -1); - } - return validateArrayItems(arrayPath, schema, value); - default: - return null; - } - } + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case "string": + if (typeof value !== "string") + return validationError(path + " must be a string."); + return null; + case "integer": + if (typeof value !== "number" || value % 1 !== 0) { + return validationError(path + " must be an integer."); + } + return validateNumericBounds(path, schema, value); + case "number": + if (typeof value !== "number") + return validationError(path + " must be a number."); + return validateNumericBounds(path, schema, value); + case "boolean": + if (typeof value !== "boolean") + return validationError(path + " must be a boolean."); + return null; + case "object": + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) + ) { + return validationError(path + " must be an object."); + } + return null; + case "list": { + if (!Array.isArray(value)) + return validationError(path + " must be an list."); + var listPath = path; + if ( + listPath.length >= 2 && + listPath.charAt(0) === '"' && + listPath.charAt(listPath.length - 1) === '"' + ) { + listPath = listPath.slice(1, -1); + } + return validateArrayItems(listPath, schema, value); + } + default: + return null; + } + } - function validateObjectProperties(pathPrefix, schema, args) { - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Unknown property "' + key + '"' + at + '.'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var name = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, name)) { - var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Missing required property "' + name + '"' + atReq + '.'); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; - var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); - if (propErr) return propErr; - } - return null; - } + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError('Unknown property "' + key + '"' + at + "."); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.hasOwn(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError( + 'Missing required property "' + name + '"' + atReq + ".", + ); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + "." + prop : prop; + var propErr = validateValue( + '"' + childPath + '"', + properties[prop], + args[prop], + ); + if (propErr) return propErr; + } + return null; + } - function validateArrayItems(path, schema, value) { - var items = schema.items; - if (!items || typeof items !== 'object' || Array.isArray(items)) return null; - if (items.type !== 'object') return null; - var itemProperties = items.properties || {}; - var itemRequired = items.required; - var hasRequired = itemRequired && itemRequired.length; - var hasProps = false; - for (var pk in itemProperties) { - if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { - hasProps = true; - break; - } - } - if (!hasProps && !hasRequired) return null; - for (var i = 0; i < value.length; i += 1) { - var element = value[i]; - var elementPath = path + '[' + i + ']'; - if (typeof element !== 'object' || element === null || Array.isArray(element)) { - return validationError('"' + elementPath + '" must be an object.'); - } - var objErr = validateObjectProperties(elementPath, items, element); - if (objErr) return objErr; - } - return null; - } + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== "object" || Array.isArray(items)) + return null; + if (items.type !== "object") return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.hasOwn(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + "[" + i + "]"; + if ( + typeof element !== "object" || + element === null || + Array.isArray(element) + ) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } - function validateInput(schema, args) { - if (!schema || schema.type !== 'object') return null; - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - return validationError('Unknown property "' + key + '".'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var reqKey = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, reqKey) || - args[reqKey] === undefined || args[reqKey] === null) { - return validationError('Missing required property: ' + reqKey); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var val = args[prop]; - if (val === undefined || val === null) continue; - var propErr = validateValue('"' + prop + '"', properties[prop], val); - if (propErr) return propErr; - } - return null; - } + function validateInput(schema, args) { + if (!schema || schema.type !== "object") return null; + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if ( + !Object.hasOwn(args, reqKey) || + args[reqKey] === undefined || + args[reqKey] === null + ) { + return validationError("Missing required property: " + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } - function fetchInvoke(name, args) { - if (!fallbackEnabled) { - return Promise.resolve({ - ok: false, - code: 'runtime_unavailable', - message: 'No Dart WebMCP runtime registered for ' + name + '.', - }); - } - return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(args || {}), - }).then(function (response) { - return response.json(); - }); - } + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: "runtime_unavailable", + message: "No Dart WebMCP runtime registered for " + name + ".", + }); + } + return global + .fetch(invokePath + "?name=" + encodeURIComponent(name), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args || {}), + }) + .then((response) => response.json()); + } - tools.forEach(function (tool) { - try { - modelContext.registerTool({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - execute: function (args) { - var err = validateInput(tool.inputSchema, args); - if (err) return Promise.resolve(err); - var dart = global.__intentcallWebMcpDartExecute; - if (typeof dart === 'function') { - return Promise.resolve(dart(tool.name, args || {})).then(function (result) { - if (result != null) return result; - return fetchInvoke(tool.name, args); - }); - } - return fetchInvoke(tool.name, args); - }, - }); - } catch (e) { - // Hot restart / Dart bootstrap may have registered the same name. - } - }); -})(typeof globalThis !== 'undefined' ? globalThis : window); + tools.forEach((tool) => { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: (args) => { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === "function") { + return Promise.resolve(dart(tool.name, args || {})).then( + (result) => { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }, + ); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== "undefined" ? globalThis : window); diff --git a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js index db4085b..a3b2e30 100644 --- a/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js @@ -1,218 +1,247 @@ // Generated by intentcall_platform — do not edit by hand. (function intentcallWebMcpBootstrap(global) { - 'use strict'; - var doc = global.document; - var nav = global.navigator; - var modelContext = - doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' - ? doc.modelContext - : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' - ? nav.modelContext - : null; - if (!modelContext) { - return; - } - var fallbackEnabled = false; - var invokePath = "/agent/invoke"; - var tools = [ - { - "name": "shop_search", - "description": "Search catalog", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Query" - } - }, - "required": [ - "query" - ] - } - } -]; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && + doc.modelContext && + typeof doc.modelContext.registerTool === "function" + ? doc.modelContext + : nav && + nav.modelContext && + typeof nav.modelContext.registerTool === "function" + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + name: "shop_search", + description: "Search catalog", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Query", + }, + }, + required: ["query"], + }, + }, + ]; - function validationError(message) { - return { ok: false, code: 'validation_error', message: message }; - } + function validationError(message) { + return { ok: false, code: "validation_error", message: message }; + } - function validateNumericBounds(path, schema, value) { - if (schema.minimum != null && value < schema.minimum) { - return validationError(path + ' must be at least ' + schema.minimum + '.'); - } - if (schema.maximum != null && value > schema.maximum) { - return validationError(path + ' must be at most ' + schema.maximum + '.'); - } - return null; - } + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError( + path + " must be at least " + schema.minimum + ".", + ); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + " must be at most " + schema.maximum + "."); + } + return null; + } - function validateValue(path, schema, value) { - var type = schema.type; - if (!type) return null; - switch (type) { - case 'string': - if (typeof value !== 'string') return validationError(path + ' must be a string.'); - return null; - case 'integer': - if (typeof value !== 'number' || value % 1 !== 0) { - return validationError(path + ' must be an integer.'); - } - return validateNumericBounds(path, schema, value); - case 'number': - if (typeof value !== 'number') return validationError(path + ' must be a number.'); - return validateNumericBounds(path, schema, value); - case 'boolean': - if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); - return null; - case 'object': - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return validationError(path + ' must be an object.'); - } - return null; - case 'array': - if (!Array.isArray(value)) return validationError(path + ' must be an array.'); - var arrayPath = path; - if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && - arrayPath.charAt(arrayPath.length - 1) === '"') { - arrayPath = arrayPath.slice(1, -1); - } - return validateArrayItems(arrayPath, schema, value); - default: - return null; - } - } + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case "string": + if (typeof value !== "string") + return validationError(path + " must be a string."); + return null; + case "integer": + if (typeof value !== "number" || value % 1 !== 0) { + return validationError(path + " must be an integer."); + } + return validateNumericBounds(path, schema, value); + case "number": + if (typeof value !== "number") + return validationError(path + " must be a number."); + return validateNumericBounds(path, schema, value); + case "boolean": + if (typeof value !== "boolean") + return validationError(path + " must be a boolean."); + return null; + case "object": + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) + ) { + return validationError(path + " must be an object."); + } + return null; + case "list": { + if (!Array.isArray(value)) + return validationError(path + " must be an list."); + var listPath = path; + if ( + listPath.length >= 2 && + listPath.charAt(0) === '"' && + listPath.charAt(listPath.length - 1) === '"' + ) { + listPath = listPath.slice(1, -1); + } + return validateArrayItems(listPath, schema, value); + } + default: + return null; + } + } - function validateObjectProperties(pathPrefix, schema, args) { - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Unknown property "' + key + '"' + at + '.'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var name = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, name)) { - var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Missing required property "' + name + '"' + atReq + '.'); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; - var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); - if (propErr) return propErr; - } - return null; - } + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError('Unknown property "' + key + '"' + at + "."); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.hasOwn(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError( + 'Missing required property "' + name + '"' + atReq + ".", + ); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + "." + prop : prop; + var propErr = validateValue( + '"' + childPath + '"', + properties[prop], + args[prop], + ); + if (propErr) return propErr; + } + return null; + } - function validateArrayItems(path, schema, value) { - var items = schema.items; - if (!items || typeof items !== 'object' || Array.isArray(items)) return null; - if (items.type !== 'object') return null; - var itemProperties = items.properties || {}; - var itemRequired = items.required; - var hasRequired = itemRequired && itemRequired.length; - var hasProps = false; - for (var pk in itemProperties) { - if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { - hasProps = true; - break; - } - } - if (!hasProps && !hasRequired) return null; - for (var i = 0; i < value.length; i += 1) { - var element = value[i]; - var elementPath = path + '[' + i + ']'; - if (typeof element !== 'object' || element === null || Array.isArray(element)) { - return validationError('"' + elementPath + '" must be an object.'); - } - var objErr = validateObjectProperties(elementPath, items, element); - if (objErr) return objErr; - } - return null; - } + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== "object" || Array.isArray(items)) + return null; + if (items.type !== "object") return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.hasOwn(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + "[" + i + "]"; + if ( + typeof element !== "object" || + element === null || + Array.isArray(element) + ) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } - function validateInput(schema, args) { - if (!schema || schema.type !== 'object') return null; - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - return validationError('Unknown property "' + key + '".'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var reqKey = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, reqKey) || - args[reqKey] === undefined || args[reqKey] === null) { - return validationError('Missing required property: ' + reqKey); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var val = args[prop]; - if (val === undefined || val === null) continue; - var propErr = validateValue('"' + prop + '"', properties[prop], val); - if (propErr) return propErr; - } - return null; - } + function validateInput(schema, args) { + if (!schema || schema.type !== "object") return null; + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if ( + !Object.hasOwn(args, reqKey) || + args[reqKey] === undefined || + args[reqKey] === null + ) { + return validationError("Missing required property: " + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } - function fetchInvoke(name, args) { - if (!fallbackEnabled) { - return Promise.resolve({ - ok: false, - code: 'runtime_unavailable', - message: 'No Dart WebMCP runtime registered for ' + name + '.', - }); - } - return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(args || {}), - }).then(function (response) { - return response.json(); - }); - } + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: "runtime_unavailable", + message: "No Dart WebMCP runtime registered for " + name + ".", + }); + } + return global + .fetch(invokePath + "?name=" + encodeURIComponent(name), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args || {}), + }) + .then((response) => response.json()); + } - tools.forEach(function (tool) { - try { - modelContext.registerTool({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - execute: function (args) { - var err = validateInput(tool.inputSchema, args); - if (err) return Promise.resolve(err); - var dart = global.__intentcallWebMcpDartExecute; - if (typeof dart === 'function') { - return Promise.resolve(dart(tool.name, args || {})).then(function (result) { - if (result != null) return result; - return fetchInvoke(tool.name, args); - }); - } - return fetchInvoke(tool.name, args); - }, - }); - } catch (e) { - // Hot restart / Dart bootstrap may have registered the same name. - } - }); -})(typeof globalThis !== 'undefined' ? globalThis : window); + tools.forEach((tool) => { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: (args) => { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === "function") { + return Promise.resolve(dart(tool.name, args || {})).then( + (result) => { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }, + ); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== "undefined" ? globalThis : window); diff --git a/packages/intentcall_codegen/example/pubspec.lock b/packages/intentcall_codegen/example/pubspec.lock index a3a94c0..3434e5e 100644 --- a/packages/intentcall_codegen/example/pubspec.lock +++ b/packages/intentcall_codegen/example/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.6" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -145,6 +161,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + decimal: + dependency: transitive + description: + name: decimal + sha256: "2c3c8b74f2948066d3f42585477aec9cfc48fefd7a723a4d4274a6c71a5c0df7" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + email_validator: + dependency: transitive + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" file: dependency: transitive description: @@ -193,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -237,6 +277,14 @@ packages: relative: true source: path version: "0.6.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" io: dependency: transitive description: @@ -261,6 +309,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + json_schema_builder: + dependency: transitive + description: + name: json_schema_builder + sha256: e46b1a2957590d2c811f47b22079710a273ebf9c8240a9e1440b759efb8ded5f + url: "https://pub.dev" + source: hosted + version: "0.1.6" lints: dependency: "direct dev" description: @@ -349,6 +405,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + rational: + dependency: transitive + description: + name: rational + sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + url: "https://pub.dev" + source: hosted + version: "2.2.3" + schemantic: + dependency: transitive + description: + name: schemantic + sha256: "3597f1c6bfcfc95e92bb215a8868be0494105b6b6c12228b184253616047d5d2" + url: "https://pub.dev" + source: hosted + version: "0.2.2" shelf: dependency: transitive description: diff --git a/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js index 52530f2..072efea 100644 --- a/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js +++ b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js @@ -1,298 +1,317 @@ // Generated by intentcall_platform — do not edit by hand. (function intentcallWebMcpBootstrap(global) { - 'use strict'; - var doc = global.document; - var nav = global.navigator; - var modelContext = - doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' - ? doc.modelContext - : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' - ? nav.modelContext - : null; - if (!modelContext) { - return; - } - var fallbackEnabled = false; - var invokePath = "/agent/invoke"; - var tools = [ - { - "name": "app_demo_host_status", - "description": "Codegen instance-method host tool", - "inputSchema": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Host label" - } - }, - "required": [ - "label" - ] - } - }, - { - "name": "app_demo_ping", - "description": "Returns pong for a message", - "inputSchema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Message to echo" - } - }, - "required": [ - "message" - ] - } - }, - { - "name": "app_demo_required_named", - "description": "Returns a required named parameter", - "inputSchema": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "description": "Mode" - } - }, - "required": [ - "mode" - ] - } - }, - { - "name": "app_demo_set_greeting", - "description": "Set greeting text for the codegen demo host.", - "inputSchema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Greeting text" - } - }, - "required": [ - "text" - ] - } - }, - { - "name": "app_demo_inbox", - "description": "Read inbox folder", - "inputSchema": { - "type": "object", - "properties": { - "folder": { - "type": "string", - "description": "Inbox folder name" - } - }, - "required": [ - "folder" - ] - } - }, - { - "name": "app_demo_handwritten", - "description": "Handwritten instance-bound tool", - "inputSchema": { - "type": "object", - "properties": { - "note": { - "type": "string", - "description": "Note to echo" - } - }, - "required": [ - "note" - ] - } - } -]; + var doc = global.document; + var nav = global.navigator; + var modelContext = + doc && + doc.modelContext && + typeof doc.modelContext.registerTool === "function" + ? doc.modelContext + : nav && + nav.modelContext && + typeof nav.modelContext.registerTool === "function" + ? nav.modelContext + : null; + if (!modelContext) { + return; + } + var fallbackEnabled = false; + var invokePath = "/agent/invoke"; + var tools = [ + { + name: "app_demo_host_status", + description: "Codegen instance-method host tool", + inputSchema: { + type: "object", + properties: { + label: { + type: "string", + description: "Host label", + }, + }, + required: ["label"], + }, + }, + { + name: "app_demo_ping", + description: "Returns pong for a message", + inputSchema: { + type: "object", + properties: { + message: { + type: "string", + description: "Message to echo", + }, + }, + required: ["message"], + }, + }, + { + name: "app_demo_required_named", + description: "Returns a required named parameter", + inputSchema: { + type: "object", + properties: { + mode: { + type: "string", + description: "Mode", + }, + }, + required: ["mode"], + }, + }, + { + name: "app_demo_set_greeting", + description: "Set greeting text for the codegen demo host.", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Greeting text", + }, + }, + required: ["text"], + }, + }, + { + name: "app_demo_inbox", + description: "Read inbox folder", + inputSchema: { + type: "object", + properties: { + folder: { + type: "string", + description: "Inbox folder name", + }, + }, + required: ["folder"], + }, + }, + { + name: "app_demo_handwritten", + description: "Handwritten instance-bound tool", + inputSchema: { + type: "object", + properties: { + note: { + type: "string", + description: "Note to echo", + }, + }, + required: ["note"], + }, + }, + ]; - function validationError(message) { - return { ok: false, code: 'validation_error', message: message }; - } + function validationError(message) { + return { ok: false, code: "validation_error", message: message }; + } - function validateNumericBounds(path, schema, value) { - if (schema.minimum != null && value < schema.minimum) { - return validationError(path + ' must be at least ' + schema.minimum + '.'); - } - if (schema.maximum != null && value > schema.maximum) { - return validationError(path + ' must be at most ' + schema.maximum + '.'); - } - return null; - } + function validateNumericBounds(path, schema, value) { + if (schema.minimum != null && value < schema.minimum) { + return validationError( + path + " must be at least " + schema.minimum + ".", + ); + } + if (schema.maximum != null && value > schema.maximum) { + return validationError(path + " must be at most " + schema.maximum + "."); + } + return null; + } - function validateValue(path, schema, value) { - var type = schema.type; - if (!type) return null; - switch (type) { - case 'string': - if (typeof value !== 'string') return validationError(path + ' must be a string.'); - return null; - case 'integer': - if (typeof value !== 'number' || value % 1 !== 0) { - return validationError(path + ' must be an integer.'); - } - return validateNumericBounds(path, schema, value); - case 'number': - if (typeof value !== 'number') return validationError(path + ' must be a number.'); - return validateNumericBounds(path, schema, value); - case 'boolean': - if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); - return null; - case 'object': - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return validationError(path + ' must be an object.'); - } - return null; - case 'array': - if (!Array.isArray(value)) return validationError(path + ' must be an array.'); - var arrayPath = path; - if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && - arrayPath.charAt(arrayPath.length - 1) === '"') { - arrayPath = arrayPath.slice(1, -1); - } - return validateArrayItems(arrayPath, schema, value); - default: - return null; - } - } + function validateValue(path, schema, value) { + var type = schema.type; + if (!type) return null; + switch (type) { + case "string": + if (typeof value !== "string") + return validationError(path + " must be a string."); + return null; + case "integer": + if (typeof value !== "number" || value % 1 !== 0) { + return validationError(path + " must be an integer."); + } + return validateNumericBounds(path, schema, value); + case "number": + if (typeof value !== "number") + return validationError(path + " must be a number."); + return validateNumericBounds(path, schema, value); + case "boolean": + if (typeof value !== "boolean") + return validationError(path + " must be a boolean."); + return null; + case "object": + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) + ) { + return validationError(path + " must be an object."); + } + return null; + case "list": { + if (!Array.isArray(value)) + return validationError(path + " must be an list."); + var listPath = path; + if ( + listPath.length >= 2 && + listPath.charAt(0) === '"' && + listPath.charAt(listPath.length - 1) === '"' + ) { + listPath = listPath.slice(1, -1); + } + return validateArrayItems(listPath, schema, value); + } + default: + return null; + } + } - function validateObjectProperties(pathPrefix, schema, args) { - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Unknown property "' + key + '"' + at + '.'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var name = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, name)) { - var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Missing required property "' + name + '"' + atReq + '.'); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; - var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); - if (propErr) return propErr; - } - return null; - } + function validateObjectProperties(pathPrefix, schema, args) { + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + var at = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError('Unknown property "' + key + '"' + at + "."); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var name = required[r]; + if (!Object.hasOwn(args, name)) { + var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ""; + return validationError( + 'Missing required property "' + name + '"' + atReq + ".", + ); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var childPath = pathPrefix ? pathPrefix + "." + prop : prop; + var propErr = validateValue( + '"' + childPath + '"', + properties[prop], + args[prop], + ); + if (propErr) return propErr; + } + return null; + } - function validateArrayItems(path, schema, value) { - var items = schema.items; - if (!items || typeof items !== 'object' || Array.isArray(items)) return null; - if (items.type !== 'object') return null; - var itemProperties = items.properties || {}; - var itemRequired = items.required; - var hasRequired = itemRequired && itemRequired.length; - var hasProps = false; - for (var pk in itemProperties) { - if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { - hasProps = true; - break; - } - } - if (!hasProps && !hasRequired) return null; - for (var i = 0; i < value.length; i += 1) { - var element = value[i]; - var elementPath = path + '[' + i + ']'; - if (typeof element !== 'object' || element === null || Array.isArray(element)) { - return validationError('"' + elementPath + '" must be an object.'); - } - var objErr = validateObjectProperties(elementPath, items, element); - if (objErr) return objErr; - } - return null; - } + function validateArrayItems(path, schema, value) { + var items = schema.items; + if (!items || typeof items !== "object" || Array.isArray(items)) + return null; + if (items.type !== "object") return null; + var itemProperties = items.properties || {}; + var itemRequired = items.required; + var hasRequired = itemRequired && itemRequired.length; + var hasProps = false; + for (var pk in itemProperties) { + if (Object.hasOwn(itemProperties, pk)) { + hasProps = true; + break; + } + } + if (!hasProps && !hasRequired) return null; + for (var i = 0; i < value.length; i += 1) { + var element = value[i]; + var elementPath = path + "[" + i + "]"; + if ( + typeof element !== "object" || + element === null || + Array.isArray(element) + ) { + return validationError('"' + elementPath + '" must be an object.'); + } + var objErr = validateObjectProperties(elementPath, items, element); + if (objErr) return objErr; + } + return null; + } - function validateInput(schema, args) { - if (!schema || schema.type !== 'object') return null; - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - return validationError('Unknown property "' + key + '".'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var reqKey = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, reqKey) || - args[reqKey] === undefined || args[reqKey] === null) { - return validationError('Missing required property: ' + reqKey); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var val = args[prop]; - if (val === undefined || val === null) continue; - var propErr = validateValue('"' + prop + '"', properties[prop], val); - if (propErr) return propErr; - } - return null; - } + function validateInput(schema, args) { + if (!schema || schema.type !== "object") return null; + args = args && typeof args === "object" && !Array.isArray(args) ? args : {}; + var properties = schema.properties || {}; + if (schema.additionalProperties === false) { + for (var key in args) { + if (Object.hasOwn(args, key) && !Object.hasOwn(properties, key)) { + return validationError('Unknown property "' + key + '".'); + } + } + } + var required = schema.required; + if (required && required.length) { + for (var r = 0; r < required.length; r += 1) { + var reqKey = required[r]; + if ( + !Object.hasOwn(args, reqKey) || + args[reqKey] === undefined || + args[reqKey] === null + ) { + return validationError("Missing required property: " + reqKey); + } + } + } + for (var prop in properties) { + if (!Object.hasOwn(properties, prop)) continue; + if (!Object.hasOwn(args, prop)) continue; + var val = args[prop]; + if (val === undefined || val === null) continue; + var propErr = validateValue('"' + prop + '"', properties[prop], val); + if (propErr) return propErr; + } + return null; + } - function fetchInvoke(name, args) { - if (!fallbackEnabled) { - return Promise.resolve({ - ok: false, - code: 'runtime_unavailable', - message: 'No Dart WebMCP runtime registered for ' + name + '.', - }); - } - return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(args || {}), - }).then(function (response) { - return response.json(); - }); - } + function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: "runtime_unavailable", + message: "No Dart WebMCP runtime registered for " + name + ".", + }); + } + return global + .fetch(invokePath + "?name=" + encodeURIComponent(name), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args || {}), + }) + .then((response) => response.json()); + } - tools.forEach(function (tool) { - try { - modelContext.registerTool({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - execute: function (args) { - var err = validateInput(tool.inputSchema, args); - if (err) return Promise.resolve(err); - var dart = global.__intentcallWebMcpDartExecute; - if (typeof dart === 'function') { - return Promise.resolve(dart(tool.name, args || {})).then(function (result) { - if (result != null) return result; - return fetchInvoke(tool.name, args); - }); - } - return fetchInvoke(tool.name, args); - }, - }); - } catch (e) { - // Hot restart / Dart bootstrap may have registered the same name. - } - }); -})(typeof globalThis !== 'undefined' ? globalThis : window); + tools.forEach((tool) => { + try { + modelContext.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + execute: (args) => { + var err = validateInput(tool.inputSchema, args); + if (err) return Promise.resolve(err); + var dart = global.__intentcallWebMcpDartExecute; + if (typeof dart === "function") { + return Promise.resolve(dart(tool.name, args || {})).then( + (result) => { + if (result != null) return result; + return fetchInvoke(tool.name, args); + }, + ); + } + return fetchInvoke(tool.name, args); + }, + }); + } catch (e) { + // Hot restart / Dart bootstrap may have registered the same name. + } + }); +})(typeof globalThis !== "undefined" ? globalThis : window); diff --git a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart index 4aa2392..d28f423 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -961,10 +961,10 @@ $fields isSearchable: isSearchable, ); - if (role == AgentEntityPropertyRole.keywords && valueType != 'array') { + if (role == AgentEntityPropertyRole.keywords && valueType != 'list') { throw InvalidGenerationSourceError( "@AgentEntity property '$propertyName' has role 'keywords' but " - "valueType '$valueType'; keywords requires valueType 'array'.", + "valueType '$valueType'; keywords requires valueType 'list'.", element: element, ); } diff --git a/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart b/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart index cea1b7d..28d4c4c 100644 --- a/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart +++ b/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart @@ -11,7 +11,7 @@ import '../registry/agent_registry.dart'; typedef AgentCallHandler = FutureOr Function(AgentArguments request); -typedef _AgentCallEntryValue = ({ +typedef AgentCallEntryValue = ({ String namespace, String description, InputSchema inputSchema, @@ -23,8 +23,8 @@ typedef _AgentCallEntryValue = ({ }); extension type const AgentCallEntry._( - MapEntry _entry -) implements MapEntry { + MapEntry _entry +) implements MapEntry { factory AgentCallEntry.tool({ required final String namespace, required final String name, diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart b/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart index ab831ba..0d6c47a 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_property_descriptor.dart @@ -9,7 +9,7 @@ enum AgentEntityPropertyValueType { number, boolean, object, - array, + list, } enum AgentEntityPrivacy { public, private, sensitive } diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart b/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart index aeaef9e..a7cac4b 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart @@ -1 +1,12 @@ -enum AgentEntityPropertyRole { none, title, subtitle, keywords } +enum AgentEntityPropertyRole { + none, + title, + subtitle, + keywords; + + static List get validValues => [ + title, + subtitle, + keywords, + ]; +} diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart index eb1f300..67bef2f 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart @@ -15,7 +15,8 @@ final class AgentEntitySnapshotKeys { ) { _validateUniqueRoles(descriptor.properties); - final titleKey = _propertyNameWithRole( + final titleKey = + _propertyNameWithRole( descriptor.properties, AgentEntityPropertyRole.title, ) ?? @@ -25,7 +26,8 @@ final class AgentEntitySnapshotKeys { ) ?? 'title'; - final subtitleKey = _propertyNameWithRole( + final subtitleKey = + _propertyNameWithRole( descriptor.properties, AgentEntityPropertyRole.subtitle, ) ?? @@ -34,29 +36,28 @@ final class AgentEntitySnapshotKeys { (final property) => property.isDisplay, 1, ) ?? - _firstOrNull( - descriptor.properties - .where( - (final property) => - property.isSearchable && property.name != titleKey, - ) - .map((final property) => property.name), - ) ?? + descriptor.properties + .where( + (final property) => + property.isSearchable && property.name != titleKey, + ) + .map((final property) => property.name) + .firstOrNull ?? 'subtitle'; - final keywordsKey = _propertyNameWithRole( + final keywordsKey = + _propertyNameWithRole( descriptor.properties, AgentEntityPropertyRole.keywords, ) ?? - _firstOrNull( - descriptor.properties - .where( - (final property) => - property.isSearchable && - property.valueType == AgentEntityPropertyValueType.array, - ) - .map((final property) => property.name), - ) ?? + descriptor.properties + .where( + (final property) => + property.isSearchable && + property.valueType == AgentEntityPropertyValueType.list, + ) + .map((final property) => property.name) + .firstOrNull ?? 'keywords'; return AgentEntitySnapshotKeys( @@ -76,11 +77,7 @@ final class AgentEntitySnapshotKeys { void _validateUniqueRoles( final Iterable properties, ) { - for (final role in [ - AgentEntityPropertyRole.title, - AgentEntityPropertyRole.subtitle, - AgentEntityPropertyRole.keywords, - ]) { + for (final role in AgentEntityPropertyRole.validValues) { final matches = properties .where((final property) => property.role == role) .toList(); @@ -134,8 +131,3 @@ String? _nthPropertyNameWhere( } return null; } - -String? _firstOrNull(final Iterable values) { - final iterator = values.iterator; - return iterator.moveNext() ? iterator.current : null; -} diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart index c6a7f41..db34c0f 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_projection.dart @@ -14,8 +14,7 @@ Map projectAgentEntitySnapshot( ) { final keys = AgentEntitySnapshotKeys.fromDescriptor(descriptor); final title = _fieldValue(snapshot, keys.titleKey) ?? snapshot.effectiveTitle; - final subtitle = - _fieldValue(snapshot, keys.subtitleKey) ?? snapshot.subtitle; + final subtitle = _fieldValue(snapshot, keys.subtitleKey) ?? snapshot.subtitle; final keywords = _fieldValue(snapshot, keys.keywordsKey) ?? (snapshot.keywords.isNotEmpty ? snapshot.keywords : null); diff --git a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart index e2c90bd..52d9304 100644 --- a/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart +++ b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_schema.dart @@ -35,5 +35,5 @@ String _jsonSchemaType(final AgentEntityPropertyValueType type) => AgentEntityPropertyValueType.number => 'number', AgentEntityPropertyValueType.boolean => 'boolean', AgentEntityPropertyValueType.object => 'object', - AgentEntityPropertyValueType.array => 'array', + AgentEntityPropertyValueType.list => 'list', }; diff --git a/packages/intentcall_core/lib/src/migrate_agent_entries.dart b/packages/intentcall_core/lib/src/migrate_agent_entries.dart index d47d56d..b79b5a5 100644 --- a/packages/intentcall_core/lib/src/migrate_agent_entries.dart +++ b/packages/intentcall_core/lib/src/migrate_agent_entries.dart @@ -44,7 +44,7 @@ final class MigrateAgentEntriesReport { /// /// Limitations (documented in migration_mcp_call_entry_to_agent_call_entry.md): /// - Extension types wrapping MCPCallEntry need manual namespace/handler review. -/// - Nested [ObjectSchema] / array fields are only partially preserved; see +/// - Nested [ObjectSchema] / list fields are only partially preserved; see /// `TODO(migrate)` comments in generated `inputSchema` and refine manually. /// - Does not remove MCPCallEntry bridge types (Phase 6b). final class MigrateAgentEntriesMigrator { @@ -394,8 +394,8 @@ final class MigrateAgentEntriesMigrator { final properties = {}; final typePattern = RegExp( r"'([^']+)':\s*(?:const\s+)?" - '(StringSchema|IntegerSchema|BooleanSchema|NumberSchema|ObjectSchema|ArraySchema|' - r'Schema\.string|Schema\.int|Schema\.bool|Schema\.num|Schema\.object|Schema\.array)\b', + '(StringSchema|IntegerSchema|BooleanSchema|NumberSchema|ObjectSchema|ArraySchema|ListSchema' + r'Schema\.string|Schema\.int|Schema\.bool|Schema\.num|Schema\.object|Schema\.array|Schema\.list)\b', ); for (final match in typePattern.allMatches(section)) { if (_braceDepthAt(section, match.start) != 0) { @@ -405,7 +405,7 @@ final class MigrateAgentEntriesMigrator { final schemaType = match.group(2)!; properties[name] = switch (schemaType) { 'ObjectSchema' || 'Schema.object' => 'object', - 'ArraySchema' || 'Schema.array' => 'array', + 'ArraySchema' || 'Schema.list' => 'list', _ => _jsonTypeForSchemaConstructor(schemaType), }; } @@ -458,7 +458,7 @@ final class MigrateAgentEntriesMigrator { final detailed = >{}; final pattern = RegExp( r"'([^']+)':\s*(?:const\s+)?" - r'(ObjectSchema|ArraySchema|Schema\.object|Schema\.array)\s*\(', + r'(ObjectSchema|ArraySchema|Schema\.object|Schema\.list)\s*\(', ); for (final match in pattern.allMatches(section)) { if (_braceDepthAt(section, match.start) != 0) { @@ -474,7 +474,10 @@ final class MigrateAgentEntriesMigrator { final body = section.substring(openParen + 1, closeParen); final json = switch (constructor) { 'ObjectSchema' || 'Schema.object' => _objectSchemaBodyToJsonMap(body), - 'ArraySchema' || 'Schema.array' => _arraySchemaBodyToJsonMap(body), + 'ArraySchema' || + 'ListSchema' || + 'Schema.array' || + 'Schema.list' => _listSchemaBodyToJsonMap(body), _ => null, }; if (json != null) { @@ -484,40 +487,40 @@ final class MigrateAgentEntriesMigrator { return detailed; } - Map? _arraySchemaBodyToJsonMap(final String arrayBody) { - final primitiveItem = _extractPrimitiveArrayItemType(arrayBody); + Map? _listSchemaBodyToJsonMap(final String listBody) { + final primitiveItem = _extractPrimitiveArrayItemType(listBody); if (primitiveItem != null) { return { - 'type': 'array', + 'type': 'list', 'items': {'type': primitiveItem}, }; } - final objectItems = _extractObjectSchemaItemsBody(arrayBody); + final objectItems = _extractObjectSchemaItemsBody(listBody); if (objectItems == null) { - return RegExp(r'items\s*:').hasMatch(arrayBody) + return RegExp(r'items\s*:').hasMatch(listBody) ? null - : {'type': 'array'}; + : {'type': 'list'}; } final itemsJson = _objectSchemaBodyToJsonMap(objectItems); if (itemsJson == null) { return null; } - return {'type': 'array', 'items': itemsJson}; + return {'type': 'list', 'items': itemsJson}; } - String? _topLevelArrayItemsTail(final String arrayBody) { - for (final match in RegExp(r'\bitems\s*:').allMatches(arrayBody)) { - if (_braceDepthAt(arrayBody, match.start) == 0) { - return arrayBody.substring(match.end).trim(); + String? _topLevelArrayItemsTail(final String listBody) { + for (final match in RegExp(r'\bitems\s*:').allMatches(listBody)) { + if (_braceDepthAt(listBody, match.start) == 0) { + return listBody.substring(match.end).trim(); } } return null; } - String? _extractPrimitiveArrayItemType(final String arrayBody) { - final itemsTail = _topLevelArrayItemsTail(arrayBody); + String? _extractPrimitiveArrayItemType(final String listBody) { + final itemsTail = _topLevelArrayItemsTail(listBody); if (itemsTail == null) { return null; } @@ -532,8 +535,8 @@ final class MigrateAgentEntriesMigrator { return _jsonTypeForSchemaConstructor(match.group(1)!); } - String? _extractObjectSchemaItemsBody(final String arrayBody) { - final itemsTail = _topLevelArrayItemsTail(arrayBody); + String? _extractObjectSchemaItemsBody(final String listBody) { + final itemsTail = _topLevelArrayItemsTail(listBody); if (itemsTail == null) { return null; } @@ -724,10 +727,10 @@ final class MigrateAgentEntriesMigrator { return false; } - const arrayCtor = r'(?:ArraySchema|Schema\.array)\s*\('; + const listCtor = r'(?:ArraySchema|Schema\.list|Schema\.array)\s*\('; const complexItem = - r'(?:ObjectSchema|ArraySchema|Schema\.object|Schema\.array)\s*\('; - final pattern = RegExp("'([^']+)':\\s*(?:const\\s+)?$arrayCtor"); + r'(?:ObjectSchema|ArraySchema|Schema\.object|Schema\.list|Schema\.array)\s*\('; + final pattern = RegExp("'([^']+)':\\s*(?:const\\s+)?$listCtor"); for (final match in pattern.allMatches(section)) { if (_braceDepthAt(section, match.start) != 0) { continue; @@ -738,10 +741,10 @@ final class MigrateAgentEntriesMigrator { if (closeParen == null) { continue; } - final arrayBody = section.substring(openParen + 1, closeParen); + final listBody = section.substring(openParen + 1, closeParen); if (!RegExp( 'items\\s*:\\s*(?:const\\s+)?$complexItem', - ).hasMatch(arrayBody)) { + ).hasMatch(listBody)) { continue; } final detailed = detailedProperties[name]; diff --git a/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart b/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart index a86174e..c667809 100644 --- a/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart +++ b/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart @@ -32,7 +32,7 @@ void main() { ), AgentEntityPropertyDescriptor( name: 'tags', - valueType: AgentEntityPropertyValueType.array, + valueType: AgentEntityPropertyValueType.list, isSearchable: true, ), ], @@ -67,7 +67,7 @@ void main() { ), AgentEntityPropertyDescriptor( name: 'labels', - valueType: AgentEntityPropertyValueType.array, + valueType: AgentEntityPropertyValueType.list, role: AgentEntityPropertyRole.keywords, ), ], diff --git a/packages/intentcall_core/test/agent_entity_snapshot_projection_test.dart b/packages/intentcall_core/test/agent_entity_snapshot_projection_test.dart index cde313d..4d67e61 100644 --- a/packages/intentcall_core/test/agent_entity_snapshot_projection_test.dart +++ b/packages/intentcall_core/test/agent_entity_snapshot_projection_test.dart @@ -21,7 +21,7 @@ void main() { ), AgentEntityPropertyDescriptor( name: 'tags', - valueType: AgentEntityPropertyValueType.array, + valueType: AgentEntityPropertyValueType.list, isSearchable: true, isIndexed: true, ), diff --git a/packages/intentcall_core/test/migrate_agent_entries_test.dart b/packages/intentcall_core/test/migrate_agent_entries_test.dart index 51412fe..7d5647e 100644 --- a/packages/intentcall_core/test/migrate_agent_entries_test.dart +++ b/packages/intentcall_core/test/migrate_agent_entries_test.dart @@ -76,7 +76,7 @@ MCPCallEntry.tool( ); '''; final migrated = migrator.migrateSource(before); - expect(migrated, contains("'ids': {'type': 'array'}")); + expect(migrated, contains("'ids': {'type': 'list'}")); expect(migrated, contains("'required': ['ids']")); expect(migrated, isNot(contains('TODO(migrate):'))); }); @@ -103,7 +103,7 @@ MCPCallEntry.tool( final migrated = migrator.migrateSource(before); expect( migrated, - contains("'tags': {'type': 'array', 'items': {'type': 'string'}}"), + contains("'tags': {'type': 'list', 'items': {'type': 'string'}}"), ); expect(migrated, contains("'required': ['tags']")); expect(migrated, isNot(contains('TODO(migrate):'))); @@ -137,7 +137,7 @@ MCPCallEntry.tool( ); '''; final migrated = migrator.migrateSource(before); - expect(migrated, contains("'type': 'array'")); + expect(migrated, contains("'type': 'list'")); expect(migrated, contains("'type': 'object'")); expect(migrated, contains("'ref'")); expect(migrated, contains("'text'")); @@ -147,7 +147,7 @@ MCPCallEntry.tool( expect(migrated, isNot(contains('TODO(migrate):'))); }); - test('emits TODO when ArraySchema items are nested arrays', () { + test('emits TODO when ListSchema items are nested lists', () { const before = ''' MCPCallEntry.tool( definition: MCPToolDefinition( @@ -170,7 +170,7 @@ MCPCallEntry.tool( '''; final migrated = migrator.migrateSource(before); expect(migrated, contains("'rows'")); - expect(migrated, contains("'type': 'array'")); + expect(migrated, contains("'type': 'list'")); expect(migrated, isNot(contains("'items': {"))); expect(migrated, contains('TODO(migrate):')); expect(migrated, contains('nested ArraySchema items')); diff --git a/packages/intentcall_platform/test/intentcall_entity_index_test.dart b/packages/intentcall_platform/test/intentcall_entity_index_test.dart index 2f9952e..1a97969 100644 --- a/packages/intentcall_platform/test/intentcall_entity_index_test.dart +++ b/packages/intentcall_platform/test/intentcall_entity_index_test.dart @@ -115,7 +115,7 @@ void main() { ), AgentEntityPropertyDescriptor( name: 'tags', - valueType: AgentEntityPropertyValueType.array, + valueType: AgentEntityPropertyValueType.list, isSearchable: true, isIndexed: true, ), diff --git a/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart b/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart index db358e3..b2777ed 100644 --- a/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart +++ b/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart @@ -58,7 +58,7 @@ void main() { ), AgentEntityPropertyDescriptor( name: 'tags', - valueType: AgentEntityPropertyValueType.array, + valueType: AgentEntityPropertyValueType.list, role: AgentEntityPropertyRole.keywords, isIndexed: true, ), @@ -138,7 +138,7 @@ String _readRepoFile(final String relativePath) { return File(p.join(repoRoot.path, relativePath)).readAsStringSync(); } -Directory _findRepoRoot(Directory start) { +Directory _findRepoRoot(final Directory start) { var dir = start; while (true) { final pubspec = File(p.join(dir.path, 'pubspec.yaml')); diff --git a/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeEntitySnapshotStore.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeEntitySnapshotStore.swift index 5e52823..53cc4f2 100644 --- a/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeEntitySnapshotStore.swift +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeEntitySnapshotStore.swift @@ -1,8 +1,9 @@ import Foundation + #if canImport(UIKit) -import UIKit + import UIKit #elseif canImport(AppKit) -import AppKit + import AppKit #endif /// Shared native cache for Dart-projected entity snapshots. @@ -152,9 +153,9 @@ public enum IntentCallNativeEntitySnapshotStore { return openId } #if canImport(UIKit) - await UIApplication.shared.open(url) + await UIApplication.shared.open(url) #elseif canImport(AppKit) - NSWorkspace.shared.open(url) + NSWorkspace.shared.open(url) #endif return openId } diff --git a/packages/intentcall_platform_sync/lib/src/agent_manifest.dart b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart index f3b54ab..df8564d 100644 --- a/packages/intentcall_platform_sync/lib/src/agent_manifest.dart +++ b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart @@ -403,7 +403,7 @@ List _readEntityTypes(final Object? value) { return const []; } if (value is! List) { - throw const FormatException('entityTypes must be an array.'); + throw const FormatException('entityTypes must be an list.'); } final out = []; final seen = {}; diff --git a/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart index ddca29b..cd0b863 100644 --- a/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart +++ b/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart @@ -98,14 +98,14 @@ final class WebMcpJsEmitter { return validationError(path + ' must be an object.'); } return null; - case 'array': - if (!Array.isArray(value)) return validationError(path + ' must be an array.'); - var arrayPath = path; - if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && - arrayPath.charAt(arrayPath.length - 1) === '"') { - arrayPath = arrayPath.slice(1, -1); + case 'list': + if (!Array.isArray(value)) return validationError(path + ' must be an list.'); + var listPath = path; + if (listPath.length >= 2 && listPath.charAt(0) === '"' && + listPath.charAt(listPath.length - 1) === '"') { + listPath = listPath.slice(1, -1); } - return validateArrayItems(arrayPath, schema, value); + return validateArrayItems(listPath, schema, value); default: return null; } diff --git a/packages/intentcall_platform_sync/test/web_emitters_test.dart b/packages/intentcall_platform_sync/test/web_emitters_test.dart index 2893baa..38bd541 100644 --- a/packages/intentcall_platform_sync/test/web_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/web_emitters_test.dart @@ -115,7 +115,7 @@ void main() { expect(js, contains('global.fetch(invokePath')); }); - test('emits array items object validation', () { + test('emits list items object validation', () { final js = const WebMcpJsEmitter().emit(_fixtureAgentManifest); expect(js, contains('validateArrayItems')); expect(js, contains('validateObjectProperties')); @@ -328,7 +328,7 @@ void main() { expect(manifest.entityTypes.single.qualifiedName, 'app_project'); }); - test('reads shortcuts and intents arrays', () { + test('reads shortcuts and intents lists', () { final manifest = AgentManifest.fromJson({ 'version': 1, 'platform': 'android', diff --git a/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index ec1018c..389fbcd 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -123,11 +123,11 @@ final json = snapshot.toJson(); // round-trips via AgentEntitySnapshot.fromJson | Root `type: object` | Yes | | `required`, `properties` | Yes | | `additionalProperties: false` | Yes | -| Property types: `string`, `integer`, `number`, `boolean`, `object`, `array` | Yes | +| Property types: `string`, `integer`, `number`, `boolean`, `object`, `list` | Yes | | `enum` on strings | Yes | | `minimum` / `maximum` on numbers | Yes | | Array `items` when each item is `type: object` with `required` / `properties` | Yes | -| `pattern`, `format`, `oneOf`, nested object property validation (except array items) | No | +| `pattern`, `format`, `oneOf`, nested object property validation (except list items) | No | | Type coercion | Use `coerceArgumentsForSchema` first | Properties without a `type` are skipped. Unknown keys are allowed unless `additionalProperties` is `false`. diff --git a/packages/intentcall_schema/lib/src/agent_entity_model.dart b/packages/intentcall_schema/lib/src/agent_entity_model.dart index f3cdb2a..c466a60 100644 --- a/packages/intentcall_schema/lib/src/agent_entity_model.dart +++ b/packages/intentcall_schema/lib/src/agent_entity_model.dart @@ -272,7 +272,7 @@ List _optionalStringList( return const []; } if (value is! List) { - throw ArgumentError.value(value, key, 'Expected an array of strings.'); + throw ArgumentError.value(value, key, 'Expected an list of strings.'); } return List.unmodifiable( value.map((final item) { diff --git a/packages/intentcall_schema/lib/src/agent_wire_args.dart b/packages/intentcall_schema/lib/src/agent_wire_args.dart index 51d851e..15a54de 100644 --- a/packages/intentcall_schema/lib/src/agent_wire_args.dart +++ b/packages/intentcall_schema/lib/src/agent_wire_args.dart @@ -19,11 +19,7 @@ extension type const AgentWireArgs(AgentWireMap _raw) { String? string(final String key) { // TODO: migrate to from_json_to_json jsonDecodeNullableString final value = _raw[key]; - if (value == null) { - return null; - } - final trimmed = value.trim(); - return trimmed.isEmpty ? null : trimmed; + return jsonDecodeNullableNotEmptyString(value?.trim()); } /// Parses common wire boolean literals: `1`/`0`, `true`/`false`, `yes`/`no`. diff --git a/packages/intentcall_schema/lib/src/json_utils.dart b/packages/intentcall_schema/lib/src/json_utils.dart index 8b97361..f6d3cb7 100644 --- a/packages/intentcall_schema/lib/src/json_utils.dart +++ b/packages/intentcall_schema/lib/src/json_utils.dart @@ -37,6 +37,14 @@ String? jsonDecodeNullableString(final dynamic value) => switch (value) { _ => null, }; +@Deprecated('migrate cases to from_json_to_json') +// ignore: avoid_annotating_with_dynamic +String? jsonDecodeNullableNotEmptyString(final dynamic value) => + switch (value) { + final String value when value.isNotEmpty => value, + _ => null, + }; + @Deprecated('migrate cases to from_json_to_json') // ignore: avoid_annotating_with_dynamic Map? jsonDecodeNullableMapAs(final dynamic json) => diff --git a/packages/intentcall_schema/lib/src/schema_coercion.dart b/packages/intentcall_schema/lib/src/schema_coercion.dart index e21bf1f..850e53e 100644 --- a/packages/intentcall_schema/lib/src/schema_coercion.dart +++ b/packages/intentcall_schema/lib/src/schema_coercion.dart @@ -9,7 +9,7 @@ import 'json_utils.dart'; /// /// VM service extensions and similar transports often deliver every value as a /// [String]. This function uses [schema] `properties` `type` fields to parse -/// integers, numbers, booleans, JSON objects, and JSON arrays before +/// integers, numbers, booleans, JSON objects, and JSON lists before /// validation. /// /// Typical pipeline: @@ -71,7 +71,7 @@ Object? _coercePropertyValue( 'number' => num.tryParse(trimmed) ?? value, 'boolean' => jsonDecodeNullableBool(trimmed) ?? value, 'object' => jsonDecodeNullableMapAs(trimmed) ?? value, - 'array' => _parseWireJsonList(trimmed, propertySchema) ?? value, + 'list' => _parseWireJsonList(trimmed, propertySchema) ?? value, _ => value, }; } @@ -79,7 +79,7 @@ Object? _coercePropertyValue( if (type == 'object' && value is Map) { return _coerceObjectValue(propertySchema, Map.from(value)); } - if (type == 'array' && value is List) { + if (type == 'list' && value is List) { return _coerceArrayValue(propertySchema, value); } @@ -145,10 +145,10 @@ Object? _coerceOpenObjectEntryValue(final Object? value) { } List _coerceArrayValue( - final Map arraySchema, + final Map listSchema, final List value, ) { - final itemSchema = arraySchema['items']; + final itemSchema = listSchema['items']; if (itemSchema is! Map) { return value; } @@ -161,13 +161,13 @@ List _coerceArrayValue( List? _parseWireJsonList( final String trimmed, - final Map arraySchema, + final Map listSchema, ) { final decoded = jsonDecode(trimmed); if (decoded is! List) { return null; } - return _coerceArrayValue(arraySchema, decoded); + return _coerceArrayValue(listSchema, decoded); } Map? _parseWireJsonMap(final String trimmed) => diff --git a/packages/intentcall_schema/lib/src/schema_validator.dart b/packages/intentcall_schema/lib/src/schema_validator.dart index f1a8da5..f7ff43c 100644 --- a/packages/intentcall_schema/lib/src/schema_validator.dart +++ b/packages/intentcall_schema/lib/src/schema_validator.dart @@ -13,7 +13,7 @@ import 'agent_validation_exception.dart'; /// - Top-level `required` and `properties` /// - `additionalProperties: false` (rejects unknown keys) /// - Per-property `type`: `string`, `integer`, `number`, `boolean`, `object`, -/// `array` +/// `list` /// - `enum` on string properties (allowed string list) /// - Array `items` when each item is `type: object` with `required` / /// `properties` @@ -22,7 +22,7 @@ import 'agent_validation_exception.dart'; /// ## Not supported /// /// - `pattern`, `format`, `oneOf`, `anyOf` -/// - Nested object property validation (except array `items` objects) +/// - Nested object property validation (except list `items` objects) /// - Type coercion (use [coerceArgumentsForSchema]) /// /// Properties without a `type` are skipped. Unknown keys are allowed when @@ -106,9 +106,9 @@ void _validateValue( if (value is! Map) { throw AgentValidationException('"$path" must be an object.'); } - case 'array': + case 'list': if (value is! List) { - throw AgentValidationException('"$path" must be an array.'); + throw AgentValidationException('"$path" must be an list.'); } _validateArrayItems(path, schema, value); } diff --git a/packages/intentcall_schema/pubspec.yaml b/packages/intentcall_schema/pubspec.yaml index 0327614..9c8defa 100644 --- a/packages/intentcall_schema/pubspec.yaml +++ b/packages/intentcall_schema/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: from_json_to_json: ^0.5.0 is_dart_empty_or_not: ^0.4.0 meta: ^1.17.0 + schemantic: ^0.2.2 dev_dependencies: lints: ^6.1.0 diff --git a/packages/intentcall_schema/test/schema_validator_test.dart b/packages/intentcall_schema/test/schema_validator_test.dart index 94c6772..cead528 100644 --- a/packages/intentcall_schema/test/schema_validator_test.dart +++ b/packages/intentcall_schema/test/schema_validator_test.dart @@ -72,13 +72,13 @@ void main() { }); }); - group('array items object required/properties', () { + group('list items object required/properties', () { const schema = { 'type': 'object', 'required': ['fields'], 'properties': { 'fields': { - 'type': 'array', + 'type': 'list', 'items': { 'type': 'object', 'additionalProperties': false, diff --git a/pubspec.lock b/pubspec.lock index 27bff56..772cfe3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" code_assets: dependency: transitive description: @@ -225,6 +233,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + decimal: + dependency: transitive + description: + name: decimal + sha256: "2c3c8b74f2948066d3f42585477aec9cfc48fefd7a723a4d4274a6c71a5c0df7" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + email_validator: + dependency: transitive + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" ffi: dependency: transitive description: @@ -315,6 +339,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -331,6 +363,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" io: dependency: transitive description: @@ -363,6 +403,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + json_schema_builder: + dependency: transitive + description: + name: json_schema_builder + sha256: e46b1a2957590d2c811f47b22079710a273ebf9c8240a9e1440b759efb8ded5f + url: "https://pub.dev" + source: hosted + version: "0.1.6" lints: dependency: "direct dev" description: @@ -475,6 +523,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + rational: + dependency: transitive + description: + name: rational + sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + url: "https://pub.dev" + source: hosted + version: "2.2.3" record_use: dependency: transitive description: @@ -483,6 +539,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + schemantic: + dependency: transitive + description: + name: schemantic + sha256: "3597f1c6bfcfc95e92bb215a8868be0494105b6b6c12228b184253616047d5d2" + url: "https://pub.dev" + source: hosted + version: "0.2.2" shelf: dependency: transitive description: diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index 0a19ff7..72c590d 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -275,7 +275,7 @@ properties onto three slots: |------|--------------|-----------|-------------| | Primary line | `titleKey` | `title` | Display name | | Secondary line | `subtitleKey` | `subtitle` | Summary or context | -| Search tokens | `keywordsKey` | `keywords` | Tags array (`valueType: 'array'`) | +| Search tokens | `keywordsKey` | `keywords` | Tags list (`valueType: 'list'`) | `AgentEntitySnapshotKeys.fromDescriptor()` in `intentcall_core` resolves slots. Prefer **explicit roles** over implicit `isDisplay` / `isSearchable` ordering. @@ -307,7 +307,7 @@ import 'package:intentcall_codegen/intentcall_codegen.dart'; ), AgentEntityProperty( name: 'tags', - valueType: 'array', + valueType: 'list', description: 'Search keywords', isSearchable: true, role: 'keywords', @@ -387,7 +387,7 @@ the native cache. ### Validation rules (codegen) - At most one property per role: `title`, `subtitle`, `keywords`. -- `keywords` role requires `valueType: 'array'`. +- `keywords` role requires `valueType: 'list'`. - Override property names must exist in `properties`. - `role` wins over conflicting `isDisplay` / `isSearchable` flags (warning logged). From f183399bb2cf5da9583773fbb62a9587f00fd65d Mon Sep 17 00:00:00 2001 From: arenukvern Date: Mon, 3 Aug 2026 05:16:57 +0300 Subject: [PATCH 26/27] refactor: rename ArraySchema to ListSchema in documentation and tests for consistency --- docs/packages/intentcall_schema.mdx | 2 +- .../intentcall_core/test/migrate_agent_entries_test.dart | 8 ++++---- .../intentcall_platform_sync/test/web_emitters_test.dart | 2 +- packages/intentcall_schema/README.md | 2 +- packages/intentcall_schema/lib/src/schema_coercion.dart | 6 +++--- packages/intentcall_schema/lib/src/schema_validator.dart | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/packages/intentcall_schema.mdx b/docs/packages/intentcall_schema.mdx index ad72bad..2e70ad5 100644 --- a/docs/packages/intentcall_schema.mdx +++ b/docs/packages/intentcall_schema.mdx @@ -123,7 +123,7 @@ final json = snapshot.toJson(); // round-trips via AgentEntitySnapshot.fromJson | Property types: `string`, `integer`, `number`, `boolean`, `object`, `list` | Yes | | `enum` on strings | Yes | | `minimum` / `maximum` on numbers | Yes | -| Array `items` when each item is `type: object` with `required` / `properties` | Yes | +| List `items` when each item is `type: object` with `required` / `properties` | Yes | | `pattern`, `format`, `oneOf`, nested object property validation (except list items) | No | | Type coercion | Use `coerceArgumentsForSchema` first | diff --git a/packages/intentcall_core/test/migrate_agent_entries_test.dart b/packages/intentcall_core/test/migrate_agent_entries_test.dart index 7d5647e..aec1496 100644 --- a/packages/intentcall_core/test/migrate_agent_entries_test.dart +++ b/packages/intentcall_core/test/migrate_agent_entries_test.dart @@ -56,7 +56,7 @@ MCPCallEntry.tool( expect(migrated, isNot(contains('TODO(migrate):'))); }); - test('preserves top-level ArraySchema without items', () { + test('preserves top-level ListSchema without items', () { const before = ''' MCPCallEntry.tool( definition: MCPToolDefinition( @@ -81,7 +81,7 @@ MCPCallEntry.tool( expect(migrated, isNot(contains('TODO(migrate):'))); }); - test('preserves ArraySchema with primitive items', () { + test('preserves ListSchema with primitive items', () { const before = ''' MCPCallEntry.tool( definition: MCPToolDefinition( @@ -109,7 +109,7 @@ MCPCallEntry.tool( expect(migrated, isNot(contains('TODO(migrate):'))); }); - test('preserves fill_form-shaped ArraySchema ObjectSchema items', () { + test('preserves fill_form-shaped ListSchema ObjectSchema items', () { const before = ''' MCPCallEntry.tool( definition: MCPToolDefinition( @@ -173,7 +173,7 @@ MCPCallEntry.tool( expect(migrated, contains("'type': 'list'")); expect(migrated, isNot(contains("'items': {"))); expect(migrated, contains('TODO(migrate):')); - expect(migrated, contains('nested ArraySchema items')); + expect(migrated, contains('nested ListSchema items')); }); test('preserves nested ObjectSchema inner properties', () { diff --git a/packages/intentcall_platform_sync/test/web_emitters_test.dart b/packages/intentcall_platform_sync/test/web_emitters_test.dart index 38bd541..7d006b2 100644 --- a/packages/intentcall_platform_sync/test/web_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/web_emitters_test.dart @@ -117,7 +117,7 @@ void main() { test('emits list items object validation', () { final js = const WebMcpJsEmitter().emit(_fixtureAgentManifest); - expect(js, contains('validateArrayItems')); + expect(js, contains('validateListItems')); expect(js, contains('validateObjectProperties')); expect(js, contains('must be an object.')); expect(js, contains('Missing required property "')); diff --git a/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index 389fbcd..14a9190 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -126,7 +126,7 @@ final json = snapshot.toJson(); // round-trips via AgentEntitySnapshot.fromJson | Property types: `string`, `integer`, `number`, `boolean`, `object`, `list` | Yes | | `enum` on strings | Yes | | `minimum` / `maximum` on numbers | Yes | -| Array `items` when each item is `type: object` with `required` / `properties` | Yes | +| List `items` when each item is `type: object` with `required` / `properties` | Yes | | `pattern`, `format`, `oneOf`, nested object property validation (except list items) | No | | Type coercion | Use `coerceArgumentsForSchema` first | diff --git a/packages/intentcall_schema/lib/src/schema_coercion.dart b/packages/intentcall_schema/lib/src/schema_coercion.dart index 850e53e..398992e 100644 --- a/packages/intentcall_schema/lib/src/schema_coercion.dart +++ b/packages/intentcall_schema/lib/src/schema_coercion.dart @@ -80,7 +80,7 @@ Object? _coercePropertyValue( return _coerceObjectValue(propertySchema, Map.from(value)); } if (type == 'list' && value is List) { - return _coerceArrayValue(propertySchema, value); + return _coerceListValue(propertySchema, value); } return value; @@ -144,7 +144,7 @@ Object? _coerceOpenObjectEntryValue(final Object? value) { return value; } -List _coerceArrayValue( +List _coerceListValue( final Map listSchema, final List value, ) { @@ -167,7 +167,7 @@ List? _parseWireJsonList( if (decoded is! List) { return null; } - return _coerceArrayValue(listSchema, decoded); + return _coerceListValue(listSchema, decoded); } Map? _parseWireJsonMap(final String trimmed) => diff --git a/packages/intentcall_schema/lib/src/schema_validator.dart b/packages/intentcall_schema/lib/src/schema_validator.dart index f7ff43c..84692d7 100644 --- a/packages/intentcall_schema/lib/src/schema_validator.dart +++ b/packages/intentcall_schema/lib/src/schema_validator.dart @@ -15,7 +15,7 @@ import 'agent_validation_exception.dart'; /// - Per-property `type`: `string`, `integer`, `number`, `boolean`, `object`, /// `list` /// - `enum` on string properties (allowed string list) -/// - Array `items` when each item is `type: object` with `required` / +/// - List `items` when each item is `type: object` with `required` / /// `properties` /// - `minimum` / `maximum` on numeric types /// @@ -110,11 +110,11 @@ void _validateValue( if (value is! List) { throw AgentValidationException('"$path" must be an list.'); } - _validateArrayItems(path, schema, value); + _validateListItems(path, schema, value); } } -void _validateArrayItems( +void _validateListItems( final String path, final Map schema, final List value, From 711c25d23328e9896211385180fc8130d291109e Mon Sep 17 00:00:00 2001 From: arenukvern Date: Mon, 3 Aug 2026 15:58:21 +0300 Subject: [PATCH 27/27] refactor: streamline stdout logging in _HooksSpineCommand and improve IntentCallStdioMcpServer initialization --- .../pigeons/intentcall_platform_bridge.dart | 1 + .../lib/src/command_runner.dart | 14 +-- .../lib/src/mcp/stdio_mcp_server.dart | 45 ++++------ .../lib/src/registry/ard_registy.dart | 90 +++++++++++++++++++ .../lib/src/init/platform_hooks_init.dart | 62 ++++++------- .../test/platform_hook_templates_test.dart | 32 +++---- 6 files changed, 162 insertions(+), 82 deletions(-) create mode 100644 packages/intentcall_core/lib/src/registry/ard_registy.dart diff --git a/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart index a1b646e..778503f 100644 --- a/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart +++ b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart @@ -45,6 +45,7 @@ class IntentCallEntityKeyBundle { } @HostApi() +// ignore: one_member_abstracts abstract class IntentCallInvocationsHostApi { List takePendingInvocations(); } diff --git a/packages/intentcall_cli/lib/src/command_runner.dart b/packages/intentcall_cli/lib/src/command_runner.dart index 37ceffa..dbf09db 100644 --- a/packages/intentcall_cli/lib/src/command_runner.dart +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -6,7 +6,6 @@ import 'package:intentcall_platform_sync/intentcall_platform_sync.dart' hide parsePlatformList; import 'package:path/path.dart' as p; -import 'catalog/catalog_loader.dart'; import 'commands/apple_app_intents_testing.dart'; import 'config/host_profiles.dart'; import 'config/intentcall_config.dart'; @@ -662,12 +661,13 @@ final class _HooksSpineCommand extends Command { if (asJson) { stdout.write(spine.encodeJson()); } else { - stdout.writeln('host: ${spine.host}'); - stdout.writeln('cliInvocation: ${spine.cliInvocation}'); - stdout.writeln('platformList: ${spine.platformList}'); - stdout.writeln('codegen: ${spine.codegenPhase.shellLine}'); - stdout.writeln('manifest: ${spine.manifestPhase.shellLine}'); - stdout.writeln('sync: ${spine.syncPhase.shellLine}'); + stdout + ..writeln('host: ${spine.host}') + ..writeln('cliInvocation: ${spine.cliInvocation}') + ..writeln('platformList: ${spine.platformList}') + ..writeln('codegen: ${spine.codegenPhase.shellLine}') + ..writeln('manifest: ${spine.manifestPhase.shellLine}') + ..writeln('sync: ${spine.syncPhase.shellLine}'); } return 0; } diff --git a/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart b/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart index 9460b80..1668123 100644 --- a/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart +++ b/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart @@ -11,23 +11,17 @@ Future runIntentCallStdioMcpServer({ final AgentRegistry? registry, final List modules = const [], }) async { - IntentCallStdioMcpServer? serverRef; - final adapter = McpPublishAdapter( - publishTool: (final tool, final impl) => - serverRef!.registerTool(tool, impl), - unpublishTool: (final name) => serverRef!.unregisterTool(name), - publishResource: (final resource, final impl) => - serverRef!.addResource(resource, impl), - unpublishResource: (final uri) => serverRef!.removeResource(uri), - publishResourceTemplate: (final template, final impl) => - serverRef!.addResourceTemplate(template, impl), + final IntentCallStdioMcpServer serverRef = IntentCallStdioMcpServer( + stdioChannel(input: io.stdin, output: io.stdout), ); - serverRef = IntentCallStdioMcpServer( - stdioChannel(input: io.stdin, output: io.stdout), - adapter: adapter, + final adapter = McpPublishAdapter( + publishTool: serverRef.registerTool, + unpublishTool: serverRef.unregisterTool, + publishResource: serverRef.addResource, + unpublishResource: serverRef.removeResource, + publishResourceTemplate: serverRef.addResourceTemplate, ); - final server = serverRef; final runtime = AgentRuntime( registry: registry, @@ -35,24 +29,17 @@ Future runIntentCallStdioMcpServer({ adapters: [adapter], ); await runtime.start(); - await server.initialized; - await server.done; + await serverRef.initialized; + await serverRef.done; await runtime.stop(); } base class IntentCallStdioMcpServer extends MCPServer with ToolsSupport, ResourcesSupport { - IntentCallStdioMcpServer( - super.channel, { - required this.adapter, - }) : super.fromStreamChannel( - implementation: Implementation( - name: 'intentcall', - version: '0.6.0', - ), - instructions: - 'IntentCall registry-backed MCP server (minimal dogfood host).', - ); - - final McpPublishAdapter adapter; + IntentCallStdioMcpServer(super.channel) + : super.fromStreamChannel( + implementation: Implementation(name: 'intentcall', version: '0.6.0'), + instructions: + 'IntentCall registry-backed MCP server (minimal dogfood host).', + ); } diff --git a/packages/intentcall_core/lib/src/registry/ard_registy.dart b/packages/intentcall_core/lib/src/registry/ard_registy.dart new file mode 100644 index 0000000..4c875db --- /dev/null +++ b/packages/intentcall_core/lib/src/registry/ard_registy.dart @@ -0,0 +1,90 @@ +import 'package:intentcall_schema/src/agent_result.dart'; + +import '../entity/agent_entity_type_descriptor.dart'; +import '../intent/agent_intent_descriptor.dart'; +import '../intent/registered_agent_intent.dart'; +import 'agent_registry.dart'; +import 'registry_events.dart'; + +/// https://agenticresourcediscovery.org +/// https://agenticresourcediscovery.org/how_ard_works/#5-reach-it-from-a-chatbot +// TODO(arenukvern): maybe add adr as unified surface? +class ARDRegistry implements AgentRegistry { + @override + // TODO: implement events + Stream get events => throw UnimplementedError(); + + @override + RegisteredAgentIntent? get(final String qualifiedName) { + // TODO: implement get + throw UnimplementedError(); + } + + @override + AgentEntityTypeDescriptor? getEntityType(final String qualifiedName) { + // TODO: implement getEntityType + throw UnimplementedError(); + } + + @override + Future invoke( + final String qualifiedName, + final AgentArguments arguments, { + final String? correlationId, + }) { + // TODO: implement invoke + throw UnimplementedError(); + } + + @override + Iterable listDescriptors({final String? namespace}) { + // TODO: implement listDescriptors + throw UnimplementedError(); + } + + @override + Iterable listEntityTypes({ + final String? namespace, + }) { + // TODO: implement listEntityTypes + throw UnimplementedError(); + } + + @override + Iterable listEntries({final String? namespace}) { + // TODO: implement listEntries + throw UnimplementedError(); + } + + @override + String qualify({ + required final String namespace, + required final String name, + }) { + // TODO: implement qualify + throw UnimplementedError(); + } + + @override + void register( + final RegisteredAgentIntent intent, { + final String? qualifiedNameOverride, + }) { + // TODO: implement register + } + + @override + void registerEntityType(final AgentEntityTypeDescriptor descriptor) { + // TODO: implement registerEntityType + } + + @override + void unregister(final String qualifiedName) { + // TODO: implement unregister + } + + @override + void unregisterEntityType(final String qualifiedName) { + // TODO: implement unregisterEntityType + } +} diff --git a/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart index f0435ce..53b511a 100644 --- a/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart +++ b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart @@ -96,38 +96,40 @@ final class PlatformHooksInit { ); } if (patchable.contains('ios')) { - targets.add( - await _patchFile( - id: 'ios_codegen_script', - path: p.join(root, 'ios', 'intentcall_codegen.sh'), - snippet: spine.renderAppleXcode().trim(), - checkOnly: checkOnly, - wholeFile: true, - ), - ); - targets.add( - _checkXcodeRunScript( - id: 'ios_xcode_run_script', - path: p.join(root, 'ios', 'Runner.xcodeproj', 'project.pbxproj'), - ), - ); + targets + ..add( + await _patchFile( + id: 'ios_codegen_script', + path: p.join(root, 'ios', 'intentcall_codegen.sh'), + snippet: spine.renderAppleXcode().trim(), + checkOnly: checkOnly, + wholeFile: true, + ), + ) + ..add( + _checkXcodeRunScript( + id: 'ios_xcode_run_script', + path: p.join(root, 'ios', 'Runner.xcodeproj', 'project.pbxproj'), + ), + ); } if (patchable.contains('macos')) { - targets.add( - await _patchFile( - id: 'macos_codegen_script', - path: p.join(root, 'macos', 'intentcall_codegen.sh'), - snippet: spine.renderAppleXcode().trim(), - checkOnly: checkOnly, - wholeFile: true, - ), - ); - targets.add( - _checkXcodeRunScript( - id: 'macos_xcode_run_script', - path: p.join(root, 'macos', 'Runner.xcodeproj', 'project.pbxproj'), - ), - ); + targets + ..add( + await _patchFile( + id: 'macos_codegen_script', + path: p.join(root, 'macos', 'intentcall_codegen.sh'), + snippet: spine.renderAppleXcode().trim(), + checkOnly: checkOnly, + wholeFile: true, + ), + ) + ..add( + _checkXcodeRunScript( + id: 'macos_xcode_run_script', + path: p.join(root, 'macos', 'Runner.xcodeproj', 'project.pbxproj'), + ), + ); } return PlatformHooksInitReport( diff --git a/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart b/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart index 4e1b230..3a8c1b0 100644 --- a/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart +++ b/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart @@ -7,9 +7,7 @@ import 'package:test/test.dart'; void main() { group('PlatformHookSpine', () { test('default flutter spine uses dart run cli invocation', () { - final spine = PlatformHookSpine.resolve( - const PlatformHookSpineInput(host: 'flutter'), - ); + final spine = PlatformHookSpine.resolve(const PlatformHookSpineInput()); expect(spine.cliInvocation, kDefaultHookCliInvocation); expect( spine.manifestPhase.shellLine, @@ -20,16 +18,15 @@ void main() { test('honors hooks.syncCommand override', () { final spine = PlatformHookSpine.resolve( - const PlatformHookSpineInput( - host: 'flutter', - syncCommand: 'intentcall', - ), + const PlatformHookSpineInput(syncCommand: 'intentcall'), ); expect(spine.cliInvocation, 'intentcall'); - expect( - spine.manifestPhase.argv, - ['intentcall', 'manifest', 'export', '--check'], - ); + expect(spine.manifestPhase.argv, [ + 'intentcall', + 'manifest', + 'export', + '--check', + ]); }); test('resolves platform list from yaml enabled platforms', () { @@ -74,7 +71,10 @@ hooks: }); test('legacy template getters delegate to default spine', () { - expect(kAndroidGradleCodegenHook, kDefaultFlutterHookSpine.renderGradle()); + expect( + kAndroidGradleCodegenHook, + kDefaultFlutterHookSpine.renderGradle(), + ); expect( kAppleXcodeCodegenRunScript, kDefaultFlutterHookSpine.renderAppleXcode(), @@ -87,10 +87,10 @@ hooks: tokenizeShellCommand('dart run intentcall_cli:intentcall'), ['dart', 'run', 'intentcall_cli:intentcall'], ); - expect( - tokenizeShellCommand('"dart run" intentcall'), - ['dart run', 'intentcall'], - ); + expect(tokenizeShellCommand('"dart run" intentcall'), [ + 'dart run', + 'intentcall', + ]); }); }); }