--- name: generate-ppmplugin-manifest description: "Validate, reconcile to the chosen target(s), and stage the `manifest.json` for a `.ppmplugin` bundle. In the normal flow the committed `./manifest.json` already exists (authored by /generate-native-extension), so this stage reads it, runs the plugin's upload-compatibility checks locally as a pre-flight gate (name regex, canonical prefix, known incompatible names, method and identifier shapes), reconciles `entrypoints` down to the platform(s) you ship, and writes the gitignored staged copy `ppmplugin/staging/manifest.json` that /assemble-ppmplugin zips. If no committed manifest exists (a hand-authored module) it falls back to deriving every field from the class name and Android module source, writing both the committed file and the staged copy. No toolchain — pure read, validate, write. Target-aware. Run after the native module exists, before the build skills." allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Skill model: sonnet --- # /generate-ppmplugin-manifest Stages the `manifest.json` that goes inside a `.ppmplugin` bundle — the small descriptor the wrap runtime and upload service read to identify the plugin and route calls into it. In the normal flow the **committed `./manifest.json` already exists** (authored by [`/generate-native-extension`](../generate-native-extension/SKILL.md) next to the code it describes); this stage's job is to **validate it, reconcile its `entrypoints` to the shipped target(s), and write the staged build copy** `ppmplugin/staging/manifest.json`. It is the "get the strings right" gate: it runs instantly, needs no build tools, and catches common upload failures before any Android build. If the repo has no `./manifest.json` (a hand-authored native module that skipped the scaffold), this stage **authors it from source** as a fallback — see Step 2. Read [`shared/ppmplugin-format.md`](../../shared/ppmplugin-format.md) — it is the source of truth for the schema, the derivation table, and the validation rules this skill enforces. **Two manifests, one contract.** `./manifest.json` (repo root, **committed**) is the source-of-truth contract — it declares *every platform the module supports*. `ppmplugin/staging/manifest.json` (gitignored) is the **build copy** this stage produces — same contract, but `entrypoints` trimmed to the platform(s) actually being shipped. The build/assemble skills only ever read the staged copy; the committed root file is what the PCF and humans read. ## What this skill does NOT do - Does not compile anything — no Gradle, no DEX. That's [`/build-android-binary`](../build-android-binary/SKILL.md). - Does not zip the bundle — that's [`/assemble-ppmplugin`](../assemble-ppmplugin/SKILL.md). - Does not verify a declared platform's binary actually exists — it stages the *intended* `entrypoints`; [`/assemble-ppmplugin`](../assemble-ppmplugin/SKILL.md) reconciles them against the binaries actually staged and gates on any mismatch. - Does not rewrite the committed `./manifest.json` on the normal path — it reads it. It only *writes* `./manifest.json` in the fallback case (no committed manifest existed). It never touches `src/`, `ios/`, `android/`, PRD, or `package.json` beyond reading them. --- ## Step 1 — Read shared docs + prereq block 1. Read [`shared/shared-instructions.md`](../../shared/shared-instructions.md), [`shared/naming-conventions.md`](../../shared/naming-conventions.md), and [`shared/ppmplugin-format.md`](../../shared/ppmplugin-format.md). 2. This skill does no installs / auth / network. Print the zero-prereq block (per shared-instructions §9.2): ``` Prereq check — /generate-ppmplugin-manifest: skipped (skill does no installs / auth / network — failures surface at validation). ``` 3. Confirm the working directory is a third-party-control repo: a `package.json` (the **dev-only, private, plain `-control`** name — NOT a published `@powerapps/extension-*` scope; this track ships a binary, not an npm package — see [`repo-layout.md`](../../shared/repo-layout.md)) **and** an `android/` and/or `ios/` native module exist. If not, STOP with `NEEDS_CONTEXT: not a third-party-control repo (no package.json / native module)`. 4. Read `.extension-state.md` — if it carries a `## ppmplugin (third-party controls)` block, note the last **target** choice and last manifest write (the re-run mode below uses them). --- ## Step 1.5 — Locate the source manifest (which mode are we in?) This stage has two modes, decided by whether the committed `./manifest.json` exists: - **Validate-and-stage mode (the normal flow).** `./manifest.json` exists at the repo root (authored by `/generate-native-extension`). This is the source of truth — **do not re-author it**. Read it, validate it (Step 3), reconcile its `entrypoints` to the chosen target (Step 2 → *Target* only), and write the staged copy (Step 4). Step 2's field-derivation is **skipped** — the contract is already authored; you're verifying and staging it, not regenerating it. (Optionally re-derive `methods` from the Android module source and, if they've drifted from `./manifest.json` — e.g. a `@ReactMethod` was added by hand after scaffold — surface the diff and offer to update the committed file; never silently rewrite it.) - **Author-from-source mode (fallback).** No `./manifest.json` exists — a hand-authored native module that skipped the scaffold. Derive every field mechanically from source (Step 2 in full), then write **both** the committed `./manifest.json` and the staged copy (Step 4). **Re-run within a build session.** If the *staged* copy `ppmplugin/staging/manifest.json` already exists from a prior run, don't blindly overwrite — diff the target/entrypoints against the committed source and ask via `AskUserQuestion`: **Update** (re-stage from the current `./manifest.json` + target) / **Keep as-is** (report and stop). Default the target to the prior choice in `.extension-state.md`; don't re-ask an answered question. --- ## Step 2 — Determine target(s); derive fields only in author-from-source mode The structure preflight + target selection below run in **both** modes (you always need to know which platforms are viable and which to ship). The **field-derivation** sub-section (items 1–6 + the compute block) runs **only in author-from-source mode** (Step 1.5) — in the normal validate-and-stage mode the fields already live in `./manifest.json`; read them from there and skip derivation, keeping only the *target* choice. **First, run a structure preflight.** The ppmplugin path expects the canonical PAM-extension layout — the shape `/generate-native-extension` produces (see [`shared/repo-layout.md`](../../shared/repo-layout.md)). A hand-rolled, foreign, or drifted repo may be missing pieces; flag exactly *what*, here and now, rather than failing later with a cryptic Gradle/Xcode error or a silently-wrong manifest. Print a visible ✓/✗ block: - **Common:** `package.json` (dev-only `-control` name — there is **no** `@powerapps/extension-*` scope and **no** `src/` TS layer in this track); class name resolvable from the **native module** (`android/.../Module.kt` / `ios/RCTModule.m`), `./manifest.json`, or `.extension-state.md`. - **Android** (if `android/` present): `android/build.gradle`; a Kotlin module class extending `ReactContextBaseJavaModule` with an `override fun getName()`; a `ReactPackage` class; ≥1 `@ReactMethod`. - **iOS** (if `ios/` present): `ios/RCTModule.h` declaring ``; `.m` with `+ (NSString *)moduleName` (**NOT** `RCT_EXPORT_MODULE`) and ≥1 `RCT_EXPORT_METHOD`. A platform whose structure is **incomplete is not a valid target** — exclude it and say which element is missing. If neither platform is structurally complete, STOP with `NEEDS_CONTEXT: repo doesn't match the expected PAM-extension layout — missing ` pointing at `shared/repo-layout.md`. (Working on a staged copy protects the *source*; it does NOT make a missing module appear — that's what this preflight is for.) **Then determine which platform(s) this bundle targets** (only from the structurally-complete ones) — it controls which `entrypoints` get declared: - Confirm via `AskUserQuestion`: **Android-only** / **iOS-only** / **Both** — offer only the targets that passed the preflight. Default to what's present, or — on a re-run in **Update** mode — to the prior target recorded in `.extension-state.md`. - Note availability: `/build-android-binary` is stable; `/build-ios-binary` is **v0** (Mac-only; **known limitation:** its React-Core weak-link config still needs validation against a live PAM/wrap shell). If the user targets iOS/Both, the manifest declares `entrypoints.ios`; `/assemble-ppmplugin` will still gate if the iOS binary isn't staged at packaging time. **Then derive fields from the actual files, not from assumptions** *(author-from-source mode only — in validate-and-stage mode skip to Step 3 with the fields read from `./manifest.json`)*: 1. **Class name** `` — from the **native module** (the Android `Module.kt` filename / its `getName()` = `"Module"`, or the iOS `RCTModule`), or the `.extension-state.md` Identity block. There is **no** `src/Extension.ts` in this track. This is the basis for `name`, `nativeModule`, `receivers[].name`. 2. **`version`** — `package.json` `version`. 3. **`nativeModule`** — read the Android module's `override fun getName(): String = ""`. Use `` verbatim. 4. **`packageClass`** — read the `ReactPackage` `.kt` file: combine its `package <...>` line with the class name → FQN (e.g. `com.powerapps.peninput.PenInputPackage`). 5. **`methods`** — scan the Android module (via the Read/Grep tools, not a shell-specific command — this skill is OS-neutral) for `@ReactMethod` and collect each annotated function name. Cross-check the count against the operations in `ARCHITECTURE.md §3` / `PRD §4`; if a documented operation has no matching `@ReactMethod`, surface it as a warning (the manifest reflects what the binary actually exposes, but the mismatch usually means an operation wasn't wired). 6. **iOS entrypoint fields** *(only when targeting iOS / Both)* — read `ios/RCTModule.h` for the class name (`@interface RCTModule : NSObject `) → that is `entrypoints.ios.moduleClass`. Read `ios/RCTModule.m` for `+ (NSString *)moduleName { return @""; }` and **assert `` equals the `nativeModule` from step 3** — it's the same bridge symbol on both platforms; a mismatch means the iOS and Android modules disagree. (The `.m` must **not** use `RCT_EXPORT_MODULE` — that macro's `+load` registration is invisible to the framework's `dlopen` flat namespace, so the module never loads on device.) Then compute, per [`ppmplugin-format.md §3`](../../shared/ppmplugin-format.md): - `name = kebab(className)` — **from the class name, not the capability/repo name.** If `kebab(className)` differs from the repo's capability kebab (e.g. repo `powerapps-pdf-control` but class `PdfViewer` → `name: pdf-viewer`), print a one-line note so the user knows the `.ppmplugin` filename won't match the repo name. This is required for the canonical-prefix rule to pass. - **`receivers[].name` — take it from the PCF if one exists, do NOT blindly default.** If a sibling PCF is present (`pcf/<…>/index.ts`), grep it for the dispatch key — `COMPOSITE_KEY = "/"` (or the `ReceiverKey` it binds) — and use **that** `` value, because the PCF already dispatches to it; a manifest that registers a different receiver name will fail on first dispatch (real bug: PCF dispatched to `Snapshot` while the manifest defaulted to `DeviceInfoExtension`). Only if no PCF exists, fall back to `Extension`. **Either way, surface the chosen value as a confirmation point:** *"PCF dispatches to `/`; the manifest will register receiver `` — confirm?"* (Audit re-checks this — see `/audit-ppmplugin` `pcf-composite-key-matches-receiver`.) > **`receivers[]` IS the runtime dispatch contract** ([ppmplugin-format §2](../../shared/ppmplugin-format.md) — *Runtime dispatch contract*). The wrap host routes a call by the composite key `/` to `NativeModules..` — there is no TS `handleMessageAsync` / `sendAsync` layer in a native-only bundle. So `nativeModule` must equal the module's `getName()`, and every entry in `methods` must be a real `@ReactMethod` / `RCT_EXPORT_METHOD` name (the host calls it directly; an absent method = `method '' not found` on device). Derive `methods` from the module source, never guess. - **`entrypoints` — declare ONLY the chosen target(s):** - Android / Both → `entrypoints.android = { dex: "Plugin.dex", packageClass: "" }` - iOS / Both → `entrypoints.ios = { framework: "Plugin", moduleClass: "RCTModule" }` --- ## Step 3 — Validate locally (pre-flight gate) Run **every** rule from [`ppmplugin-format.md §4`](../../shared/ppmplugin-format.md) against the derived manifest. For each, pass or fail with the exact rule: - `name` matches `^[a-z0-9][a-z0-9-]{0,63}$` - each `nativeModule` starts with the **canonical prefix** of `name` — computed precisely as *split `name` on `-`/`_`, PascalCase each segment, join* (ppmplugin-format §3), Ordinal/case-sensitive. (Note the `helloworld` → `Helloworld` subtlety; `kebab(className)` must hyphenate at camelCase boundaries so the round-trip holds.) - no `nativeModule` starts with a reserved prefix (case-insensitive list in §4) - **no `nativeModule` is a known incompatible exact name** — block (Ordinal, case-sensitive) if it matches the locally checked subset in [§4](../../shared/ppmplugin-format.md) (`DeviceInfo`, `AuthenticationHelper`, `NetworkClient`, `DataverseOfflineProvider`, `IntuneMAM`). The subset is **non-exhaustive**: passing it locally does not guarantee the upload service will accept the name. Rename the module's `getName()` (and iOS `+moduleName`) to a non-reserved form — add a `Module` suffix (`DeviceInfo` → `DeviceInfoModule`) or a vendor prefix (`ContosoDeviceInfo`) — and re-derive `nativeModule` to match. - **`nativeModule` looks like a generic platform noun → WARNING.** If `nativeModule` matches `^(Device|Network|File|Audio|Camera|Sensor|Location|Storage|Notification|Bluetooth|Wifi|Media|Photo|Contact|Calendar|Battery)`, warn that generic single-noun names are both upload-conflict-prone and collision-prone (`NativeModules` is a shared namespace across every plugin the wrap host loads). Recommend a `Module` suffix or vendor prefix. Surface it; let the user proceed if deliberate. - `methods` non-empty; ≤32; each matches `^[a-zA-Z_$][a-zA-Z0-9_$]{0,127}$` - each `receivers[].name` matches the same JS-identifier regex - **no SDK-era / JS-layer fields** — the manifest carries none of `entrypoints.js`, `entrypoints.ts`, `extension.js`, `extension.hbc`, `extensionClassName`, `jsLayer` (the bundle is native-only; these are leakage the wrap runtime no longer reads — §4). Since this skill authors the manifest it won't emit them, but the re-run mode reads an existing manifest, so assert it. Print the result as a visible block: ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ manifest.json validation ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🟢 ✓ name 'pen-input' — valid kebab identifier 🟢 ✓ nativeModule 'PenInputModule' starts with canonical prefix 'PenInput' 🟢 ✓ nativeModule 'PenInputModule' — no reserved prefix 🟢 ✓ nativeModule 'PenInputModule' — not a known reserved name 🟢 ✓ methods ['capturePenInput'] — non-empty, valid identifiers 🟢 ✓ receiver name 'PenInputExtension' — valid identifier 🟢 ✓ no SDK-era / JS-layer fields (native-only manifest) 🟢 7 checks passed, 0 failed. ⓘ Known incompatible-name subset checked locally; the upload service may apply additional checks. ``` If any check **fails**, STOP with `BLOCKED: manifest validation — ` and the concrete fix (e.g. "rename the Kotlin module's `getName()` so it starts with `PenInput`"). Do NOT write an invalid manifest. --- ## Step 4 — Confirmation gate + write Show the manifest (the validated source + the target-reconciled `entrypoints`) and wait for confirmation (shared-instructions §7.1). On approval: 1. Ensure `ppmplugin/` is in `.gitignore` (append the line if absent). Do **not** add `manifest.json` (the committed root file stays tracked). 2. **Write the staged build copy** `ppmplugin/staging/manifest.json` — the contract with `entrypoints` trimmed to the chosen target(s). This is what `/assemble-ppmplugin` zips. 3. **Author-from-source mode only:** also write the committed `./manifest.json` at the repo root (the full contract, all viable platforms) — this is the source of truth the scaffold would normally have produced. In validate-and-stage mode, leave `./manifest.json` untouched (it's already the source) unless the user accepted a drift-update offer in Step 1.5. 4. Update the `## ppmplugin (third-party controls)` block in `.extension-state.md` (create it if absent — schema in [`repo-layout.md`](../../shared/repo-layout.md)): set `Target`, `Manifest: staged `. This is what the re-run mode (Step 1.5) and the build/assemble skills read for state. Return `DONE` with the manifest path. Then surface next steps via `AskUserQuestion` (shared-instructions §9.1), **offering the build skill(s) for the chosen target(s)**: - **Run /build-android-binary** — if target is Android or Both (compile the Kotlin module → DEX) - **Run /build-ios-binary** — if target is iOS or Both (compile the Obj-C module → framework; Mac-only) - **Run /assemble-ppmplugin** — only if the binary/binaries already exist - **Stay — I'll review the manifest first** (For a Both target, list both build skills. Cap at 4 options per `AskUserQuestion`.) **When the user picks a `Run /…` option, immediately invoke that skill via the `Skill` tool in the same turn** (sub-skill invocation, shared-instructions §8 + §9.1 "Execute, don't describe"). Do NOT print a "run it when ready" instruction and stop — selecting the option IS the request to run it. Only "Stay" ends the run. --- ## Worked example (pen-input) ```json { "name": "pen-input", "version": "0.1.4", "abi": { "compatibleShells": ">=1.0.0", "builtAgainst": "1.0.0" }, "entrypoints": { "android": { "dex": "PenInputPlugin.dex", "packageClass": "com.powerapps.peninput.PenInputPackage" } }, "receivers": [ { "name": "PenInputExtension", "nativeModule": "PenInputModule", "methods": ["capturePenInput"] } ] } ``` (This example targets **Android-only**, so `entrypoints.ios` is omitted. For a **Both** target it would also carry `"ios": { "framework": "PenInputPlugin", "moduleClass": "RCTPenInputModule" }`. `name` = `pen-input` = kebab of class `PenInput`; `nativeModule` = `PenInputModule` (the `Module` suffix avoids reserved bare names) and starts with canonical prefix `PenInput` ✓; not a reserved prefix ✓.)