页面加载中...
校验和修复小程序 AI SKILLs 产物。在以下场景触发:对 skills/ 目录做静态校验、跑通原子接口、验证原子组件渲染、修复校验报错、输出交付文档。依托微信开发者工具进行真机验证。
对小程序 AI SKILLs 产物执行"静态校验 → 原子接口执行 → 原子组件渲染 → 交付文档"的闭环校验,并在每一步失败时按错误类型分类就地修复 skill 源文件。
scripts/*.mjs 用到 node:crypto / 原生 fetch)<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出现下列任一情况时启动本技能:
| 项 | 说明 | 缺失时动作 |
|---|---|---|
<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~V017 规则详解 | 出现校验报错需定位 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 节)。阶段 1 — 静态校验 + 编译校验 - [ ] 运行 `node validate.mjs <project-path>`(单参数,脚本自动发现 skill 分包并决定是否跑 preview) - [ ] summary.errors === 0(含 V001~V017),否则按 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(文件会被覆盖);不同接口必须用不同文件名。
运行:
node <skill-dir>/scripts/validate.mjs <miniprogram-project-path>
入参只需要一个——小程序项目根目录(含 project.config.json + app.json)。脚本自动:
app.json 的 agent.skills[].path 发现 skill 分包(没配置时回退到顶层 metaServicePkg/ 或 skills/)cli-agent-run/ 写入 project.config.json 的 packOptions.ignore(打包忽略)和 watchOptions.ignore(监听忽略),避免开发者工具持续监听产物变更触发循环编译(已存在不会重复追加;产物落盘前完成同步,结果挂在报告 ignoreSync 字段)errors === 0)后自动调用 cli preview 做编译校验;有 error 则跳过 preview<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" 意味着静态未过,先按修复决策表修复)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。
执行顺序(脚本内部闭环):
project.config.json 的 packOptions.ignore + watchOptions.ignore(追加 cli-agent-run/)skipped(节省 preview 成本);0 error → 调 cli previewupload 阶段视为编译通过;即便上传失败(服务端校验、网络等),也不标记 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 字段 |
| T-handoff | 接力页 _meta.ui.pagePath 格式错/页面不存在/带 query,或声明了 pagePath 却未返回 handoff(V017) | mcp.json + apis/{name}.js | pagePath 以 / 开头、不含 query、页面真实存在;返回值顶层补 handoff: { query, payload? }(详见 wxa-skills-generate SKILL.md C.3.3) |
| T9 能力无法实现 | 所有候选都违反硬约束 | — | ⛔ 终止,告知用户 |
V001~V017 规则详情见 references/VALIDATE_RULES.md。
判别口诀:文件内能改完 → T1~T6;需改 storage 清单或接口划分 → T7/T8;连修复方案都违规 → T9。
迭代规则:
| 情况 | 动作 |
|---|---|
summary.errors === 0 | ✅ 进入阶段 2 |
| errors 数较上一轮减少 | 继续修复,重跑 |
| 连续 3 轮相同 finding id | 升级为 T7/T8 跨文件调整 |
| 累计 5 轮仍未通过 | ⛔ 终止,请求人工介入 |
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。
读取 <project-path> 下 app.json 发现的每个 skill 分包的 mcp.json(validate-report.json 中的 skillDirs 字段给出了具体分包路径):
apis[] 的 name / description / inputSchema / outputSchema / _meta.ui.componentPath。description 或 inputSchema 含 "需要先调用 X" 类表述时,将 X 前置。structuredContent 存入池中,供下游参数引用。execute 和 render 是两个独立可重入的命令:
execute 调用原子接口,产出业务数据(invokeResult.structuredContent)。render 通过 --from-execute 把 execute 的 invokeResult.structuredContent 作为渲染数据源喂给组件;
也可以 --name + --args 独立指定。CLI 内部每次 render 会自动生成一次性 toolCallId / sessionId,
不依赖 execute 的运行时上下文。执行灵活度:
--args 显式指定 > --from-execute 读到的 invokeResult.structuredContent硬约束(仅保留真正必要的):
apis[] 顺序依赖关系准备好入参(下游接口的 args 若依赖上游 structuredContent,仍需先 execute 上游)componentPath 的接口最终都要 render 通过;完整通过的判据仍然是"execute 成功 + render 5 项核对通过"render.mjs 不能并发执行(CLI 后台 auto 是串行的)--from-execute 的 execute 产物必须含 invokeResult.structuredContent;若缺失,render.mjs 会直接报错,需先重新 execute 成功后再 render运行:
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>.json
execute.mjs 只接受 上述参数;toolCallId / sessionId / auto 相关票据由 CLI 内部自动处理,脚本不再暴露。
入参来源优先级:
structuredContent 同名/同义字段inputSchema 允许为空 → 省略 --args""、number 0、array []、object {}),日志标注"使用默认值"成功判据:status === "ok" 且 invokeResult.isError !== true 且 invokeResult.structuredContent 为非空对象
(后者是 render --from-execute 的前置条件)。
execute 失败:先检查产物 _meta.diagnosis 是否为不可修复类(若是则立即停止),否则按下方"阶段 4 失败分类"的 A/B/C/D 类处理。
_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>.json
render.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 不足以判通过):
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 节。任一不通过 → 留在本接口继续修复。
每个带 componentPath 的接口都满足下列全部才允许标为通过:
execute-result.<name>.json,其 status === "ok" 且 invokeResult.isError !== truerender-result.<name>.jsonconsoleMessages.snapshotCard 中存在 [ai-mode] ... overflow monitor=on 基线日志、且不出现 overflowed=true;截图仅在具备图像读取能力时作为辅助信号)在进入修复流程之前,首先检查产物的 _meta.diagnosis 字段。若非 null,说明脚本已自动识别为环境问题,必须立即停止,禁止尝试修改代码。
特征(以下任一即命中):
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 权限状态:
project.config.json 的 appid 字段后重跑 execute / render- [ ] 步骤 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 次
核心原则:真相只在主包里。分包是独立运行的拷贝,逻辑差异以主包为准。禁止在未读主包源码的情况下臆测修改。
特征:missing required parameter / xxx is undefined / 参数格式错误。
--args(上限 3 次)apis/<name>.js 入参拼装 → 重跑特征:no data / getStorageSync 返回 null。
<skill>/SKILL.md 的 storage 清单中找写入方接口,先跑一次再重跑当前接口(上限 2 次)app.js 初始化 → 读主包 → 在 utils/util.js 的 ensureStorageInit() 补初始化逻辑 → 重跑特征:network / timeout / 500 / unauthorized / not registered / JS 抛异常 / 返回字段与 outputSchema 不一致。
execute-result.<name>.json 的 invokeResult.error + consoleMessages([ai-mode] 前缀日志)锁定失败步骤.js:拼装入参(headers / token / 签名 / 查询串)utils/request.js / utils/http.js / api/*.js:baseUrl / 鉴权头 / 错误码 / 返回结构(是否包了 data/code/msg)app.js:wx.cloud.init({ env }) / 全局 token / globalDatacloudfunctions/<fn>/index.js 的真实字段名apis/<name>.js / utils/util.js,不重写整个文件components/<name>/index.js 的访问路径与 index.wxml 绑定mcp.json 的 apis[] 结构 + ensureStorageInit,重跑阶段 1report.md 记录后终止本接口特征:运行时报 wx.<xxx> is not a function / Cannot read property '<xxx>' of undefined(wx.<ns> 为 undefined)。
优先假设不是代码写错,而是该 JSAPI 不在技能分包白名单内。按以下顺序处理:
SKILL.md 的 C.1(接口侧)/ C.2(组件侧)白名单(完整清单见 wxa-skills-generate/references/JSAPI_WHITELIST.md)wx.request 在组件侧是否漏声明 permissions["scope.dynamic"]chooseImage → chooseMedia)或改网络请求实现if (wx.x) / try/catch 吞异常当修好特征:运行时报 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/):
app.json 的 subPackages 是否把 skills 声明为独立分包(缺此项是最常见原因):
"subPackages": [ { "root": "skills", "name": "skills", "pages": [], "independent": true } ]
root 必须是 skills 目录的相对路径;independent 必须为 true;pages 可为 []app.json 的 agent.skills[].path 是否指向 skills/<skill>(与 subPackages.root 一致)project.config.json 的 packOptions.include 是否包含 { "type": "folder", "value": "skills" }(否则 CLI 构建时不打包该目录)skills/<skill>/index.js 实际存在,且其中通过 wx.modelContext.registerAPI('<name>', fn) 注册了报错对应的 <name>apis/<name>.js 的代码对照 wxa-skills-generate
SKILL.md阶段 6 与references/CODE_TEMPLATES.md第六节 的配置片段做核对,不要乱写。
consoleMessages.snapshotCard(主要依据,尤其是 [ai-mode] ... overflow 日志)+ elementTree(若 CLI 下发,辅助核对字段绑定 / 列表长度 / 空状态文案);仅在环境具备图像读取能力时,再以图像方式 read_file 读 snapshot.png 作为样式还原度的辅助信号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)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 中核验components/<name>/ 后重跑 render(UI 类改动不需要重新 execute,可通过 --from-execute 复用现有产物)→ 再次读 render-result.<name>.json 的 consoleMessages.snapshotCard:基线 overflow monitor=on 日志必须存在、且不出现 overflowed=true(具备图像读取能力时可附加读取截图作为样式还原度的辅助信号),不得仅依据退出码 0 放行curl / fetch / HTTP 工具直接验证网络接口。原因:① 小程序沙箱的鉴权上下文(session / cookie / 签名 / wx.login code)在终端无法复现;② 验证结果对 skill 分包无参考价值。网络请求改动只能通过 execute.mjs 验证。--from-execute 复用已有 execute 产物(args 取自 invokeResult.structuredContent);但 render 关心的是 UI 渲染正确,因此若改动会影响接口返回数据(改 apis/ / outputSchema),需要先重新 execute 再 render,不应用旧产物;仅改 UI(wxml/wxss/components/index.js)时可复用。<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 }
满足任一即终止:
api execute 成功 + 有 componentPath 的接口 5 项核对全部通过_meta.diagnosis 非 null)→ 立即停止,向用户确认权限或获取新 AppID./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`:<一行摘要>
./DELIVERY.md(仅在全部接口通过时输出,强制)终止条件 1 成立时必须产出:
./DELIVERY.md(项目根;用户指定其它路径时以用户为准,但必须是 .md)references/DELIVERY_TEMPLATE.md,所有 {占位符} 必须替换为实际值execute-trace.json 存在时在"已知限制"节引用仅输出 report.md 不算任务完成;DELIVERY.md 才是最终交付物。
mcp.json 的 apis[] / utils/util.js 的 storage 逻辑 / index.js 的 registerAPI,重跑 validateapis/<name>.js 入参拼装或 utils/util.js 的 ensureStorageInit,重跑 executeapis/<name>.js / utils/util.js,必要时调整 mcp.json 的 outputSchema 与组件取值路径,重跑 executecomponents/<name>/ 的 wxml/wxss/js,重跑 rendercomponentPath 的原子组件都必须通过;挂起仅限"连续 5 轮仍未通过"硬上限consoleMessages.snapshotCard 做判断,不能只看 execute-result;具备图像读取能力时再辅助读截图consoleMessages.snapshotCard 中存在 [ai-mode] ... overflow monitor=on 基线日志且不出现 overflowed=true 为准(缺 monitor=on = 未接入监听,按不通过处理);样式还原度在具备图像读取能力时再读 snapshot.png 作为辅助信号,否则以 elementTree 字段完整性兜底mcp.json 的 apis[] 依赖关系安排 execute 顺序;存在上游依赖时,上游 execute 必须先于下游。render 无此顺序约束#!/usr/bin/env node
import { readFile, writeFile, stat, readdir, mkdir, unlink, access } from "node:fs/promises";
import { constants as FS } from "node:fs";
import { join, resolve, relative, dirname, sep } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { runCli, DEFAULT_CLI_PATH } from "./lib.mjs";
const BUILTIN_RULES = {
rules: [
{
id: "V001", name: "禁止依赖主包",
stage: "registration", level: "error", type: "regex_absent",
targets: ["**/*.js"],
patterns: [
{ regex: "getApp\\s*\\(\\s*\\)", message: "独立分包中禁止使用 getApp()" },
{ regex: "import\\s+[^;]+from\\s+['\"]@/", message: "禁止 import 主包模块(@/)" },
],
escapeCheck: [
{ regex: "require\\s*\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)", message: "require 引用超出 skill 分包边界" },
{ regex: "import\\s+[^;]+from\\s+['\"]([^'\"]+)['\"]", message: "import 引用超出 skill 分包边界" },
],
},
{
id: "V003", name: "WXML 组件白名单",
stage: "component", level: "error", type: "regex_absent",
targets: ["*/components/*/index.wxml"],
patterns: [
{ regex: "<(?!view|text|image|map|button|canvas|scroll-view|block|template|\\/|!--)[a-zA-Z]", message: "WXML 仅允许 view/text/image/map/button/canvas/scroll-view 七种组件(含 block/template/注释),不支持 navigator/swiper/input/textarea/picker/checkbox/radio 等" },
{ regex: "<button[^>]*\\sopen-type\\s*=", message: "原子组件的 button 不支持 open-type 属性(任何 open-type 值均不允许),改为 bindtap + 调用对应 JSAPI" },
{ regex: "<scroll-view(?![^>]*\\sscroll-x(?:[\\s=>]|$))", message: "原子组件的 scroll-view 必须显式声明 scroll-x(仅支持横向滚动),如:<scroll-view scroll-x=\"true\">" },
{ regex: "<scroll-view[^>]*\\sscroll-y\\s*=\\s*[\"']?(?:true|\\{\\{\\s*true\\s*\\}\\})", message: "原子组件的 scroll-view 不支持纵向滚动(scroll-y),仅支持横向滚动(scroll-x)" },
],
},
{
id: "V005", name: "CSS 禁止属性",
stage: "component", level: "error", type: "regex_absent",
targets: ["*/components/*/index.wxss"],
patterns: [
{ regex: "position\\s*:\\s*fixed", message: "不支持 position: fixed(仅支持 relative/absolute)" },
{ regex: "position\\s*:\\s*sticky", message: "不支持 position: sticky" },
{ regex: "z-index\\s*:", message: "不支持 z-index" },
{ regex: "display\\s*:\\s*grid", message: "不支持 display: grid" },
{ regex: "display\\s*:\\s*table", message: "不支持 display: table" },
{ regex: "display\\s*:\\s*inline-flex", message: "不支持 display: inline-flex" },
{ regex: "float\\s*:", message: "不支持 float" },
{ regex: "text-decoration\\s*:", message: "不支持 text-decoration" },
{ regex: "--[a-zA-Z][\\w-]*\\s*:", message: "不支持 CSS 变量(--*)" },
{ regex: "transition\\s*:[^;]*(?!opacity|transform)[a-zA-Z-]+", message: "transition 仅支持 opacity 和 transform" },
],
},
{
id: "V006", name: "CSS 禁止选择器",
stage: "component", level: "error", type: "regex_absent",
targets: ["*/components/*/index.wxss"],
patterns: [
{ regex: ">\\s*[.#a-zA-Z][\\w-]*\\s*\\{", message: "不支持子选择器 >" },
{ regex: "\\+\\s*[.#a-zA-Z][\\w-]*\\s*\\{", message: "不支持相邻兄弟选择器 +" },
{ regex: "~\\s*[.#a-zA-Z][\\w-]*\\s*\\{", message: "不支持通用兄弟选择器 ~" },
{ regex: "::[a-z-]+\\s*\\{", message: "不支持伪元素选择器 ::" },
{ regex: ":(hover|focus|active|checked|disabled|first-child|last-child|nth-child)", message: "不支持伪类选择器" },
{ regex: "\\[[a-zA-Z][\\w-]*[\\^$*~|]?=", message: "不支持属性选择器 []" },
],
},
],
crossFileRules: [
{ id: "V002", name: "已注册接口必须为 async function", stage: "registration", level: "error" },
{ id: "V007", name: "定义-注册一致性", stage: "registration", level: "error" },
{ id: "V008", name: "注册-实现一致性", stage: "registration", level: "error" },
{ id: "V009", name: "接口返回值-outputSchema一致性", stage: "output", level: "error" },
{ id: "V010", name: "组件取值-接口返回一致性", stage: "component", level: "error" },
{ id: "V011", name: "setData-WXML绑定一致性", stage: "component", level: "error" },
{ id: "V012", name: "原子接口若关联原子组件则需文件齐全", stage: "component", level: "error",
pathPattern: "^components/[\\w-]+/index$", requireFiles: ["index.js", "index.json", "index.wxml", "index.wxss"] },
{ id: "V013", name: "mcp.json 体积限制", stage: "registration", level: "error",
maxChars: 24000 },
{ id: "V014", name: "SKILL.md 必须存在且文件名严格大写", stage: "registration", level: "error" },
{ id: "V016", name: "app.json 的 agent.skills[].description 必须存在且非空", stage: "registration", level: "error" },
{ id: "V017", name: "handoff 接力页 pagePath 校验", stage: "registration", level: "error" },
],
};
const mk = (r, status, message, extra = {}) => ({ id: r.id, stage: r.stage, level: extra.level ?? r.level, status, message, ...extra });
const pass = (r, message, extra = {}) => mk(r, "pass", message, extra);
const fail = (r, message, extra = {}) => mk(r, "fail", message, extra);
async function glob(patterns, basePath) {
const out = new Set();
for (const p of patterns) for (const f of await matchPattern(p, basePath)) out.add(f);
return [...out];
}
async function matchPattern(pattern, basePath) {
async function traverse(dir, parts) {
if (parts.length === 0) {
try { return (await stat(dir)).isFile() ? [dir] : []; } catch { return []; }
}
const [cur, ...rest] = parts;
if (cur === "**") {
const res = [...await traverse(dir, rest)];
try {
for (const e of await readdir(dir, { withFileTypes: true }))
if (e.isDirectory()) res.push(...await traverse(join(dir, e.name), parts));
} catch {}
return res;
}
if (cur.includes("*")) {
const re = new RegExp("^" + cur.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$");
const res = [];
try {
for (const e of await readdir(dir, { withFileTypes: true })) {
if (!re.test(e.name)) continue;
const fp = join(dir, e.name);
if (rest.length === 0) { if (e.isFile()) res.push(fp); }
else if (e.isDirectory()) res.push(...await traverse(fp, rest));
}
} catch {}
return res;
}
const fp = join(dir, cur);
try {
const s = await stat(fp);
if (rest.length === 0) return s.isFile() ? [fp] : [];
if (s.isDirectory()) return traverse(fp, rest);
} catch {}
return [];
}
return traverse(resolve(basePath), pattern.split("/"));
}
function findMatchingBracket(text, openIdx) {
const open = text[openIdx];
const close = { "{": "}", "[": "]", "(": ")" }[open];
if (!close) return -1;
let depth = 1, inStr = false, strCh = "", esc = false;
for (let i = openIdx + 1; i < text.length; i++) {
const c = text[i];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === strCh) inStr = false;
continue;
}
if (c === "'" || c === '"' || c === "`") { inStr = true; strCh = c; continue; }
if (c === open) depth++;
else if (c === close) { depth--; if (depth === 0) return i; }
}
return -1;
}
const parseMcpApis = raw => (raw && typeof raw === "object" && Array.isArray(raw.apis)) ? raw.apis : [];
const extractMcpNames = apis => apis.filter(x => x && typeof x.name === "string").map(x => x.name);
function extractRegisterNames(js) {
return [...js.matchAll(/registerAPI\s*\(\s*['"](\w+)['"]/g)].map(m => m[1]);
}
function extractRequireNames(js) {
return [...js.matchAll(/require\s*\(\s*['"]\.\/apis\/(\w+)['"]/g)].map(m => m[1]);
}
function extractStructuredFields(js) {
const fields = [];
for (const m of js.matchAll(/structuredContent\s*:\s*\{([^}]+)\}/g))
for (const fm of m[1].matchAll(/(\w+)\s*:/g)) fields.push(fm[1]);
return fields;
}
function extractOutputSchemaFields(schema) {
return schema?.properties ? Object.keys(schema.properties) : [];
}
function extractPropertiesFields(js) {
const fields = new Set();
for (const m of js.matchAll(/properties\s*:\s*\{([^}]+)\}/g))
for (const fm of m[1].matchAll(/(\w+)\s*:/g)) fields.add(fm[1]);
return fields;
}
function extractComponentStructuredFields(js) {
return [...js.matchAll(/result\.structuredContent\.(\w+)/g)].map(m => m[1]);
}
function collectTopLevelKeys(body, sink) {
let depth = 0, inStr = false, strCh = "", esc = false, cur = "";
const tokens = [];
for (const c of body) {
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === strCh) inStr = false;
cur += c; continue;
}
if (c === "'" || c === '"' || c === "`") { inStr = true; strCh = c; cur += c; continue; }
if ("{[(".includes(c)) { depth++; cur += c; continue; }
if ("}])".includes(c)) { depth--; cur += c; continue; }
if (c === "," && depth === 0) { tokens.push(cur); cur = ""; continue; }
cur += c;
}
if (cur.trim()) tokens.push(cur);
for (const t of tokens) {
const s = t.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "").trim();
if (!s || s.startsWith("...")) continue;
let d = 0, iS = false, sCh = "", e = false, colon = -1;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (iS) { if (e) e = false; else if (c === "\\") e = true; else if (c === sCh) iS = false; continue; }
if (c === "'" || c === '"' || c === "`") { iS = true; sCh = c; continue; }
if ("{[(".includes(c)) d++;
else if ("}])".includes(c)) d--;
else if (c === ":" && d === 0) { colon = i; break; }
}
if (colon === -1) continue;
let key = s.slice(0, colon).trim().replace(/^['"`]|['"`]$/g, "");
if (key.startsWith("[")) continue;
key = key.split(".")[0];
if (/^[A-Za-z_$][\w$]*$/.test(key)) sink.add(key);
}
}
function extractSetDataFields(js) {
const fields = new Set();
for (let i = js.indexOf("setData"); i !== -1; i = js.indexOf("setData", i + 7)) {
let j = i + 7;
while (j < js.length && /\s/.test(js[j])) j++;
if (js[j] !== "(") continue;
const close = findMatchingBracket(js, j);
if (close === -1) continue;
const arg = js.slice(j + 1, close).replace(/^\s+/, "");
if (!arg.startsWith("{")) continue;
const innerClose = findMatchingBracket(arg, 0);
if (innerClose !== -1) collectTopLevelKeys(arg.slice(1, innerClose), fields);
}
for (const m of js.matchAll(/setData\s*\(\s*\{[^}]*['"]([\w.]+)['"]\s*:/g)) fields.add(m[1].split(".")[0]);
for (const entry of ["Component", "Page"]) {
const entryRe = new RegExp("\\b" + entry + "\\s*\\(\\s*\\{", "g");
for (const m of js.matchAll(entryRe)) {
const rootOpen = m.index + m[0].length - 1;
const rootClose = findMatchingBracket(js, rootOpen);
if (rootClose === -1) continue;
let depth = 1, inStr = false, strCh = "", esc = false;
for (let k = rootOpen + 1; k < rootClose; k++) {
const c = js[k];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === strCh) inStr = false;
continue;
}
if (c === "'" || c === '"' || c === "`") { inStr = true; strCh = c; continue; }
if (c === "{") {
if (depth === 1 && /(^|[^A-Za-z0-9_$])data\s*:\s*$/.test(js.slice(Math.max(0, k - 32), k))) {
const dClose = findMatchingBracket(js, k);
if (dClose !== -1) {
collectTopLevelKeys(js.slice(k + 1, dClose), fields);
k = dClose;
continue;
}
}
depth++;
} else if (c === "}") depth--;
}
}
}
return [...fields];
}
function extractWxmlBindings(wxml) {
const scope = new Set(["item", "index"]);
for (const m of wxml.matchAll(/wx:for-item\s*=\s*["']([\w$]+)["']/g)) scope.add(m[1]);
for (const m of wxml.matchAll(/wx:for-index\s*=\s*["']([\w$]+)["']/g)) scope.add(m[1]);
for (const m of wxml.matchAll(/<wxs[^>]*\smodule\s*=\s*["']([\w$]+)["']/g)) scope.add(m[1]);
const stripped = wxml.replace(/<template\s+name=[^>]*>[\s\S]*?<\/template>/g, "");
const fields = [];
for (const m of stripped.matchAll(/\{\{([\w.$]+)/g)) {
const f = m[1].split(".")[0];
if (!f || scope.has(f) || /^(true|false|null|undefined)$/.test(f) || /^\d/.test(f)) continue;
if (!fields.includes(f)) fields.push(f);
}
return fields;
}
function hasDynamicSetData(js) {
const signals = [
[/setData\s*\(\s*(?!\{)[A-Za-z_$]/m, "setData 传入变量(非对象字面量)"],
[/setData\s*\(\s*\{[^}]*\.\.\./, "setData 使用了 ...展开"],
[/setData\s*\(\s*\{[^}]*['"][A-Za-z_$][\w$]*\[/, "setData 使用了数组下标路径 key"],
[/\bbehaviors\s*:\s*\[|\bBehavior\s*\(/, "组件使用 behaviors,可能注入额外 data/属性"],
[/\bobservers\s*:\s*\{/, "组件使用 observers 动态派生字段"],
];
for (const [re, reason] of signals) if (re.test(js)) return { reason };
return null;
}
const API_IMPL_CANDIDATES = ["apis", "tools/services", "tools"];
const exists = p => stat(p).then(() => true).catch(() => false);
async function resolveApiFile(skillDir, name) {
for (const sub of API_IMPL_CANDIDATES) {
const p = join(skillDir, sub, `${name}.js`);
if (await exists(p)) return { path: p, subdir: sub };
}
return null;
}
async function listApiFiles(skillDir) {
const out = [];
for (const sub of API_IMPL_CANDIDATES) {
try {
for (const e of await readdir(join(skillDir, sub), { withFileTypes: true })) {
if (!e.isFile() || !e.name.endsWith(".js")) continue;
if (["util.js", "index.js"].includes(e.name) || e.name.endsWith("Store.js")) continue;
out.push({ path: join(skillDir, sub, e.name), subdir: sub, name: e.name.slice(0, -3) });
}
} catch {}
}
return out;
}
async function isSkillRoot(p) {
return (await exists(join(p, "mcp.json"))) || (await exists(join(p, "index.js")));
}
async function findSkillDirs(skillsPath) {
if (await isSkillRoot(skillsPath)) return [skillsPath];
const dirs = [];
try {
for (const e of await readdir(skillsPath, { withFileTypes: true })) {
if (e.isDirectory() && await isSkillRoot(join(skillsPath, e.name))) dirs.push(join(skillsPath, e.name));
}
} catch {}
return dirs;
}
async function findComponentDirs(skillPath) {
const dirs = [];
try {
for (const e of await readdir(join(skillPath, "components"), { withFileTypes: true }))
if (e.isDirectory()) dirs.push(join(skillPath, "components", e.name));
} catch {}
return dirs;
}
async function checkSingleRule(rule, skillsPath, ctx = {}) {
const files = await glob(rule.targets, skillsPath);
const ex = rule.exclude?.length ? new Set(await glob(rule.exclude, skillsPath)) : null;
const filtered = ex ? files.filter(f => !ex.has(f)) : files;
if (filtered.length === 0) {
if (rule.type === "regex_absent") return [pass(rule, `${rule.name}: 无匹配文件,自动通过`)];
if (rule.passIfNoFiles) return [pass(rule, `${rule.name}: 无匹配文件,跳过(skill 可能使用聚合入口)`)];
return [fail(rule, `${rule.name}: 无匹配文件`, { fix: `检查路径模式 ${rule.targets.join(", ")}` })];
}
const results = [];
const skillRoots = await findSkillDirs(skillsPath);
const skillRootOf = fp => skillRoots.find(r => fp === r || fp.startsWith(r + sep)) || skillsPath;
const packageRoot = ctx.packageRoot || skillsPath;
const siblingSkillRoots = (ctx.allSkillRoots || skillRoots).filter(r => r !== skillsPath);
for (const fp of filtered) {
const rel = relative(skillsPath, fp);
if (rule.type === "file_exists") {
try { await stat(fp); results.push(pass(rule, `${rule.name}: ${rel} 存在`, { file: rel })); }
catch { results.push(fail(rule, `${rule.name}: ${rel} 不存在`, { file: rel, fix: `创建文件 ${rel}` })); }
continue;
}
let content;
try { content = await readFile(fp, "utf-8"); }
catch { results.push(fail(rule, `${rule.name}: 无法读取 ${rel}`, { file: rel, fix: "检查文件权限" })); continue; }
for (const p of rule.patterns ?? []) {
const re = new RegExp(p.regex);
const hit = re.test(content);
if (rule.type === "regex_present") {
results.push(hit
? pass(rule, `${rule.name}: ${p.message}`, { file: rel })
: fail(rule, `${rule.name}: ${rel} - ${p.message}`, { file: rel, fix: `在 ${rel} 中添加匹配 /${p.regex}/ 的内容` }));
} else if (rule.type === "regex_absent") {
results.push(!hit
? pass(rule, `${rule.name}: ${rel} - ${p.message}(未发现违规)`, { file: rel })
: fail(rule, `${rule.name}: ${rel} - ${p.message}`, { file: rel, fix: `在 ${rel} 中移除匹配 /${p.regex}/ 的内容` }));
}
}
if (Array.isArray(rule.escapeCheck) && rule.escapeCheck.length) {
results.push(...checkRelativeEscape(rule, fp, rel, content, skillRootOf(fp), skillsPath, packageRoot, siblingSkillRoots));
}
}
return results;
}
function checkRelativeEscape(rule, fp, rel, content, skillRoot, skillsPath, packageRoot = skillRoot, siblingSkillRoots = []) {
const pkgAbs = resolve(packageRoot);
const skillAbs = resolve(skillRoot);
const fileDir = dirname(fp);
const inside = (abs, root) => abs === root || abs.startsWith(root + sep);
const escapes = [];
for (const p of rule.escapeCheck) {
for (const m of content.matchAll(new RegExp(p.regex, "g"))) {
const spec = m[1];
if (!spec || !spec.startsWith(".")) continue;
const abs = resolve(fileDir, spec);
if (!inside(abs, pkgAbs)) {
escapes.push({ spec, abs, message: p.message });
continue;
}
const intoOtherSkill = siblingSkillRoots.find(r => inside(abs, resolve(r)) && !inside(abs, skillAbs));
if (intoOtherSkill) {
escapes.push({
spec, abs,
message: `相对引用落入另一个 skill 的私有目录(${relative(packageRoot, intoOtherSkill)})`,
});
}
}
}
if (escapes.length === 0) {
return [pass(rule, `${rule.name}: ${rel} - 相对引用均在分包内(未发现违规)`, { file: rel })];
}
const pkgName = packageRoot.split(sep).pop() || ".";
return escapes.map(e => fail(rule, `${rule.name}: ${rel} - ${e.message}:'${e.spec}' → ${relative(skillsPath, e.abs)}`, {
file: rel,
fix: `保持引用在分包 "${pkgName}/" 子树内;跨 skill 共享请抽到分包根下 _shared/ 公共目录`,
}));
}
const CROSS_CHECKERS = {
V002: checkV002_AsyncApi,
V007: checkV007_DefineRegister,
V008: checkV008_RegisterImpl,
V009: checkV009_OutputSchema,
V010: checkV010_ComponentApi,
V011: checkV011_WxmlSetData,
V012: checkV012_ComponentFiles,
V013: checkV013_McpSize,
V014: checkV014_SkillMdCase,
V016: checkV016_SkillDescription,
V017: checkV017_HandoffPagePath,
};
async function checkCrossFileRule(rule, skillsPath, ctx = {}) {
return (CROSS_CHECKERS[rule.id] || (async () => []))(rule, skillsPath, ctx);
}
async function checkV002_AsyncApi(rule, skillsPath) {
const out = [];
for (const skillDir of await findSkillDirs(skillsPath)) {
let mcpNames;
try { mcpNames = extractMcpNames(parseMcpApis(JSON.parse(await readFile(join(skillDir, "mcp.json"), "utf-8")))); }
catch { continue; }
if (mcpNames.length === 0) continue;
for (const name of mcpNames) {
const found = await resolveApiFile(skillDir, name);
if (!found) continue;
const fileRel = relative(skillsPath, found.path);
let body;
try { body = await readFile(found.path, "utf-8"); }
catch { out.push(fail(rule, `${rule.name}: ${fileRel} 无法读取`, { file: fileRel, fix: "检查文件权限" })); continue; }
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const asyncPatterns = [
new RegExp(`async\\s+function\\s+${esc}\\b`),
new RegExp(`\\b(?:const|let|var)\\s+${esc}\\s*=\\s*async\\b`),
new RegExp(`\\b${esc}\\s*=\\s*async\\b`),
new RegExp(`(?:^|[,{\\s])${esc}\\s*:\\s*async\\b`),
new RegExp(`(?:^|[,{\\s])async\\s+${esc}\\s*\\(`),
];
const isAsync = asyncPatterns.some(re => re.test(body));
if (isAsync) {
out.push(pass(rule, `${rule.name}: ${fileRel} - '${name}' 是 async 函数`, { file: fileRel }));
} else {
out.push(fail(rule, `${rule.name}: ${fileRel} - 已注册接口 '${name}' 必须声明为 async function`, {
file: fileRel,
fix: `将 ${name} 改为 async function ${name}(...) { ... } 或 const ${name} = async (...) => { ... };若 ${name} 只是工具函数,请将其移到同级 utils/ 目录并在 mcp.json 中取消注册`,
}));
}
}
}
return out;
}
async function checkV007_DefineRegister(rule, skillsPath) {
const out = [];
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
let mcpNames;
try { mcpNames = extractMcpNames(parseMcpApis(JSON.parse(await readFile(join(skillDir, "mcp.json"), "utf-8")))); }
catch { out.push(fail(rule, `${rule.name}: ${rel}/mcp.json 不存在或无法解析`, { file: `${rel}/mcp.json`, fix: "创建 mcp.json 或检查文件格式" })); continue; }
let regNames;
try { regNames = extractRegisterNames(await readFile(join(skillDir, "index.js"), "utf-8")); }
catch { out.push(fail(rule, `${rule.name}: ${rel}/index.js 不存在或无法读取`, { file: `${rel}/index.js`, fix: "创建 index.js" })); continue; }
const mcpSet = new Set(mcpNames), regSet = new Set(regNames);
for (const n of mcpSet) if (!regSet.has(n))
out.push(fail(rule, `mcp.json 定义了 '${n}',但 index.js 未注册`, { file: `${rel}/index.js`, fix: `添加 wx.modelContext.registerAPI('${n}', ${n})` }));
for (const n of regSet) if (!mcpSet.has(n))
out.push(fail(rule, `index.js 注册了 '${n}',但 mcp.json 未定义`, { file: `${rel}/mcp.json`, fix: `在 mcp.json 中添加 '${n}' 的定义` }));
if (mcpSet.size === regSet.size && [...mcpSet].every(n => regSet.has(n)))
out.push(pass(rule, `${rule.name}: ${rel} - 一致`));
}
return out;
}
async function checkV008_RegisterImpl(rule, skillsPath) {
const out = [];
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
try {
const reqNames = extractRequireNames(await readFile(join(skillDir, "index.js"), "utf-8"));
let allExist = true;
for (const n of reqNames) {
if (!await resolveApiFile(skillDir, n)) {
allExist = false;
out.push(fail(rule, `require('./apis/${n}') 但未找到对应实现文件(已在 ${API_IMPL_CANDIDATES.join("/, ")}/ 下查找 ${n}.js)`,
{ file: `${rel}/apis/${n}.js`, fix: `创建 ${rel}/apis/${n}.js 或 ${rel}/tools/services/${n}.js` }));
}
}
if (allExist) out.push(pass(rule, `${rule.name}: ${rel} - 通过`));
} catch {
out.push(fail(rule, `${rule.name}: ${rel}/index.js 不存在`, { file: `${rel}/index.js`, fix: "创建 index.js" }));
}
}
return out;
}
async function checkV009_OutputSchema(rule, skillsPath) {
const out = [];
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
let apis;
try { apis = parseMcpApis(JSON.parse(await readFile(join(skillDir, "mcp.json"), "utf-8"))); } catch { continue; }
for (const item of apis) {
if (!item?.name) continue;
const { name } = item;
const found = await resolveApiFile(skillDir, name);
if (!found) {
out.push(fail(rule, `${rule.name}: ${rel} 接口 '${name}' 未找到实现文件(已查找 ${API_IMPL_CANDIDATES.map(s => `${s}/${name}.js`).join(" / ")})`,
{ file: `${rel}/tools/services/${name}.js`, fix: `创建 ${name}.js 实现文件,或检查 index.js 的 require 路径` }));
continue;
}
const fileRel = relative(skillsPath, found.path);
let retFields, schemaFields;
try {
const body = await readFile(found.path, "utf-8");
retFields = extractStructuredFields(body);
schemaFields = extractOutputSchemaFields(item.outputSchema);
} catch {
out.push(fail(rule, `${rule.name}: ${fileRel} 无法读取`, { file: fileRel, fix: "检查文件权限" }));
continue;
}
if (!retFields.length) { out.push(pass(rule, `${rule.name}: ${fileRel} - 未识别到显式 structuredContent 字面量,跳过静态对比`, { file: fileRel })); continue; }
if (!schemaFields.length) { out.push(pass(rule, `${rule.name}: ${fileRel} - outputSchema 未声明字段,跳过`)); continue; }
const retSet = new Set(retFields), schemaSet = new Set(schemaFields);
for (const f of retSet) if (!schemaSet.has(f))
out.push(fail(rule, `接口 ${name} 返回了 '${f}' 但 outputSchema 未声明`, { file: fileRel, fix: `在 mcp.json 的 ${name} outputSchema 中添加 '${f}'` }));
const required = Array.isArray(item.outputSchema?.required) ? item.outputSchema.required : [];
for (const f of required) if (!retSet.has(f))
out.push(fail(rule, `接口 ${name} outputSchema required 声明了 '${f}' 但接口未返回`, { file: fileRel, fix: `在 ${name} structuredContent 中添加 '${f}'` }));
const allOk = [...retSet].every(f => schemaSet.has(f)) && required.every(f => retSet.has(f));
if (allOk) out.push(pass(rule, `${rule.name}: ${fileRel} - 一致`));
}
}
return out;
}
async function checkV010_ComponentApi(rule, skillsPath) {
const out = [];
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
const apiFiles = await listApiFiles(skillDir);
for (const compDir of await findComponentDirs(skillDir)) {
const compName = compDir.split("/").pop();
const compRel = `${rel}/components/${compName}`;
let compJs;
try { compJs = await readFile(join(compDir, "index.js"), "utf-8"); } catch { continue; }
const usedFields = extractComponentStructuredFields(compJs);
if (!usedFields.length) continue;
let apiName = "", apiPath = "";
const m = compJs.match(/atomicApi\s*[:=]\s*['"](\w+)['"]/);
if (m) {
apiName = m[1];
const f = await resolveApiFile(skillDir, apiName);
if (f) apiPath = f.path;
}
if (!apiName) {
for (const af of apiFiles) {
try {
const fields = extractStructuredFields(await readFile(af.path, "utf-8"));
if (fields.length && usedFields.every(u => fields.includes(u))) { apiName = af.name; apiPath = af.path; break; }
} catch {}
}
}
if (!apiName) {
out.push(pass(rule, `${rule.name}: ${compRel} - 未确定关联接口(跳过检查)`, { file: `${compRel}/index.js` }));
continue;
}
try {
const apiSet = new Set(extractStructuredFields(await readFile(apiPath, "utf-8")));
let ok = true;
for (const f of usedFields) if (!apiSet.has(f)) {
ok = false;
out.push(fail(rule, `组件 ${compName} 引用 structuredContent.${f} 但接口 ${apiName} 未返回`,
{ file: `${compRel}/index.js`, fix: `在 ${apiName} structuredContent 中添加 '${f}'` }));
}
if (ok) out.push(pass(rule, `${rule.name}: ${compRel} - 与接口 ${apiName} 一致`));
} catch {
out.push(fail(rule, `${rule.name}: 关联接口 ${apiName} 实现文件无法读取`, { file: relative(skillsPath, apiPath), fix: `检查 ${apiName} 实现文件` }));
}
}
}
return out;
}
async function checkV011_WxmlSetData(rule, skillsPath) {
const out = [];
const IGNORE = new Set(["item", "index", "wx"]);
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
for (const compDir of await findComponentDirs(skillDir)) {
const compName = compDir.split("/").pop();
const compRel = `${rel}/components/${compName}`;
let jsText, setFields, propFields, wxmlFields;
try {
jsText = await readFile(join(compDir, "index.js"), "utf-8");
setFields = extractSetDataFields(jsText);
propFields = extractPropertiesFields(jsText);
} catch { continue; }
try { wxmlFields = extractWxmlBindings(await readFile(join(compDir, "index.wxml"), "utf-8")); } catch { continue; }
if (!setFields.length && !wxmlFields.length) continue;
const setSet = new Set(setFields), wxmlSet = new Set(wxmlFields);
const dynamic = hasDynamicSetData(jsText);
let hasError = false;
for (const f of setSet) if (!IGNORE.has(f) && !wxmlSet.has(f)) {
out.push(fail(rule, `组件 ${compName} setData/data 有 '${f}' 但 WXML 未使用 {{${f}}}(可能是仅 JS 内部使用的状态)`,
{ level: "warning", file: `${compRel}/index.js`, fix: `在 WXML 中使用 {{${f}}}、或移除 '${f}'、或确认其用途仅限 JS` }));
}
for (const f of wxmlSet) if (!IGNORE.has(f) && !propFields.has(f) && !setSet.has(f)) {
const level = dynamic ? "warning" : rule.level;
if (!dynamic) hasError = true;
const suffix = dynamic ? `(${dynamic.reason},静态无法完全解析 data 字段,降级提示)` : "";
out.push(fail(rule, `组件 ${compName} WXML 使用 {{${f}}} 但 setData/data/properties 均未定义${suffix}`,
{ level, file: `${compRel}/index.wxml`, fix: `在 data 或 properties 中显式声明 '${f}',或修正 WXML 绑定` }));
}
if (!hasError) out.push(pass(rule, `${rule.name}: ${compRel} - WXML 绑定字段均已声明`));
}
}
return out;
}
async function checkV012_ComponentFiles(rule, skillsPath) {
const out = [];
const pathPat = new RegExp(rule.pathPattern ?? "^components/[\\w-]+/index$");
const reqFiles = rule.requireFiles ?? ["index.js", "index.json", "index.wxml", "index.wxss"];
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
let apis;
try { apis = parseMcpApis(JSON.parse(await readFile(join(skillDir, "mcp.json"), "utf-8"))); } catch { continue; }
for (const item of apis) {
const name = typeof item?.name === "string" ? item.name : "(unknown)";
const compPath = item?._meta?.ui?.componentPath;
if (!compPath) { out.push(pass(rule, `${rule.name}: ${rel} 接口 '${name}' 未声明 componentPath(跳过组件校验)`)); continue; }
if (!pathPat.test(compPath)) { out.push(fail(rule, `接口 '${name}' componentPath '${compPath}' 格式不对`, { file: `${rel}/mcp.json`, fix: `格式应为 "components/<name>/index"` })); continue; }
const compDir = join(skillDir, compPath.replace(/\/index$/, ""));
const missing = [];
for (const f of reqFiles) if (!await exists(join(compDir, f))) missing.push(f);
if (missing.length) out.push(fail(rule, `接口 '${name}' 组件 ${rel}/${compPath.replace(/\/index$/, "")} 缺少:${missing.join(", ")}`, { file: `${rel}/mcp.json`, fix: `补齐 ${missing.join(", ")}` }));
else out.push(pass(rule, `${rule.name}: ${rel} 接口 '${name}' 组件完整`));
}
}
return out;
}
async function checkV013_McpSize(rule, skillsPath) {
const out = [];
const maxChars = rule.maxChars ?? 24000;
const warnRatio = rule.warnRatio ?? 0.9;
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
const mcpRel = `${rel}/mcp.json`;
let raw;
try { raw = await readFile(join(skillDir, "mcp.json"), "utf-8"); }
catch { continue; }
let parsed;
try { parsed = JSON.parse(raw); }
catch { out.push(fail(rule, `${rule.name}: ${mcpRel} JSON 解析失败`, { file: mcpRel, fix: "修复 JSON 语法" })); continue; }
const apis = Array.isArray(parsed?.apis) ? parsed.apis : [];
const stripped = { ...parsed, apis: apis.map(({ outputSchema: _outputSchema, ...rest }) => rest) };
const body = JSON.stringify(stripped);
const len = body.length;
if (len > maxChars) {
out.push(fail(rule, `${rule.name}: ${mcpRel} 去除 outputSchema 后长度 ${len} 字符,超过上限 ${maxChars}`, {
file: mcpRel,
fix: `精简 description/title/inputSchema 描述文字;若接口数量多难以精简,按职责拆分为多个 skill 分包;当前超出 ${len - maxChars} 字符`,
}));
} else if (len >= Math.floor(maxChars * warnRatio)) {
out.push(fail(rule, `${rule.name}: ${mcpRel} 去除 outputSchema 后长度 ${len} 字符,接近上限 ${maxChars}(${Math.round(len / maxChars * 100)}%)`, {
level: "warning",
file: mcpRel,
fix: `建议精简 description/title/inputSchema 描述文字,避免后续后台侧超限;若接口数量多可按职责拆分为多个 skill 分包`,
}));
} else {
out.push(pass(rule, `${rule.name}: ${mcpRel} ${len}/${maxChars} 字符`));
}
}
return out;
}
async function checkV014_SkillMdCase(rule, skillsPath) {
const out = [];
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
let names = [];
try { names = (await readdir(skillDir, { withFileTypes: true })).filter(e => e.isFile()).map(e => e.name); } catch {}
if (names.includes("SKILL.md")) { out.push(pass(rule, `${rule.name}: ${rel}/SKILL.md`)); continue; }
const wrong = names.find(n => n.toLowerCase() === "skill.md");
out.push(fail(rule, `${rule.name}: ${rel} 缺少严格大写的 SKILL.md${wrong ? `(发现 ${wrong})` : ""}`, {
file: `${rel}/${wrong || "SKILL.md"}`,
fix: wrong ? `将 ${wrong} 重命名为 SKILL.md(若文件系统不区分大小写,先改临时名再改回:mv skill.md tmp && mv tmp SKILL.md)` : `创建 ${rel}/SKILL.md(文件名严格全大写)`,
}));
}
return out;
}
async function checkV016_SkillDescription(rule, _skillsPath, ctx = {}) {
const out = [];
if (!ctx.projectRoot) return out;
let appJson;
try { appJson = JSON.parse(await readFile(join(ctx.projectRoot, "app.json"), "utf-8")); } catch { return out; }
const skills = appJson?.agent?.skills;
if (!Array.isArray(skills) || skills.length === 0) return out;
for (const s of skills) {
const entry = typeof s === "object" && s !== null ? s : {};
const name = entry.name || entry.path || "(unknown)";
const desc = typeof entry.description === "string" ? entry.description.trim() : "";
if (!desc) {
out.push(fail(rule, `app.json 中 agent.skills 条目 '${name}' 缺少 description 或 description 为空`, {
file: "app.json",
fix: `在 app.json 的 agent.skills 中为该条目补充非空的 description 字段,如:{ "name": "${name}", "description": "该 skill 的业务描述", "path": "${entry.path || ""}" }`,
}));
} else {
out.push(pass(rule, `${rule.name}: skill '${name}' description 已配置`));
}
}
return out;
}
async function checkV017_HandoffPagePath(rule, skillsPath, ctx = {}) {
const out = [];
let appPages = null;
if (ctx.projectRoot) {
try {
const appJson = JSON.parse(await readFile(join(ctx.projectRoot, "app.json"), "utf-8"));
appPages = collectAppPages(appJson);
} catch {}
}
for (const skillDir of await findSkillDirs(skillsPath)) {
const rel = relative(skillsPath, skillDir);
const mcpRel = `${rel}/mcp.json`;
let mcp;
try { mcp = JSON.parse(await readFile(join(skillDir, "mcp.json"), "utf-8")); } catch { continue; }
for (const item of parseMcpApis(mcp)) {
const name = typeof item?.name === "string" ? item.name : "(unknown)";
const pp = item?._meta?.ui?.pagePath;
if (pp === undefined || pp === null) continue; // pagePath 按需,未声明则跳过
const ppStr = typeof pp === "string" ? pp.trim() : "";
if (!ppStr) {
out.push(fail(rule, `接口 '${name}' _meta.ui.pagePath 为空`, {
file: mcpRel,
fix: `删除该字段,或填真实接力页 path(必须以 '/' 开头、不含 query,取自 app.json 主包 pages[] 或分包 root+pages)`,
}));
continue;
}
if (!ppStr.startsWith("/")) {
out.push(fail(rule, `接口 '${name}' pagePath='${ppStr}' 必须以 '/' 开头(绝对路径)`, {
file: mcpRel,
fix: `将 pagePath 改为 '/${ppStr.replace(/^\/+/, "")}'`,
}));
continue;
}
if (ppStr.includes("?")) {
out.push(fail(rule, `接口 '${name}' pagePath='${ppStr}' 不应带 query`, {
file: mcpRel,
fix: `去掉 '?' 及其后内容;query 由原子接口返回值的 handoff.query 传递(详见 wxa-skills-generate SKILL.md C.3.3)`,
}));
continue;
}
if (appPages && !appPages.includes(ppStr.replace(/^\/+/, ""))) {
out.push(fail(rule, `接口 '${name}' pagePath='${ppStr}' 不在项目 app.json 的主包 pages[] 与分包 root+pages 拼接结果中`, {
file: mcpRel,
fix: `改为 app.json 中真实存在的页面(带前导 '/'、不含 query)`,
}));
continue;
}
// 声明了 pagePath 表示"停下等用户确认后进接力页",实现应返回 handoff 承接 query/payload
let handoffWarned = false;
const found = await resolveApiFile(skillDir, name);
if (found) {
try {
const body = await readFile(found.path, "utf-8");
if (!/\bhandoff\b/.test(body)) {
handoffWarned = true;
out.push(fail(rule, `接口 '${name}' 声明了 pagePath 但实现未返回 handoff(用户点卡片进接力页时缺少 query/payload 传递)`, {
level: "warning",
file: relative(skillsPath, found.path),
fix: `在返回值顶层(与 content/structuredContent 同级)增加 handoff: { query: '...', payload? }(详见 wxa-skills-generate SKILL.md C.3.3)`,
}));
}
} catch {}
}
if (!handoffWarned) out.push(pass(rule, `${rule.name}: ${rel} 接口 '${name}' pagePath='${ppStr}'`));
}
}
return out;
}
function collectAppPages(appJson) {
const set = new Set();
const trimSlash = s => String(s).replace(/^\/+/, "").replace(/\/+$/, "");
if (Array.isArray(appJson?.pages)) {
for (const p of appJson.pages) {
if (typeof p === "string" && p.trim()) set.add(trimSlash(p));
}
}
const subs = Array.isArray(appJson?.subPackages)
? appJson.subPackages
: (Array.isArray(appJson?.subpackages) ? appJson.subpackages : []);
for (const sp of subs) {
if (!sp || typeof sp !== "object") continue;
const root = typeof sp.root === "string" ? trimSlash(sp.root) : "";
if (!root) continue;
if (!Array.isArray(sp.pages)) continue;
for (const p of sp.pages) {
if (typeof p !== "string" || !p.trim()) continue;
set.add(`${root}/${trimSlash(p)}`);
}
}
return Array.from(set);
}
function groupFindings(findings) {
const map = new Map(), standalone = [];
for (const f of findings) {
if (!f.file) { standalone.push(f); continue; }
const key = [f.id, f.level, f.detail || "", f.fix || ""].join("\u0001");
let g = map.get(key);
if (!g) {
g = { id: f.id, level: f.level, files: [] };
if (f.detail) g.detail = f.detail;
if (f.fix) g.fix = f.fix;
map.set(key, g);
}
g.files.push(f.file);
}
const grouped = [...map.values()].map(g => {
if (g.files.length === 1) return { id: g.id, level: g.level, file: g.files[0], ...(g.detail && { detail: g.detail }), ...(g.fix && { fix: g.fix }) };
return g;
});
return [...grouped, ...standalone];
}
function compactFinding(r, ruleNameById) {
const ruleName = ruleNameById[r.id] || "";
let detail = r.message || "";
if (ruleName && detail.startsWith(ruleName + ": ")) detail = detail.slice(ruleName.length + 2);
if (r.file && detail.startsWith(r.file + " - ")) detail = detail.slice(r.file.length + 3);
let fix = r.fix;
if (fix && r.file) {
fix = fix.replace(new RegExp("^在\\s*" + r.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*中\\s*"), "");
}
return {
id: r.id, level: r.level,
...(r.file && { file: r.file }),
...(detail && { detail }),
...(fix && { fix }),
};
}
async function loadRules(customPath) {
const builtin = structuredClone(BUILTIN_RULES);
if (!customPath) return builtin;
const custom = JSON.parse(await readFile(resolve(customPath), "utf-8"));
const merge = (base, add = []) => {
const m = new Map(base.map(r => [r.id, r]));
for (const r of add) m.set(r.id, r);
return [...m.values()];
};
return { rules: merge(builtin.rules, custom.rules), crossFileRules: merge(builtin.crossFileRules, custom.crossFileRules) };
}
function mapTargets(ruleSet, fn) {
return {
rules: ruleSet.rules.map(r => ({ ...r, targets: r.targets.map(fn), exclude: r.exclude?.map(fn) })),
crossFileRules: ruleSet.crossFileRules,
};
}
const stripSkillsPrefix = ruleSet => mapTargets(ruleSet, p => p.replace(/^skills\//, ""));
const adaptTargetsForSingleSkill = ruleSet => mapTargets(ruleSet, p => {
const parts = p.split("/");
return parts.length >= 2 && parts[0] === "*" ? parts.slice(1).join("/") : p;
});
async function resolveCliPath(userInput) {
const candidates = (typeof userInput === "string" && userInput)
? [userInput]
: [process.env.WECHAT_DEVTOOLS_CLI, process.env.WXA_CLI, DEFAULT_CLI_PATH]
.filter(p => typeof p === "string" && p.length > 0);
for (const p of candidates) {
try { await access(p, FS.X_OK); return p; } catch {}
}
return null;
}
async function runBuildCheck(projectPath, { cliPath, timeoutMs = 180000 } = {}) {
const startedAt = Date.now();
const effectiveCliPath = await resolveCliPath(cliPath);
if (!effectiveCliPath) {
return { ran: false, success: false, stage: "unknown", durationMs: 0, message: `跳过:未找到微信开发者工具 CLI(已尝试 --cli-path / $WECHAT_DEVTOOLS_CLI / $WXA_CLI / 默认路径 ${DEFAULT_CLI_PATH})。请通过 --cli-path <cli 绝对路径> 或环境变量 WECHAT_DEVTOOLS_CLI 指定后重试。`, logTail: "", exitCode: 0 };
}
const workDir = join(tmpdir(), `wxa-validate-build-${randomUUID()}`);
await mkdir(workDir, { recursive: true });
const infoFile = join(workDir, "info.json"), qrFile = join(workDir, "qr.png");
let raw;
try {
raw = await runCli(effectiveCliPath, [
"preview", "--project", resolve(projectPath),
"--info-output", infoFile, "--qr-output", qrFile, "--qr-format", "base64",
], timeoutMs);
} catch (err) {
return { ran: true, success: false, stage: "unknown", durationMs: Date.now() - startedAt, message: `CLI 启动失败: ${err.message}`, logTail: "", exitCode: -1 };
} finally {
unlink(infoFile).catch(() => {});
unlink(qrFile).catch(() => {});
}
const combined = (raw.stdout || "") + "\n" + (raw.stderr || "");
const durationMs = Date.now() - startedAt;
const logTail = combined.split(/\r?\n/).filter(Boolean).slice(-20).join("\n");
const compileSignals = [
/compile\s+(error|fail|failed)/i, /SyntaxError/, /Unexpected token/i, /Unterminated/i,
/\[error\][^\n]*(compile|wxml|wxss|wxs|parser|syntax|模板|语法)/i,
/✖\s*preparing/i, /✖\s*compile/i,
];
const reachedUpload = /(^|\n)\s*[-✔✖]?\s*Upload(ing)?/i.test(combined) || /doUpload/.test(combined);
const reachedPreview = /(^|\n)\s*[-✔✖]?\s*preview\b/i.test(combined);
const hasCompileFail = compileSignals.some(re => re.test(combined));
const hasAnyError = /\[error\]/i.test(combined) || raw.code !== 0 || raw.timedOut;
if (raw.timedOut) return { ran: true, success: false, stage: "timeout", durationMs, message: `CLI 在 ${timeoutMs}ms 内未完成(可能是 preview 卡住或网络慢);如需更长时间请加 --build-timeout <ms>`, logTail, exitCode: raw.code };
if (hasCompileFail) return { ran: true, success: false, stage: "compile", durationMs, message: "编译失败(存在语法或编译错误)", logTail, exitCode: raw.code };
if (reachedUpload) {
const uploadFailed = /Error: 上传失败|✖\s*Upload/i.test(combined);
return { ran: true, success: true, stage: "upload", durationMs,
message: uploadFailed ? "编译通过;上传阶段失败(与本地代码编译无关,仅供参考)" : "编译通过且已生成预览二维码",
logTail, exitCode: raw.code };
}
if (reachedPreview && !hasAnyError) return { ran: true, success: true, stage: "preview", durationMs, message: "编译通过(preview 阶段完成)", logTail, exitCode: raw.code };
if (hasAnyError) return { ran: true, success: false, stage: "unknown", durationMs, message: "CLI 返回非预期错误,未识别到明确阶段", logTail, exitCode: raw.code };
return { ran: true, success: true, stage: "unknown", durationMs, message: "CLI 未报编译错误", logTail, exitCode: raw.code };
}
async function ensureValidateIgnoreConfig(projectRoot) {
const configPath = join(projectRoot, "project.config.json");
try {
const raw = await readFile(configPath, "utf-8");
const config = JSON.parse(raw);
const packOptions = (config.packOptions && typeof config.packOptions === "object") ? config.packOptions : {};
const packIgnore = Array.isArray(packOptions.ignore) ? packOptions.ignore.slice() : [];
const packTarget = { type: "folder", value: "cli-agent-run" };
const packExists = packIgnore.some(it => it?.type === packTarget.type && it?.value === packTarget.value);
const watchOptions = (config.watchOptions && typeof config.watchOptions === "object") ? config.watchOptions : {};
const watchIgnore = Array.isArray(watchOptions.ignore) ? watchOptions.ignore.slice() : [];
const watchPattern = "cli-agent-run/**/*";
const watchExists = watchIgnore.includes(watchPattern);
if (packExists && watchExists) {
return { changed: false, added: [] };
}
const added = [];
if (!packExists) { packIgnore.push(packTarget); added.push({ scope: "packOptions", ...packTarget }); }
if (!watchExists) { watchIgnore.push(watchPattern); added.push({ scope: "watchOptions", value: watchPattern }); }
config.packOptions = { ...packOptions, ignore: packIgnore };
config.watchOptions = { ...watchOptions, ignore: watchIgnore };
await writeFile(configPath, JSON.stringify(config, null, 2) + (raw.endsWith("\n") ? "\n" : ""), "utf-8");
return { changed: true, added };
} catch (err) {
return { changed: false, added: [], reason: `跳过配置同步: ${err.message}` };
}
}
async function loadSubPackageRoots(projectRoot) {
try {
const appJson = JSON.parse(await readFile(join(projectRoot, "app.json"), "utf-8"));
const list = [...(appJson.subPackages || []), ...(appJson.subpackages || [])];
const roots = [];
for (const s of list) {
const root = typeof s === "string" ? s : s?.root;
if (root) roots.push(resolve(projectRoot, root));
}
return [...new Set(roots)];
} catch {
return [];
}
}
async function discoverSkillDirsFromProject(projectRoot) {
const dirs = [];
try {
const appJson = JSON.parse(await readFile(join(projectRoot, "app.json"), "utf-8"));
const skills = appJson?.agent?.skills;
if (Array.isArray(skills)) {
for (const s of skills) {
const p = typeof s === "string" ? s : s?.path;
if (!p) continue;
const abs = resolve(projectRoot, p);
if (await isSkillRoot(abs)) dirs.push(abs);
}
}
} catch {}
if (dirs.length === 0) {
for (const candidate of ["metaServicePkg", "skills"]) {
const abs = join(projectRoot, candidate);
if (await isSkillRoot(abs)) dirs.push(abs);
else {
try {
for (const e of await readdir(abs, { withFileTypes: true })) {
if (e.isDirectory() && await isSkillRoot(join(abs, e.name))) dirs.push(join(abs, e.name));
}
} catch {}
}
}
}
return [...new Set(dirs)];
}
async function validate(projectPath, opts = {}) {
const projectRoot = resolve(projectPath);
if (!await exists(join(projectRoot, "project.config.json"))) {
throw new Error(`${projectRoot} 下未找到 project.config.json,请传入小程序项目根目录`);
}
const ignoreSync = await ensureValidateIgnoreConfig(projectRoot);
const skillDirs = await discoverSkillDirsFromProject(projectRoot);
if (skillDirs.length === 0) {
throw new Error(`未在 ${projectRoot}/app.json 的 agent.skills 中发现 skill 分包;也未在 metaServicePkg/ 或 skills/ 下找到合法 skill 目录`);
}
const subPkgRoots = await loadSubPackageRoots(projectRoot);
const packageRootOf = (skillDir) => {
const cands = subPkgRoots.filter(r => skillDir === r || skillDir.startsWith(r + sep));
if (cands.length) return cands.sort((a, b) => b.length - a.length)[0];
const parent = dirname(skillDir);
return parent && parent !== skillDir ? parent : skillDir;
};
const ruleSet = stripSkillsPrefix(await loadRules(opts.rules));
const ruleDict = {};
for (const r of [...ruleSet.rules, ...ruleSet.crossFileRules]) ruleDict[r.id] = { stage: r.stage, level: r.level, name: r.name };
const ruleNameById = Object.fromEntries(Object.entries(ruleDict).map(([k, v]) => [k, v.name]));
const skillsByPkg = new Map();
for (const skillDir of skillDirs) {
const pkgRoot = packageRootOf(skillDir);
if (!skillsByPkg.has(pkgRoot)) skillsByPkg.set(pkgRoot, []);
skillsByPkg.get(pkgRoot).push(skillDir);
}
const sharedDirsByPkg = new Map();
for (const [pkgRoot, skills] of skillsByPkg) {
const dirs = [];
try {
for (const e of await readdir(pkgRoot, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const abs = join(pkgRoot, e.name);
if (skills.includes(abs)) continue;
if (await isSkillRoot(abs)) continue;
dirs.push(abs);
}
} catch {}
if (dirs.length) sharedDirsByPkg.set(pkgRoot, dirs);
}
const all = [];
const localRuleSet = adaptTargetsForSingleSkill(ruleSet);
const projectLevelRuleIds = new Set(["V016"]);
for (const skillDir of skillDirs) {
const pkgRoot = packageRootOf(skillDir);
const allSkillRoots = skillsByPkg.get(pkgRoot) || [skillDir];
for (const r of localRuleSet.rules) all.push(...await checkSingleRule(r, skillDir, { packageRoot: pkgRoot, allSkillRoots }));
for (const r of localRuleSet.crossFileRules) {
if (projectLevelRuleIds.has(r.id)) continue;
all.push(...await checkCrossFileRule(r, skillDir, { projectRoot }));
}
}
for (const r of localRuleSet.crossFileRules) {
if (!projectLevelRuleIds.has(r.id)) continue;
all.push(...await checkCrossFileRule(r, null, { projectRoot }));
}
const sharedRuleIds = new Set(["V001"]);
const sharedRules = localRuleSet.rules.filter(r => sharedRuleIds.has(r.id));
for (const [pkgRoot, sharedDirs] of sharedDirsByPkg) {
const allSkillRoots = skillsByPkg.get(pkgRoot) || [];
for (const sharedDir of sharedDirs) {
for (const r of sharedRules) {
all.push(...await checkSingleRule(r, sharedDir, { packageRoot: pkgRoot, allSkillRoots }));
}
}
}
const fails = all.filter(r => r.status === "fail");
const passes = all.filter(r => r.status === "pass");
const passedByRule = {};
for (const r of passes) {
const e = passedByRule[r.id] || { id: r.id, name: ruleNameById[r.id] || r.id, count: 0 };
e.count++;
passedByRule[r.id] = e;
}
const staticErrors = fails.filter(r => r.level === "error").length;
let build;
if (staticErrors > 0) {
build = { ran: false, success: false, stage: "unknown", durationMs: 0, message: `跳过:静态校验存在 ${staticErrors} 个 error,修复后会自动重新触发`, logTail: "", exitCode: 0 };
} else {
build = await runBuildCheck(projectRoot, { cliPath: opts.cliPath, timeoutMs: opts.buildTimeoutMs ?? 180000 });
}
return {
timestamp: new Date().toISOString(),
projectPath: projectRoot,
skillDirs: skillDirs.map(d => relative(projectRoot, d)),
ignoreSync,
summary: {
total: all.length, passed: passes.length, failed: fails.length,
errors: fails.filter(r => r.level === "error").length,
warnings: fails.filter(r => r.level === "warning").length,
buildStatus: build ? (build.ran ? (build.success ? "pass" : "fail") : "skipped") : null,
},
rules: ruleDict,
results: groupFindings(fails.map(r => compactFinding(r, ruleNameById))),
passedSummary: Object.values(passedByRule).sort((a, b) => a.id.localeCompare(b.id)),
build,
};
}
function printSummary(report, outputPath) {
const { summary, results, passedSummary = [], rules = {}, build, projectPath, skillDirs = [], ignoreSync } = report;
console.log(`[validate] project=${projectPath} skills=[${skillDirs.join(", ")}] ${report.timestamp}`);
console.log(`total=${summary.total} passed=${summary.passed} failed=${summary.failed} errors=${summary.errors} warnings=${summary.warnings}`);
if (ignoreSync) {
if (ignoreSync.changed && Array.isArray(ignoreSync.added) && ignoreSync.added.length) {
const groups = { packOptions: [], watchOptions: [] };
for (const it of ignoreSync.added) {
if (it.scope === "packOptions") groups.packOptions.push(`${it.type}:${it.value}`);
else if (it.scope === "watchOptions") groups.watchOptions.push(it.value);
}
const parts = [];
if (groups.packOptions.length) parts.push(`packOptions.ignore +${groups.packOptions.length} [${groups.packOptions.join(", ")}]`);
if (groups.watchOptions.length) parts.push(`watchOptions.ignore +${groups.watchOptions.length} [${groups.watchOptions.join(", ")}]`);
console.log(`project.config.json: ${parts.join(" / ")}(避免循环编译)`);
} else if (ignoreSync.reason) {
console.log(`project.config.json: 跳过同步 - ${ignoreSync.reason}`);
}
}
if (passedSummary.length) {
console.log(`Passed (by rule): ${passedSummary.map(p => `${p.id}:${p.count}`).join(" ")}`);
}
if (results.length) {
console.log("\nFailed:");
for (const r of results) {
const ruleName = rules[r.id]?.name || "";
console.log(` [${r.level.toUpperCase()}] ${r.id}${ruleName ? " " + ruleName : ""}${r.detail ? " - " + r.detail : ""}`);
if (r.file) console.log(` @ ${r.file}`);
else if (Array.isArray(r.files)) for (const f of r.files) console.log(` @ ${f}`);
if (r.fix) console.log(` fix: ${r.fix}`);
}
}
if (build) {
if (!build.ran) {
console.log(`\nBuild: SKIPPED - ${build.message}`);
} else {
const status = build.success ? "PASS" : "FAIL";
console.log(`\nBuild: ${status} [stage=${build.stage}, ${(build.durationMs / 1000).toFixed(1)}s] - ${build.message}`);
if (!build.success && build.logTail) {
console.log("Build log (last lines):");
for (const l of build.logTail.split("\n")) console.log(` > ${l}`);
}
}
}
console.log(`\nreport: ${outputPath}`);
const buildFail = build && build.ran && !build.success;
if (summary.errors > 0 || buildFail) {
const bits = [];
if (summary.errors > 0) bits.push(`${summary.errors} errors`);
if (buildFail) bits.push(`build ${build.stage} failed`);
console.log(`RESULT: FAIL (${bits.join(", ")})`);
} else if (summary.warnings > 0) {
console.log(`RESULT: PASS with ${summary.warnings} warnings`);
} else {
console.log("RESULT: PASS");
}
}
const argv = process.argv.slice(2);
const cli = { output: null, rules: null, cliPath: null, buildTimeout: null, projectPath: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i], next = argv[i + 1];
if (a === "--output" && next) cli.output = argv[++i];
else if (a === "--rules" && next) cli.rules = argv[++i];
else if (a === "--cli-path" && next) cli.cliPath = argv[++i];
else if (a === "--build-timeout" && next) cli.buildTimeout = Number(argv[++i]);
else if (!a.startsWith("--")) cli.projectPath = a;
}
if (!cli.projectPath) {
console.error("usage: node validate.mjs <miniprogram-project-path> [--output <path>] [--rules <path>] [--cli-path <path>] [--build-timeout <ms>]");
console.error("说明:<miniprogram-project-path> 指小程序项目根目录(含 project.config.json + app.json);脚本自动从 app.json 的 agent.skills[].path 发现 skill 分包并只在分包范围内做静态校验,静态 0 error 后自动调用 cli preview 做编译校验。");
process.exit(2);
}
const defaultOutput = join(resolve(cli.projectPath), "cli-agent-run", "validate-report.json");
try {
const report = await validate(cli.projectPath, {
rules: cli.rules,
cliPath: cli.cliPath,
buildTimeoutMs: cli.buildTimeout,
});
const out = resolve(cli.output || defaultOutput);
await mkdir(dirname(out), { recursive: true });
await writeFile(out, JSON.stringify(report, null, 2), "utf-8");
printSummary(report, out);
const buildFail = report.build && report.build.ran && !report.build.success;
process.exit(report.summary.errors > 0 || buildFail ? 1 : 0);
} catch (err) {
console.error("validate error:", err.message);
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);
});#!/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);
});来源:用户上传
用户上传标准 Skill 文件夹。