generate-ppmplugin-manifest
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-a
/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 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 — 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. - Does not zip the bundle — that's
/assemble-ppmplugin. - Does not verify a declared platform's binary actually exists — it stages the intended
entrypoints;/assemble-ppmpluginreconciles them against the binaries actually staged and gates on any mismatch. - Does not rewrite the committed
./manifest.jsonon the normal path — it reads it. It only writes./manifest.jsonin the fallback case (no committed manifest existed). It never touchessrc/,ios/,android/, PRD, orpackage.jsonbeyond reading them.
Step 1 — Read shared docs + prereq block
- Read
shared/shared-instructions.md,shared/naming-conventions.md, andshared/ppmplugin-format.md. - 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).
- Confirm the working directory is a third-party-control repo: a
package.json(the dev-only, private, plain<kebab>-controlname — NOT a published@powerapps/extension-*scope; this track ships a binary, not an npm package — seerepo-layout.md) and anandroid/and/orios/native module exist. If not, STOP withNEEDS_CONTEXT: not a third-party-control repo (no package.json / native module). - 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.jsonexists 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 itsentrypointsto 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-derivemethodsfrom the Android module source and, if they've drifted from./manifest.json— e.g. a@ReactMethodwas 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.jsonexists — 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.jsonand 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). 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<kebab>-controlname — there is no@powerapps/extension-*scope and nosrc/TS layer in this track); class name resolvable from the native module (android/.../<Pascal>Module.kt/ios/RCT<Pascal>Module.m),./manifest.json, or.extension-state.md. - Android (if
android/present):android/build.gradle; a Kotlin module class extendingReactContextBaseJavaModulewith anoverride fun getName(); aReactPackageclass; ≥1@ReactMethod. - iOS (if
ios/present):ios/RCT<Pascal>Module.hdeclaring<RCTBridgeModule>;.mwith+ (NSString *)moduleName(NOTRCT_EXPORT_MODULE) and ≥1RCT_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 <list> 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-binaryis stable;/build-ios-binaryis 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 declaresentrypoints.ios;/assemble-ppmpluginwill 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):
-
Class name
<Pascal>— from the native module (the Android<Pascal>Module.ktfilename / itsgetName()="<Pascal>Module", or the iOSRCT<Pascal>Module), or the.extension-state.mdIdentity block. There is nosrc/<Pascal>Extension.tsin this track. This is the basis forname,nativeModule,receivers[].name. -
version—package.jsonversion. -
nativeModule— read the Android module'soverride fun getName(): String = "<X>". Use<X>verbatim. -
packageClass— read theReactPackage.ktfile: combine itspackage <...>line with the class name → FQN (e.g.com.powerapps.peninput.PenInputPackage). -
methods— scan the Android module (via the Read/Grep tools, not a shell-specific command — this skill is OS-neutral) for@ReactMethodand collect each annotated function name. Cross-check the count against the operations inARCHITECTURE.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). -
iOS entrypoint fields (only when targeting iOS / Both) — read
ios/RCT<Pascal>Module.hfor the class name (@interface RCT<Pascal>Module : NSObject <RCTBridgeModule>) → that isentrypoints.ios.moduleClass. Readios/RCT<Pascal>Module.mfor+ (NSString *)moduleName { return @"<X>"; }and assert<X>equals thenativeModulefrom step 3 — it's the same bridge symbol on both platforms; a mismatch means the iOS and Android modules disagree. (The.mmust not useRCT_EXPORT_MODULE— that macro's+loadregistration is invisible to the framework'sdlopenflat namespace, so the module never loads on device.)
Then compute, per ppmplugin-format.md §3:
name = kebab(className)— from the class name, not the capability/repo name. Ifkebab(className)differs from the repo's capability kebab (e.g. repopowerapps-pdf-controlbut classPdfViewer→name: pdf-viewer), print a one-line note so the user knows the.ppmpluginfilename 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 = "<name>/<receiver>"(or theReceiverKeyit binds) — and use that<receiver>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 toSnapshotwhile the manifest defaulted toDeviceInfoExtension). Only if no PCF exists, fall back to<Pascal>Extension. Either way, surface the chosen value as a confirmation point: "PCF dispatches to<name>/<receiver>; the manifest will register receiver<receiver>— confirm?" (Audit re-checks this — see/audit-ppmpluginpcf-composite-key-matches-receiver.)
receivers[]IS the runtime dispatch contract (ppmplugin-format §2 — Runtime dispatch contract). The wrap host routes a call by the composite key<name>/<receivers[].name>toNativeModules.<nativeModule>.<method>— there is no TShandleMessageAsync/sendAsynclayer in a native-only bundle. SonativeModulemust equal the module'sgetName(), and every entry inmethodsmust be a real@ReactMethod/RCT_EXPORT_METHODname (the host calls it directly; an absent method =method '<m>' not foundon device). Derivemethodsfrom the module source, never guess.
entrypoints— declare ONLY the chosen target(s):- Android / Both →
entrypoints.android = { dex: "<Pascal>Plugin.dex", packageClass: "<FQN from step 4>" } - iOS / Both →
entrypoints.ios = { framework: "<Pascal>Plugin", moduleClass: "RCT<Pascal>Module" }
- Android / Both →
Step 3 — Validate locally (pre-flight gate)
Run every rule from ppmplugin-format.md §4 against the derived manifest. For each, pass or fail with the exact rule:
namematches^[a-z0-9][a-z0-9-]{0,63}$- each
nativeModulestarts with the canonical prefix ofname— computed precisely as splitnameon-/_, PascalCase each segment, join (ppmplugin-format §3), Ordinal/case-sensitive. (Note thehelloworld→Helloworldsubtlety;kebab(className)must hyphenate at camelCase boundaries so the round-trip holds.) - no
nativeModulestarts with a reserved prefix (case-insensitive list in §4) - no
nativeModuleis a known incompatible exact name — block (Ordinal, case-sensitive) if it matches the locally checked subset in §4 (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'sgetName()(and iOS+moduleName) to a non-reserved form — add aModulesuffix (DeviceInfo→DeviceInfoModule) or a vendor prefix (ContosoDeviceInfo) — and re-derivenativeModuleto match. nativeModulelooks like a generic platform noun → WARNING. IfnativeModulematches^(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 (NativeModulesis a shared namespace across every plugin the wrap host loads). Recommend aModulesuffix or vendor prefix. Surface it; let the user proceed if deliberate.methodsnon-empty; ≤32; each matches^[a-zA-Z_$][a-zA-Z0-9_$]{0,127}$- each
receivers[].namematches 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 — <rule> 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:
- Ensure
ppmplugin/is in.gitignore(append the line if absent). Do not addmanifest.json(the committed root file stays tracked). - Write the staged build copy
ppmplugin/staging/manifest.json— the contract withentrypointstrimmed to the chosen target(s). This is what/assemble-ppmpluginzips. - Author-from-source mode only: also write the committed
./manifest.jsonat 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.jsonuntouched (it's already the source) unless the user accepted a drift-update offer in Step 1.5. - Update the
## ppmplugin (third-party controls)block in.extension-state.md(create it if absent — schema inrepo-layout.md): setTarget,Manifest: staged <ISO timestamp>. 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)
{
"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 ✓.)
microsoft/power-platform-skills · MIT · Revision 8a36dab92613
Be the first to comment
Share what worked or leave a question for the creator.