From 51da9b2169b14a83c056bd271ca2de462fa1e3ae Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Wed, 24 Jun 2026 18:01:25 +0300 Subject: [PATCH 1/6] docs: add docs.page validation gate and newcomer onboarding Bootstrap docs.json and a published landing page, wire pnpm docs:check into CI and Steward, fix in-site links, and clarify repo purpose for newcomers and agents. Co-authored-by: Cursor --- .github/workflows/ci.yml | 14 + .gitignore | 2 + AGENTS.md | 5 +- CONTRIBUTING.md | 8 +- README.md | 2 +- docs.json | 106 +++++ docs/DESIGN_FAQ.mdx | 2 +- docs/DX_FAQ.mdx | 23 +- docs/NORTH_STAR.mdx | 14 +- docs/contributing/enable_docs_page.mdx | 24 + .../0010-adopt-intentcall-product-name.md | 8 +- ...t-skills-discoverability-for-intentcall.md | 4 +- .../0012-adopt-platform-support-tiers.md | 6 +- ...lemented-plans-after-durable-extraction.md | 6 +- ...0014-own-runtime-sessions-in-intentcall.md | 4 +- docs/index.mdx | 36 ++ docs/start_here/docs_map.mdx | 58 ++- justfile | 4 + package.json | 18 + pnpm-lock.yaml | 438 ++++++++++++++++++ steward.yaml | 37 +- 21 files changed, 777 insertions(+), 42 deletions(-) create mode 100644 docs.json create mode 100644 docs/contributing/enable_docs_page.mdx create mode 100644 docs/index.mdx create mode 100644 package.json create mode 100644 pnpm-lock.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 303e98e..b023a39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,17 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: pnpm install (docs.page CLI) + run: pnpm install --frozen-lockfile + - uses: subosito/flutter-action@v2 with: channel: stable @@ -36,3 +47,6 @@ jobs: - name: pub.dev dry-run (all packages) run: dart run tool/intentcall/bin/intentcall.dart publish-all + + - name: docs.page check + run: pnpm run docs:check diff --git a/.gitignore b/.gitignore index 10eb150..c8935c2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ build/ *.iml .flutter-plugins .flutter-plugins-dependencies +node_modules/ +package-lock.json diff --git a/AGENTS.md b/AGENTS.md index 68a351b..55c55c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,9 @@ # IntentCall — Agent Map -IntentCall is a **transport-agnostic agent intent platform** for Dart/Flutter. +**IntentCall** lets Dart/Flutter apps define agent-callable actions once in `AgentRegistry`, then project them to MCP, WebMCP, shortcuts, and deep links — without rewriting per transport. **Building a Flutter app?** Start with [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). **Building adapters or platform projection?** You're in the right repo. + +Published docs: [docs.page/Arenukvern/intentcall](https://docs.page/Arenukvern/intentcall) · Full router: [docs/start_here/docs_map.mdx](docs/start_here/docs_map.mdx) + Install Skill Steward meta-skills for this repo: ```bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70837da..3b25a9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Thanks for your interest! IntentCall is a pre-release platform library. Contribu - [Dart SDK](https://dart.dev/get-dart) `^3.11.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) ## Quick start @@ -19,7 +20,12 @@ just analyze # static analysis just publish-dry-run # pub.dev validation (no credentials needed) ``` -All three must be green before opening a PR. +All three must be green before opening a PR. If you changed `docs/` or `docs.json`, also run: + +```bash +pnpm install # once +just docs-check +``` ## Conventional commits diff --git a/README.md b/README.md index 0d665ac..8c5af6e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Transport-agnostic agent intent platform for Dart/Flutter: define intent truth once in `AgentRegistry`, then project it into the strongest available surface: MCP/WebMCP, native semantic action systems where supported, assistant/shortcut fulfillment, and canonical deep-link fallback where native support is incomplete. Extracted from [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). -**Charter:** [docs/NORTH_STAR.mdx](docs/NORTH_STAR.mdx) · **Agent map:** [AGENTS.md](AGENTS.md) +**Charter:** [docs/NORTH_STAR.mdx](docs/NORTH_STAR.mdx) · **Agent map:** [AGENTS.md](AGENTS.md) · **Docs site:** [docs.page/Arenukvern/intentcall](https://docs.page/Arenukvern/intentcall) **Why / how:** [docs/DESIGN_FAQ.mdx](docs/DESIGN_FAQ.mdx) · [docs/DX_FAQ.mdx](docs/DX_FAQ.mdx) · [Decisions](docs/decisions/) · [CONTRIBUTING.md](CONTRIBUTING.md) GitHub: [Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) diff --git a/docs.json b/docs.json new file mode 100644 index 0000000..ad49bd2 --- /dev/null +++ b/docs.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://docs.page/schema.json", + "name": "IntentCall", + "description": "Register intents. Call them everywhere.", + "github": "https://github.com/Arenukvern/intentcall", + "theme": { + "defaultTheme": "system" + }, + "header": { + "showName": true, + "showThemeToggle": true, + "showGitHubCard": true, + "links": [ + { + "title": "GitHub", + "href": "https://github.com/Arenukvern/intentcall" + }, + { + "title": "mcp_flutter", + "href": "https://github.com/Arenukvern/mcp_flutter" + } + ] + }, + "content": { + "headerDepth": 3, + "automaticallyInferNextPrevious": true, + "showPageTitle": true + }, + "sidebar": [ + { + "group": "Start Here", + "pages": [ + { "title": "Overview", "href": "/" }, + { "title": "North Star", "href": "/NORTH_STAR" }, + { "title": "Docs map", "href": "/start_here/docs_map" } + ] + }, + { + "group": "FAQs", + "pages": [ + { "title": "Design FAQ", "href": "/DESIGN_FAQ" }, + { "title": "DX FAQ", "href": "/DX_FAQ" } + ] + }, + { + "group": "Decisions", + "pages": [ + { "title": "Index", "href": "/decisions/README" }, + { + "title": "0010 — IntentCall product name", + "href": "/decisions/0010-adopt-intentcall-product-name" + }, + { + "title": "0011 — Agent skills discoverability", + "href": "/decisions/0011-agent-skills-discoverability-for-intentcall" + }, + { + "title": "0012 — Platform support tiers", + "href": "/decisions/0012-adopt-platform-support-tiers" + }, + { + "title": "0013 — Plan hygiene", + "href": "/decisions/0013-delete-implemented-plans-after-durable-extraction" + }, + { + "title": "0014 — Runtime sessions", + "href": "/decisions/0014-own-runtime-sessions-in-intentcall" + } + ] + }, + { + "group": "Contributing", + "pages": [ + { + "title": "Enable docs.page", + "href": "/contributing/enable_docs_page" + } + ] + }, + { + "group": "Repo root (GitHub)", + "pages": [ + { + "title": "README", + "href": "https://github.com/Arenukvern/intentcall/blob/main/README.md" + }, + { + "title": "AGENTS.md", + "href": "https://github.com/Arenukvern/intentcall/blob/main/AGENTS.md" + }, + { + "title": "CONTRIBUTING", + "href": "https://github.com/Arenukvern/intentcall/blob/main/CONTRIBUTING.md" + }, + { + "title": "PRE_RELEASE", + "href": "https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md" + }, + { + "title": "PUBLISHING", + "href": "https://github.com/Arenukvern/intentcall/blob/main/PUBLISHING.md" + } + ] + } + ] +} diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index 0debc25..b6483fb 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -1,6 +1,6 @@ # IntentCall — Design FAQ -*Why IntentCall is built the way it is.* For usage patterns, see [DX_FAQ.mdx](DX_FAQ.mdx). +*Why IntentCall is built the way it is.* For usage patterns, see [DX FAQ](/DX_FAQ). --- diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index b10cfa8..c6b59aa 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -1,6 +1,25 @@ # IntentCall — DX FAQ -*How to use and extend IntentCall.* For architectural rationale, see [DESIGN_FAQ.mdx](DESIGN_FAQ.mdx). +> Published copy for [docs.page](https://docs.page/Arenukvern/intentcall): [docs/DX_FAQ.mdx](docs/DX_FAQ.mdx) + +*How to use and extend IntentCall.* For architectural rationale, see [Design FAQ](/DESIGN_FAQ). + +--- + +## 📚 Documentation site + +**Q: Where is the published documentation?** + +[docs.page/Arenukvern/intentcall](https://docs.page/Arenukvern/intentcall) — site config in `docs.json`, pages under `docs/`. Maintainer guide: [Enable docs.page](/contributing/enable_docs_page). + +**Q: How do I validate docs before a PR?** + +```bash +pnpm install +just docs-check +``` + +Run this when you change `docs/` or `docs.json`. --- @@ -312,7 +331,7 @@ make check-intentcall-integration **Q: How do I publish?** -See [PUBLISHING.md](../PUBLISHING.md) for full instructions. In short: +See [PUBLISHING.md](https://github.com/Arenukvern/intentcall/blob/main/PUBLISHING.md) for full instructions. In short: ```bash # historical first-publish check: only for brand-new package names diff --git a/docs/NORTH_STAR.mdx b/docs/NORTH_STAR.mdx index 5551deb..383dd1d 100644 --- a/docs/NORTH_STAR.mdx +++ b/docs/NORTH_STAR.mdx @@ -88,13 +88,13 @@ IntentCall is **pre-release platform infrastructure**, not a consumer product. T ## Pre-release status -All packages are **0.1.x** — experimental. APIs may change without a major semver bump. See [PRE_RELEASE.md](../PRE_RELEASE.md). +All packages are **0.1.x** — experimental. APIs may change without a major semver bump. See [PRE_RELEASE.md](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). ## Key docs -- [AGENTS.md](../AGENTS.md) — agent map and navigation pointers -- [DESIGN_FAQ.mdx](DESIGN_FAQ.mdx) — why IntentCall is built this way -- [DX_FAQ.mdx](DX_FAQ.mdx) — how to use and extend IntentCall -- [docs/decisions/](decisions/) — architecture decision records -- [CONTRIBUTING.md](../CONTRIBUTING.md) — how to contribute -- [PUBLISHING.md](../PUBLISHING.md) — pub.dev publishing guide +- [AGENTS.md](https://github.com/Arenukvern/intentcall/blob/main/AGENTS.md) — agent map and navigation pointers +- [DESIGN_FAQ.mdx](/DESIGN_FAQ) — why IntentCall is built this way +- [DX_FAQ.mdx](/DX_FAQ) — how to use and extend IntentCall +- [docs/decisions/](/decisions/README) — architecture decision records +- [CONTRIBUTING.md](https://github.com/Arenukvern/intentcall/blob/main/CONTRIBUTING.md) — how to contribute +- [PUBLISHING.md](https://github.com/Arenukvern/intentcall/blob/main/PUBLISHING.md) — pub.dev publishing guide diff --git a/docs/contributing/enable_docs_page.mdx b/docs/contributing/enable_docs_page.mdx new file mode 100644 index 0000000..cb3777d --- /dev/null +++ b/docs/contributing/enable_docs_page.mdx @@ -0,0 +1,24 @@ +# Enable docs.page + +This repo publishes docs via [docs.page](https://docs.page) using [docs.json](https://github.com/Arenukvern/intentcall/blob/main/docs.json). + +Live site: [docs.page/Arenukvern/intentcall](https://docs.page/Arenukvern/intentcall) + +## Maintainer check + +```bash +pnpm install +pnpm run docs:check +# or: just docs-check +``` + +## Add a page + +1. Add markdown or MDX under `docs/`. +2. Register the page in `docs.json` `sidebar`. +3. Run `just docs-check` on PRs that touch `docs/` or `docs.json`. + +## Link policy + +- **Published pages** (`docs/**`): use docs.page paths in tables, e.g. `/NORTH_STAR`, `/decisions/README`. +- **Repo-root maintainer files** (`CONTRIBUTING.md`, `AGENTS.md`, etc.): use full GitHub blob URLs in link tables. diff --git a/docs/decisions/0010-adopt-intentcall-product-name.md b/docs/decisions/0010-adopt-intentcall-product-name.md index c713ead..76dd6f8 100644 --- a/docs/decisions/0010-adopt-intentcall-product-name.md +++ b/docs/decisions/0010-adopt-intentcall-product-name.md @@ -79,8 +79,8 @@ Chosen option: **IntentCall** as the **public product name**. ## Links -* [IntentCall North Star](../NORTH_STAR.mdx) -* [IntentCall Design FAQ](../DESIGN_FAQ.mdx) -* [PRE_RELEASE.md](../../PRE_RELEASE.md) -* [PUBLISHING.md](../../PUBLISHING.md) +* [IntentCall North Star](/NORTH_STAR) +* [IntentCall Design FAQ](/DESIGN_FAQ) +* [PRE_RELEASE.md](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md) +* [PUBLISHING.md](https://github.com/Arenukvern/intentcall/blob/main/PUBLISHING.md) * [Skill Steward ADR 0008 — naming pattern](https://github.com/Arenukvern/skill_steward/blob/main/docs/decisions/0008-adopt-skill-steward-product-name.md) diff --git a/docs/decisions/0011-agent-skills-discoverability-for-intentcall.md b/docs/decisions/0011-agent-skills-discoverability-for-intentcall.md index 71f38b8..36b8be1 100644 --- a/docs/decisions/0011-agent-skills-discoverability-for-intentcall.md +++ b/docs/decisions/0011-agent-skills-discoverability-for-intentcall.md @@ -64,5 +64,5 @@ Chosen option: **Option 2**. We will introduce IntentCall-specific developer ski ## Links -* [Skill Steward — plugin-marketplace-setup](../../.agents/skills/plugin-marketplace-setup/SKILL.md) -* [Skill Steward — create-skill](../../.agents/skills/create-skill/SKILL.md) +* [Skill Steward — plugin-marketplace-setup](https://github.com/Arenukvern/skill_steward/blob/main/skills/plugin-marketplace-setup/SKILL.md) +* [Skill Steward — create-skill](https://github.com/Arenukvern/skill_steward/blob/main/skills/create-skill/SKILL.md) diff --git a/docs/decisions/0012-adopt-platform-support-tiers.md b/docs/decisions/0012-adopt-platform-support-tiers.md index ba8ad15..d9f8fe3 100644 --- a/docs/decisions/0012-adopt-platform-support-tiers.md +++ b/docs/decisions/0012-adopt-platform-support-tiers.md @@ -51,6 +51,6 @@ Roadmap targets include Android AppFunctions, richer Android App Actions capabil ## Links -* [NORTH_STAR.md](../NORTH_STAR.mdx) -* [DESIGN_FAQ.md](../DESIGN_FAQ.mdx) -* [PRE_RELEASE.md](../../PRE_RELEASE.md) +* [NORTH_STAR.md](/NORTH_STAR) +* [DESIGN_FAQ.md](/DESIGN_FAQ) +* [PRE_RELEASE.md](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md) diff --git a/docs/decisions/0013-delete-implemented-plans-after-durable-extraction.md b/docs/decisions/0013-delete-implemented-plans-after-durable-extraction.md index 68ba42c..00c0405 100644 --- a/docs/decisions/0013-delete-implemented-plans-after-durable-extraction.md +++ b/docs/decisions/0013-delete-implemented-plans-after-durable-extraction.md @@ -49,6 +49,6 @@ Archiving implemented plans is not the default. If a public durable pointer is n ## Links -* [NORTH_STAR.md](../NORTH_STAR.mdx) -* [DESIGN_FAQ.md](../DESIGN_FAQ.mdx) -* [DX_FAQ.md](../DX_FAQ.mdx) +* [NORTH_STAR.md](/NORTH_STAR) +* [DESIGN_FAQ.md](/DESIGN_FAQ) +* [DX_FAQ.md](/DX_FAQ) diff --git a/docs/decisions/0014-own-runtime-sessions-in-intentcall.md b/docs/decisions/0014-own-runtime-sessions-in-intentcall.md index 9ae8cab..5c4e991 100644 --- a/docs/decisions/0014-own-runtime-sessions-in-intentcall.md +++ b/docs/decisions/0014-own-runtime-sessions-in-intentcall.md @@ -67,5 +67,5 @@ package and compatibility re-export shims are removed. ## Links -* [NORTH_STAR.md](../NORTH_STAR.mdx) -* [DESIGN_FAQ.md](../DESIGN_FAQ.mdx) +* [NORTH_STAR.md](/NORTH_STAR) +* [DESIGN_FAQ.md](/DESIGN_FAQ) diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 0000000..ba8cdd9 --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,36 @@ +# IntentCall + +**IntentCall** lets Dart/Flutter apps define agent-callable actions once in `AgentRegistry`, then project them to MCP, WebMCP, shortcuts, and deep links — without rewriting per transport. + +**Building a Flutter app?** Start with [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). **Building adapters or platform projection?** You're in the right repo. + +*Register intents. Call them everywhere.* + +## Quick links + +| I want to… | Go to | +|------------|--------| +| Charter and boundaries | [North Star](/NORTH_STAR) | +| Commands, test, release | [DX FAQ](/DX_FAQ) | +| Architectural why | [Design FAQ](/DESIGN_FAQ) · [Decisions](/decisions/README) | +| Agent map + skills | [AGENTS.md](https://github.com/Arenukvern/intentcall/blob/main/AGENTS.md) | +| Full doc index | [Docs map](/start_here/docs_map) | +| Contribute | [CONTRIBUTING.md](https://github.com/Arenukvern/intentcall/blob/main/CONTRIBUTING.md) | + +## Get started + +```bash +git clone https://github.com/Arenukvern/intentcall.git +cd intentcall +dart pub get +just test +just analyze +``` + +**Agents:** install meta-skills, then probe the repo contract: + +```bash +npx skills add arenukvern/skill_steward +steward doctor --json +steward probe --json --profile quick +``` diff --git a/docs/start_here/docs_map.mdx b/docs/start_here/docs_map.mdx index 3243153..1a6a976 100644 --- a/docs/start_here/docs_map.mdx +++ b/docs/start_here/docs_map.mdx @@ -1,16 +1,46 @@ # IntentCall Documentation Map -Welcome to IntentCall. Use this map to navigate the codebase documentation. - -## Quick Router - -| I want to... | Go to | -|---|---| -| Understand the project charter & boundaries | [docs/NORTH_STAR.mdx](../NORTH_STAR.mdx) | -| Find why architectural decisions were made | [docs/decisions/README.md](../decisions/README.md) | -| Understand the stewardship rule for temporary plans | [ADR 0013](../decisions/0013-delete-implemented-plans-after-durable-extraction.md) | -| Learn how to run build/tests / release packages | [DX_FAQ.mdx](../DX_FAQ.mdx) | -| Understand the architectural design choices | [DESIGN_FAQ.mdx](../DESIGN_FAQ.mdx) | -| Learn how to contribute to IntentCall | [CONTRIBUTING.md](../../CONTRIBUTING.md) | -| Understand the pre-release and publishing status | [PRE_RELEASE.md](../../PRE_RELEASE.md) and [PUBLISHING.md](../../PUBLISHING.md) | -| See the installed agent skills | [AGENTS.md](../../AGENTS.md) | +Index for the IntentCall documentation site and repository. + +**IntentCall** lets Dart/Flutter apps define agent-callable actions once in `AgentRegistry`, then project them to MCP, WebMCP, shortcuts, and deep links. App authors start with [mcp_flutter](https://github.com/Arenukvern/mcp_flutter); this repo is for registry, adapters, and platform projection. + +## Quick router + +| I want to… | Go to | +|------------|--------| +| Charter and scope | [North Star](/NORTH_STAR) | +| Commands, test, release | [DX FAQ](/DX_FAQ) | +| Architectural why | [Design FAQ](/DESIGN_FAQ) · [Decisions](/decisions/README) | +| Stewardship rule for temporary plans | [ADR 0013](/decisions/0013-delete-implemented-plans-after-durable-extraction) | +| 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) | +| Pre-release and publishing | [PRE_RELEASE.md](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md) · [PUBLISHING.md](https://github.com/Arenukvern/intentcall/blob/main/PUBLISHING.md) | +| Installed agent skills | [.agents/skills/](https://github.com/Arenukvern/intentcall/tree/main/.agents/skills) | + +## Published site (`docs/`) + +- [Overview](/) +- [North Star](/NORTH_STAR) +- [Design FAQ](/DESIGN_FAQ) +- [DX FAQ](/DX_FAQ) +- [Decisions](/decisions/README) +- [Enable docs.page](/contributing/enable_docs_page) + +## Repo root (GitHub) + +| File | Role | +|------|------| +| `README.md` | Install, packages, ecosystem | +| `AGENTS.md` | Agent map (~60 lines) | +| `DESIGN_FAQ.md` | Why (compressed) | +| `DX_FAQ.md` | How — `just` tasks, releases | +| `CONTRIBUTING.md` | PR checklist | +| `PRE_RELEASE.md` | Pre-release status | +| `PUBLISHING.md` | pub.dev publishing | + +## Verify + +```bash +just test +just docs-check # if docs/ or docs.json changed +``` diff --git a/justfile b/justfile index 06fa9d0..727c8ea 100644 --- a/justfile +++ b/justfile @@ -66,3 +66,7 @@ list-skills: @echo "Available Custom Agent Skills:" @echo " - register-intents: Guide to manual and codegen intent registration (skills/register-intents/SKILL.md)" @echo " - write-adapter: Guide to implementing custom platform/transport adapters (skills/write-adapter/SKILL.md)" + +# Validate docs.page config and internal doc links +docs-check: + pnpm run docs:check diff --git a/package.json b/package.json new file mode 100644 index 0000000..635ffaf --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "intentcall", + "version": "0.0.0", + "private": true, + "description": "IntentCall — docs.page validation for published documentation", + "license": "MIT", + "packageManager": "pnpm@9.15.4", + "scripts": { + "docs:check": "node node_modules/@docs.page/cli/dist/cli.js check" + }, + "engines": { + "node": ">=18", + "pnpm": ">=9" + }, + "devDependencies": { + "@docs.page/cli": "^1.0.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..79e382f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,438 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@docs.page/cli': + specifier: ^1.0.4 + version: 1.0.4 + +packages: + + '@docs.page/cli@1.0.4': + resolution: {integrity: sha512-vR6QLsY4lfxofXDV9oJJN0ZGWKmjxRof3YW5RayspldFA3BZVkgjuKKnZbYvG6IwIxvhDOyrcuJbtj5RUCEt2g==} + hasBin: true + + '@inquirer/checkbox@2.5.0': + resolution: {integrity: sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==} + engines: {node: '>=18'} + + '@inquirer/confirm@3.2.0': + resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} + engines: {node: '>=18'} + + '@inquirer/core@9.2.1': + resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} + engines: {node: '>=18'} + + '@inquirer/editor@2.2.0': + resolution: {integrity: sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==} + engines: {node: '>=18'} + + '@inquirer/expand@2.3.0': + resolution: {integrity: sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==} + engines: {node: '>=18'} + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@2.3.0': + resolution: {integrity: sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==} + engines: {node: '>=18'} + + '@inquirer/number@1.1.0': + resolution: {integrity: sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==} + engines: {node: '>=18'} + + '@inquirer/password@2.2.0': + resolution: {integrity: sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==} + engines: {node: '>=18'} + + '@inquirer/prompts@5.5.0': + resolution: {integrity: sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog==} + engines: {node: '>=18'} + + '@inquirer/rawlist@2.3.0': + resolution: {integrity: sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==} + engines: {node: '>=18'} + + '@inquirer/search@1.1.0': + resolution: {integrity: sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==} + engines: {node: '>=18'} + + '@inquirer/select@2.5.0': + resolution: {integrity: sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==} + engines: {node: '>=18'} + + '@inquirer/type@1.5.5': + resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==} + engines: {node: '>=18'} + + '@inquirer/type@2.0.0': + resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} + engines: {node: '>=18'} + + '@types/mute-stream@0.0.4': + resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + '@types/wrap-ansi@3.0.0': + resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + +snapshots: + + '@docs.page/cli@1.0.4': + dependencies: + '@inquirer/prompts': 5.5.0 + chalk: 5.6.2 + commander: 12.1.0 + rimraf: 6.1.3 + + '@inquirer/checkbox@2.5.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 1.5.5 + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.3 + + '@inquirer/confirm@3.2.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + + '@inquirer/core@9.2.1': + dependencies: + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 + '@types/mute-stream': 0.0.4 + '@types/node': 22.20.0 + '@types/wrap-ansi': 3.0.0 + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 1.0.0 + signal-exit: 4.1.0 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + + '@inquirer/editor@2.2.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + external-editor: 3.1.0 + + '@inquirer/expand@2.3.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + yoctocolors-cjs: 2.1.3 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@2.3.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + + '@inquirer/number@1.1.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + + '@inquirer/password@2.2.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + ansi-escapes: 4.3.2 + + '@inquirer/prompts@5.5.0': + dependencies: + '@inquirer/checkbox': 2.5.0 + '@inquirer/confirm': 3.2.0 + '@inquirer/editor': 2.2.0 + '@inquirer/expand': 2.3.0 + '@inquirer/input': 2.3.0 + '@inquirer/number': 1.1.0 + '@inquirer/password': 2.2.0 + '@inquirer/rawlist': 2.3.0 + '@inquirer/search': 1.1.0 + '@inquirer/select': 2.5.0 + + '@inquirer/rawlist@2.3.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + yoctocolors-cjs: 2.1.3 + + '@inquirer/search@1.1.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 1.5.5 + yoctocolors-cjs: 2.1.3 + + '@inquirer/select@2.5.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 1.5.5 + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.3 + + '@inquirer/type@1.5.5': + dependencies: + mute-stream: 1.0.0 + + '@inquirer/type@2.0.0': + dependencies: + mute-stream: 1.0.0 + + '@types/mute-stream@0.0.4': + dependencies: + '@types/node': 22.20.0 + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + '@types/wrap-ansi@3.0.0': {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + chalk@5.6.2: {} + + chardet@0.7.0: {} + + cli-width@4.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@12.1.0: {} + + emoji-regex@8.0.0: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + is-fullwidth-code-point@3.0.0: {} + + lru-cache@11.5.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minipass@7.1.3: {} + + mute-stream@1.0.0: {} + + os-tmpdir@1.0.2: {} + + package-json-from-dist@1.0.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + safer-buffer@2.1.2: {} + + signal-exit@4.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + type-fest@0.21.3: {} + + undici-types@6.21.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + yoctocolors-cjs@2.1.3: {} diff --git a/steward.yaml b/steward.yaml index 868e10e..0530955 100644 --- a/steward.yaml +++ b/steward.yaml @@ -129,10 +129,45 @@ actions: 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. + command: + argv: [just, docs-check] + shell: false + cwd: . + effects: + fs_read: + - docs/** + - docs.json + - package.json + - pnpm-lock.yaml + - justfile + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 60000 + 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] probes: quick: profile: quick - actions: [intentcall.validate, intentcall.adapter-contract-test] + actions: [intentcall.validate, intentcall.adapter-contract-test, intentcall.docs-check] diagnostics: cases: {} unknown_cases: From 4d5eaae19f31e2c5acba6f40280111766710c396 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Fri, 26 Jun 2026 04:35:47 +0300 Subject: [PATCH 2/6] feat: add Dart-first native invocation surfaces --- .github/workflows/pub_publish.yml | 2 +- CONTRIBUTING.md | 4 +- PRE_RELEASE.md | 4 +- PUBLISHING.md | 19 +- README.md | 10 +- docs/DESIGN_FAQ.mdx | 19 +- docs/DX_FAQ.mdx | 17 +- docs/NORTH_STAR.mdx | 12 +- .../0012-adopt-platform-support-tiers.md | 2 +- .../0015-dart-first-native-bridge.md | 69 ++++++ docs/decisions/README.md | 3 +- packages/intentcall_android/README.md | 9 +- packages/intentcall_android/pubspec.yaml | 2 +- packages/intentcall_apple/README.md | 13 +- packages/intentcall_apple/pubspec.yaml | 2 +- packages/intentcall_codegen/README.md | 8 +- .../example/demo_ping_tool.dart | 18 +- .../example/demo_ping_tool.g.dart | 43 +++- .../src/generators/agent_tool_generator.dart | 75 +++++- packages/intentcall_codegen/pubspec.yaml | 4 +- .../test/agent_tool_generator_test.dart | 34 +++ packages/intentcall_core/README.md | 2 +- .../lib/src/registry/agent_registry.dart | 11 + .../registry/in_memory_agent_registry.dart | 14 +- packages/intentcall_core/pubspec.yaml | 3 +- .../test/in_memory_agent_registry_test.dart | 22 ++ packages/intentcall_gemma/README.md | 2 +- .../lib/src/gemma_publish_adapter.dart | 15 +- packages/intentcall_gemma/pubspec.yaml | 6 +- .../test/gemma_publish_adapter_test.dart | 71 ++++-- packages/intentcall_mcp/README.md | 2 +- .../lib/src/mcp_publish_adapter.dart | 11 +- packages/intentcall_mcp/pubspec.yaml | 6 +- .../test/mcp_publish_adapter_test.dart | 96 ++++++++ packages/intentcall_platform/README.md | 12 +- .../Classes/IntentCallPlatformPlugin.swift | 26 +- .../lib/intentcall_platform.dart | 1 + .../lib/intentcall_platform_flutter.dart | 1 + .../bootstrap/agent_web_mcp_bootstrap.dart | 8 + .../agent_web_mcp_bootstrap_stub.dart | 7 + .../agent_web_mcp_bootstrap_web.dart | 72 +++++- .../apple_swift_app_intents_emitter.dart | 137 ++++++++++- .../lib/src/emitters/web_mcp_js_emitter.dart | 29 ++- .../intentcall_pending_invocations.dart | 28 +++ .../src/invocation/intentcall_invocation.dart | 128 ++++++++++ .../Classes/IntentCallPlatformPlugin.swift | 29 +++ .../macos/intentcall_platform.podspec | 18 ++ packages/intentcall_platform/pubspec.yaml | 6 +- .../test/agent_web_mcp_bootstrap_test.dart | 7 + .../test/intentcall_invocation_test.dart | 87 +++++++ .../test/native_emitters_test.dart | 38 ++- .../test/web_emitters_test.dart | 222 ++---------------- packages/intentcall_schema/README.md | 4 +- .../lib/src/agent_result_envelope.dart | 2 +- packages/intentcall_session/README.md | 2 +- packages/intentcall_session/pubspec.yaml | 4 +- packages/intentcall_testing/README.md | 2 +- .../lib/src/adapter_contract.dart | 83 +++++-- packages/intentcall_testing/pubspec.yaml | 4 +- .../test/adapter_contract_test.dart | 16 +- packages/intentcall_webmcp/README.md | 2 +- .../lib/src/webmcp_publish_adapter.dart | 21 +- packages/intentcall_webmcp/pubspec.yaml | 6 +- .../test/webmcp_publish_adapter_test.dart | 83 +++++-- skills/register-intents/SKILL.md | 8 +- skills/write-adapter/SKILL.md | 33 ++- steward.yaml | 2 +- tool/intentcall/bin/intentcall.dart | 75 +++++- .../test/publish_preflight_test.dart | 19 ++ 69 files changed, 1426 insertions(+), 426 deletions(-) create mode 100644 docs/decisions/0015-dart-first-native-bridge.md create mode 100644 packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart create mode 100644 packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart create mode 100644 packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift create mode 100644 packages/intentcall_platform/macos/intentcall_platform.podspec create mode 100644 packages/intentcall_platform/test/intentcall_invocation_test.dart diff --git a/.github/workflows/pub_publish.yml b/.github/workflows/pub_publish.yml index e3aa534..1a59b22 100644 --- a/.github/workflows/pub_publish.yml +++ b/.github/workflows/pub_publish.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Release tag to preflight, for example intentcall_core-v0.1.1' + description: 'Release tag to preflight, for example intentcall_core-v0.2.1' required: true type: string diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b25a9f..79b4834 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,8 +50,8 @@ See [docs/DX_FAQ.mdx](docs/DX_FAQ.mdx) — "How do I add a new `intentcall_*` pa ## Publishing -Publishing to pub.dev is maintainer-gated. Maintainers run `just publish-preflight-first` for the first publish, `just publish-preflight` for later releases, then `just publish-dry-run` before `just publish-execute`. See [PUBLISHING.md](PUBLISHING.md). +Publishing to pub.dev is maintainer-gated. Maintainers normally merge the Release Please PR and let tag-triggered GitHub Actions publish through pub.dev automated publishing. `just publish-dry-run` and tag dry-runs are preflight checks; manual execute commands are recovery-only. See [PUBLISHING.md](PUBLISHING.md). ## Pre-release note -All packages are `0.1.x`. APIs may change without a semver major. See [PRE_RELEASE.md](PRE_RELEASE.md). +All packages are `0.2.x` train. APIs may change without a semver major. See [PRE_RELEASE.md](PRE_RELEASE.md). diff --git a/PRE_RELEASE.md b/PRE_RELEASE.md index 4019729..3139c79 100644 --- a/PRE_RELEASE.md +++ b/PRE_RELEASE.md @@ -1,9 +1,9 @@ # Pre-release notice (all intentcall packages) -**Status:** Pre-release `0.1.x` — not production-ready. +**Status:** Pre-release `0.2.x` train — not production-ready. - APIs are **highly experimental** and may change without a major semver bump. -- Breaking changes can land in any `0.1.x` patch while the design stabilizes. +- Breaking changes can land in any `0.2.x` train patch while the design stabilizes. - Standalone repo at [github.com/Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) (sibling to `mcp_flutter` locally). - Prefer **path** or **pinned git refs** for dogfood; use pub.dev only after explicit release notes. diff --git a/PUBLISHING.md b/PUBLISHING.md index ffd205d..5094bb3 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,8 +1,9 @@ # Publishing intentcall to pub.dev -Status: `0.1.0` has been published to pub.dev for the `intentcall_*` -package train. Use this runbook for future releases; keep the first-publish -checks only as historical/diagnostic guidance. +Status: the current published train is `0.2.x`; all publishable +`intentcall_*` packages should move together. Use this runbook for normal +tag-triggered releases; keep first-publish and manual execute commands only as +historical or recovery guidance. ## Automatic release and publish flow @@ -16,7 +17,7 @@ publishing: into one package train, including `intentcall_session`, so versions remain synchronized. 4. Merging the release PR creates component tags such as - `intentcall_core-v0.1.1`. + `intentcall_core-v0.2.1`. 5. `.github/workflows/pub_publish.yml` runs for each `intentcall_*-v*` tag and publishes the package named by that tag. The workflow is skip-existing safe, so rerunning a tag does not republish an already visible package version. @@ -87,16 +88,16 @@ just publish-preflight just publish-dry-run # Validate one package tag the way automated publishing does -just publish-tag-dry-run intentcall_session-v0.1.0 +just publish-tag-dry-run intentcall_session-v0.2.1 # Diagnostic only while release-critical files are dirty; still fails archive/content errors just publish-dry-run-ignore-warnings -# After credentials are configured +# Recovery-only: after credentials are configured and automated publishing is unavailable just publish-execute # CI normally runs this from .github/workflows/pub_publish.yml on tag push -just publish-tag-execute intentcall_session-v0.1.0 +just publish-tag-execute intentcall_session-v0.2.1 ``` For a brand-new package name, treat `just publish-preflight-first` as the release desk: @@ -104,7 +105,7 @@ For a brand-new package name, treat `just publish-preflight-first` as the releas - All `intentcall_*` package names must report available on pub.dev. - The release-critical tree must be clean: publishable `packages/intentcall_*` files plus `tool/intentcall`, including newly added public API files such as `packages/intentcall_core/lib/intentcall_core_migration.dart`. - `dart pub token list` must show a configured token for pub.dev. -- `just publish-dry-run` must pass from the same clean release commit before `just publish-execute`. +- `just publish-dry-run` must pass from the same clean release commit before any recovery manual publish. `just publish-dry-run-ignore-warnings` is only for diagnosing archive/content issues before the release-critical tree is clean. It must not replace the strict dry-run above. @@ -119,7 +120,7 @@ cd packages/intentcall_platform && flutter pub publish --dry-run ## After publish (mcp_flutter cutover) -Status: the initial `0.1.0` hosted cutover is complete in `mcp_flutter`. +Status: the initial hosted cutover is complete in `mcp_flutter`. For future IntentCall releases, update consumers only after the pub.dev package pages exist for the full publish order above. diff --git a/README.md b/README.md index 8c5af6e..fd3ff74 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![maintained with Skill Steward](https://raw.githubusercontent.com/Arenukvern/skill_steward/main/docs/brand/assets/svg/badge-solid.svg)](https://github.com/Arenukvern/skill_steward) -> **Pre-release (`0.1.x`)** — Highly experimental. APIs may change without notice. **Not for production.** See [PRE_RELEASE.md](PRE_RELEASE.md). +> **Pre-release (`0.2.x` train)** — Highly experimental. APIs may change without notice. **Not for production.** See [PRE_RELEASE.md](PRE_RELEASE.md). *Register intents. Call them everywhere.* -Transport-agnostic agent intent platform for Dart/Flutter: define intent truth once in `AgentRegistry`, then project it into the strongest available surface: MCP/WebMCP, native semantic action systems where supported, assistant/shortcut fulfillment, and canonical deep-link fallback where native support is incomplete. Extracted from [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). +Transport-agnostic agent intent platform for Dart/Flutter: define intent truth once in `AgentRegistry`, then project it into the strongest available surface: MCP/WebMCP, native action metadata where supported, assistant/shortcut fulfillment, and canonical deep-link fallback where native support is incomplete. Extracted from [mcp_flutter](https://github.com/Arenukvern/mcp_flutter). **Charter:** [docs/NORTH_STAR.mdx](docs/NORTH_STAR.mdx) · **Agent map:** [AGENTS.md](AGENTS.md) · **Docs site:** [docs.page/Arenukvern/intentcall](https://docs.page/Arenukvern/intentcall) **Why / how:** [docs/DESIGN_FAQ.mdx](docs/DESIGN_FAQ.mdx) · [docs/DX_FAQ.mdx](docs/DX_FAQ.mdx) · [Decisions](docs/decisions/) · [CONTRIBUTING.md](CONTRIBUTING.md) @@ -35,7 +35,7 @@ GitHub: [Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) | `intentcall_testing` | Contract / invoke test helpers | | `intentcall_gemma` / `intentcall_apple` / `intentcall_android` | Optional experimental surface adapters | -Platform support is tiered during `0.1.x`: current emitters cover web/PWA, Apple App Intents artifacts, Android shortcuts/deep links, Windows protocol activation, and Linux `x-scheme-handler`; Android AppFunctions, Android App Actions capabilities, Windows App Actions / Agent Launchers, and AAIF ecosystem alignment are roadmap targets unless documented otherwise. +Platform support is tiered during the `0.2.x` train: current code covers MCP, Dart-first WebMCP registration, Apple App Intents dispatch wrappers, Android shortcuts/deep links, Windows protocol activation, and Linux `x-scheme-handler`. Apple App Intents currently launch/wake the app and dispatch an invocation envelope for Dart execution; they do not claim app-extension-hosted Dart execution or native background business logic. Android AppFunctions, Android App Actions capabilities, Windows App Actions / Agent Launchers, and AAIF ecosystem alignment are roadmap targets unless documented otherwise. ## Agent Skills @@ -57,7 +57,7 @@ just analyze just publish-dry-run # pub.dev dry-run (all packages) ``` -Release maintainers additionally run `just publish-preflight-first` for the initial `0.1.0` publish, or `just publish-preflight` for later releases. +Release maintainers use Release Please. Merging the release PR creates package tags; tag-triggered GitHub Actions publishes through pub.dev automated publishing. See [docs/DX_FAQ.mdx](docs/DX_FAQ.mdx) for detailed workflows. @@ -75,4 +75,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). All PRs must pass `just test && just ana ## Publishing -See [PUBLISHING.md](PUBLISHING.md). Execute publish only from a clean release commit with pub.dev credentials: `just publish-execute`. +See [PUBLISHING.md](PUBLISHING.md). The normal path is Release Please merge -> tag-triggered GitHub Actions publish; manual publish commands are recovery-only. diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index b6483fb..eaebb5d 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -37,11 +37,12 @@ as `intentcall_mcp` may re-export the types for source compatibility, but the canonical import for non-MCP consumers is `package:intentcall_core/intentcall_core.dart`. **Q: Does IntentCall need a separate dynamic catalog gateway?** -A: Not for the current public surface. `AgentRegistry.listDescriptors()` is the -neutral catalog snapshot and `AgentRegistry.events` is the neutral change feed. -Adapters consume that surface and project it into their transport. A named -gateway should appear only when it owns behavior beyond listing descriptors and -listening to registry events. +A: Not for the current public surface. `AgentRegistry.listEntries()` is the +neutral catalog snapshot for adapters because it preserves stored registry keys +and descriptors, while `AgentRegistry.events` is the neutral change feed. +`listDescriptors()` remains compatibility sugar for display-only catalog reads. +A named gateway should appear only when it owns behavior beyond listing entries +and listening to registry events. **Q: Why is `intentcall_testing` a separate package?** A: Test helpers have `test` as a dependency, which must not bleed into production packages. Isolating them in `intentcall_testing` lets app authors add it as a `dev_dependency` without the production graph growing. @@ -83,7 +84,7 @@ A: Snapshot persistence is session-adjacent durable state: save a JSON runtime a ## Transport model **Q: Why is IntentCall "transport-agnostic" rather than MCP-first?** -A: MCP is one wire format today; WebMCP, Apple App Intents, Android AppFunctions / App Actions, Windows App Actions, and protocol handlers all expose different contracts. IntentCall keeps the registry contract central, then projects it into the strongest available surface for each platform: semantic action/tool APIs where available, assistant or shortcut fulfillment where appropriate, and deep-link fallback where native support is incomplete. +A: MCP is one wire format today; WebMCP, Apple App Intents, Android AppFunctions / App Actions, Windows App Actions, and protocol handlers all expose different contracts. IntentCall keeps the registry contract central, then projects it into the strongest available surface for each platform: semantic action/tool APIs where available, assistant or shortcut fulfillment where appropriate, and deep-link fallback where native support is incomplete. When platform metadata exists but native execution is not proven, generated wrappers dispatch to Dart instead of duplicating business logic. **Q: Why does `intentcall_mcp` use `dart_mcp` rather than a custom implementation?** A: Maintaining a custom MCP wire implementation would duplicate protocol work tracked upstream in the Dart ecosystem. `dart_mcp` is the canonical Dart MCP library; IntentCall wraps it with the registry bridge, staying thin. @@ -92,16 +93,16 @@ A: Maintaining a custom MCP wire implementation would duplicate protocol work tr A: WebMCP's browser-hosted tool registration model requires different lifecycle management from request/response MCP. Current WebMCP drafts use `document.modelContext`; compatibility with older `navigator.modelContext` experiments should be treated as a shim, not the primary contract. **Q: Why does IntentCall use deep links if the goal is native agent integration?** -A: Deep links are the universal fallback, not the whole vision. Some platforms expose semantic agent/action registries; others only provide launch routing today. IntentCall preserves one invocation contract across both: native metadata where the platform supports it, and `intentcall://invoke/...` or platform protocol handlers where it does not. +A: Deep links are the universal fallback, not the whole vision. Some platforms expose semantic agent/action registries; others only provide launch routing today. IntentCall preserves one invocation contract across both: native metadata where the platform supports it, and `intentcall://invoke/...` or platform protocol handlers where it does not. Treat plain deep links as untrusted input unless they originate from generated native wrappers or an app-provided allowlist. **Q: How should platform support be described during pre-release?** -A: Be explicit about tiers. Apple App Intents and WebMCP are semantic surfaces; Android AppFunctions and Windows App Actions are roadmap native semantic targets; Android App Actions and shortcuts are assistant/shortcut fulfillment; Windows protocol activation and Linux `x-scheme-handler` are protocol fallback. AAIF is ecosystem alignment around agent infrastructure, not a Linux desktop intent API. +A: Be explicit about tiers. WebMCP is a Dart-first in-page registration surface. Apple App Intents are currently generated parameter wrappers that dispatch to Dart after launch/wake; app-extension-hosted Dart is an experiment, not supported. Android AppFunctions and Windows App Actions are roadmap native semantic targets; Android App Actions and shortcuts are assistant/shortcut fulfillment; Windows protocol activation and Linux `x-scheme-handler` are protocol fallback. AAIF is ecosystem alignment around agent infrastructure, not a Linux desktop intent API. --- ## Quality & stability -**Q: Why are all packages at `0.1.x` pre-release instead of `1.0.0`?** +**Q: Why are all packages pre-1.0 on the current `0.2.x` train instead of `1.0.0`?** A: The wire contract in `intentcall_schema` and the registry API in `intentcall_core` are still being exercised across real adapters. Publishing a `1.0.0` would imply API stability we cannot guarantee yet. Pre-release allows breaking changes to land without a semver major while the design stabilizes. **Q: Why does CI run a publish dry-run on every PR?** diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index c6b59aa..6db58e2 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -60,15 +60,15 @@ just publish-dry-run Release Please opens a release PR from `release-please-config.json` and `.release-please-manifest.json`. The packages are linked as one synchronized `intentcall_*` train, so merging the release PR creates component tags such as -`intentcall_core-v0.1.1`. Each tag triggers `.github/workflows/pub_publish.yml`, +`intentcall_core-v0.2.1`. Each tag triggers `.github/workflows/pub_publish.yml`, which publishes the package named by the tag through pub.dev automated publishing. **Q: How do I dry-run the package selected by a release tag?** ```bash -just publish-tag-dry-run intentcall_session-v0.1.0 -# equivalent to: dart run tool/intentcall/bin/intentcall.dart publish-tag --tag intentcall_session-v0.1.0 --skip-existing +just publish-tag-dry-run intentcall_session-v0.2.1 +# equivalent to: dart run tool/intentcall/bin/intentcall.dart publish-tag --tag intentcall_session-v0.2.1 --skip-existing ``` Pub.dev automated publishing must be enabled for every package with tag pattern @@ -122,12 +122,13 @@ An adapter imports `intentcall_core`, reads entries from `AgentRegistry`, and br import 'package:intentcall_core/intentcall_core.dart'; final registry = InMemoryAgentRegistry(); -final descriptors = registry.listDescriptors(); +final entries = registry.listEntries(); ``` Subscribe to `registry.events` when the target transport supports hot-sync. -That gives adapters a neutral catalog snapshot plus a neutral change feed -without a transport-specific discovery layer. +Adapters should publish and invoke by `AgentRegistryEntry.key`; descriptors are +metadata, not a safe substitute for the stored registry key. `listDescriptors()` +is fine for display-only catalog reads. **Q: Where do wire types live?** @@ -343,13 +344,13 @@ just publish-preflight # archive validation, no publish just publish-dry-run -# execute from the same clean release commit +# recovery-only when automated tag publishing is unavailable just publish-execute ``` **Q: How do I cut a release?** -This repo uses [release-please](https://github.com/googleapis/release-please). Merge a release PR generated by release-please, then run the publish command above. +This repo uses [release-please](https://github.com/googleapis/release-please). Merge a release PR generated by Release Please; the resulting package tags trigger GitHub Actions publishing. Manual publish commands are recovery-only. `intentcall_gemma` stays in the workspace as an example-only adapter package. It is marked `publish_to: none` and is not part of the pub.dev release train. diff --git a/docs/NORTH_STAR.mdx b/docs/NORTH_STAR.mdx index 383dd1d..7f7998d 100644 --- a/docs/NORTH_STAR.mdx +++ b/docs/NORTH_STAR.mdx @@ -4,9 +4,9 @@ IntentCall is a **transport-agnostic agent intent platform** for Dart/Flutter. It provides a central registry (`AgentRegistry`), a typed invocation model (`AgentCallEntry` / `RegisteredAgentIntent`), and adapters that publish intents to MCP, WebMCP, and platform artifacts. -The north star is **define intent truth once, then project it into the strongest available platform surface**. That means native semantic agent/tool registries where platforms provide them, assistant or shortcut declarations where those are the available user-facing surface, and a canonical protocol/deep-link fallback where native support is absent or still immature. +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. 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, MCP/WebMCP adapters, and platform artifact emitters for `web`, `android`, `ios`, `macos`, `linux`, and `windows`. 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, MCP/WebMCP adapters, Dart-first invocation primitives, and platform artifact emitters for `web`, `android`, `ios`, `macos`, `linux`, and `windows`. 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`. 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. @@ -16,12 +16,12 @@ IntentCall is also the canonical home for IntentCall philosophy, platform projec | Tier | Meaning | Current / target surfaces | |---|---|---| -| Native semantic | Platform-recognized tool/action models that preserve intent metadata and structured invocation semantics. | Current: Apple App Intents emitter, MCP adapter, WebMCP adapter. Target: Android AppFunctions, Windows App Actions / Agent Launchers, WebMCP `document.modelContext` compatibility. | +| Native semantic | Platform-recognized tool/action models that preserve intent metadata and structured invocation semantics. | Current: MCP adapter, Dart-first WebMCP adapter, Apple App Intents dispatch wrappers. Target: Android AppFunctions, Windows App Actions / Agent Launchers. | | Assistant / shortcut fulfillment | Assistant, shortcut, or launcher declarations that can route user-visible actions into app intent handling. | Current: Apple shortcuts artifacts, Android shortcut/deep-link artifacts, web/PWA artifacts. Target: richer Android App Actions capability generation. | | Protocol fallback | Stable URI/protocol invocation when no native semantic surface is available or implemented yet. | Current: `intentcall://invoke/...`, web protocol handlers, Windows protocol activation artifacts, Linux `x-scheme-handler/intentcall` artifacts. | | Ecosystem alignment | Compatibility with agent ecosystem conventions without claiming an OS-level integration contract. | Current: MCP conventions. Target: AAIF-hosted ecosystem alignment where relevant. | -Support tiers are explicit on purpose. Linux is currently protocol-fallback first. Windows currently has protocol activation artifacts, while native Windows App Actions / Agent Launcher support remains roadmap. Android currently has shortcut/deep-link artifacts, while AppFunctions and fuller App Actions capability generation remain roadmap. +Support tiers are explicit on purpose. Apple App Intents are currently parameterized native wrappers that enqueue an invocation envelope and open/wake the app for Dart execution; app-extension-hosted Dart is experimental only. Linux is currently protocol-fallback first. Windows currently has protocol activation artifacts, while native Windows App Actions / Agent Launcher support remains roadmap. Android currently has shortcut/deep-link artifacts, while AppFunctions and fuller App Actions capability generation remain roadmap. --- @@ -48,7 +48,7 @@ Support tiers are explicit on purpose. Linux is currently protocol-fallback firs 1. A Flutter app can register intent truth once and expose it over MCP, WebMCP, and platform artifacts without per-transport boilerplate where support exists. 2. The `intentcall_schema` wire contract is stable enough that adapters can evolve independently without breaking consumers. 3. Platform support is documented by tier: native semantic, assistant / shortcut fulfillment, protocol fallback, or roadmap. -4. `intentcall://invoke/...` remains the canonical fallback invocation contract across platforms that lack implemented native semantic support. +4. `intentcall://invoke/...` remains the canonical fallback invocation contract across platforms that lack implemented native semantic support; fallback routes are untrusted unless a generated wrapper or app allowlist marks the source as trusted. 5. `just test && just analyze && just publish-dry-run` stays green on every PR. 6. A new adapter author can read `intentcall_core` + one existing adapter and ship a working adapter in a single session. @@ -88,7 +88,7 @@ IntentCall is **pre-release platform infrastructure**, not a consumer product. T ## Pre-release status -All packages are **0.1.x** — experimental. APIs may change without a major semver bump. See [PRE_RELEASE.md](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +All packages are on the **pre-1.0 / current `0.2.x` train** — experimental. APIs may change without a major semver bump. See [PRE_RELEASE.md](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). ## Key docs diff --git a/docs/decisions/0012-adopt-platform-support-tiers.md b/docs/decisions/0012-adopt-platform-support-tiers.md index d9f8fe3..b32b5ab 100644 --- a/docs/decisions/0012-adopt-platform-support-tiers.md +++ b/docs/decisions/0012-adopt-platform-support-tiers.md @@ -19,7 +19,7 @@ Platform reality is not flat. Apple App Intents, Android AppFunctions, Android A * **Honesty** - document what is implemented now versus roadmap. * **Portability** - preserve one intent model and one fallback invocation contract. * **Platform fit** - use native semantic action/tool systems where they exist. -* **Pre-release clarity** - keep `0.1.x` docs ambitious without implying false parity. +* **Pre-release clarity** - keep pre-1.0 docs ambitious without implying false parity. ## Considered Options diff --git a/docs/decisions/0015-dart-first-native-bridge.md b/docs/decisions/0015-dart-first-native-bridge.md new file mode 100644 index 0000000..629ca96 --- /dev/null +++ b/docs/decisions/0015-dart-first-native-bridge.md @@ -0,0 +1,69 @@ +# 0015. Dart-first Native Bridge for Platform Surfaces + +Date: 2026-06-26 + +## Status + +Accepted + +## Context + +IntentCall's promise is that app authors define intent logic once in Dart and +project it into MCP, WebMCP, native platform metadata, shortcuts, and fallback +routes. The previous platform emitters could describe native surfaces, but they +risked implying that Swift, Kotlin, or JS should own semantic business logic. + +Apple App Intents can collect typed parameters and wake the app, but stable +support for app-extension-hosted Dart execution requires separate proof: +app-group storage, Flutter engine bootstrap, plugin registration, extension +lifecycle behavior, and compatibility with Flutter's documented app-extension +constraints. + +WebMCP similarly needs a first-class in-page Dart path so sites do not have to +ship a JS-first `/agent/invoke` endpoint by default. + +## Decision + +IntentCall v1 platform projection is Dart-first: + +- `IntentCallInvocationEnvelope` is the shared native/WebMCP invocation unit. +- `IntentCallAuthorizationPolicy` gates source and intent-name access before + dispatch. +- `IntentCallNativeBridge.bindRegistry(...)` executes authorized envelopes + through the Dart `AgentRegistry`. +- `registerAgentWebMcpFromRegistry(...)` registers WebMCP tools from Dart and + invokes Dart handlers in-page. +- WebMCP network fallback is opt-in only. +- Generated Apple App Intents collect supported primitive parameters, enqueue + an invocation envelope, open or wake the Flutter app, and return dispatch + status. They do not claim background Dart execution or native semantic result + execution in this pass. + +## Consequences + +Good: + +- App authors keep business logic in Dart. +- Native wrappers stay thin and auditable. +- Fallback paths have explicit authorization hooks. +- Docs can truthfully distinguish metadata/dispatch support from native + background execution. + +Tradeoffs: + +- Apple App Intents return dispatch status in v1, not the Dart handler result. +- Apps must drain pending native invocations after launch or wake. +- App-extension-hosted Dart remains an experiment until it has separate runtime + proof and compatibility documentation. + +## Future Experiment: App-extension-hosted Dart + +Do not document app-extension-hosted Dart as supported until a separate proof +covers: + +- app-group queue/storage semantics, +- Flutter engine startup inside an extension target, +- plugin registration and unavailable-API filtering, +- extension memory/time limits, +- result propagation back to App Intents, +- failure envelopes for stale, denied, unavailable, and unknown-intent states. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ae2539e..7275d66 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: **0015** +Next ADR number: **0016** --- @@ -16,6 +16,7 @@ Next ADR number: **0015** | [0012](0012-adopt-platform-support-tiers.md) | accepted | Adopt platform support tiers for IntentCall | 2026-06-10 | | [0013](0013-delete-implemented-plans-after-durable-extraction.md) | accepted | Delete implemented plans after durable extraction | 2026-06-10 | | [0014](0014-own-runtime-sessions-in-intentcall.md) | accepted | Own runtime sessions in IntentCall | 2026-06-22 | +| [0015](0015-dart-first-native-bridge.md) | accepted | Dart-first Native Bridge for Platform Surfaces | 2026-06-26 | --- diff --git a/packages/intentcall_android/README.md b/packages/intentcall_android/README.md index 52a262c..d0bfe01 100644 --- a/packages/intentcall_android/README.md +++ b/packages/intentcall_android/README.md @@ -1,9 +1,12 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_android -Android manifest codegen for intentcall (App Actions / dynamic shortcuts export). +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 @@ -32,4 +35,4 @@ Example XML-oriented snippet derived from manifest: Input: `agent_manifest.json` (`platform: android`, `shortcuts[]`). Output: JSON manifest + documented XML mapping for App Actions. -See `test/agent_manifest_generator_test.dart`. \ No newline at end of file +See `test/agent_manifest_generator_test.dart`. diff --git a/packages/intentcall_android/pubspec.yaml b/packages/intentcall_android/pubspec.yaml index 439752f..abc44e2 100644 --- a/packages/intentcall_android/pubspec.yaml +++ b/packages/intentcall_android/pubspec.yaml @@ -15,7 +15,7 @@ environment: resolution: workspace dependencies: - intentcall_core: ^0.2.0 + intentcall_core: ^0.2.1 meta: ^1.17.0 path: ^1.9.1 diff --git a/packages/intentcall_apple/README.md b/packages/intentcall_apple/README.md index e80ac9a..482c7b4 100644 --- a/packages/intentcall_apple/README.md +++ b/packages/intentcall_apple/README.md @@ -1,16 +1,21 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_apple -Apple platform manifest codegen for intentcall (App Intents / Shortcuts export). +Apple platform manifest and App Intents codegen 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. They return dispatch status in v1; they do not run Dart business +logic inside an App Intent extension. ## 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 snippet** — map manifest intents to Shortcuts / App Intents plist entries. +4. **Platform wrapper** — generate Shortcuts / App Intents metadata that dispatches to Dart. ```dart import 'package:intentcall_apple/intentcall_apple.dart'; @@ -32,4 +37,4 @@ Example Swift-oriented snippet derived from manifest (hand-off to Xcode codegen) Input: `agent_manifest.json` (`platform: apple`, `intents[]`). Output: JSON manifest + documented Swift/Info.plist mapping for Siri/Shortcuts. -See `test/agent_manifest_generator_test.dart`. \ No newline at end of file +See `test/agent_manifest_generator_test.dart`. diff --git a/packages/intentcall_apple/pubspec.yaml b/packages/intentcall_apple/pubspec.yaml index 4cca71c..5b706b5 100644 --- a/packages/intentcall_apple/pubspec.yaml +++ b/packages/intentcall_apple/pubspec.yaml @@ -15,7 +15,7 @@ environment: resolution: workspace dependencies: - intentcall_core: ^0.2.0 + intentcall_core: ^0.2.1 meta: ^1.17.0 path: ^1.9.1 diff --git a/packages/intentcall_codegen/README.md b/packages/intentcall_codegen/README.md index df6bbc4..f369c9a 100644 --- a/packages/intentcall_codegen/README.md +++ b/packages/intentcall_codegen/README.md @@ -1,4 +1,4 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_codegen @@ -13,9 +13,9 @@ Hand-written `AgentCallEntry` remains first-class; codegen is opt-in for stable ```yaml dependencies: - intentcall_codegen: ^0.1.0 - intentcall_core: ^0.1.0 - intentcall_schema: ^0.1.0 + intentcall_codegen: ^0.2.1 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 dev_dependencies: build_runner: ^2.4.15 diff --git a/packages/intentcall_codegen/example/demo_ping_tool.dart b/packages/intentcall_codegen/example/demo_ping_tool.dart index c90fa03..427ed3a 100644 --- a/packages/intentcall_codegen/example/demo_ping_tool.dart +++ b/packages/intentcall_codegen/example/demo_ping_tool.dart @@ -9,6 +9,22 @@ part 'demo_ping_tool.g.dart'; name: 'demo_ping', description: 'Returns pong for a message', ) -Future demoPing(@AgentParam('Message to echo') String message) async { +Future demoPing( + @AgentParam('Message to echo') String message, +) async { return AgentResult.success(data: {'pong': message}); } + +@AgentTool( + namespace: 'app', + name: 'demo_cart', + description: 'Returns a cart total', +) +Future demoCart( + @AgentParam('Currency code') String currency, { + @AgentParam('Include tax', required: false) bool includeTax = false, +}) async { + return AgentResult.success( + data: {'currency': currency, 'includeTax': includeTax}, + ); +} diff --git a/packages/intentcall_codegen/example/demo_ping_tool.g.dart b/packages/intentcall_codegen/example/demo_ping_tool.g.dart index 057f410..8a57379 100644 --- a/packages/intentcall_codegen/example/demo_ping_tool.g.dart +++ b/packages/intentcall_codegen/example/demo_ping_tool.g.dart @@ -28,5 +28,46 @@ AgentCallEntry get demoPingCallEntry => AgentCallEntry.tool( name: 'demo_ping', description: 'Returns pong for a message', inputSchema: _demo_pingInputSchema, - handler: (final args) async => demoPing(args['message'] as String), + handler: (final args) async { + final result = Function.apply(demoPing, [ + args['message'] as String, + ], {}); + return await (result as Future); + }, +); + +const _demo_cartInputSchema = { + 'type': 'object', + 'properties': { + 'currency': { + 'type': 'string', + 'description': 'Currency code', + }, + 'includeTax': { + 'type': 'boolean', + 'description': 'Include tax', + }, + }, + 'required': ['currency'], +}; + +RegisteredAgentIntent get demoCartRegistration => + demoCartCallEntry.toRegistration(); + +AgentCallEntry get demoCartCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_cart', + description: 'Returns a cart total', + inputSchema: _demo_cartInputSchema, + handler: (final args) async { + final result = Function.apply( + demoCart, + [args['currency'] as String], + { + if (args.containsKey('includeTax')) + #includeTax: args['includeTax'] as bool, + }, + ); + return await (result as Future); + }, ); 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 a32ac55..9f1ec5d 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart @@ -1,4 +1,5 @@ import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; @@ -54,7 +55,9 @@ AgentCallEntry get $entryGetter => AgentCallEntry.tool( name: ${_literalString(name)}, description: ${_literalString(description)}, inputSchema: $schemaName, - handler: (final args) async => ${element.name}($handlerArgs), + handler: (final args) async { +$handlerArgs + }, ); '''; } @@ -76,18 +79,30 @@ AgentCallEntry get $entryGetter => AgentCallEntry.tool( final required = []; for (final param in element.formalParameters) { + if (param.isOptionalPositional) { + throw InvalidGenerationSourceError( + '@AgentTool does not support optional positional parameters. Use optional named parameters instead.', + element: param, + ); + } final paramName = param.name; if (paramName == null) { continue; } final paramAnnotation = _readAgentParam(param); - final description = paramAnnotation?.read('description').stringValue ?? - paramName; + final description = + paramAnnotation?.read('description').stringValue ?? paramName; final isRequired = paramAnnotation?.read('required').boolValue ?? param.isRequired; final jsonType = _jsonTypeFor(param.type); + if (jsonType == null) { + throw InvalidGenerationSourceError( + 'Unsupported @AgentTool parameter type ${param.type.getDisplayString()} for "$paramName". Supported types: String, int, bool, double.', + element: param, + ); + } properties.add(''' ${_literalString(paramName)}: { 'type': ${_literalString(jsonType)}, @@ -109,13 +124,38 @@ ${properties.join('\n')} }'''; } - String _buildHandlerArgs(final TopLevelFunctionElement element) => element.formalParameters.map((final param) { + String _buildHandlerArgs(final TopLevelFunctionElement element) { + final positional = []; + final named = []; + for (final param in element.formalParameters) { final name = param.name; if (name == null) { - return ''; + continue; + } + final cast = + 'args[${_literalString(name)}] as ${_dartTypeName(param.type)}'; + if (param.isNamed) { + if (param.isRequiredNamed) { + named.add('#$name: $cast,'); + } else { + named.add( + 'if (args.containsKey(${_literalString(name)})) #$name: $cast,', + ); + } + } else { + positional.add(cast); } - return 'args[${_literalString(name)}] as ${_dartTypeName(param.type)}'; - }).join(', '); + } + return ''' + final result = Function.apply( + ${element.name}, + [${positional.join(', ')}], + { +${named.map((final line) => ' $line').join('\n')} + }, + ); + return await (result as Future);'''; + } ConstantReader? _readAgentParam(final FormalParameterElement param) { for (final meta in param.metadata.annotations) { @@ -127,7 +167,7 @@ ${properties.join('\n')} return null; } - String _jsonTypeFor(final DartType type) { + String? _jsonTypeFor(final DartType type) { if (type.isDartCoreInt) { return 'integer'; } @@ -137,20 +177,29 @@ ${properties.join('\n')} if (type.isDartCoreDouble) { return 'number'; } - return 'string'; + if (type.isDartCoreString) { + return 'string'; + } + return null; } String _dartTypeName(final DartType type) { + final suffix = type.nullabilitySuffix == NullabilitySuffix.question + ? '?' + : ''; if (type.isDartCoreInt) { - return 'int'; + return 'int$suffix'; } if (type.isDartCoreBool) { - return 'bool'; + return 'bool$suffix'; } if (type.isDartCoreDouble) { - return 'double'; + return 'double$suffix'; + } + if (type.isDartCoreString) { + return 'String$suffix'; } - return 'String'; + throw StateError('Unsupported Dart type ${type.getDisplayString()}'); } String _literalString(final String value) => diff --git a/packages/intentcall_codegen/pubspec.yaml b/packages/intentcall_codegen/pubspec.yaml index 676a0e6..bffe8d5 100644 --- a/packages/intentcall_codegen/pubspec.yaml +++ b/packages/intentcall_codegen/pubspec.yaml @@ -18,8 +18,8 @@ resolution: workspace dependencies: analyzer: ^8.0.0 build: ^4.0.0 - intentcall_core: ^0.2.0 - intentcall_schema: ^0.2.0 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 meta: ^1.17.0 source_gen: ^4.0.0 diff --git a/packages/intentcall_codegen/test/agent_tool_generator_test.dart b/packages/intentcall_codegen/test/agent_tool_generator_test.dart index b5ff40c..4fbb571 100644 --- a/packages/intentcall_codegen/test/agent_tool_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_tool_generator_test.dart @@ -31,4 +31,38 @@ void main() { expect(demoPingCallEntry.name, 'demo_ping'); expect(demoPingCallEntry.toRegistration().qualifiedName, 'app_demo_ping'); }); + + test( + 'generated demoCartCallEntry omits absent optional named args', + () async { + final result = await demoCartRegistration.execute( + AgentInvocation( + descriptor: demoCartRegistration.descriptor, + arguments: const {'currency': 'USD'}, + ), + ); + + expect(result.ok, isTrue); + expect(result.data['currency'], 'USD'); + expect(result.data['includeTax'], isFalse); + }, + ); + + test( + 'generated demoCartCallEntry passes present optional named args', + () async { + final result = await demoCartRegistration.execute( + AgentInvocation( + descriptor: demoCartRegistration.descriptor, + arguments: const { + 'currency': 'USD', + 'includeTax': true, + }, + ), + ); + + expect(result.ok, isTrue); + expect(result.data['includeTax'], isTrue); + }, + ); } diff --git a/packages/intentcall_core/README.md b/packages/intentcall_core/README.md index 4142e34..20a01ce 100644 --- a/packages/intentcall_core/README.md +++ b/packages/intentcall_core/README.md @@ -1,4 +1,4 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_core diff --git a/packages/intentcall_core/lib/src/registry/agent_registry.dart b/packages/intentcall_core/lib/src/registry/agent_registry.dart index d47b88a..a173d30 100644 --- a/packages/intentcall_core/lib/src/registry/agent_registry.dart +++ b/packages/intentcall_core/lib/src/registry/agent_registry.dart @@ -4,6 +4,15 @@ import '../intent/agent_intent_descriptor.dart'; import '../intent/registered_agent_intent.dart'; import 'registry_events.dart'; +final class AgentRegistryEntry { + const AgentRegistryEntry({required this.key, required this.intent}); + + final String key; + final RegisteredAgentIntent intent; + + AgentIntentDescriptor get descriptor => intent.descriptor; +} + abstract interface class AgentRegistry { String qualify({required final String namespace, required final String name}); @@ -16,6 +25,8 @@ abstract interface class AgentRegistry { RegisteredAgentIntent? get(final String qualifiedName); + Iterable listEntries({final String? namespace}); + Iterable listDescriptors({final String? namespace}); Future invoke( diff --git a/packages/intentcall_core/lib/src/registry/in_memory_agent_registry.dart b/packages/intentcall_core/lib/src/registry/in_memory_agent_registry.dart index dca6cea..137b84b 100644 --- a/packages/intentcall_core/lib/src/registry/in_memory_agent_registry.dart +++ b/packages/intentcall_core/lib/src/registry/in_memory_agent_registry.dart @@ -59,14 +59,22 @@ final class InMemoryAgentRegistry implements AgentRegistry { _intents[qualifiedName]; @override - Iterable listDescriptors({final String? namespace}) { - final values = _intents.values.map((final e) => e.descriptor); + Iterable listEntries({final String? namespace}) { + final values = _intents.entries.map( + (final entry) => AgentRegistryEntry(key: entry.key, intent: entry.value), + ); if (namespace == null) { return values; } - return values.where((final d) => d.namespace == namespace); + return values.where( + (final entry) => entry.descriptor.namespace == namespace, + ); } + @override + Iterable listDescriptors({final String? namespace}) => + listEntries(namespace: namespace).map((final entry) => entry.descriptor); + @override Future invoke( final String qualifiedName, diff --git a/packages/intentcall_core/pubspec.yaml b/packages/intentcall_core/pubspec.yaml index 7f110dd..d465742 100644 --- a/packages/intentcall_core/pubspec.yaml +++ b/packages/intentcall_core/pubspec.yaml @@ -15,7 +15,7 @@ environment: resolution: workspace dependencies: - intentcall_schema: ^0.2.0 + intentcall_schema: ^0.2.1 meta: ^1.17.0 path: ^1.9.1 @@ -23,4 +23,3 @@ dev_dependencies: lints: ^6.1.0 test: ^1.31.1 xsoulspace_lints: ^0.1.2 - diff --git a/packages/intentcall_core/test/in_memory_agent_registry_test.dart b/packages/intentcall_core/test/in_memory_agent_registry_test.dart index 745e5e6..87a9b9e 100644 --- a/packages/intentcall_core/test/in_memory_agent_registry_test.dart +++ b/packages/intentcall_core/test/in_memory_agent_registry_test.dart @@ -128,4 +128,26 @@ void main() { throwsA(isA()), ); }); + + test('listEntries preserves qualifiedNameOverride keys', () { + final registry = InMemoryAgentRegistry(); + final intent = RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'demo', + name: 'echo', + description: 'echo', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + execute: (_) async => AgentResult.success(), + ); + + registry.register(intent, qualifiedNameOverride: 'custom_transport_key'); + + final entry = registry.listEntries().single; + expect(entry.key, 'custom_transport_key'); + expect(entry.intent, same(intent)); + expect(entry.descriptor.qualifiedName, 'demo_echo'); + expect(registry.listDescriptors().single.qualifiedName, 'demo_echo'); + }); } diff --git a/packages/intentcall_gemma/README.md b/packages/intentcall_gemma/README.md index 030123c..489c5be 100644 --- a/packages/intentcall_gemma/README.md +++ b/packages/intentcall_gemma/README.md @@ -1,4 +1,4 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_gemma diff --git a/packages/intentcall_gemma/lib/src/gemma_publish_adapter.dart b/packages/intentcall_gemma/lib/src/gemma_publish_adapter.dart index 760e2e9..d524c66 100644 --- a/packages/intentcall_gemma/lib/src/gemma_publish_adapter.dart +++ b/packages/intentcall_gemma/lib/src/gemma_publish_adapter.dart @@ -41,9 +41,9 @@ final class GemmaPublishAdapter implements AgentAdapter { @override Future attach(final AgentRegistry registry) async { - for (final descriptor in registry.listDescriptors()) { - if (descriptor.kind == AgentIntentKind.tool) { - _registerTool(registry, descriptor); + for (final entry in registry.listEntries()) { + if (entry.descriptor.kind == AgentIntentKind.tool) { + _registerTool(registry, key: entry.key, descriptor: entry.descriptor); } } } @@ -55,10 +55,11 @@ final class GemmaPublishAdapter implements AgentAdapter { } void _registerTool( - final AgentRegistry registry, - final AgentIntentDescriptor descriptor, - ) { - final name = descriptor.qualifiedName; + final AgentRegistry registry, { + required final String key, + required final AgentIntentDescriptor descriptor, + }) { + final name = key; register( GemmaToolDefinition( name: name, diff --git a/packages/intentcall_gemma/pubspec.yaml b/packages/intentcall_gemma/pubspec.yaml index 6ec9977..4466002 100644 --- a/packages/intentcall_gemma/pubspec.yaml +++ b/packages/intentcall_gemma/pubspec.yaml @@ -16,12 +16,12 @@ environment: resolution: workspace dependencies: - intentcall_core: ^0.2.0 - intentcall_schema: ^0.2.0 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 meta: ^1.17.0 dev_dependencies: - intentcall_testing: ^0.2.0 + intentcall_testing: ^0.2.1 lints: ^6.1.0 test: ^1.31.1 xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_gemma/test/gemma_publish_adapter_test.dart b/packages/intentcall_gemma/test/gemma_publish_adapter_test.dart index 8f73291..c33ed4f 100644 --- a/packages/intentcall_gemma/test/gemma_publish_adapter_test.dart +++ b/packages/intentcall_gemma/test/gemma_publish_adapter_test.dart @@ -8,23 +8,25 @@ void main() { final registry = InMemoryAgentRegistry() ..register( RegisteredAgentIntent( - descriptor: AgentIntentDescriptor( - namespace: 'gemma', - name: 'sum', - description: 'add numbers', - kind: AgentIntentKind.tool, - inputSchema: const { - 'type': 'object', - 'properties': {'a': {'type': 'integer'}}, - }, + descriptor: AgentIntentDescriptor( + namespace: 'gemma', + name: 'sum', + description: 'add numbers', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': { + 'a': {'type': 'integer'}, + }, + }, + ), + execute: (final inv) async => AgentResult.success( + data: { + 'sum': (inv.arguments['a'] as int? ?? 0) + 1, + }, + ), ), - execute: (final inv) async => AgentResult.success( - data: { - 'sum': (inv.arguments['a'] as int? ?? 0) + 1, - }, - ), - ), - ); + ); final invokers = {}; final adapter = GemmaPublishAdapter( @@ -41,4 +43,41 @@ void main() { await adapter.detach(); }); + + test( + 'GemmaPublishAdapter registers overridden attach-time tool keys', + () async { + final registry = InMemoryAgentRegistry() + ..register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'gemma', + name: 'sum', + description: 'add numbers', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + execute: (_) async => + AgentResult.success(data: const {'sum': 5}), + ), + qualifiedNameOverride: 'custom_sum', + ); + + final invokers = {}; + final adapter = GemmaPublishAdapter( + register: (final def, final invoker) => invokers[def.name] = invoker, + unregister: (_) {}, + ); + + await adapter.attach(registry); + + expect(invokers, contains('custom_sum')); + expect(invokers, isNot(contains('gemma_sum'))); + final out = await invokers['custom_sum']!(const {}); + expect(out['ok'], isTrue); + expect(out['sum'], 5); + + await adapter.detach(); + }, + ); } diff --git a/packages/intentcall_mcp/README.md b/packages/intentcall_mcp/README.md index 6e5457a..7009ac6 100644 --- a/packages/intentcall_mcp/README.md +++ b/packages/intentcall_mcp/README.md @@ -1,4 +1,4 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_mcp diff --git a/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart b/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart index 746427a..621fe1e 100644 --- a/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart +++ b/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart @@ -50,6 +50,7 @@ final class McpPublishAdapter implements AgentAdapter { final Set _publishedResources = {}; final Set _publishedResourceTemplates = {}; final Set _publishedResourceTemplatePatterns = {}; + final Map _resourceTemplatePatternByKey = {}; StreamSubscription? _events; AgentRegistry? _registry; @@ -62,8 +63,8 @@ final class McpPublishAdapter implements AgentAdapter { @override Future attach(final AgentRegistry registry) async { _registry = registry; - for (final descriptor in registry.listDescriptors()) { - _syncDescriptor(registry, descriptor); + for (final entry in registry.listEntries()) { + _syncDescriptor(registry, entry.descriptor, registryKey: entry.key); } _events = registry.events.listen((final event) { final reg = _registry; @@ -266,6 +267,7 @@ final class McpPublishAdapter implements AgentAdapter { ); _publishedResourceTemplates.add(key); _publishedResourceTemplatePatterns.add(uriTemplate); + _resourceTemplatePatternByKey[key] = uriTemplate; } void _publishResourceTemplateIntent({ @@ -304,6 +306,7 @@ final class McpPublishAdapter implements AgentAdapter { ); _publishedResourceTemplates.add(key); _publishedResourceTemplatePatterns.add(uriTemplate); + _resourceTemplatePatternByKey[key] = uriTemplate; } void _unpublishTransportKey(final String key) { @@ -314,6 +317,10 @@ final class McpPublishAdapter implements AgentAdapter { unpublishResource?.call(key); } if (_publishedResourceTemplates.remove(key)) { + final pattern = _resourceTemplatePatternByKey.remove(key); + if (pattern != null) { + _publishedResourceTemplatePatterns.remove(pattern); + } // dart_mcp has no removeResourceTemplate; registry unregister is enough. } } diff --git a/packages/intentcall_mcp/pubspec.yaml b/packages/intentcall_mcp/pubspec.yaml index 31247ab..28bd942 100644 --- a/packages/intentcall_mcp/pubspec.yaml +++ b/packages/intentcall_mcp/pubspec.yaml @@ -16,12 +16,12 @@ resolution: workspace dependencies: dart_mcp: ^0.5.0 - intentcall_core: ^0.2.0 - intentcall_schema: ^0.2.0 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 meta: ^1.17.0 dev_dependencies: - intentcall_testing: ^0.2.0 + intentcall_testing: ^0.2.1 lints: ^6.1.0 test: ^1.31.1 xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart b/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart index 3aa8a81..7716e9d 100644 --- a/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart +++ b/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart @@ -146,6 +146,64 @@ void main() { }, ); + test( + 'McpPublishAdapter uses override key for resources already registered before attach', + () async { + final registry = InMemoryAgentRegistry(); + final publishedResources = + < + String, + FutureOr Function(ReadResourceRequest) + >{}; + const uri = 'visual://localhost/pre/attached'; + registry.register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'pre_attached', + description: 'pre-attached resource', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + resourceUri: uri, + mimeType: 'application/json', + ), + execute: (final invocation) async => AgentResult.success( + data: { + 'contents': [ + { + 'type': 'text', + 'text': '{"uri":"${invocation.arguments['uri']}"}', + 'mimeType': 'application/json', + }, + ], + }, + ), + ), + qualifiedNameOverride: uri, + ); + + final adapter = McpPublishAdapter( + publishTool: (_, final _) {}, + unpublishTool: (_) {}, + publishResource: (final resource, final impl) { + publishedResources[resource.uri] = impl; + }, + unpublishResource: (_) {}, + ); + + await adapter.attach(registry); + expect(publishedResources, contains(uri)); + + final read = await publishedResources[uri]!( + ReadResourceRequest(uri: uri), + ); + final text = (read.contents.first as TextResourceContents).text; + expect(text, '{"uri":"$uri"}'); + + await adapter.detach(); + }, + ); + test( 'McpPublishAdapter de-duplicates resource templates by URI pattern', () async { @@ -243,4 +301,42 @@ void main() { expect(unpublished, contains('app_hello')); expect(registry.get('app_hello'), isNotNull); }); + + test( + 'McpPublishAdapter clears resource template patterns on detach and reattach', + () async { + final registry = InMemoryAgentRegistry(); + const template = 'intentcall://resource/app/{id}'; + registry.register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'template', + description: 'template', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + resourceUri: template, + mimeType: 'application/json', + ), + execute: (_) async => AgentResult.success(), + ), + qualifiedNameOverride: template, + ); + final publishedTemplates = []; + final adapter = McpPublishAdapter( + publishTool: (_, final _) {}, + unpublishTool: (_) {}, + publishResourceTemplate: (final resourceTemplate, final impl) { + publishedTemplates.add(resourceTemplate.uriTemplate); + }, + ); + + await adapter.attach(registry); + await adapter.detach(); + await adapter.attach(registry); + await adapter.detach(); + + expect(publishedTemplates, [template, template]); + }, + ); } diff --git a/packages/intentcall_platform/README.md b/packages/intentcall_platform/README.md index bd81895..14e0a79 100644 --- a/packages/intentcall_platform/README.md +++ b/packages/intentcall_platform/README.md @@ -1,9 +1,15 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_platform -Platform emitters, `PlatformSync`, and optional Flutter plugin for native invoke + web WebMCP artifacts. +Platform emitters, `PlatformSync`, Dart-first WebMCP bootstrap, and optional +Flutter plugin support for pending native invocation dispatch. + +Native platform code should stay thin. Generated wrappers collect supported +parameters, enqueue an `IntentCallInvocationEnvelope`, and let Dart execute the +registered `AgentRegistry` handler after app launch or wake. App-extension +hosted Dart execution is experimental and not a stable support claim. ## Manifest workflow (I4) @@ -25,4 +31,4 @@ flutter-mcp-toolkit init intentcall-platform --project-dir ### Future -Registry-backed `generateWebAgentManifest` is deferred — edit `agent_manifest.json`, then `codegen sync`. \ No newline at end of file +Registry-backed `generateWebAgentManifest` is deferred — edit `agent_manifest.json`, then `codegen sync`. diff --git a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift index 1162a5e..261869d 100644 --- a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift @@ -1,7 +1,29 @@ import Flutter import UIKit -/// Thin plugin anchor; deep links use app_links from Dart. +/// Plugin bridge for pending native intent dispatch into Dart. public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) {} + private static let pendingKey = "intentcall.pending_invocations" + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "intentcall_platform/invocations", + binaryMessenger: registrar.messenger() + ) + let instance = IntentCallPlatformPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } +} + +extension IntentCallPlatformPlugin { + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "takePendingInvocations": + let pending = UserDefaults.standard.array(forKey: Self.pendingKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: Self.pendingKey) + result(pending) + default: + result(FlutterMethodNotImplemented) + } + } } diff --git a/packages/intentcall_platform/lib/intentcall_platform.dart b/packages/intentcall_platform/lib/intentcall_platform.dart index 5265e2d..1b4f71c 100644 --- a/packages/intentcall_platform/lib/intentcall_platform.dart +++ b/packages/intentcall_platform/lib/intentcall_platform.dart @@ -10,5 +10,6 @@ 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'; diff --git a/packages/intentcall_platform/lib/intentcall_platform_flutter.dart b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart index 7ab1e3b..15e8aca 100644 --- a/packages/intentcall_platform/lib/intentcall_platform_flutter.dart +++ b/packages/intentcall_platform/lib/intentcall_platform_flutter.dart @@ -2,3 +2,4 @@ library; export 'src/flutter/intentcall_invoke_link.dart'; +export 'src/flutter/intentcall_pending_invocations.dart'; diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart index 3afcfab..e4bb928 100644 --- a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart +++ b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart @@ -1,5 +1,6 @@ import 'package:intentcall_core/intentcall_core.dart'; +import '../invocation/intentcall_invocation.dart'; import 'agent_web_mcp_bootstrap_stub.dart' if (dart.library.js_interop) 'agent_web_mcp_bootstrap_web.dart' as impl; @@ -8,6 +9,13 @@ import 'agent_web_mcp_bootstrap_stub.dart' void registerAgentWebMcpFromEntries(final Set entries) => impl.registerFromEntries(entries); +/// Registers WebMCP tools directly from [registry] and executes them in Dart. +void registerAgentWebMcpFromRegistry( + final AgentRegistry registry, { + final IntentCallAuthorizationPolicy policy = + const IntentCallAuthorizationPolicy.allowAll(), +}) => impl.registerFromRegistry(registry, policy: policy); + /// Whether a tool was already registered on WebMCP (web only; stub returns false). bool isAgentWebMcpToolRegistered(final String qualifiedName) => impl.isAgentWebMcpToolRegistered(qualifiedName); diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart index 31cb5cb..e50218f 100644 --- a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart +++ b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart @@ -1,5 +1,12 @@ import 'package:intentcall_core/intentcall_core.dart'; +import '../invocation/intentcall_invocation.dart'; + void registerFromEntries(final Set entries) {} +void registerFromRegistry( + final AgentRegistry registry, { + required final IntentCallAuthorizationPolicy policy, +}) {} + 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/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart index f63803e..aa8a9c5 100644 --- a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart +++ b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart @@ -5,6 +5,8 @@ import 'dart:js_interop_unsafe'; import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; +import '../invocation/intentcall_invocation.dart'; + @JS('JSON.parse') external JSAny? _jsonParse(final JSString source); @@ -13,6 +15,7 @@ final _webMcpRegisteredToolNames = {}; /// Entries available to [__intentcallWebMcpDartExecute] when JS registered first. final _entriesByQualifiedName = {}; +final _bridgesByQualifiedName = {}; var _dartExecuteHookInstalled = false; @@ -84,6 +87,51 @@ void registerFromEntries(final Set entries) { } } +void registerFromRegistry( + final AgentRegistry registry, { + required final IntentCallAuthorizationPolicy policy, +}) { + final modelContext = _readModelContext(); + if (modelContext == null) { + return; + } + + _ensureDartExecuteHook(); + final bridge = IntentCallNativeBridge.bindRegistry( + registry: registry, + policy: policy, + ); + + for (final entry in registry.listEntries()) { + final descriptor = entry.descriptor; + if (descriptor.kind != AgentIntentKind.tool) { + continue; + } + final qualifiedName = entry.key; + _bridgesByQualifiedName[qualifiedName] = bridge; + + if (_webMcpRegisteredToolNames.contains(qualifiedName)) { + continue; + } + final toolDefinition = _WebMcpToolDefinition( + name: qualifiedName.toJS, + description: descriptor.description.toJS, + inputSchema: _jsonParse(jsonEncode(descriptor.inputSchema).toJS)!, + execute: ((final JSAny? rawArgs) => _invokeBridge( + bridge, + qualifiedName, + rawArgs, + ).toJS).toJS, + ); + try { + modelContext.registerTool(toolDefinition); + _webMcpRegisteredToolNames.add(qualifiedName); + } on Object { + // Duplicate name (JS bootstrap registered first) — JS execute uses hook. + } + } +} + void _ensureDartExecuteHook() { if (_dartExecuteHookInstalled) { return; @@ -102,7 +150,12 @@ Future _dartExecuteHook( final JSString nameJS, final JSAny? rawArgs, ) async { - final entry = _entriesByQualifiedName[nameJS.toDart]; + final qualifiedName = nameJS.toDart; + final bridge = _bridgesByQualifiedName[qualifiedName]; + if (bridge != null) { + return _invokeBridge(bridge, qualifiedName, rawArgs); + } + final entry = _entriesByQualifiedName[qualifiedName]; if (entry == null) { return null; } @@ -138,6 +191,23 @@ Future _invokeEntry( return _encodeResult(result).jsify(); } +Future _invokeBridge( + final IntentCallNativeBridge bridge, + final String qualifiedName, + final JSAny? rawArgs, +) async { + final args = _decodeArgs(rawArgs); + final result = await bridge.execute( + IntentCallInvocationEnvelope( + id: 'webmcp-${DateTime.now().microsecondsSinceEpoch}', + qualifiedName: qualifiedName, + arguments: args, + source: IntentCallInvocationSource.webMcpDart, + ), + ); + return _encodeResult(result).jsify(); +} + Map _decodeArgs(final JSAny? rawArgs) { if (rawArgs == null) { return const {}; diff --git a/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart index 3c3a528..4e2b7ff 100644 --- a/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -6,9 +6,7 @@ import 'emitter_utils.dart'; /// Logic mirrors [generateAppleAgentManifest] in `intentcall_apple`; this emitter /// turns manifest rows into compile-time Swift for `Runner/Generated/`. final class AppleSwiftAppIntentsEmitter { - const AppleSwiftAppIntentsEmitter({ - this.protocolScheme = 'intentcall', - }); + const AppleSwiftAppIntentsEmitter({this.protocolScheme = 'intentcall'}); final String protocolScheme; @@ -33,14 +31,30 @@ final class AppleSwiftAppIntentsEmitter { : tool.description, ); final phrase = escapeSwiftString(humanizeAgentName(tool.name)); + final parameters = _swiftParameters(tool); + final argumentLines = _swiftArgumentLines(parameters); buffer ..writeln('@available(iOS 16.0, macOS 13.0, *)') ..writeln('struct $typeName: AppIntent {') ..writeln(' static var title: LocalizedStringResource = "$title"') - ..writeln() + ..writeln(); + for (final parameter in parameters) { + buffer + ..writeln( + ' @Parameter(title: "${escapeSwiftString(parameter.title)}")', + ) + ..writeln(' var ${parameter.variableName}: ${parameter.swiftType}') + ..writeln(); + } + buffer ..writeln(' func perform() async throws -> some IntentResult {') + ..writeln(' var arguments: [String: Any] = [:]'); + for (final line in argumentLines) { + buffer.writeln(' $line'); + } + buffer ..writeln( - ' await IntentCallNativeBridge.invoke(qualifiedName: "${escapeSwiftString(tool.qualifiedName)}")', + ' await IntentCallNativeBridge.enqueue(qualifiedName: "${escapeSwiftString(tool.qualifiedName)}", arguments: arguments)', ) ..writeln(' return .result()') ..writeln(' }') @@ -68,7 +82,26 @@ final class AppleSwiftAppIntentsEmitter { ..writeln('}') ..writeln() ..writeln('enum IntentCallNativeBridge {') - ..writeln(' static func invoke(qualifiedName: String) async {') + ..writeln( + ' private static let pendingKey = "intentcall.pending_invocations"', + ) + ..writeln() + ..writeln( + ' static func enqueue(qualifiedName: String, arguments: [String: Any]) async {', + ) + ..writeln( + ' var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? []', + ) + ..writeln(' pending.append([') + ..writeln(' "id": UUID().uuidString,') + ..writeln(' "qualifiedName": qualifiedName,') + ..writeln(' "arguments": arguments,') + ..writeln(' "source": "native.generated",') + ..writeln( + ' "createdAt": ISO8601DateFormatter().string(from: Date())', + ) + ..writeln(' ])') + ..writeln(' UserDefaults.standard.set(pending, forKey: pendingKey)') ..writeln( ' guard let url = URL(string: "$protocolScheme://invoke/\\(qualifiedName)") else { return }', ) @@ -83,3 +116,95 @@ final class AppleSwiftAppIntentsEmitter { return buffer.toString(); } } + +final class _SwiftParameter { + const _SwiftParameter({ + required this.name, + required this.variableName, + required this.title, + required this.swiftType, + required this.required, + }); + + final String name; + final String variableName; + final String title; + final String swiftType; + final bool required; +} + +List<_SwiftParameter> _swiftParameters(final AgentManifestEntry tool) { + final properties = tool.inputSchema['properties']; + if (properties is! Map) { + return const <_SwiftParameter>[]; + } + final rawRequired = tool.inputSchema['required']; + final required = { + ...(rawRequired is List ? rawRequired : const []) + .whereType(), + }; + final out = <_SwiftParameter>[]; + for (final entry in properties.entries) { + final name = '${entry.key}'; + final schema = entry.value; + if (schema is! Map) { + continue; + } + final baseType = _swiftTypeFor('${schema['type']}'); + if (baseType == null) { + continue; + } + final isRequired = required.contains(name); + out.add( + _SwiftParameter( + name: name, + variableName: _swiftIdentifier(name), + title: humanizeAgentName(name), + swiftType: isRequired ? baseType : '$baseType?', + required: isRequired, + ), + ); + } + return out; +} + +List _swiftArgumentLines(final List<_SwiftParameter> parameters) { + final out = []; + for (final parameter in parameters) { + if (parameter.required) { + out.add( + 'arguments["${escapeSwiftString(parameter.name)}"] = ${parameter.variableName}', + ); + } else { + out.add( + 'if let value = ${parameter.variableName} { arguments["${escapeSwiftString(parameter.name)}"] = value }', + ); + } + } + return out; +} + +String? _swiftTypeFor(final String type) => switch (type) { + 'string' => 'String', + 'integer' => 'Int', + 'number' => 'Double', + 'boolean' => 'Bool', + _ => null, +}; + +String _swiftIdentifier(final String name) { + final parts = name + .split(RegExp('[^A-Za-z0-9]+')) + .where((final p) => p.isNotEmpty); + if (parts.isEmpty) { + return 'value'; + } + final first = parts.first; + final rest = parts + .skip(1) + .map((final part) => '${part[0].toUpperCase()}${part.substring(1)}'); + final candidate = first + rest.join(); + return RegExp('^[A-Za-z_]').hasMatch(candidate) + ? candidate + : 'value$candidate'; +} diff --git a/packages/intentcall_platform/lib/src/emitters/web_mcp_js_emitter.dart b/packages/intentcall_platform/lib/src/emitters/web_mcp_js_emitter.dart index 089a476..17e15d0 100644 --- a/packages/intentcall_platform/lib/src/emitters/web_mcp_js_emitter.dart +++ b/packages/intentcall_platform/lib/src/emitters/web_mcp_js_emitter.dart @@ -3,10 +3,24 @@ import 'dart:convert'; import '../agent_manifest.dart'; /// Generates `web/intentcall_webmcp.generated.js` for static WebMCP bootstrap. -final class WebMcpJsEmitter { - const WebMcpJsEmitter({this.invokePath = '/agent/invoke'}); +final class WebMcpFallbackPolicy { + const WebMcpFallbackPolicy.disabled() + : enabled = false, + invokePath = '/agent/invoke'; + + const WebMcpFallbackPolicy.enabled({this.invokePath = '/agent/invoke'}) + : enabled = true; + final bool enabled; final String invokePath; +} + +final class WebMcpJsEmitter { + const WebMcpJsEmitter({ + this.fallbackPolicy = const WebMcpFallbackPolicy.disabled(), + }); + + final WebMcpFallbackPolicy fallbackPolicy; String emit(final AgentManifest manifest) { final tools = manifest.tools @@ -20,7 +34,8 @@ final class WebMcpJsEmitter { .toList(growable: false); final toolsJson = const JsonEncoder.withIndent(' ').convert(tools); - final invokePathJson = jsonEncode(invokePath); + final invokePathJson = jsonEncode(fallbackPolicy.invokePath); + final fallbackEnabled = fallbackPolicy.enabled ? 'true' : 'false'; return ''' // Generated by intentcall_platform — do not edit by hand. (function intentcallWebMcpBootstrap(global) { @@ -36,6 +51,7 @@ final class WebMcpJsEmitter { if (!modelContext) { return; } + var fallbackEnabled = $fallbackEnabled; var invokePath = $invokePathJson; var tools = $toolsJson; @@ -182,6 +198,13 @@ final class WebMcpJsEmitter { } 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' }, diff --git a/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart new file mode 100644 index 0000000..620caf1 --- /dev/null +++ b/packages/intentcall_platform/lib/src/flutter/intentcall_pending_invocations.dart @@ -0,0 +1,28 @@ +import 'package:flutter/services.dart'; + +import '../invocation/intentcall_invocation.dart'; + +final class IntentCallPendingInvocations { + const IntentCallPendingInvocations({ + this.channel = const MethodChannel('intentcall_platform/invocations'), + }); + + final MethodChannel channel; + + 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); + } +} diff --git a/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart b/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart new file mode 100644 index 0000000..3e0bb46 --- /dev/null +++ b/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +typedef IntentCallConfirmation = + FutureOr Function(IntentCallInvocationEnvelope envelope); + +final class IntentCallInvocationSource { + const IntentCallInvocationSource._(); + + static const String webMcpDart = 'webmcp.dart'; + static const String webMcpFallback = 'webmcp.fallback'; + static const String nativeGenerated = 'native.generated'; + static const String deepLink = 'deeplink'; +} + +final class IntentCallInvocationEnvelope { + IntentCallInvocationEnvelope({ + required this.id, + required this.qualifiedName, + required this.arguments, + required this.source, + final DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now().toUtc(); + + factory IntentCallInvocationEnvelope.fromJson( + final Map json, + ) { + final args = json['arguments']; + return IntentCallInvocationEnvelope( + id: '${json['id'] ?? ''}', + qualifiedName: '${json['qualifiedName'] ?? ''}', + arguments: args is Map + ? Map.from(args) + : const {}, + source: '${json['source'] ?? ''}', + createdAt: DateTime.tryParse('${json['createdAt'] ?? ''}'), + ); + } + + final String id; + final String qualifiedName; + final Map arguments; + final String source; + final DateTime createdAt; + + Map toJson() => { + 'id': id, + 'qualifiedName': qualifiedName, + 'arguments': arguments, + 'source': source, + 'createdAt': createdAt.toIso8601String(), + }; +} + +final class IntentCallAuthorizationPolicy { + const IntentCallAuthorizationPolicy({ + this.allowedSources, + this.allowedQualifiedNames, + this.confirm, + }); + + const IntentCallAuthorizationPolicy.allowAll() + : allowedSources = null, + allowedQualifiedNames = null, + confirm = null; + + const IntentCallAuthorizationPolicy.denyAll() + : allowedSources = const {}, + allowedQualifiedNames = const {}, + confirm = null; + + final Set? allowedSources; + final Set? allowedQualifiedNames; + final IntentCallConfirmation? confirm; + + Future allows(final IntentCallInvocationEnvelope envelope) async { + final sourceAllowed = + allowedSources == null || allowedSources!.contains(envelope.source); + final nameAllowed = + allowedQualifiedNames == null || + allowedQualifiedNames!.contains(envelope.qualifiedName); + if (!sourceAllowed || !nameAllowed) { + return false; + } + final approve = confirm; + return approve == null || await approve(envelope); + } +} + +final class IntentCallNativeBridge { + IntentCallNativeBridge._({required this.registry, required this.policy}); + + factory IntentCallNativeBridge.bindRegistry({ + required final AgentRegistry registry, + final IntentCallAuthorizationPolicy policy = + const IntentCallAuthorizationPolicy.denyAll(), + }) => IntentCallNativeBridge._(registry: registry, policy: policy); + + final AgentRegistry registry; + final IntentCallAuthorizationPolicy policy; + + Future execute( + final IntentCallInvocationEnvelope envelope, { + final String? correlationId, + }) async { + if (!await policy.allows(envelope)) { + return AgentResult.failure( + code: 'invocation_denied', + message: 'Invocation denied for ${envelope.qualifiedName}.', + details: {'source': envelope.source}, + ); + } + if (registry.get(envelope.qualifiedName) == null) { + return AgentResult.failure( + code: 'intent_not_found', + message: 'No intent registered for ${envelope.qualifiedName}', + details: {'source': envelope.source}, + ); + } + return registry.invoke( + envelope.qualifiedName, + envelope.arguments, + correlationId: correlationId ?? envelope.id, + ); + } +} diff --git a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift new file mode 100644 index 0000000..73c8d66 --- /dev/null +++ b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift @@ -0,0 +1,29 @@ +import Cocoa +import FlutterMacOS + +/// Plugin bridge for pending native intent dispatch into Dart. +public class IntentCallPlatformPlugin: NSObject, FlutterPlugin { + private static let pendingKey = "intentcall.pending_invocations" + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "intentcall_platform/invocations", + binaryMessenger: registrar.messenger + ) + let instance = IntentCallPlatformPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } +} + +extension IntentCallPlatformPlugin { + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "takePendingInvocations": + let pending = UserDefaults.standard.array(forKey: Self.pendingKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: Self.pendingKey) + result(pending) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/packages/intentcall_platform/macos/intentcall_platform.podspec b/packages/intentcall_platform/macos/intentcall_platform.podspec new file mode 100644 index 0000000..c4f1ed0 --- /dev/null +++ b/packages/intentcall_platform/macos/intentcall_platform.podspec @@ -0,0 +1,18 @@ +Pod::Spec.new do |s| + s.name = 'intentcall_platform' + s.version = '0.2.1' + 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/pubspec.yaml b/packages/intentcall_platform/pubspec.yaml index fc0f674..ad0352e 100644 --- a/packages/intentcall_platform/pubspec.yaml +++ b/packages/intentcall_platform/pubspec.yaml @@ -20,8 +20,8 @@ dependencies: app_links: ^6.4.0 flutter: sdk: flutter - intentcall_core: ^0.2.0 - intentcall_schema: ^0.2.0 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 meta: ^1.17.0 path: ^1.9.1 web: ^1.1.1 @@ -34,6 +34,8 @@ flutter: pluginClass: IntentCallPlatformPlugin ios: pluginClass: IntentCallPlatformPlugin + macos: + pluginClass: IntentCallPlatformPlugin dev_dependencies: lints: ^6.1.0 diff --git a/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart b/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart index c482b94..ced6517 100644 --- a/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart +++ b/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart @@ -9,4 +9,11 @@ void main() { returnsNormally, ); }); + + test('registerAgentWebMcpFromRegistry is safe on VM', () { + expect( + () => registerAgentWebMcpFromRegistry(InMemoryAgentRegistry()), + returnsNormally, + ); + }); } diff --git a/packages/intentcall_platform/test/intentcall_invocation_test.dart b/packages/intentcall_platform/test/intentcall_invocation_test.dart new file mode 100644 index 0000000..8c72e49 --- /dev/null +++ b/packages/intentcall_platform/test/intentcall_invocation_test.dart @@ -0,0 +1,87 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_platform/intentcall_platform.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:test/test.dart'; + +void main() { + test('IntentCallInvocationEnvelope serializes stable JSON', () { + final createdAt = DateTime.utc(2026, 6, 26); + final envelope = IntentCallInvocationEnvelope( + id: 'native-1', + qualifiedName: 'app_echo', + arguments: const {'text': 'hi'}, + source: IntentCallInvocationSource.nativeGenerated, + createdAt: createdAt, + ); + + expect( + IntentCallInvocationEnvelope.fromJson(envelope.toJson()).toJson(), + envelope.toJson(), + ); + }); + + test('IntentCallNativeBridge denies invocations by default', () async { + final bridge = IntentCallNativeBridge.bindRegistry( + registry: InMemoryAgentRegistry(), + ); + + final result = await bridge.execute( + IntentCallInvocationEnvelope( + id: 'deep-link-1', + qualifiedName: 'app_echo', + arguments: const {}, + source: IntentCallInvocationSource.deepLink, + ), + ); + + expect(result.ok, isFalse); + expect(result.code, 'invocation_denied'); + }); + + test('IntentCallNativeBridge executes allowed registry invocation', () async { + final registry = InMemoryAgentRegistry() + ..register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'echo', + description: 'echo', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'required': ['text'], + 'properties': { + 'text': {'type': 'string'}, + }, + }, + ), + execute: (final invocation) async => AgentResult.success( + data: { + 'text': invocation.arguments['text'], + 'correlationId': invocation.correlationId, + }, + ), + ), + ); + final bridge = IntentCallNativeBridge.bindRegistry( + registry: registry, + policy: const IntentCallAuthorizationPolicy( + allowedSources: {IntentCallInvocationSource.webMcpDart}, + allowedQualifiedNames: {'app_echo'}, + ), + ); + + final result = await bridge.execute( + IntentCallInvocationEnvelope( + id: 'webmcp-1', + qualifiedName: 'app_echo', + arguments: const {'text': 'hello'}, + source: IntentCallInvocationSource.webMcpDart, + ), + ); + + expect(result.ok, isTrue); + expect(result.data['text'], 'hello'); + expect(result.data['correlationId'], 'webmcp-1'); + }); +} diff --git a/packages/intentcall_platform/test/native_emitters_test.dart b/packages/intentcall_platform/test/native_emitters_test.dart index 3a606e4..20bdecb 100644 --- a/packages/intentcall_platform/test/native_emitters_test.dart +++ b/packages/intentcall_platform/test/native_emitters_test.dart @@ -12,7 +12,14 @@ void main() { 'name': 'cart_total', 'description': 'Return cart total', 'kind': 'tool', - 'inputSchema': {'type': 'object'}, + 'inputSchema': { + 'type': 'object', + 'required': ['currency'], + 'properties': { + 'currency': {'type': 'string'}, + 'includeTax': {'type': 'boolean'}, + }, + }, }, ], }); @@ -35,8 +42,13 @@ void main() { test('emits AppIntent and AppShortcutsProvider', () { final swift = const AppleSwiftAppIntentsEmitter().emit(manifest); expect(swift, contains('struct AppCartTotalIntent: AppIntent')); + expect(swift, contains('@Parameter(title: "Currency")')); + expect(swift, contains('var currency: String')); + expect(swift, contains('var includeTax: Bool?')); + expect(swift, contains('arguments["currency"] = currency')); expect(swift, contains('IntentCallShortcutsProvider')); expect(swift, contains('IntentCallNativeBridge')); + expect(swift, contains('intentcall.pending_invocations')); expect(swift, contains('intentcall://invoke/')); }); @@ -103,8 +115,17 @@ import AppKit struct AppCartTotalIntent: AppIntent { static var title: LocalizedStringResource = "Return cart total" + @Parameter(title: "Currency") + var currency: String + + @Parameter(title: "IncludeTax") + var includeTax: Bool? + func perform() async throws -> some IntentResult { - await IntentCallNativeBridge.invoke(qualifiedName: "app_cart_total") + var arguments: [String: Any] = [:] + arguments["currency"] = currency + if let value = includeTax { arguments["includeTax"] = value } + await IntentCallNativeBridge.enqueue(qualifiedName: "app_cart_total", arguments: arguments) return .result() } } @@ -119,7 +140,18 @@ struct IntentCallShortcutsProvider: AppShortcutsProvider { } enum IntentCallNativeBridge { - static func invoke(qualifiedName: String) async { + private static let pendingKey = "intentcall.pending_invocations" + + static func enqueue(qualifiedName: String, arguments: [String: Any]) async { + var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] + pending.append([ + "id": UUID().uuidString, + "qualifiedName": qualifiedName, + "arguments": arguments, + "source": "native.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()) + ]) + UserDefaults.standard.set(pending, forKey: pendingKey) guard let url = URL(string: "intentcall://invoke/\(qualifiedName)") else { return } #if canImport(UIKit) await UIApplication.shared.open(url) diff --git a/packages/intentcall_platform/test/web_emitters_test.dart b/packages/intentcall_platform/test/web_emitters_test.dart index 286a3f8..27f93e4 100644 --- a/packages/intentcall_platform/test/web_emitters_test.dart +++ b/packages/intentcall_platform/test/web_emitters_test.dart @@ -67,6 +67,19 @@ void main() { expect(js, contains('validateInput')); expect(js, contains('Unknown property')); expect(js, contains('validateValue')); + expect(js, contains('fallbackEnabled = false')); + expect(js, contains('runtime_unavailable')); + }); + + test('emits opt-in network fallback', () { + final js = const WebMcpJsEmitter( + fallbackPolicy: WebMcpFallbackPolicy.enabled( + invokePath: '/secure-agent/invoke', + ), + ).emit(_fixtureAgentManifest); + expect(js, contains('fallbackEnabled = true')); + expect(js, contains('"/secure-agent/invoke"')); + expect(js, contains('global.fetch(invokePath')); }); test('emits array items object validation', () { @@ -108,9 +121,10 @@ void main() { expect(js, contains('validateNumericBounds')); }); - test('matches golden js output', () { + test('emits Dart hook before opt-in fetch fallback', () { final js = const WebMcpJsEmitter().emit(_fixtureAgentManifest); - expect(js, _goldenWebMcpJs); + expect(js, contains('global.__intentcallWebMcpDartExecute')); + expect(js, contains('return fetchInvoke(tool.name, args);')); }); test('skips non-tool intents', () { @@ -207,207 +221,3 @@ const _goldenWebManifest = ''' } ] }'''; - -const _goldenWebMcpJs = ''' -// Generated by intentcall_platform — do not edit by hand. -(function intentcallWebMcpBootstrap(global) { - 'use strict'; - var doc = global.document; - var nav = global.navigator; - var modelContext = - doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' - ? doc.modelContext - : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' - ? nav.modelContext - : null; - if (!modelContext) { - return; - } - var invokePath = "/agent/invoke"; - var tools = [ - { - "name": "app_cart_total", - "description": "Return cart total", - "inputSchema": { - "type": "object" - } - } -]; - - function validationError(message) { - return { ok: false, code: 'validation_error', message: message }; - } - - function validateNumericBounds(path, schema, value) { - if (schema.minimum != null && value < schema.minimum) { - return validationError(path + ' must be at least ' + schema.minimum + '.'); - } - if (schema.maximum != null && value > schema.maximum) { - return validationError(path + ' must be at most ' + schema.maximum + '.'); - } - return null; - } - - function validateValue(path, schema, value) { - var type = schema.type; - if (!type) return null; - switch (type) { - case 'string': - if (typeof value !== 'string') return validationError(path + ' must be a string.'); - return null; - case 'integer': - if (typeof value !== 'number' || value % 1 !== 0) { - return validationError(path + ' must be an integer.'); - } - return validateNumericBounds(path, schema, value); - case 'number': - if (typeof value !== 'number') return validationError(path + ' must be a number.'); - return validateNumericBounds(path, schema, value); - case 'boolean': - if (typeof value !== 'boolean') return validationError(path + ' must be a boolean.'); - return null; - case 'object': - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return validationError(path + ' must be an object.'); - } - return null; - case 'array': - if (!Array.isArray(value)) return validationError(path + ' must be an array.'); - var arrayPath = path; - if (arrayPath.length >= 2 && arrayPath.charAt(0) === '"' && - arrayPath.charAt(arrayPath.length - 1) === '"') { - arrayPath = arrayPath.slice(1, -1); - } - return validateArrayItems(arrayPath, schema, value); - default: - return null; - } - } - - function validateObjectProperties(pathPrefix, schema, args) { - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - var at = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Unknown property "' + key + '"' + at + '.'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var name = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, name)) { - var atReq = pathPrefix ? ' at "' + pathPrefix + '"' : ''; - return validationError('Missing required property "' + name + '"' + atReq + '.'); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var childPath = pathPrefix ? pathPrefix + '.' + prop : prop; - var propErr = validateValue('"' + childPath + '"', properties[prop], args[prop]); - if (propErr) return propErr; - } - return null; - } - - function validateArrayItems(path, schema, value) { - var items = schema.items; - if (!items || typeof items !== 'object' || Array.isArray(items)) return null; - if (items.type !== 'object') return null; - var itemProperties = items.properties || {}; - var itemRequired = items.required; - var hasRequired = itemRequired && itemRequired.length; - var hasProps = false; - for (var pk in itemProperties) { - if (Object.prototype.hasOwnProperty.call(itemProperties, pk)) { - hasProps = true; - break; - } - } - if (!hasProps && !hasRequired) return null; - for (var i = 0; i < value.length; i += 1) { - var element = value[i]; - var elementPath = path + '[' + i + ']'; - if (typeof element !== 'object' || element === null || Array.isArray(element)) { - return validationError('"' + elementPath + '" must be an object.'); - } - var objErr = validateObjectProperties(elementPath, items, element); - if (objErr) return objErr; - } - return null; - } - - function validateInput(schema, args) { - if (!schema || schema.type !== 'object') return null; - args = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; - var properties = schema.properties || {}; - if (schema.additionalProperties === false) { - for (var key in args) { - if (Object.prototype.hasOwnProperty.call(args, key) && - !Object.prototype.hasOwnProperty.call(properties, key)) { - return validationError('Unknown property "' + key + '".'); - } - } - } - var required = schema.required; - if (required && required.length) { - for (var r = 0; r < required.length; r += 1) { - var reqKey = required[r]; - if (!Object.prototype.hasOwnProperty.call(args, reqKey) || - args[reqKey] === undefined || args[reqKey] === null) { - return validationError('Missing required property: ' + reqKey); - } - } - } - for (var prop in properties) { - if (!Object.prototype.hasOwnProperty.call(properties, prop)) continue; - if (!Object.prototype.hasOwnProperty.call(args, prop)) continue; - var val = args[prop]; - if (val === undefined || val === null) continue; - var propErr = validateValue('"' + prop + '"', properties[prop], val); - if (propErr) return propErr; - } - return null; - } - - function fetchInvoke(name, args) { - return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(args || {}), - }).then(function (response) { - return response.json(); - }); - } - - tools.forEach(function (tool) { - try { - modelContext.registerTool({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - execute: function (args) { - var err = validateInput(tool.inputSchema, args); - if (err) return Promise.resolve(err); - var dart = global.__intentcallWebMcpDartExecute; - if (typeof dart === 'function') { - return Promise.resolve(dart(tool.name, args || {})).then(function (result) { - if (result != null) return result; - return fetchInvoke(tool.name, args); - }); - } - return fetchInvoke(tool.name, args); - }, - }); - } catch (e) { - // Hot restart / Dart bootstrap may have registered the same name. - } - }); -})(typeof globalThis !== 'undefined' ? globalThis : window); -'''; diff --git a/packages/intentcall_schema/README.md b/packages/intentcall_schema/README.md index c67a892..8768bcc 100644 --- a/packages/intentcall_schema/README.md +++ b/packages/intentcall_schema/README.md @@ -1,4 +1,4 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_schema @@ -7,5 +7,5 @@ Wire types, `AgentResult`, validation, and `AgentArguments` for the intentcall s ```yaml dependencies: - intentcall_schema: ^0.1.0 + intentcall_schema: ^0.2.1 ``` \ No newline at end of file diff --git a/packages/intentcall_schema/lib/src/agent_result_envelope.dart b/packages/intentcall_schema/lib/src/agent_result_envelope.dart index 9978c08..b9fa4e7 100644 --- a/packages/intentcall_schema/lib/src/agent_result_envelope.dart +++ b/packages/intentcall_schema/lib/src/agent_result_envelope.dart @@ -18,7 +18,7 @@ extension AgentResultEnvelope on AgentResult { 'tool_name': kind, 'snapshot': snapshot, 'snapshot_json': jsonEncode(snapshot), - if (extra != null) ...extra, + ...?extra, }, ); diff --git a/packages/intentcall_session/README.md b/packages/intentcall_session/README.md index cbfa92b..7a0e8fd 100644 --- a/packages/intentcall_session/README.md +++ b/packages/intentcall_session/README.md @@ -1,4 +1,4 @@ -> WARNING: Pre-release (0.1.x) — Highly experimental. APIs may change without notice. Not for production. See the root PRE_RELEASE.md. +> WARNING: Pre-release (0.2.x train) — Highly experimental. APIs may change without notice. Not for production. See the root PRE_RELEASE.md. # intentcall_session diff --git a/packages/intentcall_session/pubspec.yaml b/packages/intentcall_session/pubspec.yaml index c38d922..64f55dc 100644 --- a/packages/intentcall_session/pubspec.yaml +++ b/packages/intentcall_session/pubspec.yaml @@ -16,8 +16,8 @@ resolution: workspace dependencies: from_json_to_json: ^0.5.0 - intentcall_core: ^0.2.0 - intentcall_schema: ^0.2.0 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 meta: ^1.17.0 path: ^1.9.1 diff --git a/packages/intentcall_testing/README.md b/packages/intentcall_testing/README.md index 79206fc..7f9f471 100644 --- a/packages/intentcall_testing/README.md +++ b/packages/intentcall_testing/README.md @@ -1,4 +1,4 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_testing diff --git a/packages/intentcall_testing/lib/src/adapter_contract.dart b/packages/intentcall_testing/lib/src/adapter_contract.dart index 0cb1c43..2a0809a 100644 --- a/packages/intentcall_testing/lib/src/adapter_contract.dart +++ b/packages/intentcall_testing/lib/src/adapter_contract.dart @@ -13,6 +13,7 @@ final class AdapterContractProof { const AdapterContractProof({ required this.adapterId, required this.initialToolName, + required this.overriddenToolName, required this.failureToolName, required this.hotSyncToolName, required this.successData, @@ -23,6 +24,7 @@ final class AdapterContractProof { final String adapterId; final String initialToolName; + final String overriddenToolName; final String failureToolName; final String hotSyncToolName; final Map successData; @@ -45,9 +47,14 @@ Future verifyNativeAdapterContract({ }) async { final registry = InMemoryAgentRegistry() ..register(_successIntent(name: 'echo')) - ..register(_failureIntent(name: 'fail')); + ..register(_failureIntent(name: 'fail')) + ..register( + _successIntent(namespace: 'descriptor', name: 'override'), + qualifiedNameOverride: 'custom_override', + ); const initialToolName = 'contract_echo'; + const overriddenToolName = 'custom_override'; const failureToolName = 'contract_fail'; const hotSyncToolName = 'contract_late'; final shouldVerifyHotSync = requireHotSync ?? adapter.watchesRegistry; @@ -66,6 +73,10 @@ Future verifyNativeAdapterContract({ isPublished(failureToolName), '${adapter.id} did not publish $failureToolName on attach', ); + _require( + isPublished(overriddenToolName), + '${adapter.id} did not publish overridden $overriddenToolName on attach', + ); final success = normalize( await invoke(initialToolName, const { @@ -87,6 +98,21 @@ Future verifyNativeAdapterContract({ '${adapter.id} did not preserve success qualifiedName', ); + final overridden = normalize( + await invoke(overriddenToolName, const { + 'text': 'override', + 'count': 4, + }), + ); + _require( + overridden.ok, + '${adapter.id} returned failure for $overriddenToolName', + ); + _require( + overridden.data['qualifiedName'] == 'descriptor_override', + '${adapter.id} did not invoke overridden registry key', + ); + final failure = normalize( await invoke(failureToolName, const {}), ); @@ -142,12 +168,17 @@ Future verifyNativeAdapterContract({ !isPublished(failureToolName), '${adapter.id} did not unpublish $failureToolName on detach', ); + _require( + !isPublished(overriddenToolName), + '${adapter.id} did not unpublish $overriddenToolName on detach', + ); detachCleanupProven = true; } return AdapterContractProof( adapterId: adapter.id, initialToolName: initialToolName, + overriddenToolName: overriddenToolName, failureToolName: failureToolName, hotSyncToolName: hotSyncToolName, successData: success.data, @@ -205,30 +236,32 @@ AgentResult normalizeJsonTextAgentResult(final Object? result) { ); } -RegisteredAgentIntent _successIntent({required final String name}) => - RegisteredAgentIntent( - descriptor: AgentIntentDescriptor( - namespace: 'contract', - name: name, - description: 'Adapter contract success fixture', - kind: AgentIntentKind.tool, - inputSchema: const { - 'type': 'object', - 'properties': { - 'text': {'type': 'string'}, - 'count': {'type': 'integer'}, - }, - 'required': ['text', 'count'], - }, - ), - execute: (final invocation) async => AgentResult.success( - data: { - 'text': invocation.arguments['text'], - 'count': invocation.arguments['count'], - 'qualifiedName': invocation.descriptor.qualifiedName, - }, - ), - ); +RegisteredAgentIntent _successIntent({ + required final String name, + final String namespace = 'contract', +}) => RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: namespace, + name: name, + description: 'Adapter contract success fixture', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'properties': { + 'text': {'type': 'string'}, + 'count': {'type': 'integer'}, + }, + 'required': ['text', 'count'], + }, + ), + execute: (final invocation) async => AgentResult.success( + data: { + 'text': invocation.arguments['text'], + 'count': invocation.arguments['count'], + 'qualifiedName': invocation.descriptor.qualifiedName, + }, + ), +); RegisteredAgentIntent _failureIntent({required final String name}) => RegisteredAgentIntent( diff --git a/packages/intentcall_testing/pubspec.yaml b/packages/intentcall_testing/pubspec.yaml index 87d5a90..6c3b5f9 100644 --- a/packages/intentcall_testing/pubspec.yaml +++ b/packages/intentcall_testing/pubspec.yaml @@ -15,8 +15,8 @@ environment: resolution: workspace dependencies: - intentcall_core: ^0.2.0 - intentcall_schema: ^0.2.0 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 meta: ^1.17.0 dev_dependencies: diff --git a/packages/intentcall_testing/test/adapter_contract_test.dart b/packages/intentcall_testing/test/adapter_contract_test.dart index ad53988..d41a667 100644 --- a/packages/intentcall_testing/test/adapter_contract_test.dart +++ b/packages/intentcall_testing/test/adapter_contract_test.dart @@ -20,6 +20,7 @@ void main() { expect(proof.adapterId, 'map'); expect(proof.initialToolName, 'contract_echo'); + expect(proof.overriddenToolName, 'custom_override'); expect(proof.failureCode, 'contract_failure'); expect(proof.hotSyncProven, isTrue); expect(proof.detachCleanupProven, isTrue); @@ -81,8 +82,8 @@ final class _MapAdapter implements AgentAdapter { @override Future attach(final AgentRegistry registry) async { _registry = registry; - for (final descriptor in registry.listDescriptors()) { - _publish(registry, descriptor); + for (final entry in registry.listEntries()) { + _publish(registry, key: entry.key, descriptor: entry.descriptor); } _events = registry.events.listen((final event) { final reg = _registry; @@ -91,7 +92,7 @@ final class _MapAdapter implements AgentAdapter { case IntentRegistered(:final qualifiedName): final intent = reg.get(qualifiedName); if (intent != null) { - _publish(reg, intent.descriptor); + _publish(reg, key: qualifiedName, descriptor: intent.descriptor); } case IntentUnregistered(:final qualifiedName): _published.remove(qualifiedName); @@ -108,11 +109,12 @@ final class _MapAdapter implements AgentAdapter { } void _publish( - final AgentRegistry registry, - final AgentIntentDescriptor descriptor, - ) { + final AgentRegistry registry, { + required final String key, + required final AgentIntentDescriptor descriptor, + }) { if (descriptor.kind != AgentIntentKind.tool) return; - final name = descriptor.qualifiedName; + final name = key; _published[name] = (final arguments) async { final result = await registry.invoke(name, arguments); if (!result.ok) { diff --git a/packages/intentcall_webmcp/README.md b/packages/intentcall_webmcp/README.md index f68ad5c..8f26f00 100644 --- a/packages/intentcall_webmcp/README.md +++ b/packages/intentcall_webmcp/README.md @@ -1,4 +1,4 @@ -> ⚠️ **Pre-release (0.1.x)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). +> ⚠️ **Pre-release (0.2.x train)** — Highly experimental. APIs may change without notice. Not for production. [Details](https://github.com/Arenukvern/intentcall/blob/main/PRE_RELEASE.md). # intentcall_webmcp diff --git a/packages/intentcall_webmcp/lib/src/webmcp_publish_adapter.dart b/packages/intentcall_webmcp/lib/src/webmcp_publish_adapter.dart index cfa8552..cfc3c2c 100644 --- a/packages/intentcall_webmcp/lib/src/webmcp_publish_adapter.dart +++ b/packages/intentcall_webmcp/lib/src/webmcp_publish_adapter.dart @@ -41,9 +41,9 @@ final class WebMcpPublishAdapter implements AgentAdapter { @override Future attach(final AgentRegistry registry) async { _registry = registry; - for (final descriptor in registry.listDescriptors()) { - if (descriptor.kind == AgentIntentKind.tool) { - _publishTool(registry, descriptor); + for (final entry in registry.listEntries()) { + if (entry.descriptor.kind == AgentIntentKind.tool) { + _publishTool(registry, key: entry.key, descriptor: entry.descriptor); } } _events = registry.events.listen((final event) { @@ -54,7 +54,11 @@ final class WebMcpPublishAdapter implements AgentAdapter { final intent = reg.get(qualifiedName); if (intent != null && intent.descriptor.kind == AgentIntentKind.tool) { - _publishTool(reg, intent.descriptor); + _publishTool( + reg, + key: qualifiedName, + descriptor: intent.descriptor, + ); } case IntentUnregistered(:final qualifiedName): _unpublish(qualifiedName); @@ -71,10 +75,11 @@ final class WebMcpPublishAdapter implements AgentAdapter { } void _publishTool( - final AgentRegistry registry, - final AgentIntentDescriptor descriptor, - ) { - final name = descriptor.qualifiedName; + final AgentRegistry registry, { + required final String key, + required final AgentIntentDescriptor descriptor, + }) { + final name = key; if (_published.contains(name)) return; publish( name: name, diff --git a/packages/intentcall_webmcp/pubspec.yaml b/packages/intentcall_webmcp/pubspec.yaml index 1b33533..469abbc 100644 --- a/packages/intentcall_webmcp/pubspec.yaml +++ b/packages/intentcall_webmcp/pubspec.yaml @@ -15,12 +15,12 @@ environment: resolution: workspace dependencies: - intentcall_core: ^0.2.0 - intentcall_schema: ^0.2.0 + intentcall_core: ^0.2.1 + intentcall_schema: ^0.2.1 meta: ^1.17.0 dev_dependencies: - intentcall_testing: ^0.2.0 + intentcall_testing: ^0.2.1 lints: ^6.1.0 test: ^1.31.1 xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_webmcp/test/webmcp_publish_adapter_test.dart b/packages/intentcall_webmcp/test/webmcp_publish_adapter_test.dart index 994f4a9..a5278a3 100644 --- a/packages/intentcall_webmcp/test/webmcp_publish_adapter_test.dart +++ b/packages/intentcall_webmcp/test/webmcp_publish_adapter_test.dart @@ -8,22 +8,20 @@ void main() { final registry = InMemoryAgentRegistry() ..register( RegisteredAgentIntent( - descriptor: AgentIntentDescriptor( - namespace: 'app', - name: 'hello', - description: 'say hello', - kind: AgentIntentKind.tool, - inputSchema: const {'type': 'object'}, - ), - execute: (_) async => AgentResult.success( - data: const {'text': 'hi'}, + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'hello', + description: 'say hello', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + execute: (_) async => + AgentResult.success(data: const {'text': 'hi'}), ), - ), - ); + ); - final published = > Function( - Map, - )>{}; + final published = + > Function(Map)>{}; final adapter = WebMcpPublishAdapter( publish: ({ @@ -51,9 +49,11 @@ void main() { 'WebMcpPublishAdapter hot-syncs register and unregister after attach', () async { final registry = InMemoryAgentRegistry(); - final published = > Function( - Map, - )>{}; + final published = + < + String, + Future> Function(Map) + >{}; final unpublished = []; final adapter = WebMcpPublishAdapter( publish: @@ -99,4 +99,53 @@ void main() { await adapter.detach(); }, ); + + test( + 'WebMcpPublishAdapter publishes overridden attach-time tool keys', + () async { + final registry = InMemoryAgentRegistry() + ..register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'hello', + description: 'say hello', + kind: AgentIntentKind.tool, + inputSchema: const {'type': 'object'}, + ), + execute: (_) async => AgentResult.success( + data: const {'text': 'override'}, + ), + ), + qualifiedNameOverride: 'custom_hello', + ); + final published = + < + String, + Future> Function(Map) + >{}; + final adapter = WebMcpPublishAdapter( + publish: + ({ + required final name, + required final description, + required final inputSchema, + required final execute, + }) { + published[name] = execute; + }, + unpublish: (_) {}, + ); + + await adapter.attach(registry); + + expect(published, contains('custom_hello')); + expect(published, isNot(contains('app_hello'))); + final out = await published['custom_hello']!(const {}); + expect(out['ok'], isTrue); + expect(out['text'], 'override'); + + await adapter.detach(); + }, + ); } diff --git a/skills/register-intents/SKILL.md b/skills/register-intents/SKILL.md index 76aeb1e..c744ec1 100644 --- a/skills/register-intents/SKILL.md +++ b/skills/register-intents/SKILL.md @@ -55,10 +55,10 @@ Add `intentcall_codegen` to your `dev_dependencies` in `pubspec.yaml`: ```yaml dependencies: - intentcall_core: ^0.1.0 + intentcall_core: ^0.2.1 dev_dependencies: - intentcall_codegen: ^0.1.0 + intentcall_codegen: ^0.2.1 build_runner: ^2.4.0 ``` @@ -102,6 +102,10 @@ void main() { } ``` +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. + --- ## Related Documents diff --git a/skills/write-adapter/SKILL.md b/skills/write-adapter/SKILL.md index 5e727ae..1fd473e 100644 --- a/skills/write-adapter/SKILL.md +++ b/skills/write-adapter/SKILL.md @@ -1,6 +1,6 @@ --- name: write-adapter -description: Guide to implementing a custom transport/surface adapter for IntentCall (such as custom MCP or native Apple/Android emitters). Use when an agent needs to publish registry-backed intents to a new protocol or platform surface. +description: Guide to implementing a custom transport adapter, platform emitter, or native bridge wrapper for IntentCall. Use when an agent needs to publish registry-backed intents to a new protocol or platform surface. license: MIT type: developer metadata: @@ -11,7 +11,10 @@ metadata: # Write a Custom Adapter in IntentCall -Learn how to write a custom adapter that connects the IntentCall registry to a target transport. +Learn how to connect the IntentCall registry to a target surface. Runtime +adapters execute `AgentRegistry` entries directly. Platform emitters generate +metadata or source artifacts. Native bridge wrappers should collect parameters, +authorize the source, and dispatch an invocation envelope back to Dart. ## 1. Implement AgentAdapter @@ -36,8 +39,8 @@ class MyCustomAdapter implements AgentAdapter { @override Future attach(AgentRegistry registry) async { // 1. Publish all currently registered intents - for (final descriptor in registry.listDescriptors()) { - _publishIntent(registry, descriptor); + for (final entry in registry.listEntries()) { + _publishIntent(registry, entry); } // 2. Listen to registry events to sync runtime registrations @@ -46,7 +49,14 @@ class MyCustomAdapter implements AgentAdapter { case IntentRegistered(:final qualifiedName): final intent = registry.get(qualifiedName); if (intent != null) { - _publishIntent(registry, intent.descriptor); + _publishIntent( + registry, + AgentRegistryEntry( + key: qualifiedName, + intent: intent, + descriptor: intent.descriptor, + ), + ); } case IntentUnregistered(:final qualifiedName): _unpublishIntent(qualifiedName); @@ -60,14 +70,15 @@ class MyCustomAdapter implements AgentAdapter { _subscription = null; } - void _publishIntent(AgentRegistry registry, AgentIntentDescriptor descriptor) { + void _publishIntent(AgentRegistry registry, AgentRegistryEntry entry) { + final descriptor = entry.descriptor; myTransportClient.registerTool( - name: descriptor.qualifiedName, + name: entry.key, description: descriptor.description, inputSchema: descriptor.inputSchema, handler: (arguments) async { // Delegate execution to the core registry - final result = await registry.invoke(descriptor.qualifiedName, arguments); + final result = await registry.invoke(entry.key, arguments); return { 'ok': result.ok, 'message': result.message, @@ -90,8 +101,10 @@ class MyCustomAdapter implements AgentAdapter { ## 2. Key Rules for Adapter Authors 1. **Keep it thin:** The adapter should only map protocol structures to and from the `AgentRegistry`. It should never implement domain logic or custom validations that differ from the core registry validation. -2. **Listen to Events:** If `watchesRegistry` is true, ensure you handle both `IntentRegistered` and `IntentUnregistered` events in real-time to support hot-sync environments (e.g. WebMCP). -3. **Use Stable Wire Contracts:** Depend on `intentcall_schema` rather than `intentcall_core` for sharing data envelopes (`AgentResult` / `AgentCallEntry`) between packages. +2. **Preserve registry keys:** Use `AgentRegistry.listEntries()` for adapter publication. `listDescriptors()` is compatibility sugar for display-only catalog reads and can lose override-key intent. +3. **Listen to Events:** If `watchesRegistry` is true, ensure you handle both `IntentRegistered` and `IntentUnregistered` events in real-time to support hot-sync environments such as WebMCP. +4. **Gate native/fallback sources:** Fallback invoke paths and native bridge wrappers should use `IntentCallAuthorizationPolicy`; plain deep links are untrusted unless generated wrappers or app allowlists mark the source as trusted. +5. **Use Stable Wire Contracts:** Depend on `intentcall_schema` rather than `intentcall_core` for sharing data envelopes (`AgentResult` / `AgentCallEntry`) between packages. --- diff --git a/steward.yaml b/steward.yaml index 0530955..94cee27 100644 --- a/steward.yaml +++ b/steward.yaml @@ -167,7 +167,7 @@ actions: probes: quick: profile: quick - actions: [intentcall.validate, intentcall.adapter-contract-test, intentcall.docs-check] + actions: [intentcall.validate, intentcall.adapter-contract-test] diagnostics: cases: {} unknown_cases: diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index 6aeee60..8940566 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -66,7 +66,7 @@ void main(List arguments) async { ..addOption( 'tag', help: - 'Release tag in the form -v, for example intentcall_core-v0.1.1.', + 'Release tag in the form -v, for example intentcall_core-v0.2.1.', ) ..addFlag( 'execute', @@ -121,7 +121,7 @@ void main(List arguments) async { case 'print-hosted-deps': final cmdResults = results.command!; final envVersion = Platform.environment['INTENTCALL_VERSION']; - final version = cmdResults['version'] as String? ?? envVersion ?? '0.1.0'; + final version = cmdResults['version'] as String? ?? envVersion ?? '0.2.1'; runPrintHostedDeps(version); exit(0); @@ -317,7 +317,16 @@ Future runValidate(Directory repoRoot) async { print('OK: All packages are synchronized at version $commonVersion.'); - // 3. Run plan hygiene check + // 3. Check internal hosted dependency floors + final dependencyFloorCode = await runInternalDependencyFloorCheck( + repoRoot, + version: commonVersion!, + ); + if (dependencyFloorCode != 0) { + return dependencyFloorCode; + } + + // 4. Run plan hygiene check print('\nChecking plan hygiene (active plan files)...'); final activePlans = []; final taskFile = File(p.join(repoRoot.path, 'task.md')); @@ -354,6 +363,64 @@ Future runValidate(Directory repoRoot) async { return 0; } +Future runInternalDependencyFloorCheck( + Directory repoRoot, { + required String version, +}) async { + print('\nChecking internal dependency floors...'); + final mismatches = []; + for (final pkg in publishOrder) { + final pubspecFile = File( + p.join(repoRoot.path, 'packages', pkg, 'pubspec.yaml'), + ); + final content = await pubspecFile.readAsString(); + for (final mismatch in internalDependencyFloorMismatches( + content, + version, + packageName: pkg, + )) { + mismatches.add(mismatch); + } + } + if (mismatches.isNotEmpty) { + stderr.writeln('FAIL: Internal intentcall dependency floors are stale.'); + for (final mismatch in mismatches) { + stderr.writeln(' - $mismatch'); + } + return 1; + } + print('OK: internal dependencies use ^$version.'); + return 0; +} + +List internalDependencyFloorMismatches( + String pubspecContent, + String version, { + required String packageName, +}) { + final mismatches = []; + for (final pkg in publishOrder) { + if (pkg == packageName) { + continue; + } + final pattern = RegExp( + '^\\s{2}${RegExp.escape(pkg)}:\\s*\\^([^\\s#]+)', + multiLine: true, + ); + final match = pattern.firstMatch(pubspecContent); + if (match == null) { + continue; + } + final actual = match.group(1); + if (actual != version) { + mismatches.add( + '$packageName depends on $pkg ^$actual, expected ^$version', + ); + } + } + return mismatches; +} + Future runCheckPathDeps(Directory repoRoot) async { print('Checking for invalid intentcall path dependencies...'); final matches = []; @@ -623,7 +690,7 @@ Future runPublishTag( final exec = isPlatform ? 'flutter' : 'dart'; if (dryRun) { - final args = ['pub', 'publish', '--dry-run', '--skip-validation']; + final args = ['pub', 'publish', '--dry-run']; final code = await runCommand(exec, args, packageDir); if (code != 0) { stderr.writeln( diff --git a/tool/intentcall/test/publish_preflight_test.dart b/tool/intentcall/test/publish_preflight_test.dart index df0efcb..672fe7d 100644 --- a/tool/intentcall/test/publish_preflight_test.dart +++ b/tool/intentcall/test/publish_preflight_test.dart @@ -75,6 +75,25 @@ dev_dependencies: expect(dependencies, ['intentcall_core', 'intentcall_testing']); }); + + test('detects stale internal dependency floors', () { + final mismatches = intentcall_cli.internalDependencyFloorMismatches( + ''' +dependencies: + intentcall_core: ^0.2.0 + intentcall_schema: ^0.2.1 +dev_dependencies: + intentcall_testing: ^0.2.0 +''', + '0.2.1', + packageName: 'intentcall_mcp', + ); + + expect(mismatches, [ + 'intentcall_mcp depends on intentcall_core ^0.2.0, expected ^0.2.1', + 'intentcall_mcp depends on intentcall_testing ^0.2.0, expected ^0.2.1', + ]); + }); }); group('runReleaseGitCleanCheck', () { From c2778018065f39a796edadae5c2fe9a45f08b6c0 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Fri, 26 Jun 2026 06:36:25 +0300 Subject: [PATCH 3/6] Sync Apple generated Swift into Xcode project --- packages/intentcall_platform/README.md | 5 + .../ios/intentcall_platform.podspec | 18 ++ .../apple_swift_app_intents_emitter.dart | 13 +- .../src/sync/apple_xcode_project_sync.dart | 215 +++++++++++++++ .../lib/src/sync/platform_sync.dart | 145 +++++++---- .../templates/platform_hook_templates.dart | 2 + .../test/apple_xcode_project_sync_test.dart | 213 +++++++++++++++ .../test/native_emitters_test.dart | 4 +- .../test/native_platform_sync_test.dart | 244 +++++++++++++++++- 9 files changed, 787 insertions(+), 72 deletions(-) create mode 100644 packages/intentcall_platform/ios/intentcall_platform.podspec create mode 100644 packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart create mode 100644 packages/intentcall_platform/test/apple_xcode_project_sync_test.dart diff --git a/packages/intentcall_platform/README.md b/packages/intentcall_platform/README.md index 14e0a79..82fe50a 100644 --- a/packages/intentcall_platform/README.md +++ b/packages/intentcall_platform/README.md @@ -11,6 +11,11 @@ parameters, enqueue an `IntentCallInvocationEnvelope`, and let Dart execute the registered `AgentRegistry` handler after app launch or wake. App-extension hosted Dart execution is experimental and not a stable support claim. +For iOS and macOS, `PlatformSync` also maintains the generated +`Runner/Generated/IntentCallGenerated.swift` file in the main `Runner` target's +Sources build phase so App Intents can be compiled and discovered by Apple +system surfaces. + ## Manifest workflow (I4) `web/agent_manifest.json` is **checked in** and refreshed by CLI — not generated live from `AgentRegistry` yet. diff --git a/packages/intentcall_platform/ios/intentcall_platform.podspec b/packages/intentcall_platform/ios/intentcall_platform.podspec new file mode 100644 index 0000000..263d43c --- /dev/null +++ b/packages/intentcall_platform/ios/intentcall_platform.podspec @@ -0,0 +1,18 @@ +Pod::Spec.new do |s| + s.name = 'intentcall_platform' + s.version = '0.2.1' + 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/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart index 4e2b7ff..a5bd10a 100644 --- a/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -61,22 +61,15 @@ final class AppleSwiftAppIntentsEmitter { ..writeln('}') ..writeln(); shortcutLines.add( - ' AppShortcut(intent: $typeName(), phrases: ["$phrase"])', + ' AppShortcut(intent: $typeName(), phrases: ["\\(.applicationName) $phrase"])', ); } buffer ..writeln('@available(iOS 16.0, macOS 13.0, *)') ..writeln('struct IntentCallShortcutsProvider: AppShortcutsProvider {') - ..writeln(' static var appShortcuts: [AppShortcut] {') - ..writeln(' ['); - if (shortcutLines.isEmpty) { - buffer.writeln(' ]'); - } else { - buffer - ..writeln('${shortcutLines.join(',\n')},') - ..writeln(' ]'); - } + ..writeln(' static var appShortcuts: [AppShortcut] {'); + shortcutLines.forEach(buffer.writeln); buffer ..writeln(' }') ..writeln('}') diff --git a/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart b/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart new file mode 100644 index 0000000..dbb0fb6 --- /dev/null +++ b/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart @@ -0,0 +1,215 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Result of ensuring generated AppIntent Swift is compiled by Runner. +final class AppleXcodeProjectSyncResult { + const AppleXcodeProjectSyncResult({ + required this.projectPath, + required this.changed, + }); + + final String projectPath; + final bool changed; +} + +/// Maintains the generated IntentCall Swift file in a Flutter Runner target. +final class AppleXcodeProjectSync { + const AppleXcodeProjectSync({ + this.generatedFileName = 'IntentCallGenerated.swift', + this.staleGeneratedFileNames = const ['IntentCallGenerated.swift'], + }); + + final String generatedFileName; + final Iterable staleGeneratedFileNames; + + AppleXcodeProjectSyncResult sync({ + required final String appleRoot, + final bool dryRun = false, + }) { + final projectFile = File( + p.join(appleRoot, 'Runner.xcodeproj', 'project.pbxproj'), + ); + final next = _desiredContent(projectFile); + final changed = next != projectFile.readAsStringSync(); + if (changed && !dryRun) { + projectFile.writeAsStringSync(next); + } + return AppleXcodeProjectSyncResult( + projectPath: projectFile.path, + changed: changed, + ); + } + + bool check(final String appleRoot) { + final projectFile = File( + p.join(appleRoot, 'Runner.xcodeproj', 'project.pbxproj'), + ); + if (!projectFile.existsSync()) { + return false; + } + return _desiredContent(projectFile) == projectFile.readAsStringSync(); + } + + String _desiredContent(final File projectFile) { + if (!projectFile.existsSync()) { + throw StateError('Missing Xcode project file: ${projectFile.path}'); + } + var content = projectFile.readAsStringSync(); + final sourcesId = _runnerSourcesBuildPhaseId(content, projectFile.path); + final runnerGroupId = _runnerGroupId(content, projectFile.path); + final fileRefId = _stablePbxId('file:$generatedFileName'); + final buildFileId = _stablePbxId('build:$generatedFileName'); + + content = _removeGeneratedReferences(content); + content = _insertIntoSection( + content, + 'PBXFileReference', + '\t\t$fileRefId /* $generatedFileName */ = {isa = PBXFileReference; ' + 'lastKnownFileType = sourcecode.swift; path = ' + 'Generated/$generatedFileName; sourceTree = ""; };\n', + ); + content = _insertIntoSection( + content, + 'PBXBuildFile', + '\t\t$buildFileId /* $generatedFileName in Sources */ = ' + '{isa = PBXBuildFile; fileRef = $fileRefId /* $generatedFileName */; };\n', + ); + content = _insertIntoListBlock( + content: content, + objectId: runnerGroupId, + listName: 'children', + line: '\t\t\t\t$fileRefId /* $generatedFileName */,\n', + ); + return _insertIntoListBlock( + content: content, + objectId: sourcesId, + listName: 'files', + line: '\t\t\t\t$buildFileId /* $generatedFileName in Sources */,\n', + ); + } + + String _removeGeneratedReferences(final String content) { + final generatedNames = { + generatedFileName, + ...staleGeneratedFileNames, + }; + final escaped = generatedNames.map(RegExp.escape).join('|'); + return content + .split('\n') + .where((final line) => !RegExp(escaped).hasMatch(line)) + .join('\n'); + } + + String _insertIntoSection( + final String content, + final String sectionName, + final String entry, + ) { + final marker = '/* End $sectionName section */'; + final index = content.indexOf(marker); + if (index == -1) { + throw StateError( + 'Unsupported Xcode project: missing $sectionName section', + ); + } + return content.replaceRange(index, index, entry); + } + + String _insertIntoListBlock({ + required final String content, + required final String objectId, + required final String listName, + required final String line, + }) { + final objectMatch = RegExp( + '^\\t\\t${RegExp.escape(objectId)} .* = \\{', + multiLine: true, + ).firstMatch(content); + if (objectMatch == null) { + throw StateError('Unsupported Xcode project: missing object $objectId'); + } + final objectStart = objectMatch.start; + final objectEnd = content.indexOf('\n\t\t};', objectStart); + if (objectEnd == -1) { + throw StateError('Unsupported Xcode project: malformed object $objectId'); + } + final listStart = content.indexOf('\n\t\t\t$listName = (\n', objectStart); + if (listStart == -1 || listStart > objectEnd) { + throw StateError( + 'Unsupported Xcode project: missing $listName list in object $objectId', + ); + } + final listEnd = content.indexOf('\n\t\t\t);', listStart); + if (listEnd == -1 || listEnd > objectEnd) { + throw StateError( + 'Unsupported Xcode project: malformed $listName list in object $objectId', + ); + } + return content.replaceRange(listEnd + 1, listEnd + 1, line); + } + + String _runnerSourcesBuildPhaseId( + final String content, + final String projectPath, + ) { + for (final targetMatch in RegExp( + r'\t\t[0-9A-F]{24} /\* Runner \*/ = \{[\s\S]*?\n\t\t\};', + ).allMatches(content)) { + final block = targetMatch.group(0)!; + if (!block.contains('isa = PBXNativeTarget;')) { + continue; + } + final phaseMatch = RegExp( + r'\n\t\t\t\t([0-9A-F]{24}) /\* Sources \*/,', + ).firstMatch(block); + if (phaseMatch == null) { + throw StateError( + 'Unsupported Xcode project: Runner target has no Sources phase in $projectPath', + ); + } + return phaseMatch.group(1)!; + } + throw StateError( + 'Unsupported Xcode project: missing Runner native target in $projectPath', + ); + } + + String _runnerGroupId(final String content, final String projectPath) { + final groupSectionMatch = RegExp( + r'/\* Begin PBXGroup section \*/[\s\S]*?/\* End PBXGroup section \*/', + ).firstMatch(content); + if (groupSectionMatch == null) { + throw StateError( + 'Unsupported Xcode project: missing PBXGroup section in $projectPath', + ); + } + for (final match in RegExp( + r'\t\t([0-9A-F]{24}) /\* Runner \*/ = \{[\s\S]*?\n\t\t\};', + ).allMatches(groupSectionMatch.group(0)!)) { + final block = match.group(0)!; + if (block.contains('isa = PBXGroup;') && + block.contains('\n\t\t\tchildren = (')) { + return match.group(1)!; + } + } + throw StateError( + 'Unsupported Xcode project: missing Runner group in $projectPath', + ); + } + + String _stablePbxId(final String seed) { + var value = 0x811C9DC5; + final out = StringBuffer(); + var round = 0; + while (out.length < 24) { + for (final unit in '$seed:$round'.codeUnits) { + value ^= unit; + value = (value * 0x01000193) & 0xFFFFFFFF; + } + out.write(value.toRadixString(16).padLeft(8, '0')); + round += 1; + } + return out.toString().substring(0, 24).toUpperCase(); + } +} diff --git a/packages/intentcall_platform/lib/src/sync/platform_sync.dart b/packages/intentcall_platform/lib/src/sync/platform_sync.dart index 5bda14c..1b1f35b 100644 --- a/packages/intentcall_platform/lib/src/sync/platform_sync.dart +++ b/packages/intentcall_platform/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 'apple_xcode_project_sync.dart'; /// Supported `codegen sync --platform` values. const kPlatformSyncTargets = { @@ -29,6 +30,8 @@ final class PlatformSyncResult { this.androidShortcutsPath, this.iosGeneratedSwiftPath, this.macosGeneratedSwiftPath, + this.iosXcodeProjectPath, + this.macosXcodeProjectPath, this.linuxDesktopPath, this.windowsProtocolPath, this.windowsMsixFragmentPath, @@ -37,6 +40,8 @@ final class PlatformSyncResult { this.wroteAndroidShortcuts = false, this.wroteIosGenerated = false, this.wroteMacosGenerated = false, + this.wroteIosXcodeProject = false, + this.wroteMacosXcodeProject = false, this.wroteLinuxDesktop = false, this.wroteWindowsProtocol = false, this.wroteWindowsMsixFragment = false, @@ -48,6 +53,8 @@ final class PlatformSyncResult { final String? androidShortcutsPath; final String? iosGeneratedSwiftPath; final String? macosGeneratedSwiftPath; + final String? iosXcodeProjectPath; + final String? macosXcodeProjectPath; final String? linuxDesktopPath; final String? windowsProtocolPath; final String? windowsMsixFragmentPath; @@ -56,6 +63,8 @@ final class PlatformSyncResult { final bool wroteAndroidShortcuts; final bool wroteIosGenerated; final bool wroteMacosGenerated; + final bool wroteIosXcodeProject; + final bool wroteMacosXcodeProject; final bool wroteLinuxDesktop; final bool wroteWindowsProtocol; final bool wroteWindowsMsixFragment; @@ -138,18 +147,15 @@ final class PlatformSync { manifestPath: _resolveManifestFile(projectRoot).path, ); for (final platform in normalized) { - result = _mergeResults( - result, - switch (platform) { - 'web' => syncWeb(projectRoot: projectRoot, dryRun: dryRun), - 'android' => syncAndroid(projectRoot: projectRoot, dryRun: dryRun), - 'ios' => syncIos(projectRoot: projectRoot, dryRun: dryRun), - 'macos' => syncMacos(projectRoot: projectRoot, dryRun: dryRun), - 'linux' => syncLinux(projectRoot: projectRoot, dryRun: dryRun), - 'windows' => syncWindows(projectRoot: projectRoot, dryRun: dryRun), - _ => throw StateError('unreachable'), - }, - ); + result = _mergeResults(result, switch (platform) { + 'web' => syncWeb(projectRoot: projectRoot, dryRun: dryRun), + 'android' => syncAndroid(projectRoot: projectRoot, dryRun: dryRun), + 'ios' => syncIos(projectRoot: projectRoot, dryRun: dryRun), + 'macos' => syncMacos(projectRoot: projectRoot, dryRun: dryRun), + 'linux' => syncLinux(projectRoot: projectRoot, dryRun: dryRun), + 'windows' => syncWindows(projectRoot: projectRoot, dryRun: dryRun), + _ => throw StateError('unreachable'), + }); } return result; } @@ -356,9 +362,7 @@ final class PlatformSync { bool checkLinux(final String projectRoot) { final manifest = readManifest(projectRoot); - final file = File( - p.join(projectRoot, linuxDirName, linuxDesktopFileName), - ); + final file = File(p.join(projectRoot, linuxDirName, linuxDesktopFileName)); if (!file.existsSync()) { return false; } @@ -376,8 +380,7 @@ final class PlatformSync { if (!reg.existsSync() || !msix.existsSync()) { return false; } - return reg.readAsStringSync() == - windowsProtocolEmitter.emit(manifest) && + return reg.readAsStringSync() == windowsProtocolEmitter.emit(manifest) && msix.readAsStringSync() == windowsProtocolEmitter.emitMsixFragment(manifest); } @@ -393,23 +396,33 @@ final class PlatformSync { if (!rootDir.existsSync()) { throw StateError('Missing $appleRoot directory under $projectRoot'); } - final generatedDir = Directory(p.join(appleRoot, 'Runner', 'Generated')) - ..createSync(recursive: true); + final generatedDir = Directory(p.join(appleRoot, 'Runner', 'Generated')); final outFile = File(p.join(generatedDir.path, appleGeneratedFileName)); final next = '${appleSwiftEmitter.emit(manifest)}\n'; - var wrote = false; + final generatedChanged = + !outFile.existsSync() || outFile.readAsStringSync() != next; + final projectSync = _appleXcodeProjectSync(); + final xcodePreview = projectSync.sync(appleRoot: appleRoot, dryRun: true); if (!dryRun) { - if (!outFile.existsSync() || outFile.readAsStringSync() != next) { + generatedDir.createSync(recursive: true); + _deleteStaleAppleGeneratedFiles(generatedDir); + if (generatedChanged) { outFile.writeAsStringSync(next); - wrote = true; } } + final xcodeResult = !dryRun && xcodePreview.changed + ? projectSync.sync(appleRoot: appleRoot) + : xcodePreview; return PlatformSyncResult( manifestPath: _resolveManifestFile(projectRoot).path, iosGeneratedSwiftPath: isMacos ? null : outFile.path, macosGeneratedSwiftPath: isMacos ? outFile.path : null, - wroteIosGenerated: !isMacos && wrote, - wroteMacosGenerated: isMacos && wrote, + iosXcodeProjectPath: isMacos ? null : xcodeResult.projectPath, + macosXcodeProjectPath: isMacos ? xcodeResult.projectPath : null, + wroteIosGenerated: !isMacos && generatedChanged, + wroteMacosGenerated: isMacos && generatedChanged, + wroteIosXcodeProject: !isMacos && xcodeResult.changed, + wroteMacosXcodeProject: isMacos && xcodeResult.changed, ); } @@ -430,7 +443,27 @@ final class PlatformSync { if (!file.existsSync()) { return false; } - return file.readAsStringSync() == '${appleSwiftEmitter.emit(manifest)}\n'; + return file.readAsStringSync() == '${appleSwiftEmitter.emit(manifest)}\n' && + _appleXcodeProjectSync().check(p.join(projectRoot, appleRoot)); + } + + AppleXcodeProjectSync _appleXcodeProjectSync() => + AppleXcodeProjectSync(generatedFileName: appleGeneratedFileName); + + void _deleteStaleAppleGeneratedFiles(final Directory generatedDir) { + for (final name in _staleAppleGeneratedFileNames()) { + final file = File(p.join(generatedDir.path, name)); + if (file.existsSync()) { + file.deleteSync(); + } + } + } + + Iterable _staleAppleGeneratedFileNames() sync* { + const defaultGeneratedFileName = 'IntentCallGenerated.swift'; + if (appleGeneratedFileName != defaultGeneratedFileName) { + yield defaultGeneratedFileName; + } } File _androidShortcutsFile(final String projectRoot) => File( @@ -449,35 +482,39 @@ final class PlatformSync { PlatformSyncResult _mergeResults( final PlatformSyncResult left, final PlatformSyncResult right, - ) => - PlatformSyncResult( - manifestPath: left.manifestPath, - webManifestPath: right.webManifestPath ?? left.webManifestPath, - webMcpJsPath: right.webMcpJsPath ?? left.webMcpJsPath, - androidShortcutsPath: - right.androidShortcutsPath ?? left.androidShortcutsPath, - iosGeneratedSwiftPath: - right.iosGeneratedSwiftPath ?? left.iosGeneratedSwiftPath, - macosGeneratedSwiftPath: - right.macosGeneratedSwiftPath ?? left.macosGeneratedSwiftPath, - linuxDesktopPath: right.linuxDesktopPath ?? left.linuxDesktopPath, - windowsProtocolPath: - right.windowsProtocolPath ?? left.windowsProtocolPath, - windowsMsixFragmentPath: - right.windowsMsixFragmentPath ?? left.windowsMsixFragmentPath, - wroteManifest: left.wroteManifest || right.wroteManifest, - wroteWebMcpJs: left.wroteWebMcpJs || right.wroteWebMcpJs, - wroteAndroidShortcuts: - left.wroteAndroidShortcuts || right.wroteAndroidShortcuts, - wroteIosGenerated: left.wroteIosGenerated || right.wroteIosGenerated, - wroteMacosGenerated: - left.wroteMacosGenerated || right.wroteMacosGenerated, - wroteLinuxDesktop: left.wroteLinuxDesktop || right.wroteLinuxDesktop, - wroteWindowsProtocol: - left.wroteWindowsProtocol || right.wroteWindowsProtocol, - wroteWindowsMsixFragment: - left.wroteWindowsMsixFragment || right.wroteWindowsMsixFragment, - ); + ) => PlatformSyncResult( + manifestPath: left.manifestPath, + webManifestPath: right.webManifestPath ?? left.webManifestPath, + webMcpJsPath: right.webMcpJsPath ?? left.webMcpJsPath, + androidShortcutsPath: + right.androidShortcutsPath ?? left.androidShortcutsPath, + iosGeneratedSwiftPath: + right.iosGeneratedSwiftPath ?? left.iosGeneratedSwiftPath, + macosGeneratedSwiftPath: + right.macosGeneratedSwiftPath ?? left.macosGeneratedSwiftPath, + iosXcodeProjectPath: right.iosXcodeProjectPath ?? left.iosXcodeProjectPath, + macosXcodeProjectPath: + right.macosXcodeProjectPath ?? left.macosXcodeProjectPath, + linuxDesktopPath: right.linuxDesktopPath ?? left.linuxDesktopPath, + windowsProtocolPath: right.windowsProtocolPath ?? left.windowsProtocolPath, + windowsMsixFragmentPath: + right.windowsMsixFragmentPath ?? left.windowsMsixFragmentPath, + wroteManifest: left.wroteManifest || right.wroteManifest, + wroteWebMcpJs: left.wroteWebMcpJs || right.wroteWebMcpJs, + wroteAndroidShortcuts: + left.wroteAndroidShortcuts || right.wroteAndroidShortcuts, + wroteIosGenerated: left.wroteIosGenerated || right.wroteIosGenerated, + wroteMacosGenerated: left.wroteMacosGenerated || right.wroteMacosGenerated, + wroteIosXcodeProject: + left.wroteIosXcodeProject || right.wroteIosXcodeProject, + wroteMacosXcodeProject: + left.wroteMacosXcodeProject || right.wroteMacosXcodeProject, + wroteLinuxDesktop: left.wroteLinuxDesktop || right.wroteLinuxDesktop, + wroteWindowsProtocol: + left.wroteWindowsProtocol || right.wroteWindowsProtocol, + wroteWindowsMsixFragment: + left.wroteWindowsMsixFragment || right.wroteWindowsMsixFragment, + ); File _resolveManifestFile(final String projectRoot) { final rootCandidate = File(p.join(projectRoot, manifestFileName)); diff --git a/packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart b/packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart index 02df54c..cfab9d2 100644 --- a/packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart +++ b/packages/intentcall_platform/lib/src/templates/platform_hook_templates.dart @@ -19,6 +19,8 @@ tasks.named("preBuild").configure { '''; /// 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}/.." diff --git a/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart b/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart new file mode 100644 index 0000000..5a1cbd2 --- /dev/null +++ b/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart @@ -0,0 +1,213 @@ +import 'dart:io'; + +import 'package:intentcall_platform/src/sync/apple_xcode_project_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('adds generated Swift to the main Runner target sources', () { + final temp = Directory.systemTemp.createTempSync('intentcall_xcode_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final projectFile = _writeProject(temp); + + final result = const AppleXcodeProjectSync().sync(appleRoot: temp.path); + + expect(result.changed, isTrue); + final content = projectFile.readAsStringSync(); + expect(_count(content, 'isa = PBXFileReference;'), 3); + expect(_count(content, 'IntentCallGenerated.swift in Sources'), 2); + expect(_runnerSourcesBlock(content), contains('IntentCallGenerated.swift')); + expect( + _runnerTestsSourcesBlock(content), + isNot(contains('IntentCallGenerated.swift')), + ); + expect(const AppleXcodeProjectSync().check(temp.path), isTrue); + }); + + test('is idempotent on repeated sync', () { + final temp = Directory.systemTemp.createTempSync('intentcall_xcode_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final projectFile = _writeProject(temp); + + const AppleXcodeProjectSync().sync(appleRoot: temp.path); + final once = projectFile.readAsStringSync(); + final second = const AppleXcodeProjectSync().sync(appleRoot: temp.path); + + expect(second.changed, isFalse); + expect(projectFile.readAsStringSync(), once); + }); + + test('repairs duplicate stale generated Swift references', () { + final temp = Directory.systemTemp.createTempSync('intentcall_xcode_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final projectFile = _writeProject(temp); + var content = projectFile.readAsStringSync(); + content = content.replaceFirst( + '/* End PBXFileReference section */', + '\t\tAAAAAAAAAAAAAAAAAAAAAAAA /* IntentCallGenerated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentCallGenerated.swift; sourceTree = ""; };\n' + '\t\tBBBBBBBBBBBBBBBBBBBBBBBB /* IntentCallGenerated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/IntentCallGenerated.swift; sourceTree = ""; };\n' + '/* End PBXFileReference section */', + ); + content = content.replaceFirst( + 'files = (\n', + 'files = (\n' + '\t\t\t\tCCCCCCCCCCCCCCCCCCCCCCCC /* IntentCallGenerated.swift in Sources */,\n', + ); + projectFile.writeAsStringSync(content); + + const AppleXcodeProjectSync().sync(appleRoot: temp.path); + final repaired = projectFile.readAsStringSync(); + + expect(repaired, isNot(contains('AAAAAAAAAAAAAAAAAAAAAAAA'))); + expect(repaired, isNot(contains('BBBBBBBBBBBBBBBBBBBBBBBB'))); + expect(repaired, isNot(contains('CCCCCCCCCCCCCCCCCCCCCCCC'))); + expect(_count(repaired, 'IntentCallGenerated.swift in Sources'), 2); + expect( + _runnerSourcesBlock(repaired), + contains('IntentCallGenerated.swift'), + ); + }); + + test('removes the default generated Swift when custom file name is used', () { + final temp = Directory.systemTemp.createTempSync('intentcall_xcode_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final projectFile = _writeProject(temp); + + const AppleXcodeProjectSync().sync(appleRoot: temp.path); + const AppleXcodeProjectSync( + generatedFileName: 'CustomIntentCall.swift', + ).sync(appleRoot: temp.path); + + final content = projectFile.readAsStringSync(); + expect(content, isNot(contains('IntentCallGenerated.swift'))); + expect(_runnerSourcesBlock(content), contains('CustomIntentCall.swift')); + expect( + const AppleXcodeProjectSync( + generatedFileName: 'CustomIntentCall.swift', + ).check(temp.path), + isTrue, + ); + }); + + test('dry run reports drift without modifying project', () { + final temp = Directory.systemTemp.createTempSync('intentcall_xcode_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final projectFile = _writeProject(temp); + final before = projectFile.readAsStringSync(); + + final result = const AppleXcodeProjectSync().sync( + appleRoot: temp.path, + dryRun: true, + ); + + expect(result.changed, isTrue); + expect(projectFile.readAsStringSync(), before); + }); +} + +File _writeProject(final Directory root) { + final projectDir = Directory(p.join(root.path, 'Runner.xcodeproj')) + ..createSync(recursive: true); + final projectFile = File(p.join(projectDir.path, 'project.pbxproj')) + ..writeAsStringSync(_minimalPbxproj); + return projectFile; +} + +int _count(final String content, final String pattern) => + RegExp(RegExp.escape(pattern)).allMatches(content).length; + +String _runnerSourcesBlock(final String content) => RegExp( + r'DDDDDDDDDDDDDDDDDDDDDDDD /\* Sources \*/ = \{[\s\S]*?\n\t\t\};', +).firstMatch(content)!.group(0)!; + +String _runnerTestsSourcesBlock(final String content) => RegExp( + r'EEEEEEEEEEEEEEEEEEEEEEEE /\* Sources \*/ = \{[\s\S]*?\n\t\t\};', +).firstMatch(content)!.group(0)!; + +const _minimalPbxproj = r''' +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 111111111111111111111111 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222222222222 /* AppDelegate.swift */; }; + 333333333333333333333333 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 444444444444444444444444 /* RunnerTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 222222222222222222222222 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 444444444444444444444444 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 555555555555555555555555 = { + isa = PBXGroup; + children = ( + 666666666666666666666666 /* Runner */, + 777777777777777777777777 /* RunnerTests */, + ); + sourceTree = ""; + }; + 666666666666666666666666 /* Runner */ = { + isa = PBXGroup; + children = ( + 222222222222222222222222 /* AppDelegate.swift */, + ); + path = Runner; + sourceTree = ""; + }; + 777777777777777777777777 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 444444444444444444444444 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 888888888888888888888888 /* Runner */ = { + isa = PBXNativeTarget; + buildPhases = ( + DDDDDDDDDDDDDDDDDDDDDDDD /* Sources */, + ); + name = Runner; + productName = Runner; + }; + 999999999999999999999999 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildPhases = ( + EEEEEEEEEEEEEEEEEEEEEEEE /* Sources */, + ); + name = RunnerTests; + productName = RunnerTests; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXSourcesBuildPhase section */ + DDDDDDDDDDDDDDDDDDDDDDDD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 111111111111111111111111 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EEEEEEEEEEEEEEEEEEEEEEEE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 333333333333333333333333 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + }; + rootObject = FFFFFFFFFFFFFFFFFFFFFFFF; +} +'''; diff --git a/packages/intentcall_platform/test/native_emitters_test.dart b/packages/intentcall_platform/test/native_emitters_test.dart index 20bdecb..6c10f85 100644 --- a/packages/intentcall_platform/test/native_emitters_test.dart +++ b/packages/intentcall_platform/test/native_emitters_test.dart @@ -133,9 +133,7 @@ struct AppCartTotalIntent: AppIntent { @available(iOS 16.0, macOS 13.0, *) struct IntentCallShortcutsProvider: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { - [ - AppShortcut(intent: AppCartTotalIntent(), phrases: ["Cart Total"]), - ] + AppShortcut(intent: AppCartTotalIntent(), phrases: ["\(.applicationName) Cart Total"]) } } diff --git a/packages/intentcall_platform/test/native_platform_sync_test.dart b/packages/intentcall_platform/test/native_platform_sync_test.dart index ffe722c..4c21128 100644 --- a/packages/intentcall_platform/test/native_platform_sync_test.dart +++ b/packages/intentcall_platform/test/native_platform_sync_test.dart @@ -26,11 +26,16 @@ void main() { final temp = Directory.systemTemp.createTempSync('intentcall_native_sync_'); addTearDown(() => temp.deleteSync(recursive: true)); - File(p.join(temp.path, 'agent_manifest.json')).writeAsStringSync(manifestJson); - Directory(p.join(temp.path, 'android', 'app', 'src', 'main', 'res', 'xml')) - .createSync(recursive: true); + File( + p.join(temp.path, 'agent_manifest.json'), + ).writeAsStringSync(manifestJson); + Directory( + p.join(temp.path, 'android', 'app', 'src', 'main', 'res', 'xml'), + ).createSync(recursive: true); Directory(p.join(temp.path, 'ios', 'Runner')).createSync(recursive: true); Directory(p.join(temp.path, 'macos', 'Runner')).createSync(recursive: true); + _writeMinimalXcodeProject(p.join(temp.path, 'ios')); + _writeMinimalXcodeProject(p.join(temp.path, 'macos')); Directory(p.join(temp.path, 'linux')).createSync(); Directory(p.join(temp.path, 'windows')).createSync(); @@ -43,15 +48,244 @@ void main() { expect(result.wroteAndroidShortcuts, isTrue); expect(result.wroteIosGenerated, isTrue); expect(result.wroteMacosGenerated, isTrue); + expect(result.wroteIosXcodeProject, isTrue); + expect(result.wroteMacosXcodeProject, isTrue); expect(result.wroteLinuxDesktop, isTrue); expect(result.wroteWindowsProtocol, isTrue); expect( - sync.checkPlatforms( + sync.checkPlatforms(temp.path, [ + 'android', + 'ios', + 'macos', + 'linux', + 'windows', + ]), + isTrue, + ); + }); + + test('Apple check fails when generated Swift is not target-membered', () { + final temp = Directory.systemTemp.createTempSync('intentcall_native_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + File( + p.join(temp.path, 'agent_manifest.json'), + ).writeAsStringSync(manifestJson); + Directory( + p.join(temp.path, 'ios', 'Runner', 'Generated'), + ).createSync(recursive: true); + _writeMinimalXcodeProject(p.join(temp.path, 'ios')); + + const sync = PlatformSync(); + final manifest = sync.readManifest(temp.path); + File( + p.join( temp.path, - ['android', 'ios', 'macos', 'linux', 'windows'], + 'ios', + 'Runner', + 'Generated', + 'IntentCallGenerated.swift', ), + ).writeAsStringSync('${sync.appleSwiftEmitter.emit(manifest)}\n'); + + expect(sync.checkIos(temp.path), isFalse); + sync.syncIos(projectRoot: temp.path); + expect(sync.checkIos(temp.path), isTrue); + }); + + test('Apple dry run reports project drift without writing', () { + final temp = Directory.systemTemp.createTempSync('intentcall_native_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + File( + p.join(temp.path, 'agent_manifest.json'), + ).writeAsStringSync(manifestJson); + Directory(p.join(temp.path, 'ios', 'Runner')).createSync(recursive: true); + final projectFile = _writeMinimalXcodeProject(p.join(temp.path, 'ios')); + final before = projectFile.readAsStringSync(); + + const sync = PlatformSync(); + final result = sync.syncIos(projectRoot: temp.path, dryRun: true); + + expect(result.wroteIosGenerated, isTrue); + expect(result.wroteIosXcodeProject, isTrue); + expect( + File( + p.join( + temp.path, + 'ios', + 'Runner', + 'Generated', + 'IntentCallGenerated.swift', + ), + ).existsSync(), + isFalse, + ); + expect(projectFile.readAsStringSync(), before); + }); + + test('Apple sync target-members custom generated file names', () { + final temp = Directory.systemTemp.createTempSync('intentcall_native_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + File( + p.join(temp.path, 'agent_manifest.json'), + ).writeAsStringSync(manifestJson); + Directory(p.join(temp.path, 'ios', 'Runner')).createSync(recursive: true); + final projectFile = _writeMinimalXcodeProject(p.join(temp.path, 'ios')); + + const sync = PlatformSync(appleGeneratedFileName: 'CustomIntentCall.swift'); + final result = sync.syncIos(projectRoot: temp.path); + + expect(result.iosGeneratedSwiftPath, endsWith('CustomIntentCall.swift')); + expect(File(result.iosGeneratedSwiftPath!).existsSync(), isTrue); + final project = projectFile.readAsStringSync(); + expect(project, contains('CustomIntentCall.swift in Sources')); + expect(project, isNot(contains('IntentCallGenerated.swift'))); + expect(sync.checkIos(temp.path), isTrue); + }); + + test('Apple sync removes stale default generated file for custom names', () { + final temp = Directory.systemTemp.createTempSync('intentcall_native_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + File( + p.join(temp.path, 'agent_manifest.json'), + ).writeAsStringSync(manifestJson); + Directory(p.join(temp.path, 'ios', 'Runner')).createSync(recursive: true); + final projectFile = _writeMinimalXcodeProject(p.join(temp.path, 'ios')); + + const PlatformSync().syncIos(projectRoot: temp.path); + final sync = const PlatformSync( + appleGeneratedFileName: 'CustomIntentCall.swift', + )..syncIos(projectRoot: temp.path); + + expect( + File( + p.join( + temp.path, + 'ios', + 'Runner', + 'Generated', + 'IntentCallGenerated.swift', + ), + ).existsSync(), + isFalse, + ); + expect( + File( + p.join( + temp.path, + 'ios', + 'Runner', + 'Generated', + 'CustomIntentCall.swift', + ), + ).existsSync(), isTrue, ); + final project = projectFile.readAsStringSync(); + expect(project, isNot(contains('IntentCallGenerated.swift'))); + expect(project, contains('CustomIntentCall.swift in Sources')); + expect(sync.checkIos(temp.path), isTrue); }); + + test( + 'Apple sync does not write generated Swift for unsupported projects', + () { + final temp = Directory.systemTemp.createTempSync( + 'intentcall_native_sync_', + ); + addTearDown(() => temp.deleteSync(recursive: true)); + File( + p.join(temp.path, 'agent_manifest.json'), + ).writeAsStringSync(manifestJson); + Directory(p.join(temp.path, 'ios', 'Runner')).createSync(recursive: true); + Directory(p.join(temp.path, 'ios', 'Runner.xcodeproj')).createSync(); + File( + p.join(temp.path, 'ios', 'Runner.xcodeproj', 'project.pbxproj'), + ).writeAsStringSync('// unsupported\n'); + + expect( + () => const PlatformSync().syncIos(projectRoot: temp.path), + throwsStateError, + ); + expect( + File( + p.join( + temp.path, + 'ios', + 'Runner', + 'Generated', + 'IntentCallGenerated.swift', + ), + ).existsSync(), + isFalse, + ); + }, + ); +} + +File _writeMinimalXcodeProject(final String appleRoot) { + final projectDir = Directory(p.join(appleRoot, 'Runner.xcodeproj')) + ..createSync(recursive: true); + final projectFile = File(p.join(projectDir.path, 'project.pbxproj')) + ..writeAsStringSync(r''' +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 111111111111111111111111 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222222222222 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 222222222222222222222222 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 555555555555555555555555 = { + isa = PBXGroup; + children = ( + 666666666666666666666666 /* Runner */, + ); + sourceTree = ""; + }; + 666666666666666666666666 /* Runner */ = { + isa = PBXGroup; + children = ( + 222222222222222222222222 /* AppDelegate.swift */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 888888888888888888888888 /* Runner */ = { + isa = PBXNativeTarget; + buildPhases = ( + DDDDDDDDDDDDDDDDDDDDDDDD /* Sources */, + ); + name = Runner; + productName = Runner; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXSourcesBuildPhase section */ + DDDDDDDDDDDDDDDDDDDDDDDD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 111111111111111111111111 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + }; + rootObject = FFFFFFFFFFFFFFFFFFFFFFFF; +} +'''); + return projectFile; } From f1f853a407217b512bb7e9520e767e4e8af1968f Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Sat, 27 Jun 2026 01:58:43 +0300 Subject: [PATCH 4/6] Tighten native bridge auth defaults and sync reporting --- docs/DX_FAQ.mdx | 16 +- .../0015-dart-first-native-bridge.md | 6 +- justfile | 4 +- packages/intentcall_platform/README.md | 19 +- .../bootstrap/agent_web_mcp_bootstrap.dart | 17 +- .../agent_web_mcp_bootstrap_stub.dart | 5 +- .../agent_web_mcp_bootstrap_web.dart | 32 +++- .../src/invocation/intentcall_invocation.dart | 60 ++++-- .../lib/src/sync/platform_sync.dart | 114 +++++++++++- .../test/agent_web_mcp_bootstrap_test.dart | 17 ++ .../test/intentcall_invocation_test.dart | 174 ++++++++++++++---- .../test/native_platform_sync_test.dart | 17 ++ .../test/platform_sync_test.dart | 10 +- .../test/web_emitters_test.dart | 91 +++++++++ skills/write-adapter/SKILL.md | 2 +- steward.yaml | 3 +- .../intentcall.adapter-contract.yaml | 15 ++ 17 files changed, 537 insertions(+), 65 deletions(-) diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 6db58e2..92713ae 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -296,7 +296,21 @@ No. You can register `AgentCallEntry` objects manually. Codegen is a convenience **Q: How do I test an adapter against the contract?** -Add `intentcall_testing` as a `dev_dependency` and use the provided `AgentCallContractTest` mixin. See the test suite in `packages/intentcall_mcp/test/` for a concrete example. +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?** diff --git a/docs/decisions/0015-dart-first-native-bridge.md b/docs/decisions/0015-dart-first-native-bridge.md index 629ca96..5a5f7ca 100644 --- a/docs/decisions/0015-dart-first-native-bridge.md +++ b/docs/decisions/0015-dart-first-native-bridge.md @@ -28,12 +28,16 @@ IntentCall v1 platform projection is Dart-first: - `IntentCallInvocationEnvelope` is the shared native/WebMCP invocation unit. - `IntentCallAuthorizationPolicy` gates source and intent-name access before - dispatch. + dispatch. Empty/default policies deny all invocations. - `IntentCallNativeBridge.bindRegistry(...)` executes authorized envelopes through the Dart `AgentRegistry`. - `registerAgentWebMcpFromRegistry(...)` registers WebMCP tools from Dart and invokes Dart handlers in-page. - WebMCP network fallback is opt-in only. +- Development builds may use `IntentCallAuthorizationPolicy.debugAllowAll()` to + expose local dogfood tools while Dart assertions are enabled. Compiled + profile/release builds must provide explicit source/name allowlists or + confirmation callbacks. - Generated Apple App Intents collect supported primitive parameters, enqueue an invocation envelope, open or wake the Flutter app, and return dispatch status. They do not claim background Dart execution or native semantic result diff --git a/justfile b/justfile index 727c8ea..631d3b3 100644 --- a/justfile +++ b/justfile @@ -57,9 +57,9 @@ doctor: validate: dart run tool/intentcall/bin/intentcall.dart validate -# Run the shared native adapter contract tests +# 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 + 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 # List custom agent skills defined in this repository list-skills: diff --git a/packages/intentcall_platform/README.md b/packages/intentcall_platform/README.md index 82fe50a..e314b22 100644 --- a/packages/intentcall_platform/README.md +++ b/packages/intentcall_platform/README.md @@ -16,9 +16,22 @@ For iOS and macOS, `PlatformSync` also maintains the generated Sources build phase so App Intents can be compiled and discovered by Apple system surfaces. +## Invocation policy + +Native and WebMCP execution is deny-by-default in compiled profile/release +builds. For local dogfood, `IntentCallAuthorizationPolicy.debugAllowAll()` opens +execution only while Dart assertions are enabled; in compiled builds it behaves +like `denyAll()`. + +Production apps should pass an explicit `IntentCallAuthorizationPolicy` with +source and qualified-name allowlists, and use `confirm` for mutating or sensitive +tools. + ## Manifest workflow (I4) -`web/agent_manifest.json` is **checked in** and refreshed by CLI — not generated live from `AgentRegistry` yet. +`agent_manifest.json` is read from the project root first, then from `web/`. +The web copy is commonly checked in and refreshed by CLI — not generated live +from `AgentRegistry` yet. ```bash flutter-mcp-toolkit codegen sync \ @@ -26,7 +39,9 @@ flutter-mcp-toolkit codegen sync \ --project-dir ``` -Use `--check` in CI (`make check-intentcall-integration`). +Use `--check` in CI (`make check-intentcall-integration`). `--check` reports +whether any generated artifact or native project membership would change without +writing files. ### One-time hooks diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart index e4bb928..1be81e3 100644 --- a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart +++ b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap.dart @@ -6,14 +6,25 @@ import 'agent_web_mcp_bootstrap_stub.dart' as impl; /// Registers WebMCP tools from [AgentCallEntry] values (Flutter web path C). -void registerAgentWebMcpFromEntries(final Set entries) => - impl.registerFromEntries(entries); +/// +/// The default policy is open only while Dart assertions are enabled. In +/// compiled profile/release builds it denies all invocations unless an app +/// passes an explicit source/name allowlist or confirmation policy. +void registerAgentWebMcpFromEntries( + final Set entries, { + final IntentCallAuthorizationPolicy policy = + const IntentCallAuthorizationPolicy.debugAllowAll(), +}) => impl.registerFromEntries(entries, policy: policy); /// Registers WebMCP tools directly from [registry] and executes them in Dart. +/// +/// The default policy is open only while Dart assertions are enabled. In +/// compiled profile/release builds it denies all invocations unless an app +/// passes an explicit source/name allowlist or confirmation policy. void registerAgentWebMcpFromRegistry( final AgentRegistry registry, { final IntentCallAuthorizationPolicy policy = - const IntentCallAuthorizationPolicy.allowAll(), + const IntentCallAuthorizationPolicy.debugAllowAll(), }) => impl.registerFromRegistry(registry, policy: policy); /// Whether a tool was already registered on WebMCP (web only; stub returns false). diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart index e50218f..4e11f1b 100644 --- a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart +++ b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_stub.dart @@ -2,7 +2,10 @@ import 'package:intentcall_core/intentcall_core.dart'; import '../invocation/intentcall_invocation.dart'; -void registerFromEntries(final Set entries) {} +void registerFromEntries( + final Set entries, { + required final IntentCallAuthorizationPolicy policy, +}) {} void registerFromRegistry( final AgentRegistry registry, { diff --git a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart index aa8a9c5..b0b6e97 100644 --- a/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart +++ b/packages/intentcall_platform/lib/src/bootstrap/agent_web_mcp_bootstrap_web.dart @@ -15,6 +15,7 @@ final _webMcpRegisteredToolNames = {}; /// Entries available to [__intentcallWebMcpDartExecute] when JS registered first. final _entriesByQualifiedName = {}; +final _entryPoliciesByQualifiedName = {}; final _bridgesByQualifiedName = {}; var _dartExecuteHookInstalled = false; @@ -48,8 +49,12 @@ extension type _WebMcpToolDefinition._(JSObject _) implements JSObject { /// loads. Those handlers run JS `validateInput`, then delegate to /// [globalContext]'s `__intentcallWebMcpDartExecute` when this bootstrap installs /// it (full [AgentCallEntry.invokeDirect] validation). If no Dart entry exists, -/// execute falls back to `fetch('/agent/invoke')` per ADR 0008. -void registerFromEntries(final Set entries) { +/// generated JS returns `runtime_unavailable` unless network fallback was +/// explicitly enabled. +void registerFromEntries( + final Set entries, { + required final IntentCallAuthorizationPolicy policy, +}) { final modelContext = _readModelContext(); if (modelContext == null) { return; @@ -65,6 +70,7 @@ void registerFromEntries(final Set entries) { final qualifiedName = descriptor.qualifiedName; _entriesByQualifiedName[qualifiedName] = entry; + _entryPoliciesByQualifiedName[qualifiedName] = policy; if (_webMcpRegisteredToolNames.contains(qualifiedName)) { continue; @@ -75,6 +81,7 @@ void registerFromEntries(final Set entries) { inputSchema: _jsonParse(jsonEncode(descriptor.inputSchema).toJS)!, execute: ((final JSAny? rawArgs) => _invokeEntry( entry, + qualifiedName, rawArgs, ).toJS).toJS, ); @@ -159,7 +166,7 @@ Future _dartExecuteHook( if (entry == null) { return null; } - return _invokeEntry(entry, rawArgs); + return _invokeEntry(entry, qualifiedName, rawArgs); } _ModelContext? _readModelContext() => @@ -184,9 +191,28 @@ _ModelContext? _readModelContextFromGlobalObject(final String name) { Future _invokeEntry( final AgentCallEntry entry, + final String qualifiedName, final JSAny? rawArgs, ) async { final args = _decodeArgs(rawArgs); + final envelope = IntentCallInvocationEnvelope( + id: 'webmcp-${DateTime.now().microsecondsSinceEpoch}', + qualifiedName: qualifiedName, + arguments: args, + source: IntentCallInvocationSource.webMcpDart, + ); + final policy = + _entryPoliciesByQualifiedName[qualifiedName] ?? + const IntentCallAuthorizationPolicy.denyAll(); + if (!await policy.allows(envelope)) { + return _encodeResult( + AgentResult.failure( + code: 'invocation_denied', + message: 'Invocation denied for $qualifiedName.', + details: {'source': envelope.source}, + ), + ).jsify(); + } final result = await entry.invokeDirect(args); return _encodeResult(result).jsify(); } diff --git a/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart b/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart index 3e0bb46..b610fe0 100644 --- a/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart +++ b/packages/intentcall_platform/lib/src/invocation/intentcall_invocation.dart @@ -56,31 +56,58 @@ final class IntentCallInvocationEnvelope { final class IntentCallAuthorizationPolicy { const IntentCallAuthorizationPolicy({ - this.allowedSources, - this.allowedQualifiedNames, + this.allowedSources = const {}, + this.allowedQualifiedNames = const {}, this.confirm, - }); + }) : allowAnySource = false, + allowAnyQualifiedName = false, + debugOnly = false; const IntentCallAuthorizationPolicy.allowAll() - : allowedSources = null, - allowedQualifiedNames = null, - confirm = null; + : allowedSources = const {}, + allowedQualifiedNames = const {}, + confirm = null, + allowAnySource = true, + allowAnyQualifiedName = true, + debugOnly = false; + + /// Allows all invocations only while Dart assertions are enabled. + /// + /// This is intended for local development and dogfood apps. In compiled + /// profile/release builds, where assertions are disabled, it behaves like + /// [IntentCallAuthorizationPolicy.denyAll]. + const IntentCallAuthorizationPolicy.debugAllowAll() + : allowedSources = const {}, + allowedQualifiedNames = const {}, + confirm = null, + allowAnySource = true, + allowAnyQualifiedName = true, + debugOnly = true; const IntentCallAuthorizationPolicy.denyAll() : allowedSources = const {}, allowedQualifiedNames = const {}, - confirm = null; + confirm = null, + allowAnySource = false, + allowAnyQualifiedName = false, + debugOnly = false; - final Set? allowedSources; - final Set? allowedQualifiedNames; + final Set allowedSources; + final Set allowedQualifiedNames; final IntentCallConfirmation? confirm; + final bool allowAnySource; + final bool allowAnyQualifiedName; + final bool debugOnly; Future allows(final IntentCallInvocationEnvelope envelope) async { + if (debugOnly && !_debugAssertionsEnabled) { + return false; + } final sourceAllowed = - allowedSources == null || allowedSources!.contains(envelope.source); + allowAnySource || allowedSources.contains(envelope.source); final nameAllowed = - allowedQualifiedNames == null || - allowedQualifiedNames!.contains(envelope.qualifiedName); + allowAnyQualifiedName || + allowedQualifiedNames.contains(envelope.qualifiedName); if (!sourceAllowed || !nameAllowed) { return false; } @@ -89,6 +116,15 @@ final class IntentCallAuthorizationPolicy { } } +bool get _debugAssertionsEnabled { + var enabled = false; + assert(() { + enabled = true; + return true; + }(), 'Detect whether Dart assertions are enabled.'); + return enabled; +} + final class IntentCallNativeBridge { IntentCallNativeBridge._({required this.registry, required this.policy}); diff --git a/packages/intentcall_platform/lib/src/sync/platform_sync.dart b/packages/intentcall_platform/lib/src/sync/platform_sync.dart index 1b1f35b..a7fbbaa 100644 --- a/packages/intentcall_platform/lib/src/sync/platform_sync.dart +++ b/packages/intentcall_platform/lib/src/sync/platform_sync.dart @@ -25,6 +25,8 @@ const kPlatformSyncTargets = { final class PlatformSyncResult { const PlatformSyncResult({ required this.manifestPath, + this.dryRun = false, + this.artifacts = const [], this.webManifestPath, this.webMcpJsPath, this.androidShortcutsPath, @@ -48,6 +50,8 @@ final class PlatformSyncResult { }); final String manifestPath; + final bool dryRun; + final List artifacts; final String? webManifestPath; final String? webMcpJsPath; final String? androidShortcutsPath; @@ -68,6 +72,27 @@ final class PlatformSyncResult { final bool wroteLinuxDesktop; final bool wroteWindowsProtocol; final bool wroteWindowsMsixFragment; + + bool get changed => artifacts.any((final artifact) => artifact.changed); +} + +/// One generated or maintained platform artifact touched by [PlatformSync]. +final class PlatformSyncArtifact { + const PlatformSyncArtifact({ + required this.target, + required this.kind, + required this.path, + required this.changed, + this.operation = 'write', + }); + + final String target; + final String kind; + final String path; + final bool changed; + + /// Stable operation label, for example `write` or `target-membership`. + final String operation; } /// Writes platform artifacts from [agent_manifest.json]. @@ -145,6 +170,7 @@ final class PlatformSync { var result = PlatformSyncResult( manifestPath: _resolveManifestFile(projectRoot).path, + dryRun: dryRun, ); for (final platform in normalized) { result = _mergeResults(result, switch (platform) { @@ -181,15 +207,19 @@ final class PlatformSync { ); final nextJs = webMcpJsEmitter.emit(manifest); final jsFile = File(p.join(webDir.path, webMcpJsFileName)); + final manifestChanged = + webManifestFile.readAsStringSync() != '$nextManifest\n'; + final jsChanged = + !jsFile.existsSync() || jsFile.readAsStringSync() != nextJs; var wroteManifest = false; var wroteJs = false; if (!dryRun) { - if (webManifestFile.readAsStringSync() != '$nextManifest\n') { + if (manifestChanged) { webManifestFile.writeAsStringSync('$nextManifest\n'); wroteManifest = true; } - if (!jsFile.existsSync() || jsFile.readAsStringSync() != nextJs) { + if (jsChanged) { jsFile.writeAsStringSync(nextJs); wroteJs = true; } @@ -197,6 +227,21 @@ final class PlatformSync { return PlatformSyncResult( manifestPath: _resolveManifestFile(projectRoot).path, + dryRun: dryRun, + artifacts: [ + PlatformSyncArtifact( + target: 'web', + kind: 'web-manifest', + path: webManifestFile.path, + changed: manifestChanged, + ), + PlatformSyncArtifact( + target: 'web', + kind: 'webmcp-js', + path: jsFile.path, + changed: jsChanged, + ), + ], webManifestPath: webManifestFile.path, webMcpJsPath: jsFile.path, wroteManifest: wroteManifest, @@ -211,16 +256,26 @@ final class PlatformSync { final manifest = readManifest(projectRoot); final outFile = _androidShortcutsFile(projectRoot); final next = '${androidShortcutsEmitter.emit(manifest)}\n'; + final changed = !outFile.existsSync() || outFile.readAsStringSync() != next; var wrote = false; if (!dryRun) { outFile.parent.createSync(recursive: true); - if (!outFile.existsSync() || outFile.readAsStringSync() != next) { + if (changed) { outFile.writeAsStringSync(next); wrote = true; } } return PlatformSyncResult( manifestPath: _resolveManifestFile(projectRoot).path, + dryRun: dryRun, + artifacts: [ + PlatformSyncArtifact( + target: 'android', + kind: 'shortcuts-xml', + path: outFile.path, + changed: changed, + ), + ], androidShortcutsPath: outFile.path, wroteAndroidShortcuts: wrote, ); @@ -257,15 +312,25 @@ final class PlatformSync { } final outFile = File(p.join(linuxDir.path, linuxDesktopFileName)); final next = linuxDesktopEmitter.emit(manifest); + final changed = !outFile.existsSync() || outFile.readAsStringSync() != next; var wrote = false; if (!dryRun) { - if (!outFile.existsSync() || outFile.readAsStringSync() != next) { + if (changed) { outFile.writeAsStringSync(next); wrote = true; } } return PlatformSyncResult( manifestPath: _resolveManifestFile(projectRoot).path, + dryRun: dryRun, + artifacts: [ + PlatformSyncArtifact( + target: 'linux', + kind: 'desktop-entry', + path: outFile.path, + changed: changed, + ), + ], linuxDesktopPath: outFile.path, wroteLinuxDesktop: wrote, ); @@ -284,20 +349,39 @@ final class PlatformSync { final msixFile = File(p.join(windowsDir.path, windowsMsixFragmentFileName)); final nextReg = windowsProtocolEmitter.emit(manifest); final nextMsix = windowsProtocolEmitter.emitMsixFragment(manifest); + final regChanged = + !regFile.existsSync() || regFile.readAsStringSync() != nextReg; + final msixChanged = + !msixFile.existsSync() || msixFile.readAsStringSync() != nextMsix; var wroteReg = false; var wroteMsix = false; if (!dryRun) { - if (!regFile.existsSync() || regFile.readAsStringSync() != nextReg) { + if (regChanged) { regFile.writeAsStringSync(nextReg); wroteReg = true; } - if (!msixFile.existsSync() || msixFile.readAsStringSync() != nextMsix) { + if (msixChanged) { msixFile.writeAsStringSync(nextMsix); wroteMsix = true; } } return PlatformSyncResult( manifestPath: _resolveManifestFile(projectRoot).path, + dryRun: dryRun, + artifacts: [ + PlatformSyncArtifact( + target: 'windows', + kind: 'protocol-registry', + path: regFile.path, + changed: regChanged, + ), + PlatformSyncArtifact( + target: 'windows', + kind: 'protocol-msix-fragment', + path: msixFile.path, + changed: msixChanged, + ), + ], windowsProtocolPath: regFile.path, windowsMsixFragmentPath: msixFile.path, wroteWindowsProtocol: wroteReg, @@ -415,6 +499,22 @@ final class PlatformSync { : xcodePreview; return PlatformSyncResult( manifestPath: _resolveManifestFile(projectRoot).path, + dryRun: dryRun, + artifacts: [ + PlatformSyncArtifact( + target: isMacos ? 'macos' : 'ios', + kind: 'apple-generated-swift', + path: outFile.path, + changed: generatedChanged, + ), + PlatformSyncArtifact( + target: isMacos ? 'macos' : 'ios', + kind: 'xcode-project', + path: xcodeResult.projectPath, + changed: xcodeResult.changed, + operation: 'target-membership', + ), + ], iosGeneratedSwiftPath: isMacos ? null : outFile.path, macosGeneratedSwiftPath: isMacos ? outFile.path : null, iosXcodeProjectPath: isMacos ? null : xcodeResult.projectPath, @@ -484,6 +584,8 @@ final class PlatformSync { final PlatformSyncResult right, ) => PlatformSyncResult( manifestPath: left.manifestPath, + dryRun: left.dryRun || right.dryRun, + artifacts: [...left.artifacts, ...right.artifacts], webManifestPath: right.webManifestPath ?? left.webManifestPath, webMcpJsPath: right.webMcpJsPath ?? left.webMcpJsPath, androidShortcutsPath: diff --git a/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart b/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart index ced6517..09d31ff 100644 --- a/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart +++ b/packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart @@ -8,6 +8,13 @@ void main() { () => registerAgentWebMcpFromEntries({}), returnsNormally, ); + expect( + () => registerAgentWebMcpFromEntries( + {}, + policy: const IntentCallAuthorizationPolicy.denyAll(), + ), + returnsNormally, + ); }); test('registerAgentWebMcpFromRegistry is safe on VM', () { @@ -15,5 +22,15 @@ void main() { () => registerAgentWebMcpFromRegistry(InMemoryAgentRegistry()), returnsNormally, ); + expect( + () => registerAgentWebMcpFromRegistry( + InMemoryAgentRegistry(), + policy: const IntentCallAuthorizationPolicy( + allowedSources: {IntentCallInvocationSource.webMcpDart}, + allowedQualifiedNames: {'app_echo'}, + ), + ), + returnsNormally, + ); }); } diff --git a/packages/intentcall_platform/test/intentcall_invocation_test.dart b/packages/intentcall_platform/test/intentcall_invocation_test.dart index 8c72e49..7e8122a 100644 --- a/packages/intentcall_platform/test/intentcall_invocation_test.dart +++ b/packages/intentcall_platform/test/intentcall_invocation_test.dart @@ -21,16 +21,14 @@ void main() { }); test('IntentCallNativeBridge denies invocations by default', () async { - final bridge = IntentCallNativeBridge.bindRegistry( - registry: InMemoryAgentRegistry(), - ); + final bridge = IntentCallNativeBridge.bindRegistry(registry: _registry()); final result = await bridge.execute( IntentCallInvocationEnvelope( - id: 'deep-link-1', + id: 'native-1', qualifiedName: 'app_echo', - arguments: const {}, - source: IntentCallInvocationSource.deepLink, + arguments: const {'text': 'hello'}, + source: IntentCallInvocationSource.nativeGenerated, ), ); @@ -38,33 +36,36 @@ void main() { expect(result.code, 'invocation_denied'); }); - test('IntentCallNativeBridge executes allowed registry invocation', () async { - final registry = InMemoryAgentRegistry() - ..register( - RegisteredAgentIntent( - descriptor: AgentIntentDescriptor( - namespace: 'app', - name: 'echo', - description: 'echo', - kind: AgentIntentKind.tool, - inputSchema: const { - 'type': 'object', - 'required': ['text'], - 'properties': { - 'text': {'type': 'string'}, - }, - }, - ), - execute: (final invocation) async => AgentResult.success( - data: { - 'text': invocation.arguments['text'], - 'correlationId': invocation.correlationId, - }, + test('IntentCallAuthorizationPolicy constructor denies by default', () async { + final allowed = await const IntentCallAuthorizationPolicy().allows( + IntentCallInvocationEnvelope( + id: 'webmcp-1', + qualifiedName: 'app_echo', + arguments: const {'text': 'hello'}, + source: IntentCallInvocationSource.webMcpDart, + ), + ); + + expect(allowed, isFalse); + }); + + test('debugAllowAll allows while assertions are enabled', () async { + final allowed = await const IntentCallAuthorizationPolicy.debugAllowAll() + .allows( + IntentCallInvocationEnvelope( + id: 'webmcp-1', + qualifiedName: 'app_echo', + arguments: const {'text': 'hello'}, + source: IntentCallInvocationSource.webMcpDart, ), - ), - ); + ); + + expect(allowed, isTrue); + }); + + test('IntentCallNativeBridge executes allowed registry invocation', () async { final bridge = IntentCallNativeBridge.bindRegistry( - registry: registry, + registry: _registry(), policy: const IntentCallAuthorizationPolicy( allowedSources: {IntentCallInvocationSource.webMcpDart}, allowedQualifiedNames: {'app_echo'}, @@ -84,4 +85,115 @@ void main() { expect(result.data['text'], 'hello'); expect(result.data['correlationId'], 'webmcp-1'); }); + + test('IntentCallNativeBridge rejects source mismatch', () async { + final bridge = IntentCallNativeBridge.bindRegistry( + registry: _registry(), + policy: const IntentCallAuthorizationPolicy( + allowedSources: {IntentCallInvocationSource.webMcpDart}, + allowedQualifiedNames: {'app_echo'}, + ), + ); + + final result = await bridge.execute( + IntentCallInvocationEnvelope( + id: 'native-1', + qualifiedName: 'app_echo', + arguments: const {'text': 'hello'}, + source: IntentCallInvocationSource.nativeGenerated, + ), + ); + + expect(result.ok, isFalse); + expect(result.code, 'invocation_denied'); + }); + + test('IntentCallNativeBridge rejects qualified-name mismatch', () async { + final bridge = IntentCallNativeBridge.bindRegistry( + registry: _registry(), + policy: const IntentCallAuthorizationPolicy( + allowedSources: {IntentCallInvocationSource.webMcpDart}, + allowedQualifiedNames: {'app_other'}, + ), + ); + + final result = await bridge.execute( + IntentCallInvocationEnvelope( + id: 'webmcp-1', + qualifiedName: 'app_echo', + arguments: const {'text': 'hello'}, + source: IntentCallInvocationSource.webMcpDart, + ), + ); + + expect(result.ok, isFalse); + expect(result.code, 'invocation_denied'); + }); + + test('IntentCallNativeBridge respects confirmation callback', () async { + final denied = IntentCallNativeBridge.bindRegistry( + registry: _registry(), + policy: IntentCallAuthorizationPolicy( + allowedSources: const {IntentCallInvocationSource.webMcpDart}, + allowedQualifiedNames: const {'app_echo'}, + confirm: (final envelope) => Future.value(false), + ), + ); + final allowed = IntentCallNativeBridge.bindRegistry( + registry: _registry(), + policy: IntentCallAuthorizationPolicy( + allowedSources: const {IntentCallInvocationSource.webMcpDart}, + allowedQualifiedNames: const {'app_echo'}, + confirm: (final envelope) => + Future.value(envelope.arguments['text'] == 'ok'), + ), + ); + + final deniedResult = await denied.execute( + IntentCallInvocationEnvelope( + id: 'webmcp-1', + qualifiedName: 'app_echo', + arguments: const {'text': 'ok'}, + source: IntentCallInvocationSource.webMcpDart, + ), + ); + final allowedResult = await allowed.execute( + IntentCallInvocationEnvelope( + id: 'webmcp-2', + qualifiedName: 'app_echo', + arguments: const {'text': 'ok'}, + source: IntentCallInvocationSource.webMcpDart, + ), + ); + + expect(deniedResult.ok, isFalse); + expect(deniedResult.code, 'invocation_denied'); + expect(allowedResult.ok, isTrue); + expect(allowedResult.data['text'], 'ok'); + }); } + +InMemoryAgentRegistry _registry() => InMemoryAgentRegistry() + ..register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'app', + name: 'echo', + description: 'echo', + kind: AgentIntentKind.tool, + inputSchema: const { + 'type': 'object', + 'required': ['text'], + 'properties': { + 'text': {'type': 'string'}, + }, + }, + ), + execute: (final invocation) async => AgentResult.success( + data: { + 'text': invocation.arguments['text'], + 'correlationId': invocation.correlationId, + }, + ), + ), + ); diff --git a/packages/intentcall_platform/test/native_platform_sync_test.dart b/packages/intentcall_platform/test/native_platform_sync_test.dart index 4c21128..fc8b8dc 100644 --- a/packages/intentcall_platform/test/native_platform_sync_test.dart +++ b/packages/intentcall_platform/test/native_platform_sync_test.dart @@ -52,6 +52,21 @@ void main() { expect(result.wroteMacosXcodeProject, isTrue); expect(result.wroteLinuxDesktop, isTrue); expect(result.wroteWindowsProtocol, isTrue); + expect(result.changed, isTrue); + expect(result.artifacts.map((final artifact) => artifact.target), [ + 'android', + 'ios', + 'ios', + 'macos', + 'macos', + 'linux', + 'windows', + 'windows', + ]); + expect( + result.artifacts.map((final artifact) => artifact.operation), + contains('target-membership'), + ); expect( sync.checkPlatforms(temp.path, [ @@ -106,6 +121,8 @@ void main() { const sync = PlatformSync(); final result = sync.syncIos(projectRoot: temp.path, dryRun: true); + expect(result.dryRun, isTrue); + expect(result.changed, isTrue); expect(result.wroteIosGenerated, isTrue); expect(result.wroteIosXcodeProject, isTrue); expect( diff --git a/packages/intentcall_platform/test/platform_sync_test.dart b/packages/intentcall_platform/test/platform_sync_test.dart index ea19f1f..7592655 100644 --- a/packages/intentcall_platform/test/platform_sync_test.dart +++ b/packages/intentcall_platform/test/platform_sync_test.dart @@ -6,7 +6,9 @@ import 'package:test/test.dart'; void main() { test('PlatformSync.syncWeb writes manifest and js artifacts', () { - final temp = Directory.systemTemp.createTempSync('intentcall_platform_sync_'); + final temp = Directory.systemTemp.createTempSync( + 'intentcall_platform_sync_', + ); addTearDown(() => temp.deleteSync(recursive: true)); final webDir = Directory(p.join(temp.path, 'web'))..createSync(); @@ -37,6 +39,12 @@ void main() { final result = sync.syncWeb(projectRoot: temp.path); expect(result.wroteManifest, isTrue); expect(result.wroteWebMcpJs, isTrue); + expect(result.changed, isTrue); + expect(result.artifacts, hasLength(2)); + expect( + result.artifacts.map((final artifact) => artifact.kind), + containsAll(['web-manifest', 'webmcp-js']), + ); expect(File(result.webMcpJsPath!).existsSync(), isTrue); expect( File(result.webManifestPath!).readAsStringSync(), diff --git a/packages/intentcall_platform/test/web_emitters_test.dart b/packages/intentcall_platform/test/web_emitters_test.dart index 27f93e4..6df3944 100644 --- a/packages/intentcall_platform/test/web_emitters_test.dart +++ b/packages/intentcall_platform/test/web_emitters_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:intentcall_platform/intentcall_platform.dart'; import 'package:test/test.dart'; @@ -127,6 +128,47 @@ void main() { expect(js, contains('return fetchInvoke(tool.name, args);')); }); + test('default runtime unavailable does not call fetch', () async { + final js = const WebMcpJsEmitter().emit(_fixtureAgentManifest); + + final result = await _runWebMcpJs(js); + + expect(result['fetchCalled'], isFalse); + expect(result['toolCount'], 1); + expect(result['result'], isA()); + expect((result['result']! as Map)['ok'], isFalse); + expect((result['result']! as Map)['code'], 'runtime_unavailable'); + }); + + test( + 'default runtime unavailable does not fetch after null Dart hook', + () async { + final js = const WebMcpJsEmitter().emit(_fixtureAgentManifest); + + final result = await _runWebMcpJs( + js, + dartHook: 'async function () { return null; }', + ); + + expect(result['fetchCalled'], isFalse); + expect((result['result']! as Map)['code'], 'runtime_unavailable'); + }, + ); + + test('opt-in runtime fallback calls configured fetch path', () async { + final js = const WebMcpJsEmitter( + fallbackPolicy: WebMcpFallbackPolicy.enabled( + invokePath: '/secure-agent/invoke', + ), + ).emit(_fixtureAgentManifest); + + final result = await _runWebMcpJs(js); + + expect(result['fetchCalled'], isTrue); + expect(result['fetchUrl'], '/secure-agent/invoke?name=app_cart_total'); + expect((result['result']! as Map)['via'], 'fetch'); + }); + test('skips non-tool intents', () { final manifest = AgentManifest.fromJson({ 'version': 1, @@ -172,6 +214,55 @@ void main() { }); } +Future> _runWebMcpJs( + final String js, { + final String? dartHook, +}) async { + final script = + ''' +Object.defineProperty(globalThis, 'document', { + value: { + modelContext: { + registerTool(tool) { + globalThis.__registered.push(tool); + } + } + }, + configurable: true +}); +Object.defineProperty(globalThis, 'navigator', { + value: {}, + configurable: true +}); +globalThis.__registered = []; +globalThis.__fetchCalled = false; +globalThis.__fetchUrl = null; +globalThis.fetch = async function(url) { + globalThis.__fetchCalled = true; + globalThis.__fetchUrl = url; + return { json: async function() { return { ok: true, via: 'fetch' }; } }; +}; +${dartHook == null ? '' : 'globalThis.__intentcallWebMcpDartExecute = $dartHook;'} +$js +const result = await globalThis.__registered[0].execute({}); +console.log(JSON.stringify({ + toolCount: globalThis.__registered.length, + result, + fetchCalled: globalThis.__fetchCalled, + fetchUrl: globalThis.__fetchUrl +})); +'''; + final process = await Process.run('node', [ + '--input-type=module', + '-e', + script, + ]); + if (process.exitCode != 0) { + fail('node failed: ${process.stderr}\n${process.stdout}'); + } + return (jsonDecode(process.stdout as String) as Map).cast(); +} + const _fixtureBaseWebManifest = ''' { "name": "test_app", diff --git a/skills/write-adapter/SKILL.md b/skills/write-adapter/SKILL.md index 1fd473e..bc294a8 100644 --- a/skills/write-adapter/SKILL.md +++ b/skills/write-adapter/SKILL.md @@ -103,7 +103,7 @@ class MyCustomAdapter implements AgentAdapter { 1. **Keep it thin:** The adapter should only map protocol structures to and from the `AgentRegistry`. It should never implement domain logic or custom validations that differ from the core registry validation. 2. **Preserve registry keys:** Use `AgentRegistry.listEntries()` for adapter publication. `listDescriptors()` is compatibility sugar for display-only catalog reads and can lose override-key intent. 3. **Listen to Events:** If `watchesRegistry` is true, ensure you handle both `IntentRegistered` and `IntentUnregistered` events in real-time to support hot-sync environments such as WebMCP. -4. **Gate native/fallback sources:** Fallback invoke paths and native bridge wrappers should use `IntentCallAuthorizationPolicy`; plain deep links are untrusted unless generated wrappers or app allowlists mark the source as trusted. +4. **Gate native/fallback sources:** Fallback invoke paths and native bridge wrappers should use `IntentCallAuthorizationPolicy`; plain deep links are untrusted unless generated wrappers or app allowlists mark the source as trusted. Production registrations should use explicit source/name allowlists or confirmation callbacks. `debugAllowAll()` is for local dogfood only: it opens while assertions are enabled and denies in compiled profile/release builds. 5. **Use Stable Wire Contracts:** Depend on `intentcall_schema` rather than `intentcall_core` for sharing data envelopes (`AgentResult` / `AgentCallEntry`) between packages. --- diff --git a/steward.yaml b/steward.yaml index 94cee27..9f6b7e3 100644 --- a/steward.yaml +++ b/steward.yaml @@ -94,7 +94,7 @@ actions: summary_fields: [exit_code, duration_ms, output_digest] intentcall.adapter-contract-test: kind: command - desc: Run native adapter contract tests for MCP, WebMCP, and Gemma. + desc: Run native adapter and platform bridge contract tests. command: argv: [just, adapter-contract-test] shell: false @@ -106,6 +106,7 @@ actions: - packages/intentcall_mcp/** - packages/intentcall_webmcp/** - packages/intentcall_gemma/** + - packages/intentcall_platform/** - pubspec.yaml - pubspec.lock fs_write: [] diff --git a/steward/scenarios/intentcall.adapter-contract.yaml b/steward/scenarios/intentcall.adapter-contract.yaml index 4750f20..22998a5 100644 --- a/steward/scenarios/intentcall.adapter-contract.yaml +++ b/steward/scenarios/intentcall.adapter-contract.yaml @@ -41,5 +41,20 @@ artifacts: path: packages/intentcall_gemma/test/gemma_adapter_contract_test.dart required: true durability: behavior + - id: platform_invocation_policy_test + kind: dart + path: packages/intentcall_platform/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 + required: true + durability: behavior + - id: platform_webmcp_bootstrap_test + kind: dart + path: packages/intentcall_platform/test/agent_web_mcp_bootstrap_test.dart + required: true + durability: behavior blocked_by: null owner: intentcall From b908e378bc933ad200a2732870b6c8c608f5c470 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Sat, 27 Jun 2026 02:06:36 +0300 Subject: [PATCH 5/6] fix: address release review hardening --- .github/workflows/ci.yml | 8 +- .github/workflows/pub_publish.yml | 6 +- .github/workflows/release-please.yml | 2 +- .../example/demo_ping_tool.dart | 11 ++ .../example/demo_ping_tool.g.dart | 26 ++++ .../src/generators/agent_tool_generator.dart | 10 +- .../test/agent_tool_generator_test.dart | 16 +++ .../Classes/IntentCallPlatformPlugin.swift | 9 +- .../apple_swift_app_intents_emitter.dart | 113 ++++++++++++++++-- .../src/sync/apple_xcode_project_sync.dart | 32 ++++- .../Classes/IntentCallPlatformPlugin.swift | 9 +- .../test/apple_xcode_project_sync_test.dart | 30 +++++ .../test/native_emitters_test.dart | 72 ++++++++++- skills/write-adapter/SKILL.md | 1 - steward.yaml | 4 +- 15 files changed, 319 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b023a39..c14d619 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,20 +15,20 @@ jobs: timeout-minutes: 25 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: "20" - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa with: version: 9.15.4 - name: pnpm install (docs.page CLI) run: pnpm install --frozen-lockfile - - uses: subosito/flutter-action@v2 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 with: channel: stable cache: true diff --git a/.github/workflows/pub_publish.yml b/.github/workflows/pub_publish.yml index 1a59b22..22264db 100644 --- a/.github/workflows/pub_publish.yml +++ b/.github/workflows/pub_publish.yml @@ -27,14 +27,14 @@ jobs: env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: subosito/flutter-action@v2 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 with: channel: stable cache: true - - uses: dart-lang/setup-dart@v1 + - uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260 - name: Workspace dependencies run: dart pub get diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index feb3aab..5e432f6 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Release Please - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5 with: token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} config-file: release-please-config.json diff --git a/packages/intentcall_codegen/example/demo_ping_tool.dart b/packages/intentcall_codegen/example/demo_ping_tool.dart index 427ed3a..7a27a11 100644 --- a/packages/intentcall_codegen/example/demo_ping_tool.dart +++ b/packages/intentcall_codegen/example/demo_ping_tool.dart @@ -28,3 +28,14 @@ Future demoCart( data: {'currency': currency, 'includeTax': includeTax}, ); } + +@AgentTool( + namespace: 'app', + name: 'demo_required_named', + description: 'Returns a required named parameter', +) +Future demoRequiredNamed({ + @AgentParam('Mode') String mode = 'fast', +}) async { + return AgentResult.success(data: {'mode': mode}); +} diff --git a/packages/intentcall_codegen/example/demo_ping_tool.g.dart b/packages/intentcall_codegen/example/demo_ping_tool.g.dart index 8a57379..0d03ec8 100644 --- a/packages/intentcall_codegen/example/demo_ping_tool.g.dart +++ b/packages/intentcall_codegen/example/demo_ping_tool.g.dart @@ -71,3 +71,29 @@ AgentCallEntry get demoCartCallEntry => AgentCallEntry.tool( return await (result as Future); }, ); + +const _demo_required_namedInputSchema = { + 'type': 'object', + 'properties': { + 'mode': {'type': 'string', 'description': 'Mode'}, + }, + 'required': ['mode'], +}; + +RegisteredAgentIntent get demoRequiredNamedRegistration => + demoRequiredNamedCallEntry.toRegistration(); + +AgentCallEntry get demoRequiredNamedCallEntry => AgentCallEntry.tool( + namespace: 'app', + name: 'demo_required_named', + description: 'Returns a required named parameter', + inputSchema: _demo_required_namedInputSchema, + handler: (final args) async { + final result = Function.apply( + demoRequiredNamed, + [], + {#mode: args['mode'] as String}, + ); + return await (result as Future); + }, +); 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 9f1ec5d..2080b27 100644 --- a/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart +++ b/packages/intentcall_codegen/lib/src/generators/agent_tool_generator.dart @@ -93,8 +93,7 @@ $handlerArgs final paramAnnotation = _readAgentParam(param); final description = paramAnnotation?.read('description').stringValue ?? paramName; - final isRequired = - paramAnnotation?.read('required').boolValue ?? param.isRequired; + final isRequired = _isRequiredParameter(param, paramAnnotation); final jsonType = _jsonTypeFor(param.type); if (jsonType == null) { @@ -135,7 +134,7 @@ ${properties.join('\n')} final cast = 'args[${_literalString(name)}] as ${_dartTypeName(param.type)}'; if (param.isNamed) { - if (param.isRequiredNamed) { + if (_isRequiredParameter(param, _readAgentParam(param))) { named.add('#$name: $cast,'); } else { named.add( @@ -167,6 +166,11 @@ ${named.map((final line) => ' $line').join('\n')} return null; } + bool _isRequiredParameter( + final FormalParameterElement param, + final ConstantReader? annotation, + ) => annotation?.read('required').boolValue ?? param.isRequired; + String? _jsonTypeFor(final DartType type) { if (type.isDartCoreInt) { return 'integer'; diff --git a/packages/intentcall_codegen/test/agent_tool_generator_test.dart b/packages/intentcall_codegen/test/agent_tool_generator_test.dart index 4fbb571..82c049f 100644 --- a/packages/intentcall_codegen/test/agent_tool_generator_test.dart +++ b/packages/intentcall_codegen/test/agent_tool_generator_test.dart @@ -65,4 +65,20 @@ void main() { expect(result.data['includeTax'], isTrue); }, ); + + test('generated required named params are emitted unconditionally', () async { + expect(demoRequiredNamedRegistration.descriptor.inputSchema['required'], [ + 'mode', + ]); + + final result = await demoRequiredNamedRegistration.execute( + AgentInvocation( + descriptor: demoRequiredNamedRegistration.descriptor, + arguments: const {'mode': 'slow'}, + ), + ); + + expect(result.ok, isTrue); + expect(result.data['mode'], 'slow'); + }); } diff --git a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift index 261869d..eb73188 100644 --- a/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/ios/Classes/IntentCallPlatformPlugin.swift @@ -19,8 +19,13 @@ extension IntentCallPlatformPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "takePendingInvocations": - let pending = UserDefaults.standard.array(forKey: Self.pendingKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: Self.pendingKey) + let pending: [[String: Any]] + objc_sync_enter(UserDefaults.standard) + do { + defer { objc_sync_exit(UserDefaults.standard) } + pending = UserDefaults.standard.array(forKey: Self.pendingKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: Self.pendingKey) + } result(pending) default: result(FlutterMethodNotImplemented) diff --git a/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart b/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart index a5bd10a..ecb637e 100644 --- a/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart +++ b/packages/intentcall_platform/lib/src/emitters/apple_swift_app_intents_emitter.dart @@ -82,10 +82,7 @@ final class AppleSwiftAppIntentsEmitter { ..writeln( ' static func enqueue(qualifiedName: String, arguments: [String: Any]) async {', ) - ..writeln( - ' var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? []', - ) - ..writeln(' pending.append([') + ..writeln(' let item: [String: Any] = [') ..writeln(' "id": UUID().uuidString,') ..writeln(' "qualifiedName": qualifiedName,') ..writeln(' "arguments": arguments,') @@ -93,7 +90,13 @@ final class AppleSwiftAppIntentsEmitter { ..writeln( ' "createdAt": ISO8601DateFormatter().string(from: Date())', ) - ..writeln(' ])') + ..writeln(' ]') + ..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( ' guard let url = URL(string: "$protocolScheme://invoke/\\(qualifiedName)") else { return }', @@ -143,9 +146,14 @@ List<_SwiftParameter> _swiftParameters(final AgentManifestEntry tool) { if (schema is! Map) { continue; } - final baseType = _swiftTypeFor('${schema['type']}'); + final schemaType = '${schema['type']}'; + final baseType = _swiftTypeFor(schemaType); if (baseType == null) { - continue; + throw UnsupportedError( + 'Apple App Intents support only primitive string/integer/number/boolean ' + 'parameters in ${tool.qualifiedName}; "$name" has unsupported type ' + '"$schemaType".', + ); } final isRequired = required.contains(name); out.add( @@ -197,7 +205,96 @@ String _swiftIdentifier(final String name) { .skip(1) .map((final part) => '${part[0].toUpperCase()}${part.substring(1)}'); final candidate = first + rest.join(); - return RegExp('^[A-Za-z_]').hasMatch(candidate) + final identifier = RegExp('^[A-Za-z_]').hasMatch(candidate) ? candidate : 'value$candidate'; + return _swiftReservedWords.contains(identifier) + ? '`$identifier`' + : identifier; } + +const _swiftReservedWords = { + 'Any', + 'Protocol', + 'Self', + 'Type', + 'actor', + 'as', + 'associatedtype', + 'associativity', + 'async', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'continue', + 'convenience', + 'default', + 'defer', + 'deinit', + 'didSet', + 'do', + 'dynamic', + 'else', + 'enum', + 'extension', + 'fallthrough', + 'false', + 'fileprivate', + 'final', + 'for', + 'func', + 'get', + 'guard', + 'if', + 'import', + 'in', + 'indirect', + 'infix', + 'init', + 'inout', + 'internal', + 'is', + 'isolated', + 'lazy', + 'left', + 'let', + 'mutating', + 'nil', + 'none', + 'nonisolated', + 'open', + 'operator', + 'optional', + 'override', + 'postfix', + 'precedence', + 'prefix', + 'private', + 'protocol', + 'public', + 'repeat', + 'required', + 'rethrows', + 'return', + 'right', + 'self', + 'set', + 'some', + 'static', + 'struct', + 'subscript', + 'super', + 'switch', + 'throw', + 'throws', + 'true', + 'try', + 'unowned', + 'var', + 'weak', + 'where', + 'while', + 'willSet', +}; diff --git a/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart b/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart index dbb0fb6..904156d 100644 --- a/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart +++ b/packages/intentcall_platform/lib/src/sync/apple_xcode_project_sync.dart @@ -94,13 +94,41 @@ final class AppleXcodeProjectSync { generatedFileName, ...staleGeneratedFileNames, }; - final escaped = generatedNames.map(RegExp.escape).join('|'); return content .split('\n') - .where((final line) => !RegExp(escaped).hasMatch(line)) + .where((final line) => !_isGeneratedReferenceLine(line, generatedNames)) .join('\n'); } + bool _isGeneratedReferenceLine(final String line, final Set names) { + for (final name in names) { + final escaped = RegExp.escape(name); + final exactComment = RegExp( + '/\\* $escaped(?: in Sources)? \\*/', + ).hasMatch(line); + final exactPath = RegExp( + 'path = (?:Generated/)?$escaped;', + ).hasMatch(line); + if (exactPath && exactComment) { + return true; + } + final exactListEntry = RegExp( + '^\\s*[0-9A-F]{24} /\\* $escaped(?: in Sources)? \\*/,\\s*\$', + ).hasMatch(line); + if (exactListEntry) { + return true; + } + final buildFile = + line.contains('isa = PBXBuildFile;') && + line.contains('/* $name in Sources */') && + line.contains('/* $name */'); + if (buildFile) { + return true; + } + } + return false; + } + String _insertIntoSection( final String content, final String sectionName, diff --git a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift index 73c8d66..eeb1d77 100644 --- a/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift +++ b/packages/intentcall_platform/macos/Classes/IntentCallPlatformPlugin.swift @@ -19,8 +19,13 @@ extension IntentCallPlatformPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "takePendingInvocations": - let pending = UserDefaults.standard.array(forKey: Self.pendingKey) as? [[String: Any]] ?? [] - UserDefaults.standard.set([], forKey: Self.pendingKey) + let pending: [[String: Any]] + objc_sync_enter(UserDefaults.standard) + do { + defer { objc_sync_exit(UserDefaults.standard) } + pending = UserDefaults.standard.array(forKey: Self.pendingKey) as? [[String: Any]] ?? [] + UserDefaults.standard.set([], forKey: Self.pendingKey) + } result(pending) default: result(FlutterMethodNotImplemented) diff --git a/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart b/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart index 5a1cbd2..49ff29b 100644 --- a/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart +++ b/packages/intentcall_platform/test/apple_xcode_project_sync_test.dart @@ -89,6 +89,36 @@ void main() { ); }); + test('does not strip unrelated references containing a short custom name', () { + final temp = Directory.systemTemp.createTempSync('intentcall_xcode_sync_'); + addTearDown(() => temp.deleteSync(recursive: true)); + final projectFile = _writeProject(temp); + var content = projectFile.readAsStringSync(); + content = content.replaceFirst( + '/* End PBXFileReference section */', + '\t\tAAAAAAAAAAAAAAAAAAAAAAAA /* MyA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyA.swift; sourceTree = ""; };\n' + '\t\tBBBBBBBBBBBBBBBBBBBBBBBB /* A.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/A.swift; sourceTree = ""; };\n' + '/* End PBXFileReference section */', + ); + content = content.replaceFirst( + 'files = (\n', + 'files = (\n' + '\t\t\t\tCCCCCCCCCCCCCCCCCCCCCCCC /* MyA.swift in Sources */,\n' + '\t\t\t\tDDDDDDDDDDDDDDDDDDDDDD01 /* A.swift in Sources */,\n', + ); + projectFile.writeAsStringSync(content); + + const AppleXcodeProjectSync( + generatedFileName: 'A.swift', + staleGeneratedFileNames: ['A.swift'], + ).sync(appleRoot: temp.path); + final repaired = projectFile.readAsStringSync(); + + expect(repaired, contains('MyA.swift')); + expect(repaired, isNot(contains('BBBBBBBBBBBBBBBBBBBBBBBB'))); + expect(repaired, isNot(contains('DDDDDDDDDDDDDDDDDDDDDD01'))); + }); + test('dry run reports drift without modifying project', () { final temp = Directory.systemTemp.createTempSync('intentcall_xcode_sync_'); addTearDown(() => temp.deleteSync(recursive: true)); diff --git a/packages/intentcall_platform/test/native_emitters_test.dart b/packages/intentcall_platform/test/native_emitters_test.dart index 6c10f85..b0ad912 100644 --- a/packages/intentcall_platform/test/native_emitters_test.dart +++ b/packages/intentcall_platform/test/native_emitters_test.dart @@ -49,6 +49,7 @@ void main() { expect(swift, contains('IntentCallShortcutsProvider')); expect(swift, contains('IntentCallNativeBridge')); expect(swift, contains('intentcall.pending_invocations')); + expect(swift, contains('objc_sync_enter(UserDefaults.standard)')); expect(swift, contains('intentcall://invoke/')); }); @@ -56,6 +57,68 @@ void main() { final swift = const AppleSwiftAppIntentsEmitter().emit(manifest); expect(swift.trim(), _goldenAppleSwift.trim()); }); + + test('escapes Swift reserved parameter names', () { + final swift = const AppleSwiftAppIntentsEmitter().emit( + AgentManifest.fromJson({ + 'version': 1, + 'platform': 'apple', + 'tools': [ + { + 'qualifiedName': 'app_reserved', + 'namespace': 'app', + 'name': 'reserved', + 'description': 'Reserved', + 'kind': 'tool', + 'inputSchema': { + 'type': 'object', + 'required': ['class'], + 'properties': { + 'class': {'type': 'string'}, + }, + }, + }, + ], + }), + ); + + expect(swift, contains('var `class`: String')); + expect(swift, contains('arguments["class"] = `class`')); + }); + + test('rejects unsupported object and array parameters', () { + final unsupported = AgentManifest.fromJson({ + 'version': 1, + 'platform': 'apple', + 'tools': [ + { + 'qualifiedName': 'app_object', + 'namespace': 'app', + 'name': 'object', + 'description': 'Object', + 'kind': 'tool', + 'inputSchema': { + 'type': 'object', + 'required': ['payload'], + 'properties': { + 'payload': {'type': 'object'}, + }, + }, + }, + ], + }); + + expect( + () => const AppleSwiftAppIntentsEmitter().emit(unsupported), + throwsA( + isA().having( + (final error) => error.message, + 'message', + contains('app_object'), + ), + ), + ); + }); }); group('LinuxDesktopEntryEmitter', () { @@ -141,14 +204,17 @@ enum IntentCallNativeBridge { private static let pendingKey = "intentcall.pending_invocations" static func enqueue(qualifiedName: String, arguments: [String: Any]) async { - var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] - pending.append([ + let item: [String: Any] = [ "id": UUID().uuidString, "qualifiedName": qualifiedName, "arguments": arguments, "source": "native.generated", "createdAt": ISO8601DateFormatter().string(from: Date()) - ]) + ] + 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) guard let url = URL(string: "intentcall://invoke/\(qualifiedName)") else { return } #if canImport(UIKit) diff --git a/skills/write-adapter/SKILL.md b/skills/write-adapter/SKILL.md index bc294a8..a0de75d 100644 --- a/skills/write-adapter/SKILL.md +++ b/skills/write-adapter/SKILL.md @@ -54,7 +54,6 @@ class MyCustomAdapter implements AgentAdapter { AgentRegistryEntry( key: qualifiedName, intent: intent, - descriptor: intent.descriptor, ), ); } diff --git a/steward.yaml b/steward.yaml index 9f6b7e3..4034923 100644 --- a/steward.yaml +++ b/steward.yaml @@ -144,7 +144,9 @@ actions: - package.json - pnpm-lock.yaml - justfile - fs_write: [] + - node_modules/@docs.page/cli/** + fs_write: + - node_modules/** git: false network: false secrets: false From 4b3c143eb3733a42877d6c32563f44882f0beaf6 Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Sat, 27 Jun 2026 02:11:51 +0300 Subject: [PATCH 6/6] docs: fix platform sync check command --- packages/intentcall_platform/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/intentcall_platform/README.md b/packages/intentcall_platform/README.md index e314b22..5afbb99 100644 --- a/packages/intentcall_platform/README.md +++ b/packages/intentcall_platform/README.md @@ -39,9 +39,9 @@ flutter-mcp-toolkit codegen sync \ --project-dir ``` -Use `--check` in CI (`make check-intentcall-integration`). `--check` reports -whether any generated artifact or native project membership would change without -writing files. +Use the same command with `--check` in CI. `--check` reports whether any +generated artifact or native project membership would change without writing +files. ### One-time hooks