发 npm 包最难受的时刻不是 CI 变红。是包已经推上 registry,才有人发现
dist里少了个入口文件,或者src下的测试快照跟着一起传上去了。版本号一旦占用就永远不能复用,你能做的只有再发一个 patch 去补救,而已经装了坏版本的人不会自动回来。
Vercel 的 AI SDK 仓库里有个东西专门治这个。它叫 list-npm-package-content,是一个 Agent Skill,放在 skills/ 目录下,和 use-ai-sdk、adr-skill、update-provider-models 这些并列。整个技能只有两个文件,一个 SKILL.md 三十行,一个 scripts/list-package-files.sh 六行。

真正值得看的不是这六行脚本,是它在三天里的变化。2026 年 1 月 22 日 PR #11957 把它加进来时,那是一份120行、400多词的分步操作手册,从 build 讲到清理,还带 troubleshooting。第二天 PR #11978 删掉100行、只加14行,把整份手册换成了脚本。
PR 描述里那句话比脚本本身更有信息量,原文是“这个 skill 过于复杂,浪费 token 还拖慢 agent”。一个天天做 AI 工具链的团队,给自己写的第一个实用技能踩了坑,然后用二十四小时把坑填了。这件事对任何写 Skill 的人都有参考价值。
架构解析
拆开看结构,分工非常干净。SKILL.md 只干三件事:用 frontmatter 的 description 声明触发条件,用一段话说明这个技能输出什么,用一份判定清单解释 npm 决定包内容的优先级。它不包含任何可执行细节,所有动作都指向脚本。
#!/usr/bin/env bash
set -e
# Build, pack, list contents, cleanup
pnpm build
tarball=$(pnpm pack | tail -1)
tar -tzf "$tarball"
rm "$tarball"
脚本只有四个动作:先 build,再 pack 拿到 tarball 文件名,然后 tar -tzf 列出内容,最后删掉 tarball。注意它用的是 -tzf 而不是 -tvzf,只要路径不要大小,输出足够给 agent 判断,又不至于刷屏。tarball 即用即删,工作区不留产物。
这里有个容易漏掉的耦合。packages/ai 的 files 字段里写了 docs/**/*,但 docs 目录不是源码里就有的,它是 prepack 阶段才从仓库根的 content/docs 拷进来的。所以必须先 build 再 pack,否则列出来的要么是上一次的残留,要么干脆是空的。
"files": [
"dist/**/*",
"docs/**/*",
"src",
"!src/**/*.test.ts",
"!src/**/*.test-d.ts",
"!src/**/__snapshots__",
"CHANGELOG.md",
"internal.d.ts",
"README.md",
"test.d.ts"
]
files 字段的语义由 npm-packlist 定义,判定顺序是写死的,不存在任何协商空间:
-
files存在,它就是唯一白名单,其余一切靠边站 -
没有 files才看.npmignore -
连 .npmignore也没有,才退回.gitignore -
package.json、README、LICENSE、CHANGELOG 无条件包含 -
.git、.npmrc、根目录node_modules无条件排除 -
符号链接永远不会被打进包里
这套规则看着简单,实际组合起来是 npm 生态里踩坑密度最高的区域之一。包管理器能算对,靠的是真实遍历文件系统;模型要算对,得在脑子里把六条规则叠加推演一遍,还得知道当前目录树长什么样。

