
Wxa Skills Validate
- 41 installs
- 169 repo stars
- Updated July 28, 2026
- wechat-miniprogram/ai-mode-skills
Helps with ai & agent building tasks during AI-assisted development.
About
wxa-skills-validate is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- wxa-skills-validate
- AI & Agent Building
- AI-coding skill
Wxa Skills Validate by the numbers
- 41 all-time installs (skills.sh)
- +3 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #8,148 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wechat-miniprogram/ai-mode-skills --skill wxa-skills-validateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 41 |
|---|---|
| repo stars | ★ 169 |
| Last updated | July 28, 2026 |
| Repository | wechat-miniprogram/ai-mode-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
wxa-skills-validate
对小程序 AI SKILLs 产物执行"静态校验 → 原子接口执行 → 原子组件渲染 → 交付文档"的闭环校验,并在每一步失败时按错误类型分类就地修复 skill 源文件。
依赖
- Node.js ≥ 18(
scripts/*.mjs用到node:crypto/ 原生fetch) - 微信开发者工具已安装,CLI 可执行:
<DEVTOOLS_APP_PATH>/Contents/MacOS/cli -h(macOS 默认<DEVTOOLS_APP_PATH>=/Applications/wechatwebdevtools.app) - 项目
project.config.json含appid;app.json含agent.skills;每个 skill 目录含mcp.json+SKILL.md
触发条件
出现下列任一情况时启动本技能:
- 显式要求对 skills 目录做 "校验 / 跑通 / 渲染 / 出交付文档" 中任一项
- 已有 skills 产物(无论来源)需要进入验证阶段
- 跑出 skills 的校验报错需要修复
必需信息
| 项 | 说明 | 缺失时动作 |
|---|---|---|
<project-path> | 小程序项目根目录(含 project.config.json + app.json;app.json 的 agent.skills[].path 指向 skill 分包) | 向用户询问 |
<DEVTOOLS_APP_PATH> | 微信开发者工具应用路径 | macOS 默认 /Applications/wechatwebdevtools.app,用户可覆盖 |
<AUTO_PORT> | auto WebSocket 端口 | 默认 9420 |
注:<skills-path>已不再作为入参,脚本自动从app.json发现分包。
参考资料(按需加载)
进入"步骤 4:真机闭环"时必须先读 references/CLI_AGENT_REFERENCE.md,内含脚本用法、产物结构、读产物后的下一步动作、5 项核对对照表、失败回溯流程。
| 文件 | 用途 | 加载时机 |
|---|---|---|
references/CLI_AGENT_REFERENCE.md | CLI agent 命令参考 | 步骤 4 执行前 |
references/VALIDATE_RULES.md | validate.mjs 内置的 V001~V012 规则详解 | 出现校验报错需定位 id 时 |
references/DELIVERY_TEMPLATE.md | DELIVERY.md 交付模板 | 最终交付时 |
---
验收目标(不可降级)
<project-path>下app.json发现的每个 skill 分包,其mcp.json声明的所有原子接口必须跑通 execute(status === "ok"且invokeResult.isError !== true)。- 所有带
_meta.ui.componentPath的原子接口,必须跑通 render 且通过 5 项核对(见references/CLI_AGENT_REFERENCE.md第 2.3 节)。 - 单接口连续修复 5 轮仍不通过才允许挂起。不得跳过任何一项。
---
执行清单(复制后勾选,逐项完成)
阶段 1 — 静态校验 + 编译校验
- [ ] 运行 `node validate.mjs <project-path>`(单参数,脚本自动发现 skill 分包并决定是否跑 preview)
- [ ] summary.errors === 0(含 V001~V013),否则按 T1~T9 分类修复后重跑
- [ ] summary.buildStatus === "pass"(静态 0 error 时 preview 会自动运行;
若为 "skipped" 说明静态未过,先按上一项修复)
- [ ] 阅读 Build 行:若 stage=compile + FAIL,说明有语法/编译错误,必须修复
阶段 2 — 准备
- [ ] 确认 CLI 可执行:<DEVTOOLS_APP_PATH>/Contents/MacOS/cli -h
- [ ] (可选)显式启动 cli auto
阶段 3 — 构建执行计划
- [ ] 解析每个 <skill>/mcp.json 的 apis[],按书写顺序 + 参数依赖做拓扑排序
- [ ] 建立"已知数据池"(空)
阶段 4 — execute 与 render(可独立执行)
对每个 {name}:
- [ ] execute 成功(status=ok 且 !isError)
- [ ] 若 mcp.json 有 _meta.ui.componentPath,render 可在任何时间点执行(不要求紧跟 execute)
- [ ] render 通过 --from-execute 复用最新的 execute 产物(args 取自 invokeResult.structuredContent);
structuredContent 缺失时必须先重跑 execute
- [ ] 5 项核对全部通过(主要依据:`consoleMessages.snapshotCard` 中的生命周期日志 + `[ai-mode] ... overflow monitor=on` 基线日志 + 不出现 `overflowed=true`;仅在具备图像读取能力时再辅助读截图)
阶段 5 — 交付
- [ ] 写 ./cli-agent-run/report.md
- [ ] 若全部通过,按 references/DELIVERY_TEMPLATE.md 写 ./DELIVERY.md 并回贴内容---
工作目录
在 <project-path> 同级建 ./cli-agent-run/ 统一存放产物:
cli-agent-run/
├── validate-report.json # 阶段 1 产物
├── execute-result.<apiName>.json # 阶段 4 execute 产物(含 invokeResult.structuredContent 供 render 继承)
├── render-result.<apiName>.json # 阶段 4 render 产物(snapshot 摘要 + consoleMessages + elementTree)
├── render-result.<apiName>.snapshot.png # 阶段 4 render 截图
├── execute-trace.json # 每次尝试的回溯日志
└── report.md # 阶段 5 执行报告
项目根目录/
└── DELIVERY.md # 全部通过时的最终交付文档同一接口重跑时必须复用 --output(文件会被覆盖);不同接口必须用不同文件名。
---
阶段 1 — 静态校验 + 编译校验(合并为一次运行)
运行:
node <skill-dir>/scripts/validate.mjs <miniprogram-project-path>入参只需要一个——小程序项目根目录(含 project.config.json + app.json)。脚本自动:
1. 读 app.json 的 agent.skills[].path 发现 skill 分包(没配置时回退到顶层 metaServicePkg/ 或 skills/) 2. 静态规则只在 skill 分包目录内 执行,不触及主包代码 3. 把校验产物目录 cli-agent-run/ 写入 project.config.json 的 packOptions.ignore(打包忽略)和 watchOptions.ignore(监听忽略),避免开发者工具持续监听产物变更触发循环编译(已存在不会重复追加;产物落盘前完成同步,结果挂在报告 ignoreSync 字段) 4. 静态校验通过(errors === 0)后自动调用 cli preview 做编译校验;有 error 则跳过 preview 5. 报告落盘到 <project>/cli-agent-run/validate-report.json(可用 --output 覆盖)
可选参数:--rules <自定义规则 json> / --cli-path <CLI 路径> / --build-timeout <ms> / --output <path>。
退出码:0 通过;1 存在 error 或 build 失败;2 运行异常。
通过判据:
summary.errors === 0(warning 允许带着进入阶段 2)summary.buildStatus === "pass"(静态 0 error 后会自动触发 build;"skipped"意味着静态未过,先按修复决策表修复)- Build 行
stage=compile + FAIL说明有语法/编译错,必须修复
Build 编译报错时:优先检查集成配置,再动源码。对照 wxa-skills-generate SKILL.md 的"阶段 6 — 配置集成"与 references/CODE_TEMPLATES.md 的"六、app.json + project.config.json 配置"核对 app.json(agent.skills / subPackages)与 project.config.json(appid / packOptions.include)。集成无误后才按日志改源码,禁止用注释/删除源码的方式绕过集成问题。
CLI 未找到时的处理:若输出 Build: SKIPPED - 跳过:未找到微信开发者工具 CLI,说明脚本未能自动定位到 cli。自动探测顺序为:--cli-path > 环境变量 WECHAT_DEVTOOLS_CLI / WXA_CLI > macOS /Applications/wechatwebdevtools.app/Contents/MacOS/cli > 同路径的用户目录变体 > Windows C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat。此时应主动向用户询问微信开发者工具的安装路径,然后:
- 重跑:
node validate.mjs <project-path> --cli-path <用户提供的绝对 cli 路径> - 或建议用户设置环境变量:
export WECHAT_DEVTOOLS_CLI=<绝对路径>后重跑
CLI 缺失不影响静态规则的输出,只会让 build 阶段被 skip。
执行顺序(脚本内部闭环): 1. 同步 project.config.json 的 packOptions.ignore + watchOptions.ignore(追加 cli-agent-run/) 2. 发现 skill 分包 → 只在分包内跑 V001~V013 3. 有 error → build=skipped(节省 preview 成本);0 error → 调 cli preview 4. 到达 upload 阶段视为编译通过;即便上传失败(服务端校验、网络等),也不标记 build 失败
失败时的修复决策表(读完 validate-report.json 中 results[].id / message / fix 后匹配):
| 错误类型 | 识别特征 | 修复范围 | 动作 |
|---|---|---|---|
| T1 命名拼写 | 字段大小写/拼写错 | 单文件单行 | 直接改 |
| T2 Schema 不一致 | structuredContent 与 outputSchema.properties 字段不匹配(V009) | apis/{name}.js + mcp.json | 对齐字段 |
| T3 组件绑定不一致 | WXML {{}} 与 setData 字段对不上(V011) | components/{x}/index.{js,wxml} | 对齐绑定 |
| T4 组件取值路径错 | result.structuredContent.xxx 与接口返回字段不符(V010) | components/{x}/index.js | 修访问路径 |
| T5 合规性违规 | 非白名单 WXML 标签 / CSS 属性(V003/V005/V006) | 单文件改写 | 用白名单实现替换 |
| T6 注册缺失 | mcp.json 的 name 在 index.js 未 registerAPI,或反之(V007/V008) | index.js | 补/删注册 |
| T7 依赖链路问题 | storage key 写入方/读取方对不上 | 跨接口 + utils/util.js | 跨文件调整 |
| T8 原子接口粒度错 | 接口职责重叠 | mcp.json + index.js + apis/*.js | 拆分/合并 apis[] |
| T-mcp-size | mcp.json 去除 outputSchema 后超过 24000 字符(V013;后台也会拒绝) | mcp.json 的 description/title/inputSchema;或重划 skill 分包 | 压缩描述文字;接口多到难以精简时按职责拆分为多个 skill 分包,不要把示例/枚举硬塞进 outputSchema |
| T-auth 鉴权缺失 | 401 / unauthorized / token 无效 等(静态阶段通常由 V007/V008 连带触发) | utils/util.js / apis/{name}.js | 读主包还原登录流程 |
| T-wx-jsapi 非白名单 | 运行时 wx.<xxx> is not a function / wx.<ns> 为 undefined | apis/{name}.js / components/{x}/index.js | 对照 wxa-skills-generate SKILL.md C.1/C.2 白名单(完整清单见 wxa-skills-generate/references/JSAPI_WHITELIST.md),按 C.4 替换或改网络请求;无替代标 T9(详见阶段 4 C.1) |
| T-build 编译失败 | Build 行显示 FAIL 且 stage=compile | 项目集成 / .js / .wxml / .wxss | 先对照 wxa-skills-generate SKILL.md 阶段 6 "配置集成" 核对 app.json / project.config.json,集成无误后再按日志修源码 |
| T-skill-description | app.json 的 agent.skills[].description 缺失或为空(V016) | app.json | 在该条目中补充非空的 description 字段 |
| T9 能力无法实现 | 所有候选都违反硬约束 | — | ⛔ 终止,告知用户 |
V001~V016 规则详情见 references/VALIDATE_RULES.md。
判别口诀:文件内能改完 → T1~T6;需改 storage 清单或接口划分 → T7/T8;连修复方案都违规 → T9。
迭代规则:
| 情况 | 动作 |
|---|---|
summary.errors === 0 | ✅ 进入阶段 2 |
| errors 数较上一轮减少 | 继续修复,重跑 |
| 连续 3 轮相同 finding id | 升级为 T7/T8 跨文件调整 |
| 累计 5 轮仍未通过 | ⛔ 终止,请求人工介入 |
---
阶段 2 — 准备 CLI agent 命令
确认 CLI 可执行:
<DEVTOOLS_APP_PATH>/Contents/MacOS/cli -h失败则告知用户 "确认微信开发者工具已安装" 后停止,不要强行绕过。
(可选)显式启动 auto 服务避免第一次调用的 cold start:
<DEVTOOLS_APP_PATH>/Contents/MacOS/cli auto \
--project <PROJECT_PATH> --auto-port <AUTO_PORT> --trust-project跳过此步时,execute.mjs / render.mjs 首次调用会自动拉起 auto。
---
阶段 3 — 构建执行计划
读取 <project-path> 下 app.json 发现的每个 skill 分包的 mcp.json(validate-report.json 中的 skillDirs 字段给出了具体分包路径):
1. 汇总 apis[] 的 name / description / inputSchema / outputSchema / _meta.ui.componentPath。 2. 默认按书写顺序;description 或 inputSchema 含 "需要先调用 X" 类表述时,将 X 前置。 3. 维护"已知数据池":每个接口成功后把 structuredContent 存入池中,供下游参数引用。
---
阶段 4 — execute 与 render
execute 和 render 是两个独立可重入的命令:
execute调用原子接口,产出业务数据(invokeResult.structuredContent)。render通过--from-execute把 execute 的invokeResult.structuredContent作为渲染数据源喂给组件;
也可以 --name + --args 独立指定。CLI 内部每次 render 会自动生成一次性 toolCallId / sessionId, 不依赖 execute 的运行时上下文。
执行灵活度:
- 可以一次 execute 所有原子接口、再统一批量 render
- 也可以"单接口 execute → render"交替进行
- render 的数据来源优先级:
--args显式指定 >--from-execute读到的invokeResult.structuredContent
硬约束(仅保留真正必要的):
- 按
apis[]顺序依赖关系准备好入参(下游接口的 args 若依赖上游structuredContent,仍需先 execute 上游) - 每个带
componentPath的接口最终都要 render 通过;完整通过的判据仍然是"execute 成功 + render 5 项核对通过" - 同一条 CLI 调用内,
render.mjs不能并发执行(CLI 后台 auto 是串行的) --from-execute的 execute 产物必须含invokeResult.structuredContent;若缺失,render.mjs会直接报错,需先重新 execute 成功后再 render
4.1 execute
运行:
node <skill-dir>/scripts/execute.mjs \
--project <PROJECT_PATH> \
--name <name> \
[--args '{"query":"..."}'] \
[--auto-port <AUTO_PORT>] \
[--skill <skill-name-or-path>] \
[--timeout <ms>] \
--output ./cli-agent-run/execute-result.<name>.jsonexecute.mjs 只接受 上述参数;toolCallId / sessionId / auto 相关票据由 CLI 内部自动处理,脚本不再暴露。
入参来源优先级:
1. 用户指定 2. 上游 structuredContent 同名/同义字段 3. inputSchema 允许为空 → 省略 --args 4. 类型默认值(string ""、number 0、array []、object {}),日志标注"使用默认值"
成功判据:status === "ok" 且 invokeResult.isError !== true 且 invokeResult.structuredContent 为非空对象 (后者是 render --from-execute 的前置条件)。
execute 失败:先检查产物 _meta.diagnosis 是否为不可修复类(若是则立即停止),否则按下方"阶段 4 失败分类"的 A/B/C/D 类处理。
4.2 render(仅当 mcp.json 中该 api 有 _meta.ui.componentPath 时执行)
只要给对的 name + args(渲染数据源)就能渲染。CLI 的 render 不会重新执行原子接口,而是把 --args 作为 structuredContent 直接喂给组件渲染;--from-execute 只是一个语法糖,用来把 execute 产物里的 invokeResult.structuredContent 直接喂给 render。
推荐运行方式(从 execute 产物继承 name / args,args 来源为 invokeResult.structuredContent):
node <skill-dir>/scripts/render.mjs \
--project <PROJECT_PATH> \
--from-execute ./cli-agent-run/execute-result.<name>.json \
[--timeout 90000] \
--output ./cli-agent-run/render-result.<name>.json若 execute 产物缺 invokeResult.structuredContent,脚本会直接 exit 2 报错——此时必须先重跑 execute 并确认status=ok+invokeResult.isError!==true+structuredContent为非空对象。
独立指定上下文(没有 execute 产物,或需要手动指定 args):
node <skill-dir>/scripts/render.mjs \
--project <PROJECT_PATH> \
--name <tool-name> \
--args '{"<字段>":"..."}' \
[--timeout 90000] \
--output ./cli-agent-run/render-result.<name>.jsonrender.mjs 自动从 --from-execute 继承 name / args;任一字段被 --name / --args 等显式参数提供时以显式值为准。CLI 下发的参数仅限 --project / --name / --args / --output / --trust-project (及必要时的 --timeout),其它上下文由 CLI 内部自动生成,无需也无法从脚本显式传入。
render cold start 通常比 execute 慢(需要创建 container + 渲染组件),首次调用或 CI 环境建议 --timeout 90000。详细参数、产物结构、读产物后下一步动作见 references/CLI_AGENT_REFERENCE.md 第 2 节。必须读取的产物(仅靠 render.mjs 退出码 0 不足以判通过):
- console 日志(主要依据):
render-result.<name>.json的consoleMessages.snapshotCard。必看: [ai-mode] ... created→[ai-mode] ... 收到接口返回→[ai-mode] ... setData三条生命周期日志(缺任何一条 → 组件初始化或 Result 监听有问题)[ai-mode] <component> overflow monitor=on(基线日志,必存在):组件已绑定NotificationType.Overflow监听。缺失 → 视为未接入监听,回 wxa-skill-generate 的组件 JS 骨架补齐[ai-mode] <component> overflow overflowed=true data=<JSON>(或data.overflowHeight > 0):有裁剪,核对 ③ 不通过。只要出现一次就判失败;只有monitor=on、没有overflowed=true记录则视为未裁剪通过- 任何
ERROR级日志基本意味着业务组件初始化失败,截图会是空白 - 组件树 `elementTree`(辅助核对,原样透传):
render-result.<name>.json的elementTree完全由 CLI render
返回,是一段缩进格式的字符串(非 JSON 对象),序列化了卡片的 shadow tree,形如 <view:view class="addr-row">...、<text:default-component class="temp">... 28°、 <(virtual):wx:if> 等节点。render.mjs / lib.mjs 不做任何加工或占位回填——CLI 没下发就没有该字段。 它不参与 pass/fail 判定,仅作为辅助信号:用来核对字段文案是否命中绑定、列表节点数量、 wx:if 空状态是否生效等(对字符串做 grep 即可)
- 截图(辅助):
render-result.<name>.snapshot.png。仅在当前运行环境具备图像读取能力时,以图像方式read_file读入,辅助核对样式还原度(核对 ④)。若当前环境不具备图像读取能力,跳过截图读取,不视为失败;核对 ③(裁剪)完全以overflow日志为准,不回退到基于截图的视觉判断
5 项核对见 references/CLI_AGENT_REFERENCE.md 第 2.3 节。任一不通过 → 留在本接口继续修复。
4.3 闭环自检(整体判通过前的硬门闩)
每个带 componentPath 的接口都满足下列全部才允许标为通过:
- [ ] 存在
execute-result.<name>.json,其status === "ok"且invokeResult.isError !== true - [ ] 存在
render-result.<name>.json - [ ] 5 项核对全部通过(含
consoleMessages.snapshotCard中存在[ai-mode] ... overflow monitor=on基线日志、且不出现overflowed=true;截图仅在具备图像读取能力时作为辅助信号)
---
阶段 4 失败分类与修复流程
不可修复类:AppID 无小程序 AI 的开发模式权限
在进入修复流程之前,首先检查产物的 `_meta.diagnosis` 字段。若非 null,说明脚本已自动识别为环境问题,必须立即停止,禁止尝试修改代码。
特征(以下任一即命中):
- CLI stdout 含
timeout waiting for auto websocket且 stderr 含Fetching AppID (wx...) detailed information ✖ invokeResult.isError === true且content[].text含agent compile mode is disabled- 产物
_meta.diagnosis.type === "appid_no_agent_permission"
处理:⛔ 立即停止,向用户确认 AppID 权限状态:
- 若用户确认当前 AppID 已有权限 → 直接重跑 execute / render
- 若用户提供了新的 AppID → 根据用户的输入,修改
project.config.json的appid字段后重跑 execute / render - 若用户无法确认 → 终止,建议用户前往微信公众平台查询或申请小程序 AI 的开发模式权限
---
修复范式(确认非不可修复类后,每次失败按此顺序走)
- [ ] 步骤 1:读运行时产物(execute-result / render-result 的 `error` / `consoleMessages`;
`elementTree` 由 CLI 返回时可辅助定位字段/绑定问题,缺失时跳过;
`snapshot.png` 仅当环境具备图像读取能力时再辅助核对,否则跳过)
- 先检查 `_meta.diagnosis` —— 若非 null → 不可修复类,立即停止
- **若报错形如 `wx.<xxx> is not a function` / `Cannot read property '<xxx>' of undefined`
→ 直接跳到 C.1 子类按白名单比对处理**
- [ ] 步骤 2:回到主包源码定位真实逻辑(页面 .js / utils/request.js / app.js / cloudfunctions/*)
- [ ] 步骤 3:对比分包实现,列出差异点再改(`apis/<name>.js` / `utils/util.js` / `components/<name>/*`)
- [ ] 步骤 4:若涉及接口划分 / storage 链路,改 mcp.json 的 apis[] 并同步 index.js 注册
- [ ] 步骤 5:重跑 execute 验证数据正确;涉及 UI/WXML/WXSS 改动时单独跑 render 验证渲染
(render 可通过 --from-execute 复用之前的 execute 产物,前提是该产物仍含 invokeResult.structuredContent)
- [ ] 步骤 6:仍失败重复步骤 1~5,单接口上限 5 次核心原则:真相只在主包里。分包是独立运行的拷贝,逻辑差异以主包为准。禁止在未读主包源码的情况下臆测修改。
---
A 类:execute 参数失败
特征:missing required parameter / xxx is undefined / 参数格式错误。
1. 依赖图找上游接口;未跑则先跑上游,提取字段后重拼 --args(上限 3 次) 2. 字段名不一致 → 做字段映射后重试 3. 找不到上游来源 → 读主包确认真实依赖 → 改 apis/<name>.js 入参拼装 → 重跑 4. 3 次仍失败 → 转 C 类
B 类:execute 读取 storage 失败
特征:no data / getStorageSync 返回 null。
1. 在 <skill>/SKILL.md 的 storage 清单中找写入方接口,先跑一次再重跑当前接口(上限 2 次) 2. 若 key 应由主包 app.js 初始化 → 读主包 → 在 utils/util.js 的 ensureStorageInit() 补初始化逻辑 → 重跑 3. 2 次仍失败 → 转 C 类
C 类:execute 代码/网络失败
特征:network / timeout / 500 / unauthorized / not registered / JS 抛异常 / 返回字段与 outputSchema 不一致。
1. 读 execute-result.<name>.json 的 invokeResult.error + consoleMessages([ai-mode] 前缀日志)锁定失败步骤 2. 读主包必读清单:
- 源页面
.js:拼装入参(headers / token / 签名 / 查询串) utils/request.js/utils/http.js/api/*.js:baseUrl/ 鉴权头 / 错误码 / 返回结构(是否包了data/code/msg)app.js:wx.cloud.init({ env })/ 全局 token /globalData- 云开发项目:
cloudfunctions/<fn>/index.js的真实字段名
3. 列差异点后只改 apis/<name>.js / utils/util.js,不重写整个文件 4. 若返回字段变了 → 同步改 components/<name>/index.js 的访问路径与 index.wxml 绑定 5. 重跑 execute;仍失败重复 1~4,上限 5 次 6. 5 次仍失败:
- 涉及接口划分 / storage 依赖 → 改
mcp.json的apis[]结构 +ensureStorageInit,重跑阶段 1 - 源码无对应能力或依赖非白名单 → 标记 T9,在
report.md记录后终止本接口
C.1 子类:wx JSAPI 未定义(非白名单)
特征:运行时报 wx.<xxx> is not a function / Cannot read property '<xxx>' of undefined(wx.<ns> 为 undefined)。
优先假设不是代码写错,而是该 JSAPI 不在技能分包白名单内。按以下顺序处理:
1. 从报错提取 API 名,对照 wxa-skills-generate SKILL.md 的 C.1(接口侧)/ C.2(组件侧)白名单(完整清单见 wxa-skills-generate/references/JSAPI_WHITELIST.md) 2. 在白名单内 → 检查调用上下文是否错位(组件/接口侧专属)、wx.request 在组件侧是否漏声明 permissions["scope.dynamic"] 3. 不在白名单 → 按 C.4 替换(如 chooseImage → chooseMedia)或改网络请求实现 4. 无等价替代 → 标 T9 终止。禁止用 `if (wx.x)` / `try/catch` 吞异常当修好
C.2 子类:Skill 模块加载失败(分包未注册)
特征:运行时报 Skill code loading failed: module 'skills/<skill>/index.js' is not defined, require args is 'skills/<skill>/index.js'(或类似 module ... is not defined / require args is ... 的模块解析错)。
优先假设不是 JS 代码错,而是分包集成没接对。按以下顺序核对(不要动 `apis/` 或 `components/`):
1. `app.json` 的 `subPackages` 是否把 skills 声明为独立分包(缺此项是最常见原因):
"subPackages": [
{ "root": "skills", "name": "skills", "pages": [], "independent": true }
]root必须是 skills 目录的相对路径;independent必须为true;pages可为[]
2. `app.json` 的 `agent.skills[].path` 是否指向 skills/<skill>(与 subPackages.root 一致) 3. `project.config.json` 的 `packOptions.include` 是否包含 { "type": "folder", "value": "skills" }(否则 CLI 构建时不打包该目录) 4. 目录自身:skills/<skill>/index.js 实际存在,且其中通过 wx.modelContext.registerAPI('<name>', fn) 注册了报错对应的 <name> 5. 以上四项完整且正确 → 才按 C 类主流程去读 apis/<name>.js 的代码
对照 wxa-skills-generate `SKILL.md` 阶段 6 与 `references/CODE_TEMPLATES.md` 第六节 的配置片段做核对,不要乱写。
D 类:render 核对不通过(含"被裁剪"/"样式还原度不达标")
1. 读产物定位:consoleMessages.snapshotCard(主要依据,尤其是 [ai-mode] ... overflow 日志)+ elementTree(若 CLI 下发,辅助核对字段绑定 / 列表长度 / 空状态文案);仅在环境具备图像读取能力时,再以图像方式 read_file 读 snapshot.png 作为样式还原度的辅助信号 2. 按问题类型改(只改 components/<name>/,不动 mcp.json / apis/*):
- 被裁剪(`[ai-mode] ... overflow overflowed=true` 或 `data.overflowHeight > 0`):
index.wxss压缩 item 高度、用-webkit-line-clamp:1~2、根节点保留overflow: hidden但不要写 `max-height` / `min-height` / `height`(外层尺寸由宿主自动施加,组件自行设高会让NotificationType.Overflow回调失效);数据超量时在index.js计算visibleItems+omittedCount = total - visibleItems.length,WXML 渲染"还有 {{omittedCount}} 条未展示" - 未接入溢出监听(`consoleMessages.snapshotCard` 中找不到 `[ai-mode] ... overflow monitor=on` 基线日志):回 wxa-skill-generate 的组件 JS 骨架,在
created中通过wx.modelContext.getViewContext(this).on(NotificationType.Overflow, ...)绑定监听,并在绑定后同步console.info('[ai-mode] {componentName} overflow monitor=on')打出基线日志 - 样式与源页面不一致(还原度不达标):回主包读
.wxml/.wxss+app.wxss,重提视觉 token(主色、字号、圆角、间距、分割线、图片比例)覆盖index.wxss。单位推荐vw(1vw ≈ 7.5rpx) - 图片不展示(软性优化):在
index.js的NotificationType.Result分支做字段归一化(如imageUrl: item.imageUrl || item.cover || item.pic || item.thumb || item.image) - 组件上行协议违规(点击"活"按钮但小程序 AI 拿不到下一跳、或组件自己调业务接口):上行合法形态有两种:① 单
text(自然语言 followUp);②text+api/call组合(结构化 toolCall)——
wx.modelContext.getContext(this).sendFollowUpMessage({ content: [{ type: 'text', text }, { type: 'api/call', data: { name, arguments } }] }), text 是用户视角的简短中文(≤ 12 字)、name 必须在当前 skill mcp.json.apis[].name 中存在、arguments 字段与目标接口 inputSchema.properties 对齐、值从 e.currentTarget.dataset / this.data 取。 违规形态包括:content 只含 api/call 不含前导 text(缺用户上下文,小程序 AI 拿不到意图描述)、缺 content 数组直接 { type: 'api/call', ... }、name 在 mcp.json 中不存在、arguments 字段名错或带占位值、组件内直接 wx.request 业务接口、在 handler 里用 this._modelCtx.sendFollowUpMessage(...) / this._viewCtx.getDimensions(...) 这种缓存引用调方法(必须改为 wx.modelContext.getContext(this).sendFollowUpMessage(...) 等现取写法)。 只改 components/<name>/index.js 的 tap handler,不改 apis/ 和 mcp.json;每次上行前补一行 console.info('[ai-mode] {componentName} send api/call name=... args=...') 便于下次 render 在 consoleMessages.snapshotCard 中核验 3. 改完 components/<name>/ 后重跑 render(UI 类改动不需要重新 execute,可通过 --from-execute 复用现有产物)→ 再次读 render-result.<name>.json 的 consoleMessages.snapshotCard:基线 overflow monitor=on 日志必须存在、且不出现 overflowed=true(具备图像读取能力时可附加读取截图作为样式还原度的辅助信号),不得仅依据退出码 0 放行 4. 上限 5 轮,仍不通过按 C 类退出条件处理
禁止动作
- 禁止用
curl/fetch/ HTTP 工具直接验证网络接口。原因:① 小程序沙箱的鉴权上下文(session / cookie / 签名 / wx.login code)在终端无法复现;② 验证结果对 skill 分包无参考价值。网络请求改动只能通过execute.mjs验证。 - 禁止在未读主包源码的情况下臆测修改分包代码。
- render 可通过
--from-execute复用已有 execute 产物(args 取自invokeResult.structuredContent);但 render 关心的是 UI 渲染正确,因此若改动会影响接口返回数据(改 apis/ / outputSchema),需要先重新 execute 再 render,不应用旧产物;仅改 UI(wxml/wxss/components/index.js)时可复用。 - 禁止多个接口的 CLI 命令并发执行。
- 禁止改动 skill 的总体目录结构(
<skill>/apis//<skill>/components//mcp.json/SKILL.md),只在文件内容层面做最小修复。
---
回溯记录
每次 execute / render 追加写入 ./cli-agent-run/execute-trace.json:
{
"skill": "<skill-dir>",
"api": "<name>",
"attempt": 1,
"argumentsUsed": { },
"argumentsSource": "user | upstream:<apiName> | default | empty",
"executeStatus": "ok | error",
"executeError": null,
"renderChecks": { "rendered": true, "fieldsComplete": true, "overflow": false, "style": true, "ellipsis": true },
"renderFailReason": null,
"recovery": null
}---
终止条件
满足任一即终止:
1. 阶段 1 通过 + 每个声明的 api execute 成功 + 有 componentPath 的接口 5 项核对全部通过 2. 阶段 1 连续 5 轮未通过 → 停止,输出失败报告 3. 阶段 4 累计 5 轮仍有接口未通过 → 停止,输出失败报告 4. 不可修复类环境错误(AppID 无小程序 AI 的开发模式权限、_meta.diagnosis 非 null)→ 立即停止,向用户确认权限或获取新 AppID 5. T9 类问题 → 立即终止,告知用户
---
阶段 5 — 交付产物
1. 执行报告 ./cli-agent-run/report.md(每次终止都输出)
# CLI `agent` 命令校验报告
- 执行时间:<ISO>
- project-path:<abs-path>
- skill 分包:<metaServicePkg, ...>(validate-report.json 中 skillDirs 字段)
- devtools:<DEVTOOLS_APP_PATH>
## 接口结果
| skill | api | componentPath | execute | render 5 项 | 产物 |
|-------|-----|---------------|---------|------------|------|
| business | searchItems | components/item-list/index | ✔ | ✔✔✔✔✔ | execute-result.searchItems.json / render-result.searchItems.snapshot.png |
## 未通过接口
- <apiName>:<原因简述>,详见 <产物路径>
## 修复摘要
- `skills/<skill>/apis/<name>.js`:<一行摘要>2. 交付文档 ./DELIVERY.md(仅在全部接口通过时输出,强制)
终止条件 1 成立时必须产出:
- 写入路径:
./DELIVERY.md(项目根;用户指定其它路径时以用户为准,但必须是.md) - 模板:严格套用
references/DELIVERY_TEMPLATE.md,所有{占位符}必须替换为实际值 execute-trace.json存在时在"已知限制"节引用- 写入后必须在对话中同时贴出完整 MD 内容,不能只说"文件已生成"
- 无法写入(权限)→ 将 MD 内容直接输出在对话中作为替代
仅输出 `report.md` 不算任务完成;`DELIVERY.md` 才是最终交付物。
3. 未通过时的修复建议(追加到 report.md 末尾)
- 阶段 4 不可修复类 → 环境问题,禁止修改代码,向用户确认权限或获取新 AppID 后改 project.config.json 重跑
- 阶段 1 T1~T6 → 直接修对应文件,重跑 validate
- 阶段 1 T7/T8 → 调整
mcp.json的apis[]/utils/util.js的 storage 逻辑 /index.js的registerAPI,重跑 validate - 阶段 4 A/B 类 → 修
apis/<name>.js入参拼装或utils/util.js的ensureStorageInit,重跑 execute - 阶段 4 C 类 → 对照主包源码修
apis/<name>.js/utils/util.js,必要时调整mcp.json的outputSchema与组件取值路径,重跑 execute - 阶段 4 D 类 → 修
components/<name>/的 wxml/wxss/js,重跑 render - T9 → 终止,告知用户功能不支持或建议更换实现路径
---
关键约束(再次强调)
- 验收目标不可降级:所有原子接口与带
componentPath的原子组件都必须通过;挂起仅限"连续 5 轮仍未通过"硬上限 - render 必须读取
consoleMessages.snapshotCard做判断,不能只看execute-result;具备图像读取能力时再辅助读截图 - "未裁剪 + 样式还原"是硬判据:未裁剪以
consoleMessages.snapshotCard中存在[ai-mode] ... overflow monitor=on基线日志且不出现overflowed=true为准(缺monitor=on= 未接入监听,按不通过处理);样式还原度在具备图像读取能力时再读snapshot.png作为辅助信号,否则以elementTree字段完整性兜底 - 修复必须跨主包 + 分包联动,真相只在主包里
- 根据
mcp.json的apis[]依赖关系安排 execute 顺序;存在上游依赖时,上游 execute 必须先于下游。render 无此顺序约束 - 不要新增依赖、不要重写整个文件
CLI agent 命令使用参考
真机闭环(阶段 4)必读:脚本怎么用 → 产物长什么样 → 读完产物下一步做什么。
目录
- 0. 前置条件
- 1. execute — 调用原子接口
- 1.1 用法
- 1.2 产物结构
- 1.3 通过判据
- 1.4 读完产物后做什么
- 2. render — 调用原子接口并拿到卡片截图
- 2.1 用法
- 2.2 产物结构
- 2.3 通过判据 + 5 项核对
- 2.4 读完产物后做什么
- 3. 产物速查表
- 4. 依赖顺序 + 失败回溯
---
0. 前置条件
| 项 | 要求 |
|---|---|
| 微信开发者工具 CLI | macOS 默认 /Applications/wechatwebdevtools.app/Contents/MacOS/cli;确认可执行:<DEVTOOLS_APP_PATH>/Contents/MacOS/cli -h |
project.config.json | 含 appid;若配 miniprogramRoot 必须正确 |
app.json | 含 agent.skills[].path |
| skill 目录 | 含 mcp.json + SKILL.md |
所有网络通信、端口、WebSocket 协议等细节都被 execute.mjs / render.mjs 封装,调用方无需关心。
---
1. execute — 调用原子接口
1.1 用法
node <skill-dir>/scripts/execute.mjs \
--project <PROJECT_PATH> \
--name <api-name> \
[--args '{"query":"..."}'] \
[--skill <skill-name-or-path>] \
[--auto-port <AUTO_PORT>] \
[--timeout <ms>] \
--output ./cli-agent-run/execute-result.<name>.jsonexecute.mjs 仅支持 --project / --name / --args / --output / --skill / --auto-port / --cli-path / --timeout / --help。 toolCallId / sessionId / auto 相关票据由 CLI 内部自动处理,脚本既不接受也不下发。
1.2 产物结构
execute-result.<name>.json:
{
"command": "execute",
"status": "ok",
"params": {
"name": "<API_NAME>",
"arguments": { "query": "..." },
"toolCallId": "<TOOL_CALL_ID>",
"subpackage": "<SKILL_SUBPACKAGE_ROOT>",
"componentPath": "<COMPONENT_PATH>"
},
"invokeResult": {
"isError": false,
"content": [{ "type": "text", "text": "..." }],
"structuredContent": { /* mcp.json outputSchema 对应的结构化数据 */ },
"hasCard": true
},
"consoleMessages": [ /* [ai-mode] 前缀的业务日志 */ ],
"_meta": {
"project": "<ABS_PATH>",
"cliStderr": "...",
"cliExitCode": 0
}
}关键字段:
status—"ok"或"error"invokeResult.isError— 业务侧是否报错invokeResult.structuredContent— 下游接口的入参来源 + render `--from-execute` 的 args 来源;缺失时 render 会直接报错params.toolCallId— 由 CLI 内部生成,用于业务日志关联;脚本不再主动下发
1.3 通过判据
status === "ok" 且 invokeResult.isError !== true 且 structuredContent 结构符合 mcp.json 的 outputSchema。
脚本退出码:0 通过 / 1 失败 / 2 运行异常。
1.4 读完产物后做什么
| 情况 | 动作 |
|---|---|
通过 且该接口 mcp.json 有 _meta.ui.componentPath | 跑 2. render |
通过 且无 componentPath | 该接口闭环完成;把 structuredContent 加入"已知数据池"供下游引用 |
| 失败 | 按 4. 依赖顺序 + 失败回溯 处理;严重错误(C 类)转 SKILL.md 阶段 4 C 类修复流程 |
---
2. render — 调用原子接口并拿到卡片截图
render 不会重新执行原子接口,而是把 --args 作为 structuredContent 直接喂给组件,触发一次完整的卡片渲染链路(创建 container → 渲染组件 → 生成 PNG 截图)。 适用场景:核对组件 UI 是否正确渲染、做视觉回归。
2.1 用法
推荐:从 execute 产物继承 name / args
node <skill-dir>/scripts/render.mjs \
--project <PROJECT_PATH> \
--from-execute ./cli-agent-run/execute-result.<name>.json \
[--timeout 90000] \
--output ./cli-agent-run/render-result.<name>.json--from-execute 会自动继承 name(来自 params.name)和 args(来自 `invokeResult.structuredContent`, 即 execute 的业务返回值,不是 execute 的原始入参)。 任一字段被显式参数(--name / --args)覆盖时以显式值为准。
structuredContent 缺失时直接报错:若 execute 产物里 invokeResult.structuredContent 为空或类型不对,render 会直接 exit 2,不会 fallback 到 params.arguments——因为用入参代替返回值会让组件渲染结果无意义。正确做法:先重跑 execute,确认status=ok且invokeResult.isError !== true且structuredContent为非空对象后,再 render。
独立指定(没有 execute 产物,或需要手动指定 args):
node <skill-dir>/scripts/render.mjs \
--project <PROJECT_PATH> \
--name <api-name> \
--args '{"<字段>":"..."}' \
[--timeout 90000] \
--output ./cli-agent-run/render-result.<name>.jsonrender.mjs 仅支持 --project / --name / --args / --from-execute / --output / --cli-path / --timeout / --help。 CLI 下发的业务参数严格限定为 --project / --name / --args / --output / --trust-project(--timeout 在非默认值时下发), toolCallId / sessionId / skill 由 CLI 内部每次自动生成,不暴露也不接受显式传入。
render 首次 cold start 较慢(需要启动渲染容器),CI 或第一次调用建议 --timeout 90000。2.2 产物结构
render.mjs --output ./x.json 会产出两份文件:
| 文件 | 内容 |
|---|---|
./x.json | 渲染元信息 + 截图摘要 + 组件日志 + elementTree(由 CLI 透传,未下发时字段缺省) |
./x.snapshot.png | 卡片渲染截图(PNG) |
`./x.json` 关键字段:
{
"command": "render",
"status": "ok",
"params": {
"name": "<API_NAME>",
"arguments": { "query": "..." },
"toolCallId": "<TOOL_CALL_ID>",
"componentPath": "<COMPONENT_PATH>"
},
"snapshot": {
"mime": "image/png",
"file": "x.snapshot.png",
"absolutePath": "<ABS_PATH>/x.snapshot.png",
"dataUrlLength": 30358
},
"consoleMessages": {
"snapshotCard": [ /* 组件生命周期 + [ai-mode] 业务日志 */ ]
},
"elementTree": "<page:...>\n <view:view class=\"card\">\n ... 缩进格式的 shadow tree 字符串",
"_meta": {
"toolCallId": "<TOOL_CALL_ID>",
"sessionId": "<SESSION_ID>",
"snapshotPng": "<ABS_PATH>/x.snapshot.png",
"verify": { "passed": true, "checks": { /* statusOk / commandIsRender / hasSnapshot / noInvokeError */ } }
}
}说明:
snapshot是摘要字段;完整 base64 已经被脚本拆出写入x.snapshot.png,JSON 可直接read_file查看consoleMessages.snapshotCard是从组件首次created到渲染完成期间采集到的所有日志,含[ai-mode]前缀业务日志和任何ERROR级异常elementTree:完全由 CLI render 下发,render.mjs/lib.mjs不做任何加工或占位回填——CLI 没下发就没有该字段。
格式是一段缩进字符串(不是 JSON 对象),序列化了卡片 shadow tree,节点形如 <view:view class="...">、<text:default-component class="temp"> 28°、<(virtual):wx:if>。 用作字段级核对的辅助信号(文案、绑定命中、列表 item 数量、wx:if 空状态),对字符串做 grep 即可; 但不参与 pass/fail,缺失时以 consoleMessages 为准(截图仅在具备图像读取能力时辅助)
- render 产物通常没有 顶层
invokeResult(业务数据体现在 consoleMessages 的 "收到接口返回" 日志里)
组件上行 toolCall 的期望日志:组件内可交互元素点击后,规范要求通过 modelCtx.sendFollowUpMessage({ content: [{ type: 'text', text }, { type: 'api/call', data: { name, arguments } }] }) 上行消息 (由 wxa-skill-generate 的 COMPONENT_TEMPLATES.md 定义)。content 数组中 text 可以单独出现(自然语言 followUp), 但 api/call 必须前面有 text 作为用户上下文。render 本身不会主动触发交互, 但在做交互可达性回归或开发态排错时,consoleMessages.snapshotCard 中应能看到如下业务日志:
[ai-mode] {componentName} send api/call name=<mcp.json 内存在的 api name> args={"<inputSchema 字段>":...}若看到组件内直接调业务接口、content 只含 api/call 不含前导 text、缺 content 数组直接 { type: 'api/call', ... }、 或 name 不在 mcp.json.apis[] 中 —— 组件实现违反上行协议,按 SKILL.md D 类修复。
2.3 通过判据 + 5 项核对
基础通过(render.mjs 退出码 0 的充要条件):command === "render" 且 status === "ok" 且有 snapshot 文件。
但基础通过不等于 UI 正确。必须再由调用方做 5 项核对:
| # | 核对项 | 主要依据:consoleMessages.snapshotCard | 辅助:截图 | 不通过时改什么 |
|---|---|---|---|---|
| ① | 组件已渲染 | 出现 created → "收到接口返回" → setData 三条日志 | 非纯白 / 纯黑,有内容 | 缺 created:查 componentPath / usingComponents;缺 setData:查 Result 监听 |
| ② | 数据字段齐全 | setData 字段名与接口返回字段一致 | 所有字段文本可见且非默认值 | 修 WXML 绑定 或 result.structuredContent.xxx 访问路径;必要时字段归一化 |
| ③ | 未被裁剪,列表 ≥ 3 item | 先看 [ai-mode] <component> overflow monitor=on 基线日志是否存在(缺失 = 未接入监听,判不通过);再看有无 [ai-mode] <component> overflow overflowed=true ...(或 data.overflowHeight > 0):出现即判裁剪不通过;只有 monitor=on、不出现 overflowed=true 即判未裁剪通过 | 底部是否存在被裁断的内容(截图仅作为辅助信号,环境不具备图像读取能力时跳过,不作判据) | overflowed=true:压缩 item 高度;列表做了 slice 时在 WXML 渲染"还有 N 项未展示";缺 monitor=on:补监听 + 基线日志(见 wxa-skills-generate 的组件 JS 骨架) |
| ④ | 样式与源页面还原度一致 | — | 字号 / 间距 / 圆角 / 颜色与主包源页面一致(环境不具备图像读取能力时跳过) | 回主包读 .wxml/.wxss/app.wxss 重提视觉 token,覆盖组件 index.wxss |
| ⑤ | 长文本省略生效 | elementTree 里可 grep 到 … 或被 -webkit-line-clamp 压缩的文本;同时核对 ③ 判定未裁剪(存在 monitor=on、不出现 overflowed=true) | 长文本末尾 …,无横向溢出(辅助信号) | 给长文本加 -webkit-line-clamp / text-overflow: ellipsis 省略样式 |
工具能力自适应:若当前执行环境不具备图像读取能力(无法以图像方式read_filePNG 或缺少多模态输入通道),跳过所有基于截图的核对步骤,仅凭consoleMessages.snapshotCard的[ai-mode]日志(尤其是overflow)和elementTree做判定,不将"无法读取图像"视为失败。
>
elementTree 由 CLI 原样透传(缩进格式的字符串),不参与 5 项核对的 pass/fail。当 CLI 下发了组件树时可作为辅助信号:核对 ② 的字段绑定、核对 ③ 的列表节点数、核对 ⑤ 的省略文本都能直接在elementTree里grep到。
该字段缺失时仍以 consoleMessages 为主。2.4 读完产物后做什么
必须读取 `render-result.<name>.json`(仅看退出码 0 不够):
1. 读 consoleMessages.snapshotCard,做核对 ①②③⑤
- 重点 1:在里面搜
[ai-mode] <component> overflow monitor=on,缺失说明组件未接入NotificationType.Overflow监听,按不通过处理,回到 wxa-skill-generate 的组件 JS 骨架补齐 - 重点 2:若能搜到
[ai-mode] <component> overflow overflowed=true ...(或data.overflowHeight > 0),判定为裁剪(核对 ③ 不通过);只有monitor=on、没有overflowed=true即视为未裁剪通过
2. 读 elementTree(若存在),辅助核对 ②⑤ 的字段绑定 / 省略文本 3. 若当前环境可以图像方式 `read_file` 读入 render-result.<name>.snapshot.png:再对照核对 ①④(样式还原度),作为辅助信号 4. 若当前环境不具备图像读取能力:跳过截图步骤,不作为通过/不通过的判据;核对 ④(样式还原度)在这种情况下退化为"elementTree 中字段存在即视为通过",并在 report.md 中注明"本次运行未进行截图比对"
然后:
| 情况 | 动作 |
|---|---|
| 5 项核对全部通过 | 该接口闭环完成 |
| 截图空白 / 内容缺失 | 查 consoleMessages.snapshotCard:若有 ERROR 级日志(如 ReferenceError: xxx is not defined),是业务组件初始化异常,按 SKILL.md D 类修复流程处理 |
| 数据字段缺失 / 路径取错(核对 ②) | 回 execute-result.<name>.json 看 invokeResult.structuredContent 的真实字段名,改 components/<name>/index.js 的取值路径,重跑 render(无需重跑 execute) |
| 样式 / 裁剪 / 省略问题(核对 ③④⑤) | 只改 components/<name>/index.wxml + index.wxss,重跑 render 复用已有 execute 产物 |
status === "error"(如 timeout waiting for snapshotCard callback) | 先重试一次(render cold start 偶发超时);重试仍失败按 SKILL.md D 类处理 |
---
3. 产物速查表
| 脚本 | 主产物 | 关键字段 | 下游用途 |
|---|---|---|---|
validate.mjs | cli-agent-run/validate-report.json | summary.errors / results[] / build | 阶段 1 通过判据 |
execute.mjs | cli-agent-run/execute-result.<name>.json | status / invokeResult.isError / invokeResult.structuredContent | 下游接口入参;render.mjs --from-execute 的 args 来源(缺 structuredContent 时 render 会直接报错) |
render.mjs | cli-agent-run/render-result.<name>.json + render-result.<name>.snapshot.png | status / snapshot.* / consoleMessages.snapshotCard / elementTree(CLI 原样透传,可缺失) | 5 项核对 |
重要约定:
1. 多接口时 --output 必须带接口名,避免互相覆盖;同一接口重跑可覆盖同名文件 2. render 不会重新执行原子接口,CLI 直接把 --args 作为 structuredContent 喂给组件渲染;--from-execute 只是把 execute 的 invokeResult.structuredContent 作为 args 喂给 render,节省手写 args 的成本 3. render 产物 JSON 里的 snapshot 已是摘要(无 base64),可安全 read_file;PNG 独立存放于同名 .snapshot.png
---
4. 依赖顺序 + 失败回溯
4.1 按依赖拓扑顺序执行 execute
执行前先构建依赖图:
- 从
mcp.json的description/inputSchema文字识别参数依赖 - 从
SKILL.md的 storage key 清单识别写入方 → 读取方关系
合并排序得到序列 [A, B, C, ...],按序跑 execute。每步成功后把 structuredContent 记入"已知数据池"。
入参来源优先级:
1. 用户明确指定的 --args 2. 上游 structuredContent 同名 / 同义字段(自动取自已知数据池) 3. storage 内部读取(该接口实现会自己读 storage,无需传参) 4. 类型默认值(同时在 execute-trace.json 记录"使用默认值")
4.2 execute 失败回溯
invokeResult.isError === true 时先按错误文本分类,不要立刻转 C 类:
| 错误特征 | 类型 | 回溯动作 | 重试上限 |
|---|---|---|---|
missing required parameter / 参数不能为空 / xxx is undefined | A | 依赖图找上游,先跑上游提取字段后带参重试 | 3 |
invalid parameter / 参数格式错误 / 类型不匹配 | A | 同上,额外做字段名 / 类型映射 | 3 |
no data / 读取 storage 失败 / getStorageSync 返回 null | B | 先跑 storage 写入方接口再重试 | 2 |
返回空列表 / 空对象但 isError === false | A 弱 | 不算失败;在 trace 记录"上游为空,下游用空入参",下游跳过或降级 | — |
network / timeout / 500 / unauthorized | C | 直接进 SKILL.md C 类(改 apis/*.js / utils/util.js) | 0 |
not registered / undefined handler | C | T6 注册缺失,改 index.js registerAPI | 0 |
Skill code loading failed / module ... is not defined / require args is ... | C | 进 SKILL.md C.2 子类:核对 app.json 的 subPackages(skills 分包 + independent:true)、agent.skills[].path、project.config.json 的 packOptions.include | 0 |
A / B 类回溯流程:
接口 X 失败
→ 读 invokeResult.error + consoleMessages 的 [ai-mode] 日志判定类型
→ A:依赖图找上游 Y → 若未跑则先 execute Y → 带参重跑 X
Y 已跑但字段名不匹配 → 字段映射后重试
依赖图无 Y → 转 C
→ B:storage key 清单找写入方 Z → execute Z → 重跑 X
Z 已跑但 storage 仍空 → 转 T7(storage 链路问题,SKILL.md)
→ C:进 SKILL.md C 类修复流程回溯全程追加写入 ./cli-agent-run/execute-trace.json(字段见 SKILL.md "回溯记录" 节)。 重试上限耗尽仍失败才升级为 C 类。
交付文档模板(DELIVERY.md)
CLIagent命令真机闭环全部通过后使用本模板。原样套用下方模板,所有{占位符}必须替换为实际值,不得保留。
# 小程序 AI SKILL 交付文档
> 生成时间:{ISO 8601 时间戳}
> 验证工具:wxa-skills-validate(validate.mjs + cli agent tool + cli agent render 真机闭环)
## 一、生成结果概览
✅ 生成成功
## 二、生成的 SKILLs
### skills/{skillName1}({N} 个原子接口)
| 原子接口 | 标题 | 关联组件 | 入参 | 返回结构 |
|---------|------|---------|------|---------|
| `{apiName1}` | {title} | `components/{xxx}/index` | {inputSchema 摘要} | {outputSchema 摘要} |
| `{apiName2}` | {title} | `components/{xxx}/index` | {inputSchema 摘要} | {outputSchema 摘要} |
### skills/{skillName2}({M} 个原子接口)
(同上格式)
## 三、覆盖的用户需求
- ✅ {需求 1}(由 {apiName1} / {apiName2} 实现)
- ✅ {需求 2}(由 {apiName3} 实现)
## 四、未能覆盖的需求
- ⚠️ {需求}(原因:源码中未定位到相关接口 / 依赖小程序插件 / 使用了非白名单 JSAPI)
> 若全部覆盖,本节写"无"。
## 五、校验结果
### 5.1 静态校验(`validate.mjs`)
- 通过状态:✅ 通过 / ⚠️ 含 {N} 个 warning
- 报告路径:`./cli-agent-run/validate-report.json`
### 5.2 真机验证(`cli agent tool` + `cli agent render`)
| 原子接口 | execute | render 5 项核对 | 截图 | 组件树 |
|---------|---------|----------------|------|-------|
| `{apiName1}` | ✅ `isError: false` | ✅✅✅✅✅ | `./cli-agent-run/render-result.{apiName1}.snapshot.png` | `./cli-agent-run/render-result.{apiName1}.json` |
| `{apiName2}` | ✅ `isError: false` | ✅✅✅✅✅ | `./cli-agent-run/render-result.{apiName2}.snapshot.png` | `./cli-agent-run/render-result.{apiName2}.json` |
## 六、产物路径
| 类别 | 路径 |
|------|------|
| SKILL 代码 | `skills/` |
| 静态校验报告 | `./cli-agent-run/validate-report.json` |
| 执行结果 | `./cli-agent-run/execute-result.*.json` |
| 回溯记录 | `./cli-agent-run/execute-trace.json` |
| 渲染组件树 | `./cli-agent-run/render-result.*.json` |
| 渲染截图 | `./cli-agent-run/render-result.*.snapshot.png` |
| 执行报告 | `./cli-agent-run/report.md` |
| 配置变更 | `app.json`(`agent.skills` + `subPackages`)、`project.config.json`(`packOptions.include`) |
## 七、建议的后续动作
1. 在微信开发者工具中打开项目,人工预览分包加载体验
2. 如更换项目(不同 `project.config.json`),需重新跑 `validate → execute → render` 整套验证
3. 如需二次验证组件渲染,重新执行 `node <skill-dir>/scripts/render.mjs --project <path> --from-execute <path>`
4. 新增原子能力时,重跑 `validate → execute → render` 确保增量不破坏既有接口
## 八、已知限制 / 注意事项
- {如有:列表截断 / 裁剪 / 外部依赖等注意项}
- {如无:本节写"无"}填充规则
1. 所有 {占位符} 必须替换为实际值。 2. "未能覆盖的需求""已知限制"即使为空也必须保留标题并写"无"。 3. 校验结果表格须如实反映真机闭环产物路径;未验证的接口标 ❌ 并在"已知限制"说明。 4. 写入文件后,在对话中同时贴出完整 MD 内容给用户。
validate.mjs 内置规则详解(V001~V014)
本文件列出scripts/validate.mjs内置的所有校验规则。当validate-report.json中出现未知的id时按本文定位。
>
自定义规则通过 --rules <path> 合并(相同 id 覆盖内置)。---
目录
- 单文件规则(regex 扫描)
- V001 禁止依赖主包
- V002 已注册接口必须为 async function
- V003 WXML 组件白名单
- V005 CSS 禁止属性
- V006 CSS 禁止选择器
- 跨文件规则
- V007 定义-注册一致性
- V008 注册-实现一致性
- V009 接口返回值-outputSchema 一致性
- V010 组件取值-接口返回一致性
- V011 setData-WXML 绑定一致性
- V012 原子接口若关联原子组件则需文件齐全
- V014 SKILL.md 必须存在且文件名严格大写
- V015 原子组件必须配置关联小程序页面
- [V016 app.json 的 agent.skills[].description 必须存在且非空](#v016-appjson-的-agentskillsdescription-必须存在且非空)
- 规则与错误类型映射
---
单文件规则(regex 扫描)
V001 禁止依赖主包
- 阶段:
registration,级别:error,目标:**/*.js - 字面量禁用:
getApp()、import ... from '@/...'(命中即报错) - 越界检查(
require/import中以.开头的相对路径):以app.json的subPackages[].root作为边界 - 落在分包根子树内 → ✅ 合法(含分包根下 `_shared/` 等公共目录)
- 落到分包根之外 → ❌ "超出 skill 分包边界"
- 落入兄弟 skill 私有子树 → ❌ "落入另一个 skill 的私有目录"
- 典型修复:跨 skill 复用工具 → 抽到
skills/_shared/,用require('../../_shared/xxx');越界相对路径 → 把目标模块挪入分包,或改为入参/JSAPI
V002 已注册接口必须为 async function
- 阶段:
registration - 级别:
error - 类型:跨文件校验(基于
mcp.json的注册列表) - 目标:仅
mcp.json中apis[].name对应的实现文件(在apis//tools/services//tools/下按同名.js解析)
规则会读取 mcp.json 列出的 API 名称,对每个已注册接口,校验其实现文件内是否存在以下任一形态:
async function <name>(...)const <name> = async (...) => .../let/var<name> = async ...(如module.exports.<name>){ <name>: async (...) => ... }{ async <name>(...) { } }
apis/ 目录下未被 mcp.json 注册的工具函数不会被检查,避免对辅助模块的误判。
典型修复:把注册接口改为 async function 或 async () => {};若目标函数只是工具函数,请将其移到同级 utils/ 目录并在 mcp.json 中取消其注册。
V003 WXML 组件白名单
- 阶段:
component - 级别:
error - 类型:
regex_absent(以下模式不允许出现) - 目标:
*/components/*/index.wxml
| 正则 | 含义 |
|---|---|
| `<(?!view\ | text\ |
<button[^>]*\sopen-type\s*= | 原子组件的 <button> 不支持 open-type 属性(share / getPhoneNumber / getRealtimePhoneNumber 等半屏页面才可用) |
| `<scroll-view(?![^>]*\sscroll-x(?:[\s=>]\ | $))` |
| `<scroll-view[^>]\sscroll-y\s=\s*["']?(?:true\ | \{\{\strue\s\}\})` |
典型修复:
- 横向超长内容 →
<scroll-view scroll-x="true">包裹横向列表(如商品横滚卡片) <scroll-view scroll-y>纵向滚动 → 改为减少展示条数 + 上行"查看更多"api/call<navigator>→<view>带bindtap(小程序 AI 不在页面栈内导航时直接删除)<swiper>→ 用<view>列表平铺,或<scroll-view scroll-x>横滚展示<input>/<textarea>/<picker>→ 交互类不适合原子组件,删除后由小程序 AI 对话收集入参<button open-type="getPhoneNumber" bindgetphonenumber="...">→<button bindtap="onTap">+ 在 tap handler 内调wx.getPhoneNumber()<button open-type="share">→<button bindtap="onShare">+ 在 handler 内调wx.shareAppMessage(...)
V005 CSS 禁止属性
- 阶段:
component - 级别:
error - 类型:
regex_absent - 目标:
*/components/*/index.wxss
| 正则 | 禁用内容 |
|---|---|
position\s*:\s*fixed | position: fixed |
position\s*:\s*sticky | position: sticky |
z-index\s*: | z-index |
display\s*:\s*grid | display: grid |
display\s*:\s*table | display: table |
display\s*:\s*inline-flex | display: inline-flex |
float\s*: | float |
text-decoration\s*: | text-decoration |
--[a-zA-Z][\w-]*\s*: | CSS 变量 --* |
| `transition\s:[^;](?!opacity\ | transform)[a-zA-Z-]+` |
典型修复:position: fixed → position: absolute;display: grid → flex;自定义变量改常量。
V006 CSS 禁止选择器
- 阶段:
component - 级别:
error - 类型:
regex_absent - 目标:
*/components/*/index.wxss
禁:子选择器 >、相邻兄弟 +、通用兄弟 ~、伪元素 ::*、伪类 :hover/:focus/:active/:checked/:disabled/:first-child/:last-child/:nth-child、属性选择器 [attr=]。
典型修复:全部用类名选择器替代;状态用类切换;奇偶行用 JS 预计算类名。
---
跨文件规则
V007 定义-注册一致性
- 阶段:
registration - 级别:
error
比对 <skill>/mcp.json 的 apis[].name 与 <skill>/index.js 中的 wx.modelContext.registerAPI('name', fn)。
典型 fail:
mcp.json定义了searchItems,但index.js未注册 → 补wx.modelContext.registerAPI('searchItems', searchItems)index.js注册了searchItems,但mcp.json未定义 → 在mcp.json中补apis[]条目
V008 注册-实现一致性
- 阶段:
registration - 级别:
error
比对 index.js 的 require('./apis/xxx') 与实际 apis/xxx.js 文件是否存在。
典型 fail:require('./apis/searchItems') 但 apis/searchItems.js 文件不存在 → 创建该文件。
V009 接口返回值-outputSchema 一致性
- 阶段:
output - 级别:
error
比对 apis/<name>.js 中 structuredContent: { ... } 字面量的字段名与 mcp.json 中 outputSchema.properties 的字段名。
典型 fail:
- 接口返回了
{ items: [] },但 outputSchema 未声明items→ 在mcp.json的outputSchema.properties补items outputSchema.required声明了items,但接口未返回 → 在structuredContent补items
V010 组件取值-接口返回一致性
- 阶段:
component - 级别:
error
比对组件 components/<name>/index.js 中的 result.structuredContent.xxx 与对应接口的 structuredContent 字段。
关联规则:
- 优先按组件 JS 中
atomicApi: 'xxx'元信息匹配接口 - 其次按
apis/*.js中找字段全集匹配
典型 fail:组件读 result.structuredContent.items,但接口返回的是 list → 在 structuredContent 补 items(或改组件读 list)。
V011 setData-WXML 绑定一致性
- 阶段:
component - 级别:
error
双向校验组件 index.js 的 setData({ field: ... }) 与 index.wxml 的 {{field}}:
setData有x但 WXML 未用{{x}}→ fail(冗余 setData)- WXML 用
{{x}}但setData/properties/ 忽略名单中都没有x→ fail(未定义字段)
忽略名单:item、index、wx(wx:for 内部变量)。
V012 原子接口若关联原子组件则需文件齐全
- 阶段:
component - 级别:
error
mcp.json 中的 apis[] 按需声明 _meta.ui.componentPath(纯操作型/中间态数据接口可不声明,仅负责执行)。若已声明,则组件目录必须完整。
检查项:
_meta.ui.componentPath未声明 → 直接 pass,跳过组件目录检查- 若已声明:
- 格式为
components/<name>/index - 目标目录下存在
index.js+index.json+index.wxml+index.wxss四件套
典型 fail:
componentPath格式不对 → 改为"components/<name>/index"- 组件目录缺
index.wxss→ 创建该文件
V014 SKILL.md 必须存在且文件名严格大写
- 阶段:
registration,级别:error
每个 skill 目录必须存在文件名严格为 SKILL.md 的文件。skill.md / Skill.md 等大小写变体一律 fail(macOS / Windows 的默认文件系统通常不区分大小写,本地编辑容易绕过检查,但 Linux / CI / 后台严格区分)。
典型修复:把文件重命名为 SKILL.md。在默认不区分大小写的系统上直接改大小写可能"改了等于没改",可先改成临时名再改回:终端执行 mv skill.md tmp && mv tmp SKILL.md(用 git 管理时也可 git mv skill.md SKILL.md)。文件不存在则按 wxa-skills-generate references/CODE_TEMPLATES.md 第五节模板新建。
V015 原子组件必须配置关联小程序页面
- 阶段:
component,级别:error
对 mcp.json.apis[] 中每个声明了 _meta.ui.componentPath 的接口,要求 mcp.json 顶层 components[] 存在一条 path 与该 componentPath 字符串完全相等(含末尾 /index,严格相等、不做归一化)的条目,且其 relatedPage 字段满足:
1. 非空(trim 后非空字符串); 2. 必须以 `/` 开头(绝对路径,强制约束); 3. 去掉前导 / 后,存在于项目 `app.json` 的可路由页面集合中。该集合 = 主包 pages[] ∪ 所有分包 subPackages[](兼容历史命名 subpackages[])的 root + '/' + page 拼接结果;两侧均做去前导/末尾 / 归一化后比对。
读不到项目 app.json 时跳过第 3 条 pages 字面值比对,第 1、2 条仍然生效。
典型修复:在 mcp.json 顶层 components[] 追加 { "path": "<与接口 _meta.ui.componentPath 完全一致的字符串>", "relatedPage": "/<主包 pages[] 中的页面 或 分包 root+page 拼接>" }(注意 relatedPage 前导 / 必填);业务上没有对应页面时兜底用 `/<app.json.pages[0]>` 首页(同样带 `/`)。详见 wxa-skills-generate SKILL.md 的 C.3 节。
V016 app.json 的 agent.skills[].description 必须存在且非空
- 阶段:
registration,级别:error - 类型:项目级校验(仅执行一次,不按 skill 目录循环)
每个 app.json 中 agent.skills[] 的条目必须包含非空的 `description` 字段。这是后台的硬性要求,若缺失将导致 skill 无法正常注册。
检查项:
agent.skills[]数组存在且非空- 每个条目中
description字段存在,且trim()后非空
典型 fail:
agent.skills条目只有{ "path": "skills/xxx" },缺少description{ "name": "xxx", "path": "skills/xxx", "description": "" }—description为空字符串
典型修复:在 app.json 的 agent.skills 中为每个条目补上非空的 description,如:{ "name": "xxx", "description": "该 skill 的业务描述", "path": "skills/xxx" }。
---
规则与错误类型映射
| 规则 id | 错误类型(SKILL.md 中的分类) | 典型修复路径 |
|---|---|---|
| V001 | T5 合规性违规 | 去除主包依赖,数据通过入参传入 |
| V002 | T1 命名/结构 | 函数头补 async |
| V003 / V005 / V006 | T5 合规性违规 | 白名单内等价实现 |
| V007 | T6 注册缺失 | 补 registerAPI 或在 mcp.json 补 apis[] |
| V008 | T6 注册缺失 | 创建缺失的 apis/<name>.js |
| V009 | T2 Schema 不一致 | 对齐 structuredContent 与 outputSchema |
| V010 | T4 组件取值路径错 | 修 result.structuredContent.xxx 访问路径 |
| V011 | T3 组件绑定不一致 | 对齐 setData 与 WXML {{}} |
| V012 | T6 注册缺失(组件维度) | 若已声明 componentPath,补齐组件四件套 / 修正路径格式 |
| V014 | T1 命名/结构 | SKILL.md 文件名严格大写 |
| V015 | T-relatedPage 关联页面缺失 / path 不一致 / 缺前导 / | 在 mcp.json.components[] 补 { path, relatedPage },path 与接口 _meta.ui.componentPath 字符串完全相等(含末尾 /index),relatedPage 必须以 `/` 开头,无业务对应页面时填首页(/<pages[0]>) |
| V016 | T-skill-description skill 描述缺失 | 在 app.json 的 agent.skills[] 中为该条目补 description 字段 |
---
自定义规则
通过 --rules <path> 合并自定义规则 JSON:
{
"rules": [
{
"id": "V100",
"name": "自定义规则示例",
"stage": "registration",
"level": "warning",
"type": "regex_absent",
"targets": ["*/apis/*.js", "*/utils/*.js"],
"patterns": [
{ "regex": "console\\.log", "message": "生产代码不应有 console.log" }
]
}
],
"crossFileRules": []
}相同 id 会覆盖内置规则。
#!/usr/bin/env node
import {
DEFAULT_CLI_PATH, DEFAULT_AUTO_PORT, DEFAULT_TIMEOUT_MS,
callAgentTool, normalizeCliResult, writeJson, parseArgs,
} from "./lib.mjs";
const SPEC = {
project: "string",
name: "string",
args: "string",
output: "string",
"auto-port": "number",
"cli-path": "string",
skill: "string",
timeout: "number",
help: "boolean",
};
const USAGE = `用法: node execute.mjs --project <path> --name <api-name> [options]
必需参数:
--project <path> 项目根目录(含 project.config.json)
--name <name> 原子接口名(对应 mcp.json 中 apis[].name)
可选参数:
--args <json> JSON 参数字符串,例如 '{"query":"手机"}'
--output <path> 落盘路径;缺省时把结果打印到 stdout
--auto-port <port> auto WebSocket 端口(默认 ${DEFAULT_AUTO_PORT})
--cli-path <path> CLI 可执行文件路径(默认 ${DEFAULT_CLI_PATH})
--skill <name|path> skill 名称或路径(CLI 自动按 name 匹配,必要时显式指定)
--timeout <ms> 请求超时(默认 ${DEFAULT_TIMEOUT_MS},仅在非默认值时下发给 CLI)
说明:
toolCallId / sessionId / auto 相关票据全部由 CLI 内部自动处理,脚本不再暴露也不下发。
`;
async function main() {
const opts = parseArgs(process.argv.slice(2), SPEC);
if (opts.help) { console.log(USAGE); process.exit(0); }
if (!opts.project || !opts.name) {
console.error(USAGE);
console.error("\n错误: --project 和 --name 必需。");
process.exit(2);
}
let raw;
try {
raw = await callAgentTool({
project: opts.project,
name: opts.name,
args: opts.args,
autoPort: opts["auto-port"] ?? DEFAULT_AUTO_PORT,
cliPath: opts["cli-path"] ?? DEFAULT_CLI_PATH,
skill: opts.skill,
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
});
} catch (err) {
console.error(`[execute] 调用 CLI 失败: ${err.message}`);
process.exit(2);
}
const { ok, result, error, diag } = normalizeCliResult(raw);
const payload = result ?? {
status: "error",
error: { message: error || "unknown" },
consoleMessages: [],
};
payload._meta = {
...(payload._meta || {}),
project: opts.project,
cliStderr: raw.stderr || undefined,
cliExitCode: raw.code,
};
if (diag) {
payload._meta.diagnosis = diag;
}
if (opts.output) {
const outPath = await writeJson(opts.output, payload);
console.log(`[execute] ${opts.name} saved -> ${outPath}`);
} else {
console.log(JSON.stringify(payload, null, 2));
}
if (diag) {
console.error(`\n⚠️ ${diag.hint}\n`);
}
const invokeError = payload?.invokeResult?.isError === true;
if (ok && !invokeError) {
console.log(`[execute] ${opts.name} RESULT: PASS`);
process.exit(0);
}
console.error(`[execute] ${opts.name} RESULT: FAIL`);
if (error) console.error(` error: ${error}`);
if (invokeError) {
const content = payload?.invokeResult?.content;
if (Array.isArray(content) && content.length) {
console.error(` invokeResult.content: ${JSON.stringify(content, null, 2)}`);
}
}
process.exit(1);
}
main().catch((err) => {
console.error(`[execute] 未处理异常: ${err?.stack || err?.message || err}`);
process.exit(2);
});
import { writeFile, mkdir, access, readFile, unlink } from "node:fs/promises";
import { resolve, dirname, join, basename } from "node:path";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants as FS } from "node:fs";
import { tmpdir } from "node:os";
export const DEFAULT_CLI_PATH = "/Applications/wechatwebdevtools.app/Contents/MacOS/cli";
export const DEFAULT_AUTO_PORT = 9420;
export const DEFAULT_TIMEOUT_MS = 45000;
export function runCli(cliPath, args, timeoutMs = DEFAULT_TIMEOUT_MS) {
return new Promise((res, rej) => {
const stdoutFile = join(tmpdir(), `wxa-cli-stdout-${randomUUID()}.log`);
const stderrFile = join(tmpdir(), `wxa-cli-stderr-${randomUUID()}.log`);
const shellCmd = [
shellQuote(cliPath),
...args.map(shellQuote),
`>${shellQuote(stdoutFile)}`,
`2>${shellQuote(stderrFile)}`,
].join(" ");
let proc;
try {
proc = spawn("/bin/sh", ["-c", shellCmd], { stdio: ["ignore", "ignore", "ignore"] });
} catch (err) {
rej(new Error(`启动 CLI 失败: ${err.message}`));
return;
}
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
try { proc.kill("SIGTERM"); } catch {}
}, timeoutMs);
proc.on("error", (err) => {
clearTimeout(timer);
rej(new Error(`启动 CLI 失败: ${err.message}(cliPath=${cliPath})`));
});
proc.on("close", async (code) => {
clearTimeout(timer);
let stdout = "", stderr = "";
try { stdout = await readFile(stdoutFile, "utf-8"); } catch {}
try { stderr = await readFile(stderrFile, "utf-8"); } catch {}
unlink(stdoutFile).catch(() => {});
unlink(stderrFile).catch(() => {});
let parsed = null;
const trimmed = stdout.trim();
if (trimmed) {
const jsonBody = extractJsonBody(trimmed);
if (jsonBody) { try { parsed = JSON.parse(jsonBody); } catch {} }
}
res({ code, stdout, stderr, parsed, timedOut });
});
});
}
function shellQuote(s) {
if (s === undefined || s === null) return "''";
const str = String(s);
if (str === "") return "''";
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(str)) return str;
return "'" + str.replace(/'/g, "'\\''") + "'";
}
export async function checkCliAvailable(cliPath = DEFAULT_CLI_PATH) {
try { await access(cliPath, FS.X_OK); } catch { return false; }
const { code, timedOut } = await runCli(cliPath, ["-h"], 5000);
return !timedOut && code === 0;
}
export async function startAutoService({
project,
autoPort = DEFAULT_AUTO_PORT,
cliPath = DEFAULT_CLI_PATH,
timeout = 60000,
autoAccount,
testTicket,
ticket,
} = {}) {
if (!project) throw new Error("缺少必需参数: project");
const args = ["auto", "--project", resolve(project), "--auto-port", String(autoPort), "--trust-project"];
if (autoAccount) args.push("--auto-account", autoAccount);
if (testTicket) args.push("--test-ticket", testTicket);
if (ticket) args.push("--ticket", ticket);
return runCli(cliPath, args, timeout);
}
export async function callAgentTool({
project, name, args,
autoPort = DEFAULT_AUTO_PORT, cliPath = DEFAULT_CLI_PATH,
skill,
timeout = DEFAULT_TIMEOUT_MS,
} = {}) {
if (!project) throw new Error("缺少必需参数: project");
if (!name) throw new Error("缺少必需参数: name");
const cliArgs = [
"agent", "tool",
"--project", resolve(project),
"--name", name,
"--auto-port", String(autoPort),
"--trust-project",
];
if (args !== undefined && args !== null && args !== "") {
cliArgs.push("--args", typeof args === "string" ? args : JSON.stringify(args));
}
if (skill) cliArgs.push("--skill", skill);
if (timeout && timeout !== DEFAULT_TIMEOUT_MS) cliArgs.push("--timeout", String(timeout));
return runCli(cliPath, cliArgs, timeout + 5000);
}
export async function callAgentRender({
project,
name,
args,
cliPath = DEFAULT_CLI_PATH,
output,
timeout = DEFAULT_TIMEOUT_MS,
} = {}) {
if (!project) throw new Error("缺少必需参数: project");
if (!name) throw new Error("缺少必需参数: name");
const hasFinalOutput = !!output;
const cliOutput = hasFinalOutput
? resolve(output)
: join(tmpdir(), `wxa-cli-render-${randomUUID()}.json`);
const cliArgs = [
"agent", "render",
"--project", resolve(project),
"--name", name,
"--output", cliOutput,
"--trust-project",
];
if (args !== undefined && args !== null && args !== "") {
cliArgs.push("--args", typeof args === "string" ? args : JSON.stringify(args));
}
if (timeout && timeout !== DEFAULT_TIMEOUT_MS) cliArgs.push("--timeout", String(timeout));
const raw = await runCli(cliPath, cliArgs, timeout + 10000);
let outputJson = null;
try {
const text = await readFile(cliOutput, "utf-8");
outputJson = JSON.parse(text);
} catch {}
const snapshotPngAbs = cliOutput.replace(/\.json$/i, "") + ".snapshot.png";
if (outputJson) {
const extracted = extractSnapshotDataUrl(outputJson);
if (extracted) {
try {
await writeFile(snapshotPngAbs, extracted.buffer);
const summary = {
mime: extracted.mime,
file: basename(snapshotPngAbs),
absolutePath: snapshotPngAbs,
dataUrlLength: extracted.dataUrlLength,
};
replaceSnapshotWithSummary(outputJson, summary);
} catch {}
}
if (hasFinalOutput) {
try {
await writeFile(cliOutput, JSON.stringify(outputJson, null, 2), "utf-8");
} catch {}
}
}
return {
...raw,
parsed: outputJson || raw.parsed,
outputFile: cliOutput,
outputFileKept: hasFinalOutput,
snapshotPngPath: snapshotPngAbs,
};
}
function extractSnapshotDataUrl(json) {
if (!json) return null;
const candidates = [];
if (typeof json.snapshotBase64 === "string") candidates.push(json.snapshotBase64);
if (json.snapshot && typeof json.snapshot.dataUrl === "string") candidates.push(json.snapshot.dataUrl);
for (const url of candidates) {
const m = /^data:([^;]+);base64,([A-Za-z0-9+/=]+)$/.exec(url);
if (!m) continue;
try {
const buffer = Buffer.from(m[2], "base64");
if (buffer.length > 0) return { buffer, mime: m[1], dataUrlLength: url.length };
} catch {}
}
return null;
}
function replaceSnapshotWithSummary(json, summary) {
if (Object.prototype.hasOwnProperty.call(json, "snapshotBase64")) {
delete json.snapshotBase64;
}
json.snapshot = { ...summary };
}
export function normalizeCliResult({ code, stdout, stderr, parsed, timedOut }) {
const diag = _diagnoseAppIdPermission(stdout, stderr, parsed);
if (timedOut) {
return { ok: false, result: null, error: "CLI 命令超时", diag };
}
if (!parsed) {
return {
ok: false, result: null,
error: `CLI 输出非 JSON(code=${code})\nstdout: ${stdout.slice(0, 1000)}\nstderr: ${stderr.slice(0, 1000)}`,
diag,
};
}
const statusOk = parsed.status === "ok";
return {
ok: code === 0 && statusOk,
result: parsed,
error: statusOk ? null : (parsed.error?.message || parsed.message || `status=${parsed.status}`),
diag,
};
}
function _diagnoseAppIdPermission(stdout, stderr, parsed) {
if (/timeout waiting for auto websocket/i.test(stdout)
&& /Fetching AppID/i.test(stderr)
&& /detailed information/i.test(stderr)
&& /✖/.test(stderr)) {
const m = stderr.match(/Fetching AppID\s*\(([^)]+)\)/);
const appid = m ? m[1] : null;
return { type: "appid_no_agent_permission", appid, hint: _appidHint(appid) };
}
const content = parsed?.invokeResult?.content;
if (Array.isArray(content) && content.some(c => c.type === "text" && /agent compile mode is disabled/i.test(c.text || ""))) {
const appid = parsed?.params?.appId || parsed?.params?.hostAppId || null;
return { type: "appid_no_agent_permission", appid, hint: _appidHint(appid) };
}
return null;
}
function _appidHint(appid) {
const id = appid ? ` (${appid})` : "";
return `当前 AppID${id} 可能没有使用小程序 AI 的开发模式权限。若已有权限可直接重试;若需更换 AppID,修改 project.config.json 的 appid 后重跑即可,无需手动重启开发者工具。`;
}
export function genId(prefix = "tc") {
return `${prefix}_${randomUUID()}`;
}
export function extractJsonBody(text) {
if (!text) return null;
let start = -1;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === "{" || c === "[") { start = i; break; }
}
if (start === -1) return null;
const open = text[start];
const close = open === "{" ? "}" : "]";
let depth = 0, inStr = false, esc = false;
for (let i = start; i < text.length; i++) {
const c = text[i];
if (inStr) {
if (esc) { esc = false; continue; }
if (c === "\\") { esc = true; continue; }
if (c === '"') inStr = false;
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === open) depth++;
else if (c === close) {
depth--;
if (depth === 0) return text.slice(start, i + 1);
}
}
return text.slice(start);
}
export async function writeJson(outputPath, data) {
const out = resolve(outputPath);
await mkdir(dirname(out), { recursive: true });
await writeFile(out, JSON.stringify(data, null, 2), "utf-8");
return out;
}
export function parseArgs(argv, spec, positional = []) {
const out = {};
const posQueue = [...positional];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const type = spec[key];
if (type === undefined) continue;
if (type === "boolean") {
out[key] = true;
} else {
const v = argv[++i];
if (v === undefined) continue;
out[key] = type === "number" ? Number(v) : v;
}
} else {
const key = posQueue.shift();
if (key) out[key] = a;
}
}
return out;
}
#!/usr/bin/env node
import { readFile, unlink, mkdir } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import {
DEFAULT_CLI_PATH, DEFAULT_TIMEOUT_MS,
callAgentRender, normalizeCliResult, writeJson, parseArgs,
} from "./lib.mjs";
const SPEC = {
project: "string",
name: "string",
args: "string",
"from-execute": "string",
output: "string",
"cli-path": "string",
timeout: "number",
help: "boolean",
};
const USAGE = `用法: node render.mjs --project <path> [--from-execute <path> | --name <name>] [options]
必需参数:
--project <path> 项目根目录(含 project.config.json + app.json)
二选一:
--from-execute <path> execute 产物 JSON;自动继承 name / args(args 取 invokeResult.structuredContent;缺失时直接报错)
--name <name> 原子接口名(独立调用时必填)
可选参数:
--args <json> JSON 参数字符串;独立调用时建议显式传;--from-execute 会继承
--output <path> 最终 JSON 落盘路径;同时会在同目录生成 <basename>.snapshot.png
缺省时只打印到 stdout(snapshot.dataUrl 仍会被替换为摘要)
--cli-path <path> CLI 可执行文件路径(默认 ${DEFAULT_CLI_PATH})
--timeout <ms> Node 侧 spawn 等待时间(默认 ${DEFAULT_TIMEOUT_MS});仅在非默认值时下发给 CLI
`;
async function loadExecuteContext(path) {
const absPath = resolve(path);
const raw = JSON.parse(await readFile(absPath, "utf-8"));
const params = raw.params || {};
const ir = raw.invokeResult || {};
const sc = ir.structuredContent;
if (!sc || typeof sc !== "object" || Array.isArray(sc)) {
throw new Error(
`execute 产物 ${absPath} 缺少 invokeResult.structuredContent,` +
`render 无法从中继承 args。请先重新执行 execute 并确认 status=ok 且 ` +
`invokeResult.isError!==true、invokeResult.structuredContent 是非空对象后,` +
`再用 --from-execute 传入该产物。`
);
}
return {
name: params.name,
args: JSON.stringify(sc),
};
}
function verifyRender(result) {
const snap = result?.snapshot || {};
const hasSnapshotSummary = !!(snap.file || snap.absolutePath);
const hasRawSnapshot = typeof result?.snapshotBase64 === "string" || typeof snap.dataUrl === "string";
const ir = result?.invokeResult;
const hasInvokeResult = ir && typeof ir === "object";
const checks = {
statusOk: result?.status === "ok",
commandIsRender: result?.command === "render",
hasSnapshot: hasSnapshotSummary || hasRawSnapshot,
noInvokeError: !hasInvokeResult || ir.isError !== true,
invokeResultOk: !hasInvokeResult || (ir.isError !== true),
};
const passed = checks.statusOk && checks.commandIsRender && checks.hasSnapshot && checks.noInvokeError;
return { passed, checks };
}
async function main() {
const opts = parseArgs(process.argv.slice(2), SPEC);
if (opts.help) { console.log(USAGE); process.exit(0); }
if (!opts.project) {
console.error(USAGE);
console.error("\n错误: --project 必需。");
process.exit(2);
}
if (!opts["from-execute"] && !opts.name) {
console.error(USAGE);
console.error("\n错误: --from-execute 或 --name 至少需提供一个。");
process.exit(2);
}
let ctx = {};
if (opts["from-execute"]) {
try {
ctx = await loadExecuteContext(opts["from-execute"]);
} catch (err) {
console.error(`[render] 读取 --from-execute 失败: ${err.message}`);
process.exit(2);
}
}
const name = opts.name || ctx.name;
if (!name) {
console.error("[render] 无法确定 --name(from-execute 中未含 params.name)");
process.exit(2);
}
const finalOutput = opts.output ? resolve(opts.output) : null;
if (finalOutput) await mkdir(dirname(finalOutput), { recursive: true });
let raw;
try {
raw = await callAgentRender({
project: opts.project,
name,
args: opts.args || ctx.args,
cliPath: opts["cli-path"] ?? DEFAULT_CLI_PATH,
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
output: finalOutput || undefined,
});
} catch (err) {
console.error(`[render] 调用 CLI 失败: ${err.message}`);
process.exit(2);
}
const { ok, result, error, diag } = normalizeCliResult(raw);
const payload = result ?? {
command: "render",
status: "error",
error: { message: error || "unknown" },
consoleMessages: [],
};
const { passed, checks } = verifyRender(payload);
payload._meta = {
...(payload._meta || {}),
name,
snapshotPng: raw.snapshotPngPath,
verify: { passed, checks },
cliStderr: raw.stderr || undefined,
cliExitCode: raw.code,
};
if (diag) {
payload._meta.diagnosis = diag;
}
if (finalOutput) {
await writeJson(finalOutput, payload);
console.log(`[render] ${name} saved -> ${finalOutput}`);
if (payload?.snapshot?.file) {
console.log(`[render] ${name} snapshot -> ${raw.snapshotPngPath}`);
}
} else {
console.log(JSON.stringify(payload, null, 2));
unlink(raw.outputFile).catch(() => {});
if (raw.snapshotPngPath) unlink(raw.snapshotPngPath).catch(() => {});
}
console.log(`[render] ${name} checks: ${JSON.stringify(checks)}`);
if (diag) {
console.error(`\n⚠️ ${diag.hint}\n`);
}
if (ok && passed) {
console.log(`[render] ${name} RESULT: PASS`);
process.exit(0);
}
console.error(`[render] ${name} RESULT: FAIL`);
if (error) console.error(` error: ${error}`);
process.exit(1);
}
main().catch((err) => {
console.error(`[render] 未处理异常: ${err?.stack || err?.message || err}`);
process.exit(2);
});