关于 generate-ppmplugin-manifest
用于第三方原生控件仓库的技能,验证插件 manifest.json,并按待发布平台生成暂存副本。它区分已提交的契约文件与构建用副本,缺少源文件时也可从原生代码生成。
主要功能
- 01验证 manifest.json 结构与字段
- 02按目标平台调整 entrypoints
- 03写入暂存构建副本
- 04无源清单时从代码生成
适用场景
- 01发布 Android 或 iOS 插件前检查清单
- 02为指定平台准备暂存 manifest.json
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ power-platform-skills --skill generate-ppmplugin-manifest安装后在 Claude Code 中输入/generate-ppmplugin-manifest确认已加载。
详细介绍
/generate-ppmplugin-manifest
暂存放入 .ppmplugin 包中的 manifest.json——这是 wrap 运行时和上传服务读取的小型描述文件,用于识别插件并将调用路由至插件。在常规流程中,已提交的 ./manifest.json 已存在(由 /generate-native-extension 在所描述的代码旁创建);此阶段负责验证该文件、将其 entrypoints 与要发布的目标平台协调一致,并写入暂存构建副本 ppmplugin/staging/manifest.json。这是“确保字符串正确”的关卡:执行迅速,无需构建工具,并可在任何 Android 构建开始前捕获常见的上传失败。如果仓库中没有 ./manifest.json(手工编写的原生模块跳过了脚手架),此阶段会从源代码创建该文件作为回退方案——参见步骤 2。
阅读 shared/ppmplugin-format.md——这是本技能所执行的架构、派生表和验证规则的权威来源。
两个清单,一份契约。 ./manifest.json(仓库根目录,已提交)是事实来源契约——它声明模块支持的所有平台。ppmplugin/staging/manifest.json(被 git 忽略)是此阶段生成的构建副本——契约相同,但 entrypoints 会精简为实际要发布的平台。构建/组装技能只会读取暂存副本;PCF 和人员读取的是已提交的根目录文件。
此技能不会做什么
- 不会编译任何内容——不运行 Gradle,也不生成 DEX。这由
/build-android-binary负责。 - 不会压缩该包——这由
/assemble-ppmplugin负责。 - 不会验证已声明平台的二进制文件是否实际存在——它会暂存预期的
entrypoints;/assemble-ppmplugin会将它们与实际暂存的二进制文件进行协调,并在不匹配时拦截。 - 常规路径下不会重写已提交的
./manifest.json——只会读取它。仅在回退情况下(不存在已提交的清单)才会写入./manifest.json。除读取外,绝不会改动src/、ios/、android/、PRD 或package.json。
步骤 1 — 阅读共享文档和先决条件部分
- 阅读
shared/shared-instructions.md、shared/naming-conventions.md和shared/ppmplugin-format.md。 - 此技能不会执行安装、身份验证或网络操作。打印零先决条件部分(依据 shared-instructions §9.2):
Prereq check — /generate-ppmplugin-manifest: skipped (skill does no installs / auth / network — failures surface at validation).
- 确认当前工作目录是第三方控件仓库:存在
package.json(名称为仅供开发使用、私有的普通<kebab>-control——不是已发布的@powerapps/extension-*作用域;此流程发布的是二进制文件,而非 npm 包——参见repo-layout.md),并且存在android/和/或ios/原生模块。如果不是,则停止并提示NEEDS_CONTEXT: not a third-party-control repo (no package.json / native module)。 - 阅读
.extension-state.md——如果其中包含## ppmplugin (third-party controls)部分,记录上次选择的目标平台和上次写入清单的记录(下文的重新运行模式会用到)。
步骤 1.5 — 定位源清单(我们处于哪种模式?)
此阶段有两种模式,取决于已提交的 ./manifest.json 是否存在:
-
验证并暂存模式(正常流程)。 仓库根目录中存在
./manifest.json(由/generate-native-extension编写)。这是事实来源——不要重新编写它。读取并验证它(步骤 3),将其entrypoints与选定目标协调一致(仅步骤 2 → 目标),并写入暂存副本(步骤 4)。跳过步骤 2 的字段推导——契约已编写完成;你要做的是验证并暂存它,而不是重新生成它。(可以选择从 Android 模块源代码重新推导methods;如果发现它们与./manifest.json不一致——例如在脚手架生成后手动添加了@ReactMethod——则展示差异并提出更新已提交文件;绝不要静默重写。) -
从源代码创建模式(备用)。 不存在
./manifest.json——这是跳过脚手架、手动编写的原生模块。根据源代码机械地推导每个字段(完整执行步骤 2),然后写入已提交的./manifest.json和暂存副本(步骤 4)。
在构建会话中重新运行。 如果上次运行已生成暂存副本 ppmplugin/staging/manifest.json,不要盲目覆盖——比较目标/入口点与已提交源文件的差异,并通过 AskUserQuestion 询问:更新(根据当前 ./manifest.json + 目标重新暂存)/ 保持原样(报告并停止)。将目标默认设为 .extension-state.md 中上次选择的目标;不要重复询问已回答的问题。
步骤 2 — 确定目标;仅在从源代码创建模式下推导字段
以下结构预检和目标选择在两种模式下都会执行(你始终需要了解哪些平台可用,以及要发布哪些平台)。字段推导小节(条目 1–6 + 计算部分)仅在从源代码创建模式下执行(步骤 1.5)——在正常的验证并暂存模式下,字段已保存在 ./manifest.json 中;从中读取字段并跳过推导,只保留目标选择。
首先,运行结构预检。 ppmplugin 路径要求采用规范的 PAM 扩展布局——即 /generate-native-extension 生成的结构(参见 shared/repo-layout.md)。手工创建、外部来源或已偏离规范的仓库可能缺少某些部分;现在就准确指出缺少什么,避免之后出现难以理解的 Gradle/Xcode 错误,或生成内容错误却未被察觉的清单。打印一个清晰可见的 ✓/✗ 区块:
- 通用:
package.json(仅用于开发的<kebab>-control名称——此流程中没有@powerapps/extension-*作用域,也没有src/TS 层);可从原生模块(android/.../<Pascal>Module.kt/ios/RCT<Pascal>Module.m)、./manifest.json或.extension-state.md解析出类名。 - Android(如果存在
android/):android/build.gradle;一个继承ReactContextBaseJavaModule且包含override fun getName()的 Kotlin 模块类;一个ReactPackage类;至少 1 个@ReactMethod。 - iOS(如果存在
ios/):声明<RCTBridgeModule>的ios/RCT<Pascal>Module.h;包含+ (NSString *)moduleName(不是RCT_EXPORT_MODULE)和至少 1 个RCT_EXPORT_METHOD的.m文件。
结构不完整的平台不是有效目标——将其排除,并说明缺少哪个元素。如果两个平台的结构都不完整,则以 NEEDS_CONTEXT: repo doesn't match the expected PAM-extension layout — missing <list> 为内容停止,并指向 shared/repo-layout.md。(在暂存副本上操作可以保护源文件;但它不会补出缺失的模块——这正是进行此预检的原因。)
然后确定此捆绑包面向哪些平台(仅从结构完整的平台中选择)——这将决定声明哪些 entrypoints:
- 通过
AskUserQuestion确认:仅 Android / 仅 iOS / 两者——只提供预检通过的目标平台。默认选择当前已有的平台;如果是在 Update 模式下重新运行,则默认选择.extension-state.md中记录的先前目标平台。 - 注意可用性:
/build-android-binary已稳定;/build-ios-binary仍处于 v0 阶段(仅限 Mac;**已知限制:**其 React-Core 弱链接配置仍需通过实际 PAM/wrap shell 验证)。如果用户选择 iOS/两者,清单将声明entrypoints.ios;如果打包时尚未暂存 iOS 二进制,/assemble-ppmplugin仍会进行拦截。
然后根据实际文件而非假设推导字段(仅限 author-from-source 模式——在 validate-and-stage 模式下,跳到步骤 3,从 ./manifest.json 读取字段):
-
类名
<Pascal>——从原生模块(Android 的<Pascal>Module.kt文件名 / 其getName()="<Pascal>Module",或 iOS 的RCT<Pascal>Module)或.extension-state.md的 Identity 部分获取。此流程中没有src/<Pascal>Extension.ts。这是name、nativeModule、receivers[].name的依据。 -
version——package.json中的version。 -
nativeModule——读取 Android 模块的override fun getName(): String = "<X>"。原样使用<X>。 -
packageClass——读取ReactPackage的.kt文件:将其package <...>行与类名组合起来 → FQN(例如com.powerapps.peninput.PenInputPackage)。 -
methods——使用 Read/Grep 工具(而非特定于 shell 的命令;此技能与操作系统无关)扫描 Android 模块中的@ReactMethod,并收集每个带注解的函数名。将数量与ARCHITECTURE.md §3/PRD §4中记录的操作进行交叉核对;如果某项已记录的操作没有对应的@ReactMethod,请将其作为警告指出(清单反映的是二进制实际公开的内容,但不匹配通常意味着某项操作尚未接入)。 -
iOS 入口点字段(仅当目标平台为 iOS / 两者时)——读取
ios/RCT<Pascal>Module.h获取类名(@interface RCT<Pascal>Module : NSObject <RCTBridgeModule>)→ 这就是entrypoints.ios.moduleClass。读取ios/RCT<Pascal>Module.m中的+ (NSString *)moduleName { return @"<X>"; },并断言<X>与步骤 3 中的nativeModule相同——这是两个平台上的同一个桥接符号;若不匹配,说明 iOS 和 Android 模块存在不一致。(.m不得使用RCT_EXPORT_MODULE——该宏的+load注册对框架的dlopen扁平命名空间不可见,因此模块不会在设备上加载。)
然后根据 ppmplugin-format.md §3 计算:
name = kebab(className)——**根据类名计算,而不是根据功能/仓库名称。**如果kebab(className)与仓库的功能 kebab 不同(例如仓库为powerapps-pdf-control,但类名为PdfViewer→name: pdf-viewer),请打印一行说明,让用户知道.ppmplugin文件名不会与仓库名称一致。这是通过规范前缀规则所必需的。receivers[].name——如果存在 PCF,则从 PCF 获取,不要盲目使用默认值。如果存在同级 PCF(pcf/<…>/index.ts),搜索其中的分发键——COMPOSITE_KEY = "<name>/<receiver>"(或其绑定的ReceiverKey)——并使用该<receiver>值,因为 PCF 已经会向其分发;如果清单注册了不同的接收器名称,首次分发时就会失败(实际问题:PCF 分发到Snapshot,而清单默认使用了DeviceInfoExtension)。只有在不存在 PCF 时,才回退为<Pascal>Extension。无论哪种情况,都要将选定的值作为确认项向用户确认: “PCF 分发到<name>/<receiver>;清单将注册接收器<receiver>——请确认?”(Audit 会重新检查此项——参见/audit-ppmpluginpcf-composite-key-matches-receiver。)
receivers[]是运行时分发契约(ppmplugin-format §2 — 运行时分发契约)。封装宿主通过复合键<name>/<receivers[].name>将调用路由到NativeModules.<nativeModule>.<method>—— 纯原生包中没有 TShandleMessageAsync/sendAsync层。因此,nativeModule必须等于模块的getName(),且methods中的每个条目都必须是真实的@ReactMethod/RCT_EXPORT_METHOD名称(宿主会直接调用该方法;如果方法不存在,设备上会报错method '<m>' not found)。methods必须从模块源码推导,切勿猜测。
entrypoints—— 仅声明选定的目标:- 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 →
步骤 3 —— 在本地验证(预检关卡)
对推导出的清单运行 ppmplugin-format.md §4 中的每条规则。逐条给出通过或失败结果,并注明确切规则:
name符合^[a-z0-9][a-z0-9-]{0,63}$- 每个
nativeModule都以name的规范前缀开头——精确计算方式为:将name按-/_拆分,将每段转换为 PascalCase,再拼接(ppmplugin-format §3),按序号区分大小写。(注意helloworld→Helloworld这一细微之处;kebab(className)必须在 camelCase 边界处插入连字符,才能保证往返转换成立。) - 没有任何
nativeModule以保留前缀开头(§4 中不区分大小写的列表) - 没有任何
nativeModule是已知的不兼容精确名称——若匹配 §4 中本地检查的子集(DeviceInfo、AuthenticationHelper、NetworkClient、DataverseOfflineProvider、IntuneMAM),则阻止(按序号区分大小写)。该子集并不完整:本地通过并不保证上传服务会接受该名称。将模块的getName()(以及 iOS+moduleName)重命名为非保留形式——添加Module后缀(DeviceInfo→DeviceInfoModule)或供应商前缀(ContosoDeviceInfo)——然后重新推导nativeModule以匹配。 nativeModule看起来像通用平台名词 → 警告。 如果nativeModule匹配^(Device|Network|File|Audio|Camera|Sensor|Location|Storage|Notification|Bluetooth|Wifi|Media|Photo|Contact|Calendar|Battery),则警告:通用的单名词名称既容易与上传服务冲突,也容易发生名称碰撞(NativeModules是封装宿主加载的所有插件共用的命名空间)。建议添加Module后缀或供应商前缀。展示警告;若用户是有意为之,则允许继续。methods非空;数量 ≤32;每项符合^[a-zA-Z_$][a-zA-Z0-9_$]{0,127}$- 每个
receivers[].name都符合相同的 JS 标识符正则表达式 - 不得包含 SDK 时代 / JS 层字段——清单中不得包含
entrypoints.js、entrypoints.ts、extension.js、extension.hbc、extensionClassName、jsLayer(该包仅含原生代码;这些字段是泄漏信息,封装运行时已不再读取——§4)。由于此技能负责生成清单,因此不会输出这些字段;但重新运行模式会读取现有清单,所以必须对此进行断言。
将结果打印为可见区块:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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.
如果任何检查失败,则停止,并输出 BLOCKED: manifest validation — <rule> 和具体修复方案(例如,“重命名 Kotlin 模块的 getName(),使其以 PenInput 开头”)。不要写入无效清单。
第 4 步 — 确认门槛 + 写入
显示清单(经过验证的源文件 + 与目标平台协调后的 entrypoints),并等待确认(shared-instructions §7.1)。获批后:
- 确保
.gitignore中包含ppmplugin/(若不存在,则追加该行)。不要添加manifest.json(已提交的根目录文件应保持跟踪状态)。 - 写入暂存构建副本
ppmplugin/staging/manifest.json— 将entrypoints精简为所选目标平台的契约。这是/assemble-ppmplugin打包的内容。 - 仅限从源文件创作模式: 还要在仓库根目录写入已提交的
./manifest.json(完整契约,包含所有可行平台)— 这是脚手架通常会生成的事实来源。在验证并暂存模式下,不要改动./manifest.json(它已是源文件),除非用户在第 1.5 步接受了漂移更新提议。 - 更新
.extension-state.md中的## ppmplugin (third-party controls)区块(若不存在则创建 — 架构见repo-layout.md):设置Target、Manifest: staged <ISO timestamp>。重新运行模式(第 1.5 步)和构建/组装技能会读取此状态。
返回 DONE 及清单路径。然后通过 AskUserQuestion 提供后续步骤(shared-instructions §9.1),为所选目标平台提供构建技能:
- 运行 /build-android-binary — 若目标为 Android 或 Both(编译 Kotlin 模块 → DEX)
- 运行 /build-ios-binary — 若目标为 iOS 或 Both(编译 Obj-C 模块 → framework;仅限 Mac)
- 运行 /assemble-ppmplugin — 仅当二进制文件已存在时
- 暂不操作 — 我先检查清单
(目标为 Both 时,列出这两项构建技能。每个 AskUserQuestion 最多提供 4 个选项。)
用户选择 Run /… 选项时,必须在同一轮中立即通过 Skill 工具调用相应技能(子技能调用,见 shared-instructions §8 + §9.1“执行,而非描述”)。不要打印“准备好后运行”之类的指示后就停止 — 选择该选项即表示请求运行。只有选择“暂不操作”才会结束运行。
操作示例(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"] }
]
}
(此示例仅针对 Android,因此省略 entrypoints.ios。若目标为 Both,还会包含 "ios": { "framework": "PenInputPlugin", "moduleClass": "RCTPenInputModule" }。name = pen-input = 类 PenInput 的 kebab-case 名称;nativeModule = PenInputModule(添加 Module 后缀以避免使用受保留的裸名称),并以规范前缀 PenInput 开头 ✓;不属于受保留前缀 ✓。)