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..27fd8dc --- /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: 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: 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: 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: completed + - id: ws-e-docs + content: "Subagent E: Update DX_FAQ, codegen README, hook PATH note; document mcp_flutter follow-up" + status: completed + - id: ws-integrate-verify + content: "Parent: Merge subagents, run just adr-gates + just test + steward probe --profile quick; MoE re-audit" + status: completed +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/.github/workflows/ci.yml b/.github/workflows/ci.yml index d762945..c65784a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,13 @@ 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: projection pipeline check + run: just projection-pipeline-check - name: analyze run: dart analyze . 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 a0c40c9..ca05e21 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,9 +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/AGENTS.md b/AGENTS.md index 55c55c6..d5d8824 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) | @@ -61,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 cd02043..4d4983c 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) @@ -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/PUBLISHING.md b/PUBLISHING.md index 120ce2d..f4795f2 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -49,9 +49,11 @@ 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}} +intentcall_bridge-v{{version}} +intentcall_cli-v{{version}} intentcall_platform-v{{version}} intentcall_testing-v{{version}} ``` @@ -71,9 +73,11 @@ 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` -5. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) -6. `intentcall_testing` +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_platform_apple`, `intentcall_platform_android` (federated impls) +8. `intentcall_testing` ## Commands diff --git a/README.md b/README.md index 980d527..7bdefb8 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,11 @@ 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` | [![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_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 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 | 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.json b/docs.json index 2ed22ed..67647b9 100644 --- a/docs.json +++ b/docs.json @@ -30,26 +30,69 @@ { "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": "Who is this for?", + "href": "/start_here/audiences" + }, + { + "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 +170,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index 375fed4..deaad26 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -50,14 +50,23 @@ 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. **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 `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)). 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 52699cf..3d936eb 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`. @@ -179,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: @@ -375,69 +383,188 @@ 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. ---- +**Q: How do instance-bound or handwritten tools join the generated catalog and manifest?** -## 🧪 Testing +Catalog sources: -**Q: How do I test an adapter against the contract?** +```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 +``` -Add `intentcall_testing` as a `dev_dependency` and call `verifyNativeAdapterContract(...)` from a package test. For example: +Put instance methods and `AgentCallEntry` getters on a host class (typically +`static final shared`), then co-locate a `List` +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 -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; - }, - ); -}); +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}, + ), + ), + ]; +} ``` -See `packages/intentcall_mcp/test/mcp_adapter_contract_test.dart` for the current reference shape. +**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 +`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` +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. 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. 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). + +**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. + +**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. -**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. +## Flutter in-app host {#flutter-in-app-host} + +**Q: How do I wire `IntentCallFlutterHost` in a Flutter app?** + +Audience overview: [Who is this for? §1](/start_here/audiences#1-in-app--internal-flutter-host). + +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. `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. + 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 @@ -468,12 +595,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 \ @@ -500,6 +627,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 @@ -524,9 +701,10 @@ 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`, `intentcall_platform_apple`, `intentcall_platform_android` +7. `intentcall_testing` **Q: How do I publish?** diff --git a/docs/NORTH_STAR.mdx b/docs/NORTH_STAR.mdx index 8512a40..3f6b1bf 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. @@ -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_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. @@ -110,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/decisions/0019-framework-neutral-intentcall-cli.md b/docs/decisions/0019-framework-neutral-intentcall-cli.md new file mode 100644 index 0000000..872243f --- /dev/null +++ b/docs/decisions/0019-framework-neutral-intentcall-cli.md @@ -0,0 +1,125 @@ +# 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 + `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 + `@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. +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/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..77a8388 --- /dev/null +++ b/docs/decisions/0021-agent-catalog-annotation.md @@ -0,0 +1,62 @@ +# 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. +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 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/docs/decisions/0022-projection-pipeline-alignment.md b/docs/decisions/0022-projection-pipeline-alignment.md new file mode 100644 index 0000000..ca4b4e8 --- /dev/null +++ b/docs/decisions/0022-projection-pipeline-alignment.md @@ -0,0 +1,90 @@ +# 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) +- [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 new file mode 100644 index 0000000..e9b69f0 --- /dev/null +++ b/docs/decisions/0023-entity-three-slot-projection.md @@ -0,0 +1,164 @@ +# 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` 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 +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 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. + +### 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: list`. +- 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` list 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) +- [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..22b118f --- /dev/null +++ b/docs/decisions/0024-dart-hooks-and-pigeon-bridge-consistency.md @@ -0,0 +1,112 @@ +# 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 — `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 + +### 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/0025-platform-subset-federated-plugins.md b/docs/decisions/0025-platform-subset-federated-plugins.md new file mode 100644 index 0000000..f57dfeb --- /dev/null +++ b/docs/decisions/0025-platform-subset-federated-plugins.md @@ -0,0 +1,123 @@ +# 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` 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 +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/`; public Swift facades `IntentCallNativeBridge`, `IntentCallNativeHandoffStore`, `IntentCallNativeEntitySnapshotStore` | +| `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 + +- 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 + +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). +- 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. + +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 2df9376..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: **0019** +Next ADR number: **0026** --- @@ -20,6 +20,13 @@ 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 | +| [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 | +| [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 new file mode 100644 index 0000000..19d254e --- /dev/null +++ b/docs/evidence/hooks-native-bridge-plan.md @@ -0,0 +1,304 @@ +# Hooks + Native Bridge Consistency Plan + +**Status:** Active implementation plan +**Date:** 2026-07-08 +**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), [0025](../decisions/0025-platform-subset-federated-plugins.md) (federated plugins — see [platform-subset-federated-plugins-plan.md](platform-subset-federated-plugins-plan.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 +(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. + +--- + +## 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 (§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 | +| 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/platform-subset-federated-plugins-plan.md b/docs/evidence/platform-subset-federated-plugins-plan.md new file mode 100644 index 0000000..e71826d --- /dev/null +++ b/docs/evidence/platform-subset-federated-plugins-plan.md @@ -0,0 +1,472 @@ +# Platform Subset + Federated Plugins Implementation Plan + +**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) +**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, federated apple+android + umbrella) +- **`intentcall_apple` / `intentcall_android` hard-deleted** from workspace (not renamed into `platform_*`) + +**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/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/packages/intentcall_schema.mdx b/docs/packages/intentcall_schema.mdx new file mode 100644 index 0000000..2e70ad5 --- /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`, `list` | Yes | +| `enum` on strings | Yes | +| `minimum` / `maximum` on numbers | 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 | + +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/audiences.mdx b/docs/start_here/audiences.mdx new file mode 100644 index 0000000..3a0671a --- /dev/null +++ b/docs/start_here/audiences.mdx @@ -0,0 +1,289 @@ +# 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). +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). + +--- + +## 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 → `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 | + +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` (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) + +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 — 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` | + +**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 a8575b4..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`, 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) | | 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 9dc5e6a..7b6287b 100644 --- a/docs/start_here/docs_map.mdx +++ b/docs/start_here/docs_map.mdx @@ -8,12 +8,14 @@ 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) | | 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) | @@ -23,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) @@ -30,6 +33,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/docs/start_here/how_it_works.mdx b/docs/start_here/how_it_works.mdx index 2b8c0ca..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"] @@ -13,15 +15,21 @@ 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 host + federated apple/android"] + core --> gemma["intentcall_gemma\nexample on-device adapter"] core --> codegen["intentcall_codegen\noptional @AgentTool helpers"] - entitySnapshot --> platform - mcp --> callers["Agents, CLIs, assistants, app hosts"] - webmcp --> 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 + entitySnapshot --> platformSync + 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 @@ -110,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..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. @@ -146,3 +149,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 + 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 + 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 +passes sync `--check`. That is **not** App Store / unsigned Siri product proof. diff --git a/justfile b/justfile index cc2546e..9bc0290 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,71 @@ 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_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: + 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 + +# 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 + +# 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 + 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 + +# Compile-proof: Runner Generated Swift builds against intentcall_platform_apple. +# Canonical script lives in mcp_flutter (dogfood consumer). Requires sibling +# mcp_flutter, Flutter, and Xcode — skips gracefully when absent. +apple-runner-compile-check: + bash ../mcp_flutter/tool/contracts/check_apple_runner_compile.sh + +# 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 +80,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: @@ -63,7 +127,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 @@ -93,7 +157,12 @@ 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 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 + 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/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/README.md b/packages/intentcall_android/README.md deleted file mode 100644 index d609076..0000000 --- a/packages/intentcall_android/README.md +++ /dev/null @@ -1,43 +0,0 @@ -> ⚠️ **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/lib/intentcall_android.dart b/packages/intentcall_android/lib/intentcall_android.dart deleted file mode 100644 index a369c1a..0000000 --- a/packages/intentcall_android/lib/intentcall_android.dart +++ /dev/null @@ -1,3 +0,0 @@ -library; - -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 da2fc4b..0000000 --- a/packages/intentcall_android/lib/src/agent_manifest_generator.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:convert'; - -import 'package:intentcall_core/intentcall_core.dart'; - -/// Builds `agent_manifest.json` for Android App Actions / shortcuts (Phase 3). -String generateAndroidAgentManifest( - final Iterable descriptors, -) { - final shortcuts = >[]; - for (final descriptor in descriptors) { - 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, - 'inputSchema': descriptor.inputSchema, - }); - } - return const JsonEncoder.withIndent(' ').convert({ - 'version': 1, - 'platform': 'android', - 'shortcuts': shortcuts, - }); -} 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/README.md b/packages/intentcall_apple/README.md deleted file mode 100644 index 5dd3152..0000000 --- a/packages/intentcall_apple/README.md +++ /dev/null @@ -1,49 +0,0 @@ -> ⚠️ **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/lib/intentcall_apple.dart b/packages/intentcall_apple/lib/intentcall_apple.dart deleted file mode 100644 index a369c1a..0000000 --- a/packages/intentcall_apple/lib/intentcall_apple.dart +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 22db6bc..0000000 --- a/packages/intentcall_apple/lib/src/agent_manifest_generator.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'dart:convert'; - -import 'package:intentcall_core/intentcall_core.dart'; - -/// Builds `agent_manifest.json` for App Intents / Shortcuts codegen (Phase 3). -String generateAppleAgentManifest( - final Iterable descriptors, { - final Iterable entityTypeDescriptors = const [], - final Iterable> entityTypes = const [], -}) { - final intents = >[]; - for (final descriptor in descriptors) { - 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, - 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, - }); -} - -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_apple/test/agent_manifest_generator_test.dart b/packages/intentcall_apple/test/agent_manifest_generator_test.dart deleted file mode 100644 index 5925ce7..0000000 --- a/packages/intentcall_apple/test/agent_manifest_generator_test.dart +++ /dev/null @@ -1,97 +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', - ), - ]); - - 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); - }); - - 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_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_android/LICENSE b/packages/intentcall_bridge/LICENSE similarity index 100% rename from packages/intentcall_android/LICENSE rename to packages/intentcall_bridge/LICENSE 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_bridge/analysis_options.yaml b/packages/intentcall_bridge/analysis_options.yaml new file mode 100644 index 0000000..4b5bddf --- /dev/null +++ b/packages/intentcall_bridge/analysis_options.yaml @@ -0,0 +1 @@ +include: package:xsoulspace_lints/public_library.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..2e0e66d --- /dev/null +++ b/packages/intentcall_bridge/lib/src/intentcall_platform_bridge.g.dart @@ -0,0 +1,474 @@ +// 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()]); +} + +/// 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({ + this.idKey = 'id', + this.titleKey = 'title', + this.subtitleKey = 'subtitle', + this.keywordsKey = 'keywords', + }); + + 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 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); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + 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); + } + } +} + +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>(); + } + + 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 new file mode 100644 index 0000000..778503f --- /dev/null +++ b/packages/intentcall_bridge/pigeons/intentcall_platform_bridge.dart @@ -0,0 +1,79 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/intentcall_platform_bridge.g.dart', + dartPackageName: 'intentcall_bridge', + swiftOut: + '../intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift', + swiftOptions: SwiftOptions(), + kotlinOut: + '../intentcall_platform_android/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; +} + +/// 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 { + IntentCallEntityKeyBundle({ + this.idKey = 'id', + this.titleKey = 'title', + this.subtitleKey = 'subtitle', + this.keywordsKey = 'keywords', + }); + String idKey; + String titleKey; + String subtitleKey; + String keywordsKey; +} + +@HostApi() +// ignore: one_member_abstracts +abstract class IntentCallInvocationsHostApi { + List takePendingInvocations(); +} + +@HostApi() +abstract class IntentCallEntitiesHostApi { + int upsertEntitySnapshots( + final String entityType, + final List> snapshots, + final IntentCallEntityKeyBundle keys, + ); + + int deleteEntitySnapshots( + final String entityType, + final List ids, + final IntentCallEntityKeyBundle keys, + ); + + int clearEntityTypeSnapshots(final String entityType); + + List> listEntitySnapshots(final String entityType); + + List> searchEntitySnapshots( + final String entityType, + final String query, + final int limit, + final IntentCallEntityKeyBundle keys, + ); + + List takePendingEntityOpens(); +} 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/CHANGELOG.md b/packages/intentcall_cli/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/intentcall_apple/LICENSE b/packages/intentcall_cli/LICENSE similarity index 100% rename from packages/intentcall_apple/LICENSE rename to packages/intentcall_cli/LICENSE diff --git a/packages/intentcall_cli/README.md b/packages/intentcall_cli/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/intentcall_android/analysis_options.yaml b/packages/intentcall_cli/analysis_options.yaml similarity index 100% rename from packages/intentcall_android/analysis_options.yaml rename to packages/intentcall_cli/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..336c3bf --- /dev/null +++ b/packages/intentcall_cli/lib/intentcall_cli.dart @@ -0,0 +1,3 @@ +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..097a46e --- /dev/null +++ b/packages/intentcall_cli/lib/src/catalog/catalog_loader.dart @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..dbf09db --- /dev/null +++ b/packages/intentcall_cli/lib/src/command_runner.dart @@ -0,0 +1,858 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart' + hide parsePlatformList; +import 'package:path/path.dart' as p; + +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(_HooksCommand()); + addCommand(_McpCommand()); + addCommand(_AppleAppIntentsTestingCommand()); + } +} + +// ignore: avoid_classes_with_only_static_members +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() => + _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()); + addSubcommand(_ConfigValidateCommand()); + } + + @override + String get name => 'config'; + + @override + 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'; + + @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 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 { + stdout + ..writeln('# intentcall.yaml (${config.sourcePath})') + ..writeln(encodePrettyJson(enriched)); + } + return 0; + } + + @override + ArgParser get argParser { + final parser = ArgParser(); + _ProjectDirOption.add(parser); + parser.addFlag('json', negatable: false); + return parser; + } +} + +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()); + 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}') + ..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; + + const exporter = ManifestExporter(); + final context = exporter.loadExportContext(projectRoot: projectRoot); + const catalogLoader = 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, + entityTypeDescriptors: entityTypeDescriptors, + ), + ); + + 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; + } +} + +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 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'); + + 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 projectRoot = _ProjectDirOption.read(results); + final spine = PlatformHookSpine.resolveFromProjectRoot(projectRoot); + final host = normalizeHostName('${results['host']}'); + final platform = '${results['platform'] ?? ''}'.trim().toLowerCase(); + + 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 + ? ['jaspr', 'web'] + : ['android', 'ios', 'macos']; + for (final key in keys) { + stdout + ..writeln('== $key ==') + ..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}') + ..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; + } + + @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 { + 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 { + _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..0b96c01 --- /dev/null +++ b/packages/intentcall_cli/lib/src/commands/apple_app_intents_testing.dart @@ -0,0 +1,423 @@ +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() => 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..4d2489e --- /dev/null +++ b/packages/intentcall_cli/lib/src/config/host_profiles.dart @@ -0,0 +1,60 @@ +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: [], + hookTemplateKeys: [], + ), + IntentCallHost.custom: HostProfile( + host: IntentCallHost.custom, + defaultPlatforms: [], + hookTemplateKeys: [], + ), +}; + +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..c3a654e --- /dev/null +++ b/packages/intentcall_cli/lib/src/config/intentcall_config.dart @@ -0,0 +1,202 @@ +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 const 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..1668123 --- /dev/null +++ b/packages/intentcall_cli/lib/src/mcp/stdio_mcp_server.dart @@ -0,0 +1,45 @@ +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 { + final IntentCallStdioMcpServer serverRef = IntentCallStdioMcpServer( + stdioChannel(input: io.stdin, output: io.stdout), + ); + + final adapter = McpPublishAdapter( + publishTool: serverRef.registerTool, + unpublishTool: serverRef.unregisterTool, + publishResource: serverRef.addResource, + unpublishResource: serverRef.removeResource, + publishResourceTemplate: serverRef.addResourceTemplate, + ); + + final runtime = AgentRuntime( + registry: registry, + modules: modules, + adapters: [adapter], + ); + await runtime.start(); + await serverRef.initialized; + await serverRef.done; + await runtime.stop(); +} + +base class IntentCallStdioMcpServer extends MCPServer + with ToolsSupport, ResourcesSupport { + 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_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..098a2e8 --- /dev/null +++ b/packages/intentcall_cli/test/command_runner_test.dart @@ -0,0 +1,233 @@ +import 'dart:io'; + +import 'package:intentcall_cli/src/command_runner.dart'; +import 'package:intentcall_cli/src/config/intentcall_config.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + late Directory flutterFixture; + late Directory jasprFixture; + + setUpAll(() { + flutterFixture = _fixtureRoot('flutter_project'); + jasprFixture = _fixtureRoot('jaspr_web_project'); + }); + + 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('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([ + '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); + }); + + 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', () { + 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 Directory(p.normalize(p.absolute(candidate))); + } + } + 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_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..8b881a9 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/lib/generated/agent_catalog.g.dart @@ -0,0 +1,12 @@ +// 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), +]; + +final List agentEntityTypeDescriptors = + []; 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..00953f9 --- /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'; + +// ************************************************************************** +// _AgentToolPartGenerator +// ************************************************************************** + +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..603fdc4 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/agent_manifest.json @@ -0,0 +1,62 @@ +{ + "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.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": { + "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..dd611d1 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/intentcall_webmcp.generated.js @@ -0,0 +1,247 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 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 "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.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.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.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((response) => response.json()); + } + + 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/codegen_dart_project/web/manifest.json b/packages/intentcall_cli/test/fixtures/codegen_dart_project/web/manifest.json new file mode 100644 index 0000000..b409fcc --- /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" + } + ] +} 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/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/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/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..eafdf70 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/lib/generated/agent_catalog.g.dart @@ -0,0 +1,12 @@ +// 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), +]; + +final List agentEntityTypeDescriptors = + []; 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..f9c8f19 --- /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'; + +// ************************************************************************** +// _AgentToolPartGenerator +// ************************************************************************** + +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..64bc2ce --- /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_core: + path: ../../../../intentcall_core + intentcall_schema: + path: ../../../../intentcall_schema + 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 new file mode 100644 index 0000000..cfefe1a --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/agent_manifest.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "platform": "unified", + "tools": [ + { + "qualifiedName": "app_cart_total", + "namespace": "app", + "name": "cart_total", + "description": "Return cart total", + "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": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "description": "Currency code" + } + }, + "required": [ + "currency" + ] + } + } + ], + "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..04fd9c3 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/flutter_project/web/intentcall_webmcp.generated.js @@ -0,0 +1,247 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 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 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 "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.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.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.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((response) => response.json()); + } + + 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/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/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/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/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..2c0d202 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/lib/generated/agent_catalog.g.dart @@ -0,0 +1,12 @@ +// 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), +]; + +final List agentEntityTypeDescriptors = + []; 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..b212d1c --- /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'; + +// ************************************************************************** +// _AgentToolPartGenerator +// ************************************************************************** + +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..30e331f --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.lock @@ -0,0 +1,432 @@ +# 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" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + 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" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.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_hooks: + dependency: "direct dev" + description: + path: "../../../../intentcall_hooks" + 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" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.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..33bceef --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/pubspec.yaml @@ -0,0 +1,38 @@ +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 + intentcall_hooks: + path: ../../../../intentcall_hooks + +hooks: + user_defines: + intentcall_hooks: + project_root: . + platforms: web + check_only: false + +dependency_overrides: + intentcall_schema: + path: ../../../../intentcall_schema + intentcall_core: + path: ../../../../intentcall_core + intentcall_platform_sync: + path: ../../../../intentcall_platform_sync + intentcall_hooks: + path: ../../../../intentcall_hooks 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..3fac999 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/agent_manifest.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "platform": "web", + "tools": [ + { + "qualifiedName": "shop_search", + "namespace": "shop", + "name": "search", + "description": "Search catalog", + "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": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Query" + } + }, + "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..a3b2e30 --- /dev/null +++ b/packages/intentcall_cli/test/fixtures/jaspr_web_project/web/intentcall_webmcp.generated.js @@ -0,0 +1,247 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 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 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 "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.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.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.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((response) => response.json()); + } + + 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/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_entity_export_test.dart b/packages/intentcall_cli/test/manifest_entity_export_test.dart new file mode 100644 index 0000000..09d9fdd --- /dev/null +++ b/packages/intentcall_cli/test/manifest_entity_export_test.dart @@ -0,0 +1,108 @@ +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']! + as Map)['x-intentcall-role'] + as String?, + 'title', + ); + }); +} 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..919e8e2 --- /dev/null +++ b/packages/intentcall_cli/test/manifest_registry_parity_test.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:intentcall_cli/src/catalog/catalog_loader.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('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 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, 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 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..7a04d28 100644 --- a/packages/intentcall_codegen/README.md +++ b/packages/intentcall_codegen/README.md @@ -11,6 +11,154 @@ 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 +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 catalog rows with `@AgentCatalog`** + +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 +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` +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** + +```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. + +`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 + +Use inline `EntryProjection` on `@AgentCatalog` rows (recommended for small hosts): + +```dart +AgentRegistryCatalogEntry( + registryKey: 'app_demo_inbox', + 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). + +### 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 1. Add dependencies from the current hosted train: @@ -41,7 +189,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,20 +206,60 @@ 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: +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, `.g.dart` emits: + +- `_InputSchema` constants +- `extension AgentCodegen on ` with `CallEntry` getters whose + handlers call instance methods on `this` +- optional top-level `Registration` aliases when a static binding field + (default `shared`) exists + +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`. +`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) -- 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/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/build.yaml b/packages/intentcall_codegen/build.yaml index 594b5fa..e13da46 100644 --- a/packages/intentcall_codegen/build.yaml +++ b/packages/intentcall_codegen/build.yaml @@ -2,8 +2,9 @@ targets: $default: builders: intentcall_codegen|agent_tool: - generate_for: - - example/** + enabled: false + intentcall_codegen|agent_catalog: + enabled: false builders: agent_tool: @@ -12,3 +13,9 @@ 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 diff --git a/packages/intentcall_codegen/example/README.md b/packages/intentcall_codegen/example/README.md new file mode 100644 index 0000000..5fe26c2 --- /dev/null +++ b/packages/intentcall_codegen/example/README.md @@ -0,0 +1,135 @@ +# 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 — 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). + + +```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/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..a21e78a --- /dev/null +++ b/packages/intentcall_codegen/example/build.yaml @@ -0,0 +1,14 @@ +targets: + $default: + builders: + intentcall_codegen|agent_tool: + generate_for: + - lib/** + 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/intentcall.yaml b/packages/intentcall_codegen/example/intentcall.yaml new file mode 100644 index 0000000..f53daaf --- /dev/null +++ b/packages/intentcall_codegen/example/intentcall.yaml @@ -0,0 +1,12 @@ +host: dart +protocolScheme: intentcallcodegen +layout: + manifest: web/agent_manifest.json + webDir: web +platforms: + enabled: + - web + - ios + - macos +defaults: + dispatchMode: openApp diff --git a/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart new file mode 100644 index 0000000..d39b438 --- /dev/null +++ b/packages/intentcall_codegen/example/lib/generated/agent_catalog.g.dart @@ -0,0 +1,27 @@ +// 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'; + +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), + AgentRegistryCatalogEntry(registryKey: 'app_demo_set_greeting', entry: demoSetGreetingCallEntry, projection: EntryProjection( + dispatchMode: AgentManifestDispatchMode.openApp, + surfaces: {AgentManifestSurface.appleAppIntents: true, AgentManifestSurface.appleAppShortcuts: true}, +)), + ...DemoHostTools.demoHostCatalogEntries, +]; + +final List agentEntityTypeDescriptors = + []; diff --git a/packages/intentcall_codegen/example/lib/main.dart b/packages/intentcall_codegen/example/lib/main.dart new file mode 100644 index 0000000..93d3865 --- /dev/null +++ b/packages/intentcall_codegen/example/lib/main.dart @@ -0,0 +1,96 @@ +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). +/// +/// **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(); + + // 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; + } + final entry = row.entry; + if (entry != null) { + 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}'); + } + + final handwritten = await registry.invoke('app_demo_handwritten', { + 'note': 'hello', + }); + if (!handwritten.ok) { + 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', + }); + 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', + ); + } + + 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 (greeting.data['greeting'] != 'hello codegen') { + throw StateError('demo_set_greeting smoke failed: unexpected greeting'); + } +} 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..2f7a00e --- /dev/null +++ b/packages/intentcall_codegen/example/lib/tools/demo_host_tools.dart @@ -0,0 +1,93 @@ +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'; + +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: {AgentManifestSurface.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), + ); + + @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/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/demo_ping_tool.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart similarity index 60% rename from packages/intentcall_codegen/example/demo_ping_tool.dart rename to packages/intentcall_codegen/example/lib/tools/demo_ping_tool.dart index 7a27a11..226f443 100644 --- a/packages/intentcall_codegen/example/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,6 +21,7 @@ Future demoPing( name: 'demo_cart', description: 'Returns a cart total', ) +@AgentProjection(surfaces: {AgentManifestSurface.webMcp: false}) Future demoCart( @AgentParam('Currency code') String currency, { @AgentParam('Include tax', required: false) bool includeTax = false, @@ -39,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/demo_ping_tool.g.dart b/packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart similarity index 77% rename from packages/intentcall_codegen/example/demo_ping_tool.g.dart rename to packages/intentcall_codegen/example/lib/tools/demo_ping_tool.g.dart index 0d03ec8..e16ae4e 100644 --- a/packages/intentcall_codegen/example/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 = { @@ -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 new file mode 100644 index 0000000..3434e5e --- /dev/null +++ b/packages/intentcall_codegen/example/pubspec.lock @@ -0,0 +1,625 @@ +# 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" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + 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" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + 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" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + 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" + 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: + 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" + 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: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + 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: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + 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" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + 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: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + 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: + 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" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + 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: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct dev" + 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" + 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: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + 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: + 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_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: + 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" + 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: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + 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: + 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" + 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: + 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..5e2d3dc --- /dev/null +++ b/packages/intentcall_codegen/example/pubspec.yaml @@ -0,0 +1,31 @@ +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 + path: ^1.9.1 + test: ^1.31.1 + 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/example/test/manifest_projection_test.dart b/packages/intentcall_codegen/example/test/manifest_projection_test.dart new file mode 100644 index 0000000..8f3d9e0 --- /dev/null +++ b/packages/intentcall_codegen/example/test/manifest_projection_test.dart @@ -0,0 +1,160 @@ +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'; + +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 = _exampleProjectRoot(); + exporter = const ManifestExporter(); + context = exporter.loadExportContext(projectRoot: projectRoot); + }); + + AgentManifest buildManifest() => exporter.buildManifest( + catalog: agentCatalogEntries, + context: context, + entityTypeDescriptors: agentEntityTypeDescriptors, + ); + + test('demo_cart respects @AgentProjection(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(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, + ); + }); + + test('handwritten inbox respects inline EntryProjection', () { + 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('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(greeting.dispatchMode, AgentManifestDispatchMode.openApp); + expect( + greeting.surfaces.includes( + AgentManifestSurface.appleAppIntents, + defaultValue: false, + ), + isTrue, + ); + expect( + greeting.surfaces.includes( + AgentManifestSurface.appleAppShortcuts, + defaultValue: false, + ), + isTrue, + ); + }); + + 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( + (final tool) => tool.qualifiedName == 'app_demo_ping', + ); + + expect( + 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, + defaultValue: true, + ), + 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 new file mode 100644 index 0000000..2b3dd53 --- /dev/null +++ b/packages/intentcall_codegen/example/web/agent_manifest.json @@ -0,0 +1,396 @@ +{ + "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.appIntents": { + "include": true + }, + "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": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Host label" + } + }, + "required": [ + "label" + ] + } + }, + { + "qualifiedName": "app_demo_ping", + "namespace": "app", + "name": "demo_ping", + "description": "Returns pong for a message", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appIntents": { + "include": true + }, + "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": { + "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.appIntents": { + "include": true + }, + "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": false + }, + "windows.protocolActivation": { + "include": false + }, + "windows.msixProtocol": { + "include": false + }, + "linux.schemeHandler": { + "include": false + } + }, + "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.appIntents": { + "include": true + }, + "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": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "Mode" + } + }, + "required": [ + "mode" + ] + } + }, + { + "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", + "name": "demo_inbox", + "description": "Read inbox folder", + "kind": "tool", + "dispatchMode": "openApp", + "surfaces": { + "apple.appIntents": { + "include": true + }, + "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": { + "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.appIntents": { + "include": true + }, + "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": { + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "Note to echo" + } + }, + "required": [ + "note" + ] + } + } + ], + "protocolScheme": "intentcallcodegen" +} 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..072efea --- /dev/null +++ b/packages/intentcall_codegen/example/web/intentcall_webmcp.generated.js @@ -0,0 +1,317 @@ +// Generated by intentcall_platform — do not edit by hand. +(function intentcallWebMcpBootstrap(global) { + 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 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 "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.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.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.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((response) => response.json()); + } + + 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/web/manifest.json b/packages/intentcall_codegen/example/web/manifest.json new file mode 100644 index 0000000..7c9cabd --- /dev/null +++ b/packages/intentcall_codegen/example/web/manifest.json @@ -0,0 +1,82 @@ +{ + "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 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", + "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_set_greeting&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/builder.dart b/packages/intentcall_codegen/lib/builder.dart index 403be6a..7d33f39 100644 --- a/packages/intentcall_codegen/lib/builder.dart +++ b/packages/intentcall_codegen/lib/builder.dart @@ -1,14 +1,37 @@ +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 // ignore_for_file: type=lint ''', ); + +/// 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/intentcall_codegen.dart b/packages/intentcall_codegen/lib/intentcall_codegen.dart index af836ef..9134d0f 100644 --- a/packages/intentcall_codegen/lib/intentcall_codegen.dart +++ b/packages/intentcall_codegen/lib/intentcall_codegen.dart @@ -1,4 +1,6 @@ -library; - +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_catalog.dart b/packages/intentcall_codegen/lib/src/agent_catalog.dart new file mode 100644 index 0000000..e04f1d7 --- /dev/null +++ b/packages/intentcall_codegen/lib/src/agent_catalog.dart @@ -0,0 +1,60 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +/// Marks a [AgentRegistryCatalogEntry] list for merge into the generated catalog. +/// +/// 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/agent_entity.dart b/packages/intentcall_codegen/lib/src/agent_entity.dart new file mode 100644 index 0000000..e27b846 --- /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 new file mode 100644 index 0000000..5275596 --- /dev/null +++ b/packages/intentcall_codegen/lib/src/agent_projection.dart @@ -0,0 +1,20 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; + +/// Platform projection hints for generated [agent_manifest.json] rows. +class AgentProjection { + const AgentProjection({ + this.dispatchMode = 'openApp', + this.surfaces = const {}, + }); + + /// `openApp`, `inlineRuntime`, or `queueOnly`. + final String dispatchMode; + + /// Per-surface inclusion overrides using typed [AgentManifestSurface] keys. + /// + /// 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 new file mode 100644 index 0000000..d28f423 --- /dev/null +++ b/packages/intentcall_codegen/lib/src/generators/agent_catalog_generator.dart @@ -0,0 +1,1145 @@ +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: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'; + +/// 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); + + 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'; + + @override + Map> get buildExtensions => const { + r'$lib$': ['generated/agent_catalog.g.dart'], + }; + + 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? ?? + _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) { + 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; + } + final entry = _catalogEntryForToolElement( + method, + codegenRegistryKeys, + ); + if (entry != null) { + fileEntries.add(entry); + } + } + } + + if (fileEntries.isEmpty) { + continue; + } + + imports.add("import '${_importForAsset(input.path, outputPath)}';"); + entries.addAll(fileEntries); + } + + final knownRegistryKeys = {...codegenRegistryKeys}; + + final agentCatalogLists = await _discoverAgentCatalogLists( + buildStep, + outputPath, + ); + final entityCodegenRows = await _discoverAgentEntityDescriptors( + buildStep, + outputPath, + ); + 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, + catalogList.assetId, + catalogList.symbolName, + ); + _assertUniqueRegistryKeys( + knownKeys: knownRegistryKeys, + newKeys: catalogKeys, + sourceLabel: + '@AgentCatalog ${catalogList.symbolName} ' + 'in ${catalogList.assetId.path}', + conflictWithCodegen: true, + ); + } + + 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 && + 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; + } + + final catalogRows = _catalogRows( + entries, + agentCatalogSymbols: agentCatalogLists + .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 +'''); + } + + 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, + ) { + 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 description = toolReader.read('description').stringValue; + final projectionLiteral = projectionReader == null + ? null + : _projectionLiteral(projectionReader); + + 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, + 'entryReference or descriptorLiteral must be provided', + ); + 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 List agentCatalogSymbols, + }) { + 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); + final libraryReader = LibraryReader(library); + for (final variable in library.topLevelVariables) { + _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.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; + } + + 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; + } + } + return false; + } + + bool _isCatalogEntryListType(final DartType type) { + if (type is! InterfaceType) { + return false; + } + if (type.element.name != 'List' || type.typeArguments.length != 1) { + return false; + } + final argument = type.typeArguments.single; + if (argument is! InterfaceType) { + return false; + } + return argument.element.name == 'AgentRegistryCatalogEntry'; + } + + Future> _readCatalogListRegistryKeys( + final BuildStep buildStep, + final AssetId assetId, + final String symbolName, + ) async { + final library = await buildStep.resolver.libraryFor(assetId); + 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.', + ); + } + + 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; + } + } + } + + final unit = await buildStep.resolver.compilationUnitFor(assetId); + final keys = []; + 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); + } + } + } + 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: 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, + ) { + 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 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 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.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 surfaceName = _surfaceEnumNameFromConstant(entry.key); + final value = entry.value?.toBoolValue(); + if (surfaceName == null || value == null) { + continue; + } + surfaceEntries.add('AgentManifestSurface.$surfaceName: $value'); + } + final surfacesBlock = surfaceEntries.isEmpty + ? 'const {}' + : '{${surfaceEntries.join(', ')}}'; + return ''' +EntryProjection( + dispatchMode: AgentManifestDispatchMode.$dispatchName, + surfaces: $surfacesBlock, +)'''; + } + + 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', + 'apple.appIntents': 'appleAppIntents', + 'apple.appShortcuts': 'appleAppShortcuts', + 'apple.spotlight': 'appleSpotlight', + 'apple.entities': 'appleEntities', + 'android.shortcuts': 'androidShortcuts', + 'windows.protocolActivation': 'windowsProtocolActivation', + 'windows.msixProtocol': 'windowsMsixProtocol', + 'linux.schemeHandler': 'linuxSchemeHandler', + }; + return manifestKeyToEnum[key]; + } + + 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(); + } + + 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 != 'list') { + throw InvalidGenerationSourceError( + "@AgentEntity property '$propertyName' has role 'keywords' but " + "valueType '$valueType'; keywords requires valueType 'list'.", + 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 { + const _CatalogSpread({ + required this.assetId, + required this.symbolName, + required this.importPath, + }); + + final AssetId assetId; + 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; +} + +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/lib/src/generators/agent_tool_generator.dart b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart index 2080b27..8a9f07b 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,67 @@ 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 = _hasBindingField(classElement, _hostBindingField) + ? _hostBindingField + : null; + + values.add( + _generateInstanceToolBlock(classElement, instanceMethods, bindingField), + ); + } + + if (values.isEmpty) { + return ''; + } + return values.join('\n\n'); + } @override String generateForAnnotatedElement( @@ -18,20 +78,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,8 +106,9 @@ class AgentToolGenerator extends GeneratorForAnnotation { final registrationGetter = '${_toCamelCase(name)}Registration'; final entryGetter = '${_toCamelCase(name)}CallEntry'; - final schema = _buildInputSchema(element); - final handlerArgs = _buildHandlerArgs(element); + final schemaMap = inputSchemaMapFor(executable); + final schema = _formatInputSchema(schemaMap); + final handlerArgs = _buildTopLevelHandlerArgs(executable); return ''' const $schemaName = $schema; @@ -62,7 +128,117 @@ $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)); + 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')} +}$registrationBlock'''; + } + + 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; } @@ -71,11 +247,18 @@ $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'); } - String _buildInputSchema(final TopLevelFunctionElement element) { - final properties = []; + /// Builds JSON-schema-shaped input metadata for manifest and registration. + Map inputSchemaMapFor(final ExecutableElement element) { + final properties = {}; final required = []; for (final param in element.formalParameters) { @@ -102,30 +285,46 @@ $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], }'''; } - 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) { @@ -135,27 +334,63 @@ ${properties.join('\n')} '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 ab0f2bf..8c0affc 100644 --- a/packages/intentcall_codegen/pubspec.yaml +++ b/packages/intentcall_codegen/pubspec.yaml @@ -12,19 +12,24 @@ 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 + glob: ^2.1.3 intentcall_core: ^0.6.0 + intentcall_platform_sync: ^0.6.0 intentcall_schema: ^0.6.0 - meta: ^1.17.0 + path: ^1.9.1 source_gen: ^4.0.0 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_build_test.dart b/packages/intentcall_codegen/test/agent_catalog_build_test.dart new file mode 100644 index 0000000..aa613be --- /dev/null +++ b/packages/intentcall_codegen/test/agent_catalog_build_test.dart @@ -0,0 +1,202 @@ +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: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) { + 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); + +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('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('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({})), + 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 new file mode 100644 index 0000000..8458a43 --- /dev/null +++ b/packages/intentcall_codegen/test/agent_catalog_generator_test.dart @@ -0,0 +1,46 @@ +import 'package:test/test.dart'; + +// ignore: avoid_relative_lib_imports +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('merges @AgentCatalog rows from example', () { + 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 @AgentEntity descriptor rows', () { + expect(agentEntityTypeDescriptors, isEmpty); + }); + + 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_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/agent_tool_codegen_test.dart b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart new file mode 100644 index 0000000..535edee --- /dev/null +++ b/packages/intentcall_codegen/test/agent_tool_codegen_test.dart @@ -0,0 +1,95 @@ +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) => 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', + () 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 binding static omits registration alias', + () async { + final output = await generateFixture( + 'test/fixtures/instance_without_host_tool.dart', + ); + + expect( + output, + contains('extension DemoHostToolsAgentCodegen on DemoHostTools'), + ); + expect(output, isNot(contains('Registration'))); + }, + ); + + test('static method @AgentTool on class is rejected', () async { + 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/agent_tool_generator_test.dart b/packages/intentcall_codegen/test/agent_tool_generator_test.dart index 82c049f..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,10 @@ 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'; +// 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() { test('generated demoPingRegistration validates before execute', () { @@ -81,4 +84,20 @@ 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..9e15b59 --- /dev/null +++ b/packages/intentcall_codegen/test/build.yaml @@ -0,0 +1,13 @@ +targets: + $default: + builders: + 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/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_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/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_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/packages/intentcall_core/README.md b/packages/intentcall_core/README.md index 2603b16..85e70aa 100644 --- a/packages/intentcall_core/README.md +++ b/packages/intentcall_core/README.md @@ -100,7 +100,8 @@ 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_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_core/lib/intentcall_core.dart b/packages/intentcall_core/lib/intentcall_core.dart index 2ff7dfe..8b89b97 100644 --- a/packages/intentcall_core/lib/intentcall_core.dart +++ b/packages/intentcall_core/lib/intentcall_core.dart @@ -1,11 +1,12 @@ -library; - 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/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/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 140d3a2..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 @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import '../naming/qualified_name.dart'; +import 'agent_entity_property_role.dart'; enum AgentEntityPropertyValueType { string, @@ -8,7 +9,7 @@ enum AgentEntityPropertyValueType { number, boolean, object, - array, + list, } enum AgentEntityPrivacy { public, private, sensitive } @@ -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..a7cac4b --- /dev/null +++ b/packages/intentcall_core/lib/src/entity/agent_entity_property_role.dart @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..67bef2f --- /dev/null +++ b/packages/intentcall_core/lib/src/entity/agent_entity_snapshot_keys.dart @@ -0,0 +1,133 @@ +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, + ) ?? + descriptor.properties + .where( + (final property) => + property.isSearchable && property.name != titleKey, + ) + .map((final property) => property.name) + .firstOrNull ?? + 'subtitle'; + + final keywordsKey = + _propertyNameWithRole( + descriptor.properties, + AgentEntityPropertyRole.keywords, + ) ?? + descriptor.properties + .where( + (final property) => + property.isSearchable && + property.valueType == AgentEntityPropertyValueType.list, + ) + .map((final property) => property.name) + .firstOrNull ?? + '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.validValues) { + 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; +} 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..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 @@ -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,22 @@ 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 +40,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 +55,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..52d9304 --- /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.list => 'list', + }; 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/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/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/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/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_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_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..c667809 --- /dev/null +++ b/packages/intentcall_core/test/agent_entity_snapshot_keys_test.dart @@ -0,0 +1,125 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:test/test.dart'; + +AgentEntityTypeDescriptor _projectFixtureDescriptor({ + final Iterable properties = + const [], +}) => 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.list, + 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.list, + 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_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..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( @@ -76,12 +76,12 @@ 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):'))); }); - test('preserves ArraySchema with primitive items', () { + test('preserves ListSchema with primitive items', () { const before = ''' MCPCallEntry.tool( definition: MCPToolDefinition( @@ -103,13 +103,13 @@ 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):'))); }); - test('preserves fill_form-shaped ArraySchema ObjectSchema items', () { + test('preserves fill_form-shaped ListSchema ObjectSchema items', () { const before = ''' MCPCallEntry.tool( definition: MCPToolDefinition( @@ -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,10 +170,10 @@ 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')); + expect(migrated, contains('nested ListSchema items')); }); test('preserves nested ObjectSchema inner properties', () { 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_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_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_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..453fbf4 --- /dev/null +++ b/packages/intentcall_hooks/README.md @@ -0,0 +1,47 @@ +# intentcall_hooks + +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: any # dart pub add --dev intentcall_hooks + +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_apple/analysis_options.yaml b/packages/intentcall_hooks/analysis_options.yaml similarity index 100% rename from packages/intentcall_apple/analysis_options.yaml rename to packages/intentcall_hooks/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_apple/pubspec.yaml b/packages/intentcall_hooks/pubspec.yaml similarity index 56% rename from packages/intentcall_apple/pubspec.yaml rename to packages/intentcall_hooks/pubspec.yaml index 02c8b0f..a23b922 100644 --- a/packages/intentcall_apple/pubspec.yaml +++ b/packages/intentcall_hooks/pubspec.yaml @@ -1,22 +1,23 @@ -name: intentcall_apple -description: PRE-RELEASE — Apple App Intents manifest generator from intentcall agent manifest JSON. +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_apple +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_apple +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_hooks topics: - mcp - - ios - - macos + - agents + - hooks environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: - intentcall_core: ^0.6.0 - meta: ^1.17.0 + code_assets: ^1.0.0 + hooks: ^2.0.0 + intentcall_platform_sync: ^0.6.0 path: ^1.9.1 dev_dependencies: 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_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_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/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_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/README.md b/packages/intentcall_platform/README.md index 4331a74..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 @@ -257,23 +262,48 @@ 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. + +Flutter MCP Toolkit consumers may delegate: + +```bash +flutter-mcp-toolkit codegen sync --platform web,ios,macos --project-dir +``` + +### Build hooks (ADR 0024) -### One-time hooks +| 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 MCP Toolkit consumer example: +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 -flutter-mcp-toolkit init intentcall-platform --project-dir +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 ``` -### Future +See [intentcall_hooks README](../intentcall_hooks/README.md) for `user_defines`. + +### 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/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt b/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt deleted file mode 100644 index ddccd5f..0000000 --- a/packages/intentcall_platform/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.intentcall.intentcall_platform - -import io.flutter.embedding.engine.plugins.FlutterPlugin - -/** Thin plugin anchor; deep links use [app_links] from Dart. */ -class IntentCallPlatformPlugin : FlutterPlugin { - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} -} diff --git a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift deleted file mode 100644 index a0c25c9..0000000 --- a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift +++ /dev/null @@ -1,191 +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 - } -} - -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) { - 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]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(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)) - } - default: - result(FlutterMethodNotImplemented) - } - } - - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> 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)) - } - } -} diff --git a/packages/intentcall_platform/ios/intentcall_platform.podspec b/packages/intentcall_platform/ios/intentcall_platform.podspec deleted file mode 100644 index b984a7e..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 = 'Classes/**/*' - 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 4a4224d..0000000 --- a/packages/intentcall_platform/ios/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ /dev/null @@ -1,191 +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 - } -} - -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) { - 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]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(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)) - } - default: - result(FlutterMethodNotImplemented) - } - } - - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> 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)) - } - } -} 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/intentcall_platform_flutter.dart b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart index da00934..d3c2846 100644 --- a/packages/intentcall_platform/lib/intentcall_platform_flutter.dart +++ b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart @@ -2,7 +2,9 @@ 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'; +export 'src/flutter/intentcall_pending_entity_opens.dart'; export 'src/flutter/intentcall_pending_invocations.dart'; diff --git a/packages/intentcall_platform/lib/src/agent_manifest_generator.dart b/packages/intentcall_platform/lib/src/agent_manifest_generator.dart deleted file mode 100644 index aa88831..0000000 --- a/packages/intentcall_platform/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/lib/src/flutter/intentcall_entity_index.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_entity_index.dart index 571875e..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,12 +1,35 @@ +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'; 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 @@ -18,19 +41,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, @@ -39,14 +49,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; } @@ -54,11 +70,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); } @@ -66,11 +84,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); } @@ -95,10 +115,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; @@ -108,19 +130,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 +141,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)) { @@ -174,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 cae114b..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 @@ -1,8 +1,77 @@ -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(Map.from).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(Map.from).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, +) => 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_flutter_host.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart index d182c58..dbeeb1f 100644 --- a/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_flutter_host.dart @@ -1,20 +1,23 @@ import 'dart:async'; import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; -import '../bootstrap/agent_web_mcp_bootstrap.dart'; -import '../invocation/intentcall_invocation.dart'; import 'intentcall_host_events.dart'; 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 = @@ -25,39 +28,45 @@ 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, - 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, 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 +93,19 @@ final class IntentCallFlutterHost { ), takePendingInvocations: takePendingInvocations ?? - const IntentCallPendingInvocations().takePending, + // ignore: prefer_const_constructors + IntentCallPendingInvocations().takePending, + takePendingEntityOpens: + takePendingEntityOpens ?? + // ignore: prefer_const_constructors + 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,11 +116,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; @@ -122,7 +141,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) { @@ -182,6 +205,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_host_events.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_host_events.dart index da55685..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,7 +1,6 @@ +import 'package:intentcall_platform_sync/intentcall_platform_sync.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; -import '../invocation/intentcall_invocation.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_entity_opens.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart new file mode 100644 index 0000000..923cb1b --- /dev/null +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_entity_opens.dart @@ -0,0 +1,24 @@ +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, +) => 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/lib/src/flutter/intentcall_pending_invocations.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart index 620caf1..4fc3e70 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,24 @@ -import 'package:flutter/services.dart'; - -import '../invocation/intentcall_invocation.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, +) => 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/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/macos/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift deleted file mode 100644 index 7f78401..0000000 --- a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift +++ /dev/null @@ -1,191 +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 - } -} - -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) { - 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]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(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)) - } - default: - result(FlutterMethodNotImplemented) - } - } - - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> 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)) - } - } -} diff --git a/packages/intentcall_platform/macos/intentcall_platform.podspec b/packages/intentcall_platform/macos/intentcall_platform.podspec deleted file mode 100644 index 3410b2e..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 = 'Classes/**/*' - 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/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift deleted file mode 100644 index 7f78401..0000000 --- a/packages/intentcall_platform/macos/intentcall_platform/Sources/intentcall_platform/IntentCallPlatformPlugin.swift +++ /dev/null @@ -1,191 +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 - } -} - -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) { - 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]] ?? [] - result(try IntentCallEntitySnapshotStore.upsert(entityType: entityType, snapshots: snapshots)) - } - case "deleteEntitySnapshots": - withEntityArgs(call, result) { args, entityType in - let ids = args["ids"] as? [String] ?? [] - result(try IntentCallEntitySnapshotStore.delete(entityType: entityType, ids: ids)) - } - case "clearEntityTypeSnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.clear(entityType: entityType)) - } - case "listEntitySnapshots": - withEntityArgs(call, result) { _, entityType in - result(try IntentCallEntitySnapshotStore.list(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)) - } - default: - result(FlutterMethodNotImplemented) - } - } - - private func withEntityArgs( - _ call: FlutterMethodCall, - _ result: @escaping FlutterResult, - _ body: ([String: Any], String) throws -> 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)) - } - } -} 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 7ce5ad5..b2add07 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 @@ -11,7 +13,7 @@ topics: - agents environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" flutter: ">=3.24.0" resolution: workspace @@ -19,9 +21,14 @@ 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 + intentcall_platform_apple: ^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 @@ -29,12 +36,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/test/intentcall_entity_index_test.dart b/packages/intentcall_platform/test/intentcall_entity_index_test.dart index c423c31..1a97969 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'); }, ); @@ -100,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/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 new file mode 100644 index 0000000..b2777ed --- /dev/null +++ b/packages/intentcall_platform/test/pigeon_bridge_contract_test.dart @@ -0,0 +1,155 @@ +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.list, + 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('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', + ); + + 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('takePendingEntityOpens()')); + expect( + generated, + contains( + 'dev.flutter.pigeon.intentcall_bridge.IntentCallInvocationsHostApi.takePendingInvocations', + ), + ); + expect( + generated, + contains( + 'dev.flutter.pigeon.intentcall_bridge.IntentCallEntitiesHostApi.takePendingEntityOpens', + ), + ); + }); + }); +} + +String _readRepoFile(final String relativePath) { + final repoRoot = _findRepoRoot(Directory.current); + return File(p.join(repoRoot.path, relativePath)).readAsStringSync(); +} + +Directory _findRepoRoot(final 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_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/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 new file mode 100644 index 0000000..82531c1 --- /dev/null +++ b/packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformBridge.g.kt @@ -0,0 +1,550 @@ +// 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 + } +} + +/** + * 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. + * + * 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 { + IntentCallEntityOpenEnvelopeDto.fromList(it) + } + } + 131.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 IntentCallEntityOpenEnvelopeDto -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is IntentCallEntityKeyBundle -> { + stream.write(131) + 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> + fun takePendingEntityOpens(): 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) + } + } + 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/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 new file mode 100644 index 0000000..64936d1 --- /dev/null +++ b/packages/intentcall_platform_android/android/src/main/kotlin/dev/intentcall/intentcall_platform/IntentCallPlatformPlugin.kt @@ -0,0 +1,52 @@ +package dev.intentcall.intentcall_platform + +import io.flutter.embedding.engine.plugins.FlutterPlugin + +/** Android stub for the Pigeon bridge; entity/invocation stores are iOS/macOS today. */ +class IntentCallPlatformPlugin : FlutterPlugin { + private val bridge = IntentCallPlatformBridgeStub() + + 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() + + override fun takePendingEntityOpens(): List = + emptyList() +} 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/.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 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_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_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 new file mode 100644 index 0000000..53cc4f2 --- /dev/null +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeEntitySnapshotStore.swift @@ -0,0 +1,212 @@ +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_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeHandoffStore.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallNativeHandoffStore.swift new file mode 100644 index 0000000..96b0a6e --- /dev/null +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/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_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift new file mode 100644 index 0000000..6e0b602 --- /dev/null +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformBridge.g.swift @@ -0,0 +1,515 @@ +// 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_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift new file mode 100644 index 0000000..16f32ad --- /dev/null +++ b/packages/intentcall_platform_apple/darwin/intentcall_platform_apple/Sources/intentcall_platform_apple/IntentCallPlatformPlugin.swift @@ -0,0 +1,161 @@ +#if os(macOS) +import FlutterMacOS +#else +import Flutter +#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: binaryMessenger, + api: bridge + ) + IntentCallEntitiesHostApiSetup.setUp( + binaryMessenger: binaryMessenger, + 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/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/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..a6e53c4 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/intentcall_platform_sync.dart @@ -0,0 +1,26 @@ +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'; +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_entity_open.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_platforms.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/lib/src/agent_manifest.dart b/packages/intentcall_platform_sync/lib/src/agent_manifest.dart similarity index 92% rename from packages/intentcall_platform/lib/src/agent_manifest.dart rename to packages/intentcall_platform_sync/lib/src/agent_manifest.dart index 42b8bd7..df8564d 100644 --- a/packages/intentcall_platform/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, }; @@ -379,6 +382,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) { @@ -386,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 = {}; @@ -713,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', @@ -724,3 +744,26 @@ 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 || 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/lib/src/bootstrap/agent_web_mcp_bootstrap.dart b/packages/intentcall_platform_sync/lib/src/bootstrap/agent_web_mcp_bootstrap.dart similarity index 80% 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 index 1be81e3..b600ab4 100644 --- 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 @@ -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/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 75% 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 index 4e11f1b..9b4c7fa 100644 --- 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 @@ -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/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 94% 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 index b0b6e97..861c3e8 100644 --- 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 @@ -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/catalog/agent_registry_catalog.dart b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart new file mode 100644 index 0000000..b972abe --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/catalog/agent_registry_catalog.dart @@ -0,0 +1,28 @@ +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, + 'Either descriptor or entry must be provided', + ); + + 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; + + String get qualifiedName => resolveDescriptor().qualifiedName; +} 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/lib/src/emitters/android_shortcuts_xml_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/android_shortcuts_xml_emitter.dart similarity index 98% 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 index eb6e0e1..f659d5b 100644 --- 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 @@ -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/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 61% 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 index 418c10d..8f8c046 100644 --- 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 @@ -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}); @@ -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, ) @@ -22,8 +32,9 @@ final class AppleSwiftAppIntentsEmitter { final buffer = StringBuffer() ..writeln('// Generated by intentcall_platform — do not edit by hand.') ..writeln('import AppIntents') - ..writeln('import Foundation'); - if (manifest.entityTypes.isNotEmpty) { + ..writeln('import Foundation') + ..writeln('import intentcall_platform_apple'); + if (emitSpotlight) { buffer.writeln('import CoreSpotlight'); } buffer @@ -35,7 +46,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( @@ -77,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."))', @@ -132,7 +146,11 @@ final class AppleSwiftAppIntentsEmitter { } for (final entityType in manifest.entityTypes) { - buffer.write(_swiftEntityType(entityType)); + if (emitEntities) { + buffer.write( + _swiftEntityType(entityType, includeSpotlight: emitSpotlight), + ); + } } buffer @@ -147,73 +165,60 @@ 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() - ..writeln('enum IntentCallNativeHandoffStore {') - ..writeln( - ' private static let pendingKey = "intentcall.pending_invocations"', - ) - ..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)}', - ) - ..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(); } } +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 +265,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 +284,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 +338,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 +359,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 +416,93 @@ 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('enum IntentCallGeneratedEntityConfig {') + ..writeln(' static func configure() {') ..writeln( - ' try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in', + ' IntentCallNativeEntitySnapshotStore.fallbackScheme = ${_swiftOptionalString(protocolScheme)}', ) - ..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() - ..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/lib/src/emitters/emitter_utils.dart b/packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart similarity index 76% rename from packages/intentcall_platform/lib/src/emitters/emitter_utils.dart rename to packages/intentcall_platform_sync/lib/src/emitters/emitter_utils.dart index 4060ae3..925284b 100644 --- a/packages/intentcall_platform/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/lib/src/emitters/linux_desktop_entry_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/linux_desktop_entry_emitter.dart similarity index 97% 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 index b582cc1..0522709 100644 --- 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 @@ -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/lib/src/emitters/web_manifest_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/web_manifest_emitter.dart similarity index 97% 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 index 8ed6303..9574043 100644 --- a/packages/intentcall_platform/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/lib/src/emitters/web_mcp_js_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/web_mcp_js_emitter.dart similarity index 96% 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 index 0acc4ee..cd0b863 100644 --- 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 @@ -27,7 +27,7 @@ final class WebMcpJsEmitter { .where( (final tool) => tool.surfaces.includes( AgentManifestSurface.webMcp, - defaultValue: true, + defaultValue: false, ), ) .map( @@ -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/lib/src/emitters/windows_protocol_emitter.dart b/packages/intentcall_platform_sync/lib/src/emitters/windows_protocol_emitter.dart similarity index 96% 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 index 090384e..d3cbc00 100644 --- a/packages/intentcall_platform/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/lib/src/init/platform_hooks_init.dart b/packages/intentcall_platform_sync/lib/src/init/platform_hooks_init.dart similarity index 63% 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..53b511a 100644 --- a/packages/intentcall_platform/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,57 +49,88 @@ final class PlatformHooksInit { final bool checkOnly = false, }) async { final root = p.normalize(p.absolute(projectRoot)); - 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: kAndroidGradleCodegenHook.trim(), - checkOnly: checkOnly, - appendIfMissing: true, - ), - await _patchFile( - id: 'android_manifest', - path: p.join( - root, - 'android', - 'app', - 'src', - 'main', - 'AndroidManifest.xml', + final spine = PlatformHookSpine.resolveFromProjectRoot(root); + 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: '', ), - snippet: kAndroidShortcutsManifestSnippet.trim(), - checkOnly: checkOnly, - insertBefore: '', - ), - await _patchFile( - id: 'ios_codegen_script', - path: p.join(root, 'ios', 'intentcall_codegen.sh'), - snippet: kAppleXcodeCodegenRunScript.trim(), - checkOnly: checkOnly, - wholeFile: true, - ), - await _patchFile( - id: 'macos_codegen_script', - path: p.join(root, 'macos', 'intentcall_codegen.sh'), - snippet: kAppleXcodeCodegenRunScript.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'), - ), - ]; + ); + } + 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, + ), + ) + ..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, + ), + ) + ..add( + _checkXcodeRunScript( + id: 'macos_xcode_run_script', + path: p.join(root, 'macos', 'Runner.xcodeproj', 'project.pbxproj'), + ), + ); + } return PlatformHooksInitReport( projectRoot: root, @@ -130,7 +162,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_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..b78f4a9 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/invocation/intentcall_entity_open.dart @@ -0,0 +1,39 @@ +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, + ) => 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_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_exporter.dart b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart new file mode 100644 index 0000000..3908cba --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_exporter.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; + +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(); + + ManifestMerger get _merger => const ManifestMerger(); + + ManifestExportContext loadExportContext({ + required final String projectRoot, + }) => _merger.loadExportContext(projectRoot: projectRoot); + + 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) => + _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 Iterable entityTypeDescriptors = const [], + final bool checkOnly = false, + }) { + final encoded = encodeManifest( + buildManifest( + catalog: catalog, + context: context, + entityTypeDescriptors: entityTypeDescriptors, + ), + ); + + 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 new file mode 100644 index 0000000..813bfe2 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/manifest_merger.dart @@ -0,0 +1,252 @@ +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 '../emitters/emitter_utils.dart'; +import 'projection_policy.dart'; +import 'surface_resolver.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, + 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]. +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 Iterable enabledPlatforms = const [], + }) { + final defaultSurfaces = policy.resolvedDefaultSurfaces( + enabledPlatforms: enabledPlatforms, + ); + final entries = []; + for (final row in catalog) { + final descriptor = row.resolveDescriptor(); + final overlay = row.projection ?? 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 = resolveEntrySurfaces( + defaultSurfaces: defaultSurfaces, + overlay: overlay, + ); + 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: _resolvedResourceUri( + descriptor: descriptor, + protocolScheme: protocolScheme, + ), + ), + ); + } + + 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'; + + 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(); + } + return ProjectionPolicy.fromYamlMap(doc); + } + + String readPlatformLabel(final String projectRoot) { + final configFile = File(p.join(projectRoot, 'intentcall.yaml')); + if (!configFile.existsSync()) { + return 'unified'; + } + 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 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 null; + } + final doc = loadYaml(configFile.readAsStringSync()); + if (doc is! YamlMap) { + return null; + } + final layout = doc['layout']; + return layout is YamlMap ? layout : null; + } + + ManifestExportContext loadExportContext({ + required final String projectRoot, + }) => ManifestExportContext( + policy: loadProjectionPolicy(projectRoot: projectRoot), + 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()) { + 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; + } +} + +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, +) { + final json = + jsonDecode(generateEntityManifestJson(descriptor)) + as Map; + return AgentManifestEntityType.fromJson(json); +} + +/// Shared entity projection JSON for one descriptor. +String generateEntityManifestJson(final AgentEntityTypeDescriptor descriptor) { + 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': 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_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/projection/projection_policy.dart b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart new file mode 100644 index 0000000..9e46b7b --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/projection/projection_policy.dart @@ -0,0 +1,192 @@ +import '../agent_manifest.dart'; + +/// Default surface inclusion when no per-entry override exists. +bool defaultSurfaceInclude(final AgentManifestSurface surface) => + switch (surface) { + AgentManifestSurface.appleAppIntents || + AgentManifestSurface.appleAppShortcuts || + AgentManifestSurface.appleSpotlight || + AgentManifestSurface.appleEntities => false, + AgentManifestSurface.androidShortcuts || + AgentManifestSurface.webManifestShortcuts || + AgentManifestSurface.webProtocolHandlers || + AgentManifestSurface.webMcp || + AgentManifestSurface.windowsProtocolActivation || + AgentManifestSurface.windowsMsixProtocol || + 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.appleAppIntents || + AgentManifestSurface.appleAppShortcuts || + AgentManifestSurface.appleSpotlight || + AgentManifestSurface.appleEntities => {'ios', 'macos'}, + AgentManifestSurface.windowsProtocolActivation || + AgentManifestSurface.windowsMsixProtocol => {'windows'}, + AgentManifestSurface.linuxSchemeHandler => {'linux'}, + }; + +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); + } + final required = platformsForManifestSurface(surface); + return required.any(enabledPlatforms.contains); +} + +/// 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) { + if (entry.value is! bool) { + continue; + } + surfaces[resolveAgentManifestSurface(entry.key.toString())] = + 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 = AgentManifestSurfacePolicy.empty, + this.overlays = const {}, + }); + + factory ProjectionPolicy.fromYamlMap(final Map yaml) { + final defaultsRaw = yaml['defaults']; + var dispatchMode = AgentManifestDispatchMode.openApp; + var defaultSurfaces = AgentManifestSurfacePolicy.empty; + 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) { + if (entry.value is! bool) { + continue; + } + overrides[resolveAgentManifestSurface(entry.key.toString())] = + AgentManifestSurfaceExposure(include: entry.value as bool); + } + defaultSurfaces = AgentManifestSurfacePolicy(overrides); + } + } + + return ProjectionPolicy( + defaultDispatchMode: dispatchMode, + defaultSurfaces: defaultSurfaces, + ); + } + + 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({ + final Iterable enabledPlatforms = const [], + }) { + final normalized = enabledPlatforms + .map((final platform) => platform.trim().toLowerCase()) + .where((final platform) => platform.isNotEmpty) + .toSet(); + final overrides = {}; + for (final surface in AgentManifestSurface.values) { + final explicit = defaultSurfaces.overrides[surface]?.include; + final include = + explicit ?? defaultSurfaceIncludeForPlatforms(surface, normalized); + overrides[surface] = AgentManifestSurfaceExposure(include: include); + } + 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_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/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 97% 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..ed3cc1e 100644 --- a/packages/intentcall_platform/lib/src/sync/platform_sync.dart +++ b/packages/intentcall_platform_sync/lib/src/sync/platform_sync.dart @@ -9,6 +9,7 @@ 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'; @@ -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); @@ -156,7 +159,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()); @@ -200,7 +203,9 @@ 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 +427,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 +687,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/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 new file mode 100644 index 0000000..e174d70 --- /dev/null +++ b/packages/intentcall_platform_sync/lib/src/templates/platform_hook_templates.dart @@ -0,0 +1,29 @@ +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()); + +/// 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. +String get kAndroidGradleCodegenHook => kDefaultFlutterHookSpine.renderGradle(); + +/// Xcode Run Script build phase — add to iOS/macOS target once. +String get kAppleXcodeCodegenRunScript => + kDefaultFlutterHookSpine.renderAppleXcode(); + +/// Jaspr / web-only hook snippet for CI or custom build steps. +String get kJasprWebCodegenHook => kDefaultJasprHookSpine.renderJasprWeb(); + +/// 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)', +}; diff --git a/packages/intentcall_android/pubspec.yaml b/packages/intentcall_platform_sync/pubspec.yaml similarity index 60% rename from packages/intentcall_android/pubspec.yaml rename to packages/intentcall_platform_sync/pubspec.yaml index 3183cb7..2adab84 100644 --- a/packages/intentcall_android/pubspec.yaml +++ b/packages/intentcall_platform_sync/pubspec.yaml @@ -1,23 +1,26 @@ -name: intentcall_android -description: PRE-RELEASE — Android shortcuts XML manifest generator from intentcall agent manifest JSON. +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_android +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_android +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_platform_sync topics: - mcp - - android - agents + - codegen environment: - sdk: ">=3.11.0 <4.0.0" + 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 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_sync/test/apple_surface_matrix_test.dart b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart new file mode 100644 index 0000000..4a6ed61 --- /dev/null +++ b/packages/intentcall_platform_sync/test/apple_surface_matrix_test.dart @@ -0,0 +1,165 @@ +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('import intentcall_platform_apple')); + 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, contains('import intentcall_platform_apple')); + 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('import intentcall_platform_apple')); + 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('import intentcall_platform_apple')); + 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 {'))); + expect(swift, isNot(contains('enum IntentCallNativeHandoffStore {'))); + }); + + 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('import intentcall_platform_apple')); + 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/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_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/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/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/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 new file mode 100644 index 0000000..3b48d3f --- /dev/null +++ b/packages/intentcall_platform_sync/test/manifest_merger_test.dart @@ -0,0 +1,107 @@ +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 catalog row projection', () { + 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'}, + ), + projection: const EntryProjection( + dispatchMode: AgentManifestDispatchMode.queueOnly, + surfaces: {AgentManifestSurface.webMcp: false}, + ), + ), + ], + policy: policy, + protocolScheme: 'myapp', + ); + expect(manifest.protocolScheme, 'myapp'); + expect( + manifest.entries.single.dispatchMode, + AgentManifestDispatchMode.queueOnly, + ); + expect( + manifest.entries.single.surfaces.includes( + AgentManifestSurface.webMcp, + defaultValue: false, + ), + isFalse, + ); + }); + + 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(); + 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: false, + ), + isFalse, + ); + expect( + surfaces.includes( + AgentManifestSurface.windowsProtocolActivation, + defaultValue: false, + ), + isFalse, + ); + }); +} 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/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..daa672d --- /dev/null +++ b/packages/intentcall_platform_sync/test/mcp_flutter_apple_sync_test.dart @@ -0,0 +1,81 @@ +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('import intentcall_platform_apple')); + expect(source, isNot(contains('enum IntentCallNativeBridge {'))); + 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/packages/intentcall_platform/test/native_emitters_test.dart b/packages/intentcall_platform_sync/test/native_emitters_test.dart similarity index 88% rename from packages/intentcall_platform/test/native_emitters_test.dart rename to packages/intentcall_platform_sync/test/native_emitters_test.dart index 15bb869..353db0f 100644 --- a/packages/intentcall_platform/test/native_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/native_emitters_test.dart @@ -1,6 +1,10 @@ -import 'package:intentcall_platform/intentcall_platform.dart'; +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', @@ -99,9 +108,14 @@ 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('intentcall.pending_invocations')); - expect(swift, contains('objc_sync_enter(UserDefaults.standard)')); + expect(swift, isNot(contains('enum IntentCallNativeBridge {'))); + expect( + swift, + isNot(contains('IntentCallNativeHandoffStore.append(item)')), + ); + expect(swift, isNot(contains('enum IntentCallNativeHandoffStore {'))); expect( swift, contains( @@ -112,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')); }); @@ -177,13 +187,12 @@ 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, isNot(contains('enum IntentCallNativeHandoffStore {'))); expect(swift, contains('static func indexAppEntities() async throws')); expect(swift, contains('CSSearchableIndex.default().indexAppEntities')); expect(swift, contains('static func deleteAppEntities(')); @@ -195,7 +204,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 +224,7 @@ void main() { 'name': 'ping', 'description': 'Ping', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': {'type': 'object'}, }, ], @@ -228,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/'))); }); @@ -247,6 +262,7 @@ void main() { 'description': 'Ping', 'kind': 'tool', 'dispatchMode': 'queueOnly', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': {'type': 'object'}, }, ], @@ -265,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', () { @@ -286,6 +299,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'platforms': { @@ -324,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')), @@ -347,6 +358,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'result': {'type': 'string'}, @@ -426,6 +438,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'dartExtensionInline', 'platforms': { @@ -463,6 +476,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'platforms': { @@ -499,6 +513,7 @@ void main() { 'description': 'Publish', 'kind': 'tool', 'surfaces': { + 'apple.appIntents': {'include': true}, 'apple.appShortcuts': {'include': true}, }, 'inputSchema': {'type': 'object'}, @@ -509,6 +524,7 @@ void main() { 'name': 'private', 'description': 'Private', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': {'type': 'object'}, }, ], @@ -582,6 +598,7 @@ void main() { 'name': 'reserved', 'description': 'Reserved', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': { 'type': 'object', 'required': ['class'], @@ -609,6 +626,7 @@ void main() { 'name': 'object', 'description': 'Object', 'kind': 'tool', + 'surfaces': _appleAppIntentsSurface, 'inputSchema': { 'type': 'object', 'required': ['payload'], @@ -707,6 +725,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'dartExtensionInline', 'result': { @@ -785,6 +804,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'dartExtensionInline', 'platforms': { @@ -864,6 +884,7 @@ void main() { 'description': 'Inline', 'kind': 'tool', 'dispatchMode': 'inlineRuntime', + 'surfaces': _appleAppIntentsSurface, 'inlineRuntime': { 'kind': 'nativeInline', 'result': {'type': 'number'}, @@ -1110,6 +1131,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) @@ -1135,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.")) } } @@ -1145,119 +1167,6 @@ struct IntentCallShortcutsProvider: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut(intent: AppCartTotalIntent(), phrases: ["\(.applicationName) Cart Total"]) } -} - -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" - - 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) - } -} - -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/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_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_hook_templates_test.dart b/packages/intentcall_platform_sync/test/platform_hook_templates_test.dart new file mode 100644 index 0000000..3a8c1b0 --- /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()); + 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(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/test/platform_hooks_init_test.dart b/packages/intentcall_platform_sync/test/platform_hooks_init_test.dart similarity index 59% 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..2425cae 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'; @@ -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/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/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_sync/test/projection_alignment_test.dart b/packages/intentcall_platform_sync/test/projection_alignment_test.dart new file mode 100644 index 0000000..36d55ec --- /dev/null +++ b/packages/intentcall_platform_sync/test/projection_alignment_test.dart @@ -0,0 +1,373 @@ +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('import intentcall_platform_apple')); + 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('import intentcall_platform_apple')); + expect(swift, contains('enum IntentCallGeneratedEntityConfig')); + expect( + swift, + contains('IntentCallNativeEntitySnapshotStore.fallbackScheme'), + ); + expect( + swift, + isNot(contains('enum IntentCallNativeEntitySnapshotStore {')), + ); + }, + ); + }); + + 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 (make check-contracts)', + ); + }); + }); +} diff --git a/packages/intentcall_platform/test/web_emitters_test.dart b/packages/intentcall_platform_sync/test/web_emitters_test.dart similarity index 95% rename from packages/intentcall_platform/test/web_emitters_test.dart rename to packages/intentcall_platform_sync/test/web_emitters_test.dart index 305a3d4..7d006b2 100644 --- a/packages/intentcall_platform/test/web_emitters_test.dart +++ b/packages/intentcall_platform_sync/test/web_emitters_test.dart @@ -1,8 +1,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() { @@ -25,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'}, }, ], @@ -112,9 +115,9 @@ 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('validateListItems')); expect(js, contains('validateObjectProperties')); expect(js, contains('must be an object.')); expect(js, contains('Missing required property "')); @@ -305,9 +308,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,16 +320,15 @@ 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', () { + test('reads shortcuts and intents lists', () { final manifest = AgentManifest.fromJson({ 'version': 1, 'platform': 'android', @@ -717,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..c56ad08 --- /dev/null +++ b/packages/intentcall_platform_sync/test/webmcp_bootstrap_surface_test.dart @@ -0,0 +1,177 @@ +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, + ); + }); + + 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/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index ab6ca95..14a9190 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -7,8 +7,198 @@ [![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( + protocolScheme: 'demoapp', + resourceName: 'cool_runtime_snapshot', + snapshot: {'phase': 'playing'}, +); +// result.data['resource_uri'] == 'demoapp://resource/cool/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`, `list` | Yes | +| `enum` on strings | Yes | +| `minimum` / `maximum` on numbers | 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 | + +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](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 + +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..3eddf74 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'; @@ -6,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_entity_model.dart b/packages/intentcall_schema/lib/src/agent_entity_model.dart index 7f4a017..c466a60 100644 --- a/packages/intentcall_schema/lib/src/agent_entity_model.dart +++ b/packages/intentcall_schema/lib/src/agent_entity_model.dart @@ -1,13 +1,36 @@ +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. +/// 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 +38,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 +66,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, @@ -62,8 +111,12 @@ final class AgentEntitySnapshot { return trimmed; }), ), - properties = _jsonObject(properties); + properties = jsonDecodeNullableStringKeyMap(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) { @@ -79,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'), @@ -94,22 +149,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, @@ -190,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) { @@ -203,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) { @@ -219,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_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..603d0ca 100644 --- a/packages/intentcall_schema/lib/src/agent_result_envelope.dart +++ b/packages/intentcall_schema/lib/src/agent_result_envelope.dart @@ -1,9 +1,26 @@ import 'dart:convert'; import 'agent_result.dart'; +import 'resource_uri.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,13 +39,22 @@ 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 [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, @@ -50,12 +76,4 @@ extension AgentResultEnvelope on AgentResult { }, ); } - - /// `intentcall://resource/a/b` from `a_b` name segments. - 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/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..15a54de 100644 --- a/packages/intentcall_schema/lib/src/agent_wire_args.dart +++ b/packages/intentcall_schema/lib/src/agent_wire_args.dart @@ -1,50 +1,47 @@ -import 'dart:convert'; +import 'package:from_json_to_json/from_json_to_json.dart'; import 'agent_result.dart'; +import 'json_utils.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) { + // 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()); } - 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; - } + /// 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) => jsonDecodeNullableBool(string(key)); - int? int_(final String key) => int.tryParse(_raw[key]?.trim() ?? ''); + /// Parses an integer from the string value, or `null` if missing or invalid. + int? int_(final String key) => jsonDecodeNullableInt(_raw[key]?.trim()); - double? double_(final String key) => double.tryParse(_raw[key]?.trim() ?? ''); + /// Parses a double from the string value, or `null` if missing or invalid. + 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). 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..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,9 +2,15 @@ // Licensed under the MIT License. import 'agent_result.dart'; +import 'json_utils.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, ) { @@ -15,10 +21,13 @@ 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 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 +40,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'], }) { @@ -56,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..f6d3cb7 --- /dev/null +++ b/packages/intentcall_schema/lib/src/json_utils.dart @@ -0,0 +1,106 @@ +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 +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) => + 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/resource_uri.dart b/packages/intentcall_schema/lib/src/resource_uri.dart new file mode 100644 index 0000000..f2286e9 --- /dev/null +++ b/packages/intentcall_schema/lib/src/resource_uri.dart @@ -0,0 +1,15 @@ +/// Builds `$protocolScheme://resource/...` from underscore-separated segments. +/// +/// Example: `cool_runtime_snapshot` with scheme `demoapp` → +/// `demoapp://resource/cool/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/lib/src/schema_coercion.dart b/packages/intentcall_schema/lib/src/schema_coercion.dart index 76ef36c..398992e 100644 --- a/packages/intentcall_schema/lib/src/schema_coercion.dart +++ b/packages/intentcall_schema/lib/src/schema_coercion.dart @@ -1,9 +1,26 @@ import 'dart:convert'; -import 'agent_result.dart'; +import 'package:from_json_to_json/from_json_to_json.dart'; -/// Coerces VM service-extension wire values (`Map` values often [String]) to -/// types expected by [validateAgainstSchema], using [schema] property types. +import 'agent_result.dart'; +import 'json_utils.dart'; + +/// 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 lists 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, @@ -50,11 +67,11 @@ 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, - 'array' => _parseWireJsonList(trimmed, propertySchema) ?? value, + 'boolean' => jsonDecodeNullableBool(trimmed) ?? value, + 'object' => jsonDecodeNullableMapAs(trimmed) ?? value, + 'list' => _parseWireJsonList(trimmed, propertySchema) ?? value, _ => value, }; } @@ -62,8 +79,8 @@ Object? _coercePropertyValue( if (type == 'object' && value is Map) { return _coerceObjectValue(propertySchema, Map.from(value)); } - if (type == 'array' && value is List) { - return _coerceArrayValue(propertySchema, value); + if (type == 'list' && value is List) { + return _coerceListValue(propertySchema, value); } return value; @@ -112,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); @@ -126,11 +144,11 @@ Object? _coerceOpenObjectEntryValue(final Object? value) { return value; } -List _coerceArrayValue( - final Map arraySchema, +List _coerceListValue( + final Map listSchema, final List value, ) { - final itemSchema = arraySchema['items']; + final itemSchema = listSchema['items']; if (itemSchema is! Map) { return value; } @@ -143,36 +161,17 @@ 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); -} - -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; + return _coerceListValue(listSchema, decoded); } -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/lib/src/schema_validator.dart b/packages/intentcall_schema/lib/src/schema_validator.dart index d64c8ab..84692d7 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`, +/// `list` +/// - `enum` on string properties (allowed string list) +/// - List `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 list `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, @@ -94,15 +106,15 @@ 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); + _validateListItems(path, schema, value); } } -void _validateArrayItems( +void _validateListItems( final String path, final Map schema, final List value, diff --git a/packages/intentcall_schema/pubspec.yaml b/packages/intentcall_schema/pubspec.yaml index 36415c2..9c8defa 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 @@ -11,11 +13,15 @@ topics: - dart environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" 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 + schemantic: ^0.2.2 dev_dependencies: lints: ^6.1.0 diff --git a/packages/intentcall_schema/test/agent_result_envelope_test.dart b/packages/intentcall_schema/test/agent_result_envelope_test.dart index 189dc42..f53bbc4 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( - resourceName: 'spark_runtime_snapshot', + protocolScheme: 'demoapp', + resourceName: 'cool_runtime_snapshot', snapshot: {'phase': 'playing'}, ); expect(result.ok, isTrue); expect( result.data['resource_uri'], - 'intentcall://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 new file mode 100644 index 0000000..2f3a2ef --- /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: 'cool_runtime_snapshot', + ), + 'demoapp://resource/cool/runtime/snapshot', + ); + }); + + test('resourceUri returns unknown segment for empty name', () { + expect( + resourceUri(protocolScheme: 'demoapp', resourceName: ''), + 'demoapp://resource/unknown', + ); + }); +} 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/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_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 80e6ab7..406f6c2 100644 --- a/packages/intentcall_session/pubspec.yaml +++ b/packages/intentcall_session/pubspec.yaml @@ -11,10 +11,11 @@ topics: - dart environment: - sdk: ">=3.11.0 <4.0.0" + sdk: ">=3.12.0 <4.0.0" resolution: workspace dependencies: + collection: ^1.19.1 from_json_to_json: ^0.5.0 intentcall_core: ^0.6.0 intentcall_schema: ^0.6.0 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_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/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/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..772cfe3 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: @@ -145,6 +153,30 @@ 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: + 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: @@ -177,6 +209,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: @@ -193,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: @@ -267,6 +323,30 @@ 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: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + 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: @@ -283,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: @@ -315,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: @@ -387,6 +483,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: @@ -419,6 +523,30 @@ 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: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + 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: @@ -633,5 +761,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..d66c2c9 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 @@ -12,9 +12,13 @@ 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 - packages/intentcall_platform + - packages/intentcall_platform_android + - packages/intentcall_platform_apple + - 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..77b8753 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,10 +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" ] } @@ -41,26 +45,46 @@ "package-name": "intentcall_webmcp", "component": "intentcall_webmcp" }, - "packages/intentcall_apple": { + "packages/intentcall_codegen": { "release-type": "dart", - "package-name": "intentcall_apple", - "component": "intentcall_apple" + "package-name": "intentcall_codegen", + "component": "intentcall_codegen" }, - "packages/intentcall_android": { + "packages/intentcall_platform_sync": { "release-type": "dart", - "package-name": "intentcall_android", - "component": "intentcall_android" + "package-name": "intentcall_platform_sync", + "component": "intentcall_platform_sync" }, - "packages/intentcall_codegen": { + "packages/intentcall_hooks": { "release-type": "dart", - "package-name": "intentcall_codegen", - "component": "intentcall_codegen" + "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", + "component": "intentcall_cli" }, "packages/intentcall_platform": { "release-type": "dart", "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", @@ -72,4 +96,4 @@ "component": "intentcall_session" } } -} +} \ No newline at end of file diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index abbf102..72c590d 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -46,7 +46,104 @@ 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 (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 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 (e.g. `...InboxHost.inboxHostCatalogEntries`). + +```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), + ); + + @AgentCatalog() + static final List inboxHostCatalogEntries = + [ + AgentRegistryCatalogEntry( + registryKey: 'app_read_inbox', + entry: shared.readInboxCallEntry, + projection: const EntryProjection( + surfaces: {AgentManifestSurface.webMcp: true}, + ), + ), + ]; +} +``` + +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) 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 +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 from +a live host at runtime and keep `qualifiedName` aligned. + +**Handwritten projection:** Use inline `EntryProjection` on catalog rows: + +```dart +AgentRegistryCatalogEntry( + registryKey: 'app_read_inbox', + 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). + +**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. 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). + +--- + +## 3. Code Generation (`@AgentTool`) We can automate registration using the code generator package `intentcall_codegen`. @@ -102,9 +199,201 @@ 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. 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}) +``` + +**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 +steward benchmark --scenario intentcall.manifest-resource-uri --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`) + +> **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 +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 list (`valueType: 'list'`) | + +`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: 'list', + 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: 'list'`. +- Override property names must exist in `properties`. +- `role` wins over conflicting `isDisplay` / `isSearchable` flags (warning logged). + --- -## 3. 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: @@ -118,6 +407,8 @@ 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 ``` --- @@ -126,3 +417,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 7c378e0..a787121 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,246 @@ 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. + 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.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 (alignment matrix, dense export, emitters, entity export, 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.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. + 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. @@ -175,7 +417,7 @@ actions: probes: quick: profile: quick - actions: [intentcall.validate, intentcall.adapter-contract-test] + 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: @@ -187,6 +429,10 @@ provenance: benchmarks: - id: intentcall.adapter-contract 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.adapter-contract.yaml b/steward/scenarios/intentcall.adapter-contract.yaml index 5d66914..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 @@ -17,6 +20,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 +51,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/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 diff --git a/steward/scenarios/intentcall.projection-pipeline.yaml b/steward/scenarios/intentcall.projection-pipeline.yaml new file mode 100644 index 0000000..6142beb --- /dev/null +++ b/steward/scenarios/intentcall.projection-pipeline.yaml @@ -0,0 +1,101 @@ +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 + - 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 + 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: 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 + 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 diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index df79c6a..2477cd0 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; @@ -14,13 +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') @@ -235,8 +246,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!; @@ -273,18 +286,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; } @@ -296,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.', @@ -815,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; } @@ -884,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.'); @@ -919,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('"intentcall_platform/invocations"')) { - findings.add( - '${spec.packageDir} SwiftPM source is missing the invocation channel', - ); - } } - - 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; } @@ -1501,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, @@ -1566,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 89ca293..f07291f 100644 --- a/tool/intentcall/bin/release_train.dart +++ b/tool/intentcall/bin/release_train.dart @@ -7,10 +7,14 @@ 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', ]; @@ -172,17 +176,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; } @@ -221,21 +214,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; } @@ -267,13 +245,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]+)', @@ -288,13 +259,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(); } @@ -336,7 +300,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/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..09b4b05 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,10 +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" } '''); @@ -576,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', @@ -600,24 +572,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; } @@ -646,17 +600,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(''' @@ -664,19 +613,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") ] @@ -688,10 +638,12 @@ 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) {} + 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" @@ -704,4 +656,8 @@ public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { '''); + + File( + p.join(sourceDir.path, 'IntentCallPlatformBridge.g.swift'), + ).writeAsStringSync('// Generated pigeon bridge fixture\n'); }