diff --git a/README.md b/README.md index bedaf63..e1a27cf 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Call Appduct directly from Swift or Kotlin. You get the same tools, deep links, There are two ways to connect an agent. Pick whichever fits how your agent works. -**Over MCP.** Add Appduct to your agent's MCP config — Claude Code, Cursor, or any other MCP client. Your app's tools show up next to the agent's own, and the connection tools explain themselves, so there's nothing else to install: +**Over MCP.** Add Appduct to your agent's MCP config — Claude Code, Cursor, or any other MCP client. Your app's tools aren't registered as MCP tools of their own: the agent reaches them through `appduct_list_tools`, `appduct_describe_tool` and `appduct_call_tool`, so a registry of hundreds costs the client three fixed tool definitions. Those tools and the connection tools explain themselves, so there's nothing else to install: ```json { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 843823c..38514ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -553,8 +553,20 @@ they cannot drift. First match wins: the state directory in use — those are *global* config, and matching them here would apply them one tier above their own) 4. `scheme` in the state directory's `config.json` -5. `/app.json`'s `expo.scheme` (a string, or the first entry of an array — the same - normalization `app.plugin.js` applies; no walk-up) +5. a static-file probe of the project in `` (no walk-up), in this order — `scheme.ts`'s + `discoverStaticProjectScheme` owns the whole step: + a. `/app.json`'s `expo.scheme` (a string, or the first entry of an array — the same + normalization `app.plugin.js` applies) + b. Android: `app/build.gradle(.kts)`'s `appductScheme` manifest placeholder, then + `app/src/main/AndroidManifest.xml`'s first `` in a `VIEW` intent + filter + c. iOS: any `Info.plist` up to two levels below the app root (excluding + `Pods`/`build`/`node_modules`/`DerivedData`) for the first `CFBundleURLSchemes` entry, + then xcodegen's `project.yml` for the same key + + `native-scheme.ts` owns 5b/5c, parses every file statically (no `plutil`, no `xcodebuild`, + no Gradle evaluation), and refuses to guess when two of its probes resolve *different* + schemes — it throws a usage error naming both sources instead. 6. otherwise an error naming every location above The project `.appduct/config.json` carries a second key alongside `scheme` since issue #63: @@ -595,9 +607,11 @@ never guesses one from a discovered value the way it never guesses `scheme` from native probe (§10's discussion of `discoverNativeScheme`). Re-running it is always safe: it keeps the scheme (and any recorded app id) already recorded and -only *notes* a scheme divergence when `app.json` has come to declare a different one — a command +only *notes* a scheme divergence when discovery (`app.json` or a native project file) has come to +declare a different one — a command documented as safe to re-run must not start failing because a scheme was renamed. `--scheme -` needs `--force` to replace a recorded value, `--force` alone re-adopts `app.json`'s; +` needs `--force` to replace a recorded value, `--force` alone re-adopts whatever +discovery currently finds; the same "replacing needs `--force`" rule applies to `--ios-app-id`/`--android-app-id`, which have no discovery tier to re-adopt on `--force` alone. `--force` merges rather than truncating. Note the inverse of the rule above: a project `.appduct/` is committed, so `--state-dir` must never @@ -760,12 +774,13 @@ deviations): the daemon's own reason — surfaced on the unified `stateChange` event's `reason` — rather than retried for the remainder of the grace window. Transport-level closes stay retryable, including `1011 send_failed` and `1001 daemon_shutdown`: the daemon may well - be back before grace expires. The `sessionChange` event itself carries only - `{ sessionId, alias }`, both `null` once the session is gone; it no longer distinguishes - a claim from a resume from a loss (that categorization was JS-tracked state that no - longer exists on this side of the bridge) — a listener that needs the departing session's - id/alias keeps the most recent non-null event, and reads the reason off the paired - `stateChange` event. + be back before grace expires. The `sessionChange` event mirrors native's `onSessionChange` + exactly: `{ type, sessionId, alias, reason? }`, where `type` is `"claimed"` / `"resumed"` / + `"lost"`, `sessionId`/`alias` go `null` once the session is gone, and `reason` is set only + for `"lost"` (`revoked`, `grace_expired`, `closed_by_app`, or a terminal close reason from + the daemon — PROTOCOL.md §7). That mirrors the paired `stateChange` event's `reason`, which + says the same thing from the state machine's perspective rather than the session's; a + listener that needs the departing session's id/alias keeps the most recent non-null event. - Native's own `handleUrl(url)` decodes the v2 bootstrap payload, checks expiry and the private-IP policy (`allowPrivateLanOnly`, read once from the same manifest/plist key `resolveTrustedPins` uses), and decides whether the link outranks a session already held diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2533723..7b62afd 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -392,8 +392,9 @@ not as the mechanism that keeps a destructive tool out of reach of a hostile one need to confirm the log is actually landing on disk (e.g. under a read-only or full filesystem). - **Inclusion defaults to dev builds only — not a compiled-in build-type check.** iOS - restricts CocoaPods linking to the `Debug` configuration; Android swaps in a no-op - `AppductPackage` for `release`. Both are real per-variant decisions, not a + restricts CocoaPods linking to the `Debug` configuration; on Android `AppductPackage` + always compiles, and the `release` variant's compile classpath gets the vendored no-op + core (`android/core-noop`) in place of the real one. Both are real per-variant decisions, not a `debuggable`/`#if DEBUG` gate compiled into every variant, and neither quietly depends on a custom build-type/configuration name being spelled `debug`/`Debug`. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 9cb6978..167db16 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,8 +1,8 @@ # Registering tools How the app side of Appduct publishes tools: the schema forms it accepts, what the -`useAppductTool` hook actually re-registers, the shape MCP requires, and how long a call -may run. The five-minute version lives in the +`useAppductTool` hook actually re-registers, the shape a published schema has to take, and +how long a call may run. The five-minute version lives in the [package README](../packages/react-native/README.md#4-define-tools-in-app-startup-code); keeping a destructive tool out of a build variant is [its own section](./SECURITY.md#gating-a-tool-by-build-variant) of the security model. @@ -168,4 +168,4 @@ useAppductTool( That deadline is enforced end to end: the app aborts the handler's `signal` at it, and it also travels to the daemon as the descriptor's `timeout_ms`, so an agent calling the tool over MCP (or `appduct invoke` with no `--timeout`) gets the same budget instead of a `tool_timeout` at 10 seconds. -The SDK clamps the value to `[1_000, 600_000]` ms before either timer is set, so the handler's abort timer and the daemon's call deadline are always the same number (a value outside that range is clamped with a dev warning). A caller that passes its own timeout (`appduct invoke --timeout`, `app.call(name, args, { timeoutMs })`) can only **shorten** the deadline, never extend it past this one — the app aborts the handler at its own timer regardless, so for a tool that declares nothing, a caller asking for 60 seconds still gets the app's 10-second default. `createAppductClient`'s `defaultToolTimeoutMs` changes only that app-side fallback for tools that declare nothing; it is deliberately not sent to the daemon, so declare `timeoutMs` per tool when the host needs to know. +The SDK clamps the value to `[1_000, 600_000]` ms before either timer is set, so the handler's abort timer and the daemon's call deadline are always the same number (a value outside that range is clamped with a dev warning). A caller that passes its own timeout (`appduct invoke --timeout`, `app.call(name, args, { timeoutMs })`) can only **shorten** the deadline, never extend it past this one — the app aborts the handler at its own timer regardless, so for a tool that declares nothing, a caller asking for 60 seconds still gets the app's 10-second default. That app-side fallback is fixed natively (`AppductClient`'s own `defaultToolTimeoutMs`, `APPDUCT_DEFAULT_TOOL_TIMEOUT_MS`) and JS cannot override it — `createAppductClient`'s options are empty, and the TurboModule spec has no channel for it. Declare `timeoutMs` per tool when a call needs longer than the default. diff --git a/packages/appduct/src/cli/create-cli.ts b/packages/appduct/src/cli/create-cli.ts index 9fbd5c4..9107b4b 100644 --- a/packages/appduct/src/cli/create-cli.ts +++ b/packages/appduct/src/cli/create-cli.ts @@ -21,7 +21,9 @@ export const createCli = () => { .command("init", "Set up the current app directory: write .appduct/config.json and print the MCP snippet.") .option( "--scheme ", - "Deep-link URI scheme to write. Only this flag and /app.json are consulted — not " + + "Deep-link URI scheme to write. Only this flag and static project files in are " + + "consulted (app.json's \"expo.scheme\", then Android app/build.gradle(.kts) and " + + "app/src/main/AndroidManifest.xml, then iOS Info.plist and project.yml) — not " + "APPDUCT_SCHEME, and no walk-up: init decides what to write here, so it never bakes " + "an ambient value into a committed file.", ) @@ -46,7 +48,8 @@ export const createCli = () => { .option("--qr", "Also render the deep link as a terminal QR code.") .option( "--scheme ", - "Deep-link URI scheme (also: APPDUCT_SCHEME; default: app.json's \"expo.scheme\").", + "Deep-link URI scheme (also: APPDUCT_SCHEME; default: .appduct/config.json, then " + + "app.json's \"expo.scheme\", then the Android/iOS project files in ).", ) .option( "--open ", diff --git a/packages/react-native/metro.d.ts b/packages/react-native/metro.d.ts index f6e3bd7..6604a20 100644 --- a/packages/react-native/metro.d.ts +++ b/packages/react-native/metro.d.ts @@ -7,15 +7,19 @@ export interface WithAppductOptions { /** - * Mirrors the `@appduct/react-native` config plugin's `include` option. `true` (default) - * leaves Metro's module resolution untouched. `false` redirects every specifier this package - * exposes as a real JS entry point to `@appduct/react-native/noop`. + * `true` leaves Metro's module resolution untouched; `false` redirects every specifier this + * package exposes as a real JS entry point to `@appduct/react-native/noop`. Omitted, it + * follows `APPDUCT_ENABLED` (via `autolink-env.js`, where unset counts as enabled) — the same + * variable that drives native autolinking, so one pipeline variable strips both surfaces. The + * config plugin's `include` option is gone; an explicit value here is for apps keying the JS + * strip off their own predicate. */ include?: boolean; } /** - * Wraps a Metro config so that, when `options.include` is `false`, imports of + * Wraps a Metro config so that, when Appduct is excluded (`options.include` is `false`, or it is + * omitted and `APPDUCT_ENABLED` is falsy), imports of * `@appduct/react-native` (and any other entry point this package exports) resolve to the * inert `/noop` entry instead. Chains to `config.resolver.resolveRequest` if already set, rather * than replacing it -- call this last, after anything else that sets `resolveRequest`. See diff --git a/playground/README.md b/playground/README.md index e985ec1..d27a3ce 100644 --- a/playground/README.md +++ b/playground/README.md @@ -37,6 +37,8 @@ one in `~/.appduct`. ### 2. Build and run the dev client +From the `playground` directory (`expo` is a dependency of this app, not of the monorepo root): + ```sh pnpm exec expo run:ios # or diff --git a/skills/appduct/SKILL.md b/skills/appduct/SKILL.md index d328589..f6406e1 100644 --- a/skills/appduct/SKILL.md +++ b/skills/appduct/SKILL.md @@ -46,8 +46,10 @@ There is no `--session-id` flag in v2 — use the positional selector instead. ## Establish a session If no session is active yet, mint a bootstrap link. **Run this from the app's root -directory and it needs no configuration at all** — the deep-link scheme is read from -`app.json`'s `expo.scheme`: +directory and it needs no configuration at all** — the deep-link scheme is read from the +project's own files: `app.json`'s `expo.scheme`, or, for an app without one, the Android +`app/build.gradle(.kts)` / `app/src/main/AndroidManifest.xml` and the iOS `Info.plist` / +`project.yml`: ```bash appduct link --json @@ -72,8 +74,10 @@ directory, or the project uses a dynamic `app.config.js` (which is never execute `APPDUCT_SCHEME=myapp` does the same for a whole shell, and `appduct init` records it once in the project (see **Setup** below). Full order: `--scheme` → `APPDUCT_SCHEME` → the nearest `.appduct/config.json` walking up from the working directory → the state -dir's `config.json` → `/app.json`. If none of them has one, the error names every -location it tried. +dir's `config.json` → the static project files in `` (`app.json`, then the Android +`build.gradle`/`AndroidManifest.xml`, then the iOS `Info.plist`/`project.yml`). If none of +them has one, the error names every location it tried. Two native probes that disagree are +an error too, never a guess. From `link`'s JSON output, use: @@ -231,7 +235,8 @@ when a group itself outgrows a screen. Each part of a group uses tool-name chara under `--json` are JSON on stderr, not bare text. - `appduct init`, run once in an app root, records the scheme in `.appduct/config.json` and prints the MCP server entry to paste. Re-running it is - always safe (it keeps the recorded scheme; `--force` re-adopts `app.json`'s), it never + always safe (it keeps the recorded scheme; `--force` re-adopts whatever the project files + currently declare), it never generates keys, and it never touches daemon state. - `appduct keygen` is only for **hardening** (rotating the host key, or provisioning one in CI ahead of a release build) — the daemon auto-generates a key on first start, diff --git a/skills/appduct/references/setup.md b/skills/appduct/references/setup.md index 2f31b08..613060e 100644 --- a/skills/appduct/references/setup.md +++ b/skills/appduct/references/setup.md @@ -3,7 +3,8 @@ Use this file when the task is to add Appduct to a new React Native project. The development path needs **no keys, no pins and no config file**. The daemon generates -its own host key on first start, the deep-link scheme is discovered from `app.json`, and a +its own host key on first start, the deep-link scheme is discovered from the project's own +files (`app.json`, or the Android/iOS project files — see below), and a debug build trusts the pin carried in the link itself. Everything under **Hardening** below is for builds that leave your machine — do not do it as part of a first-time setup. @@ -32,7 +33,7 @@ is for builds that leave your machine — do not do it as part of a first-time s (`docs/SECURITY.md`, "Trust modes"). 7. Optional: use `addAppductListener("error", ...)` to observe bootstrap parse failures, connect failures, or socket errors — one unified channel for all of them. -6. Advanced: use `getAppductState()` / `addAppductListener("stateChange", ...)` for +8. Advanced: use `getAppductState()` / `addAppductListener("stateChange", ...)` for manual connection-state UI. 9. Production builds that shouldn't ship Appduct at all should be built with `APPDUCT_ENABLED=0` rather than gated by a runtime flag. That variable drops the @@ -74,8 +75,16 @@ If the project uses a dynamic `app.config.js` / `app.config.ts`, discovery does 2. Run the normal native dependency installation steps for the project. 3. Configure URL schemes / intent filters so bootstrap links (`{scheme}:///?appduct=…`) open your app. -4. Run `appduct init --scheme ` in the project root — there is no `app.json` - `expo.scheme` to discover, so name the scheme you configured in step 3. Add +4. Run `appduct init` in the project root. With no `app.json` `expo.scheme`, discovery + falls back to static native project files, all relative to the directory you run it in + (never a walk-up): `app/build.gradle(.kts)`'s `appductScheme` manifest placeholder, then + `app/src/main/AndroidManifest.xml`'s first `` in a `VIEW` intent + filter, then any `Info.plist` up to two levels down for the first `CFBundleURLSchemes` + entry, then xcodegen's `project.yml`. From a bare React Native root the iOS plist probe + reaches `ios//Info.plist`, but the Android ones expect an Android project root + (`android/`), so pass `appduct init --scheme ` with the scheme you configured in + step 3 whenever discovery comes up empty. It also refuses to guess when two probes + resolve different schemes — `--scheme` is the answer there too. Add `--android-app-id --ios-app-id ` so device delivery works without an `--app-id` on every call. 5. Add the optional private-LAN-only setting only if the project wants that restriction.