有几条规则反直觉,也是 agent 最容易猜错的地方。glob 是相对包根求值的,*.js 只匹配根目录那一层,要递归得写 **/*.js。! 否定按出现顺序求值,写错顺序就没效果。还有一条特别阴:只要 files 字段存在,根目录的 .npmignore 会被整个忽略,但子目录里的 .npmignore 照样生效。
工作流分析
一次完整调用是这样走的。用户说“看看这个包会发出去哪些文件”,agent 拿这句话去匹配各 Skill 的 description,命中后把 30 行的 SKILL.md 读进上下文,判断该跑脚本,然后脚本接管,agent 退回到只读输出的位置。
问题在于脚本不是只读的。pnpm build 展开来是 pnpm clean && tsup --tsconfig tsconfig.build.json,而 clean 是 del-cli dist docs *.tsbuildinfo。也就是说,你为了“看一眼会发什么”,先把 dist 整个删掉重建了一遍。构建缓存和 tsbuildinfo 全没了。
副作用链在 pack 阶段继续。prepack 把 content/docs 拷进包目录,postpack 用 del-cli docs 再删掉。只要 pack 完整跑完,工作区是干净的。但这条链依赖 pack 跑完,中间任何一步炸了,docs 就会残留在包目录里,下一次 pack 又把它卷进去。
还有一个更实在的缺陷。set -e 只检查管道里最后一个命令的退出码,pnpm pack | tail -1 这个管道的最后一段是 tail,它几乎不可能失败。所以 pack 失败时脚本不会停,会拿着一个空变量去跑 tar -tzf "",报一个完全误导性的错误。
#!/usr/bin/env bash
set -euo pipefail
trap 'rm -f "$tarball"' EXIT
pnpm build
tarball=$(pnpm pack --pack-destination "${TMPDIR:-/tmp}" | tail -1)
tar -tzf "$tarball"
改法不复杂,两行就够。加 pipefail 让 pack 的失败能传出来,加 trap 让 tarball 在异常退出时也被清掉。--pack-destination 是 pnpm 官方参数,比依赖 tail -1 抓标准输出的最后一行稳得多,后者属于未契约化的输出格式。
还有个权限层面的问题值得单独说。这个技能的 frontmatter 里没有 allowed-tools 之类的约束,agent 拿到的是完整工具权限。于是一个从描述上看纯查询的技能,实际会触发一次全量重建并重写工作区文件。对内部仓库无所谓,放到别人的项目里,第一次调用就会让对方的构建产物蒸发。

使用场景
最对口的场景是改完打包配置之后的自检。你在 monorepo 里动了 files 字段、改了 tsup 配置、或者加了一条 ! 否定规则,发布前想确认结果。这时候跑一遍,三十秒内就能看到真实的文件清单,比人肉推演 glob 语义靠谱得多。
第二个场景是排查“装了包但 import 不到”。exports 里写的是 ./dist/index.js,files 里没含它,本地开发一切正常因为 node_modules 里是 workspace 软链,一发布就炸。这类问题的根因在打包层,看源码永远看不出来,只能看 tarball。
有个场景它覆盖不了:体积。v1 的 description 里还写着 check tarball size,v2 连同那 100 行一起被删掉了,因为脚本用的是 -tzf 不带大小信息。想看体积得另外跑 pnpm check-bundle-size,那是 tsx scripts/check-bundle-size.ts,属于另一套东西。
还有个边界容易混淆。它列的是“当前工作区状态下会打成什么样”,不是“线上已发布的版本里有什么”。这两者在改了 files 但没发版时是不一样的。要看线上的,得用 npm view <pkg> dist 或者真的把 tarball 下载下来解包。
把它放回 vercel/ai 那个具体环境里看,设计取舍就更说得通了。那是一个 turbo 驱动的 pnpm monorepo,根 package.json 里锁着 pnpm@11.23.0,包与包之间靠 workspace 软链互相引用。软链意味着本地开发时你看到的目录结构跟真实安装后的完全不是一回事,这也是为什么“看源码判断”在这个仓库里尤其不靠谱。
洞察与反思
三个版本的体积变化本身就是结论。v1 122行419词,v2 28行149词外加六行脚本,v3 30行152词只多了 internal: true 这个标记。一百行的文档,换成六行可执行的东西。

背后是对 Agent Skills 规范的正确理解。规范设计的渐进式披露是这样的:SKILL.md 常驻上下文,scripts/、references/ 按需装载。把操作手册写进 SKILL.md,等于让400多个词永久占用每一次对话的上下文预算,哪怕这次根本不发包。写成脚本,常驻成本降到三十行。
代价也是真的。v1 的 troubleshooting 段讲了“tarball 比预期大怎么办”和“文件缺失怎么排查”,现在全没了。agent 遇到缺文件只能靠自己的先验知识去猜。对 Vercel 内部团队这个赌注成立,他们熟自己的 monorepo;对外部分发,这部分引导的损失是实打实的。
最后一层落差在治理上。2026 年 1 月 27 日那次提交由 Andrew Qu 提交,标题是“把面向贡献者的内部 skill 打上标记,别让 npx skills add 抓到”,frontmatter 里加了 metadata: internal: true。但第三方目录并不认这个约定,Smithery 上有它的独立页面,skills.sh 的第三方统计显示累计安装量在一千四百上下。
资源地址
| 资源 | 链接 |
|---|---|
| Smithery 技能页 | https://smithery.ai/skills/vercel/list-npm-package-content |
| 源码目录 | https://github.com/vercel/ai/tree/main/skills/list-npm-package-content |
| 仓库主页 | https://github.com/vercel/ai |
| PR #11957(初版手册) | https://github.com/vercel/ai/pull/11957 |
| PR #11978(脚本化重构) | https://github.com/vercel/ai/pull/11978 |
| pnpm pack 文档 | https://pnpm.io/cli/pack |
| npm-packlist 规则 | https://www.npmjs.com/package/npm-packlist |
总结
这个技能的核心特征可以概括成一句话:把不确定性最高的那一步判断,从模型推理外包给包管理器的真实执行。glob 语义、! 否定的求值顺序、子目录 .npmignore 的优先级,这些东西让模型去推演,错的概率远大于跑一遍命令。
它适合的场景很窄:
-
pnpm workspace 里同时维护多个包 -
打包配置( files、tsup、.npmignore)经常改动 -
发版前需要确认清单,而不是发完再回滚
它不适合这三件事:想看体积,脚本用的是 -tzf 没有大小信息;想看线上已发布的内容,它读的是本地工作区;项目里没有 pnpm build,脚本第一步就会直接挂掉。
更大的启发在方法论层面。写 Skill 的时候先问自己一句:这段内容是要常驻上下文的索引,还是按需调用的能力?答案不同,存放位置就该不同。Vercel 花了二十四小时想明白这件事,你可以不用花。
顺带一个判断标准,可以拿去套任何技能:凡是能用一条命令产出确定结果的事情,就别写进 SKILL.md。模型的价值在判断何时执行、如何解读结果,不在复述命令本身。反过来说,凡是需要人类经验才能拿捏的取舍,比如“这个包该不该带 src”,脚本永远替代不了,那部分才值得留在正文里。
