深度解析 Anthony Fu 的又一个「理念先行」开源作品
一、为什么需要 skills-npm?
如果你正在用 Cursor、Claude Code 或 Windsurf 等 AI 编码助手,你可能已经体验过「Skills」的概念 ------ 一组自定义的指令和上下文,告诉 AI 如何与你的项目协作。但当你想把这些 Skills 分享给团队、跨项目复用,甚至随工具库一起分发时,问题就来了:
| 痛点 | 典型场景 |
|---|---|
| 分发碎片化 | 每个 Skill 都散落在独立 Git 仓库中,手动克隆安装 |
| 版本不对齐 | 工具升级了,Skill 还停留在旧版本,行为不可预测 |
| 手动管理 | 从安装到更新,全程手动操作 |
| 团队同步困难 | 新成员加入需要重新配置一遍 Skills 环境 |
Anthony Fu(Vue/Vite 核心贡献者)的解法优雅而大胆:不要重新发明轮子,让 npm 来做它最擅长的事。
skills-npm 的核心主张是:将 Agent Skills 内嵌到 npm 包中,利用 npm 已有的版本管理、依赖解析、lockfile 锁定和私有仓库等基础设施,让 Skills 的分发和管理变得和安装一个 npm 依赖一样简单。
bash
# 安装一个包含 Skills 的 npm 包
npm install @slidev/cli
# 运行 skills-npm,自动发现并链接 Skills
npx skills-npm
就这么简单。.cursor/skills/ 下自动出现了 npm-slidev-cli-presenter-mode --- 一个符号链接,指向刚安装的 npm 包中的 Skill 目录。
二、约定优于配置:一切始于 skills/*/SKILL.md
skills-npm 的分发协议极其简洁。如果你是工具库作者,想随包附带 Agent Skills,只需要在你的 npm 包中按如下结构组织:
css
your-awesome-lib/
├── src/
├── package.json
└── skills/
├── debug-helper/
│ └── SKILL.md
└── code-generation/
└── SKILL.md
每个 SKILL.md 需要包含 YAML frontmatter 来声明元信息:
yaml
---
name: debug-helper
description: 帮助 AI 代理理解项目的调试约定
---
在调试本项目时,请遵循以下规范...
这就是全部的「协议」。没有 JSON Schema,没有注册中心,没有额外的构建步骤 ------ 约定优于配置的极致实践。
skills-npm 会使用 gray-matter 库解析 frontmatter,并验证 name 和 description 字段的存在性。不符合规范的 Skill 会被标记为无效并在终端中报告,但不会中断整个流程。
三、架构剖析:一个精简的 ETL 管道
整个 skills-npm 的核心代码仅约 1,400 行 ,却实现了完整的 CLI 工具链。它采用了经典的 ETL(Extract-Transform-Load)管道架构:
配置加载 → 技能扫描 → 过滤处理 → Agent 检测 → 符号链接创建 → .gitignore 更新
cac] --> B[配置解析
unconfig] B --> C[Skills 扫描
scan.ts] C --> D[Include/Exclude
过滤] D --> E[Agent 检测] E --> F[用户确认
@clack/prompts] F --> G[符号链接
symlink.ts] G --> H[.gitignore
更新] C -.-> J[缓存层
cache.ts] J -.-> C
每个环节对应一个独立模块,职责单一、边界清晰。让我们逐一拆解。
3.1 Extract --- 技能扫描引擎
扫描引擎(scan.ts)是整个工具的核心,支持两种扫描策略:
策略一:node_modules 全扫描
遍历整个 node_modules 目录,检查每个包是否包含 skills/ 子目录。这种方式覆盖面广,能发现所有层级的 Skills,包括间接依赖中的 Skills。
策略二:package.json 精准扫描
只检查 dependencies 和 devDependencies 中显式声明的直接依赖。更快、更精准,适合大型项目。
typescript
export async function scanCurrentNodeModules(
cwd: string,
source: ScanOptions['source'] = 'node_modules'
): Promise<ScanResult> {
// 根据策略决定扫描范围
const packageNames = source === 'package.json'
? await getPackageDeps(cwd)
: null
for (const entry of entries) {
if (entry.name.startsWith('@')) {
// 处理作用域包:进入 @org/ 二级目录继续扫描
} else {
await scanPackageForSkills(entry)
}
}
}
一个值得注意的细节:扫描时使用 isDirectoryOrSymlink() 而非简单的 isDirectory() 检查,这是对 pnpm 符号链接结构的专门适配 。pnpm 为了节省磁盘空间,在 node_modules 中使用符号链接指向全局存储,如果只检查目录类型会遗漏所有包。
对于 Monorepo 场景 ,skills-npm 提供 --recursive 参数,通过解析 pnpm-workspace.yaml 或 package.json 的 workspaces 字段,找到所有子包并独立扫描,使用 Map 按包名去重。
3.2 Transform --- 过滤系统
过滤系统(utils/skills.ts)支持两种粒度的控制:
typescript
// 包级别过滤:匹配整个包的所有 Skills
include: ['@slidev/cli']
// 技能级别过滤:精确到特定包的特定 Skill
exclude: [{ package: '@some/lib', skills: ['deprecated-skill'] }]
过滤使用经典的白名单 + 黑名单模式:先通过 include 筛选出想要的,再通过 exclude 排除不需要的。
3.3 Load --- 符号链接引擎
符号链接创建(symlink.ts)是最体现工程素养的模块。126 行代码中,边界处理堪称教科书级别:
typescript
async function createSymlink(target: string, linkPath: string) {
// 1. 幂等性:目标路径相同 → 直接跳过
if (resolvedTarget === resolvedLinkPath) return true
// 2. 已有链接处理:
// - 符号链接已指向正确目标 → 跳过
// - 符号链接指向错误目标 → 删除后重建
// - 非符号链接文件占位 → 递归删除后重建
// 3. 循环引用防护:捕获 ELOOP 错误并强制清除
// 4. 跨平台兼容:Windows 使用 junction 类型
const symlinkType = platform() === 'win32' ? 'junction' : undefined
// 5. 使用相对路径:项目移动后链接依然有效
const relativePath = relative(linkDir, target)
await symlink(relativePath, linkPath, symlinkType)
}
为什么用相对路径而不是绝对路径? 考虑一个场景:你在 CI 环境中 clone 项目到 /home/runner/project,本地开发在 /Users/you/project。如果使用绝对路径创建符号链接,链接在不同机器上会失效。相对路径确保了项目目录可以整体移动或在不同路径下使用,符号链接始终有效。
命名规则 使用 npm-{sanitized-package}-{skill-name} 的格式:
perl
@slidev/cli 的 presenter-mode → npm-slidev-cli-presenter-mode
my-lib 的 debug-helper → npm-my-lib-debug-helper
npm- 前缀让用户一眼就能区分来自 npm 的 Skill 和手动创建的 Skill。
四、缓存:小而精的性能优化
在大型项目中,node_modules 可能包含数千个包。每次运行都全量扫描显然不切实际。skills-npm 的缓存策略(cache.ts,58 行)巧妙而实用:
核心思路:lockfile 不变 = 依赖不变 = 扫描结果不变。
typescript
export async function getPackageManagerLockFileHash(cwd: string) {
// 按优先级尝试:bun.lockb → pnpm-lock.yaml → yarn.lock → package-lock.json
for (const file of LOCK_FILES.all) {
const encoding = isBinaryLockFile(file) ? undefined : 'utf-8'
const content = await readFile(join(cwd, file), encoding)
return {
hash: createHash('md5').update(content).digest('hex'),
path: file,
}
}
}
缓存存储在 node_modules/.skills-npm/cache.json。这个路径的选择也值得玩味 ------ 当你执行 rm -rf node_modules 重装依赖时,旧缓存自动消失,新一次扫描会重建缓存。缓存的清理和 node_modules 的生命周期自然对齐,不需要额外的清理机制。
五、配置系统:三层覆盖 + TypeScript 原生支持
skills-npm 提供三层配置合并机制,遵循「最近原则」:
arduino
默认配置 < skills-npm.config.ts < CLI 参数
配置文件使用 Anthony Fu 自己维护的 unconfig 库加载,原生支持 TypeScript 格式:
typescript
// skills-npm.config.ts
import { defineConfig } from 'skills-npm'
export default defineConfig({
source: 'package.json', // 只扫描直接依赖
recursive: true, // 递归扫描 Monorepo
include: ['@slidev/*'], // 只安装 Slidev 相关的 Skills
exclude: [{ package: '@some/lib', skills: ['experimental'] }],
confirm: false, // 跳过确认提示(CI 环境)
})
defineConfig 是一个经典的 identity 函数,唯一的作用是提供 TypeScript 类型推导,让你在写配置时获得完整的自动补全。
六、终端体验:TTY 感知的双模式输出
skills-npm 的终端体验做了精细的环境适配(printer.ts):
TTY 模式 (交互式终端):使用 @clack/prompts 提供交互式体验,包括渐变灰色的 ASCII Art Logo、spinner 进度动画、彩色技能列表(picocolors)、以及确认/多选提示。
非 TTY 模式(CI/CD 管道):自动降级为纯文本输出,无 ANSI 颜色码,无交互式提示,确保在日志系统中可读。
typescript
if (isTTY) {
p.log.info('Discovered skills:')
console.log(` ${c.green('●')} ${c.bold(skill.name)} ${c.dim(`from ${pkg}`)}`)
} else {
console.log(` - ${skill.name} (${pkg})`)
}
这种双模式设计保证了一个命令在本地开发和 CI/CD 环境中都表现良好,是优秀 CLI 工具的标准实践。
七、Vendor 子模块:站在巨人肩膀上
skills-npm 通过 Git submodule 引入了 vercel-labs/skills 作为 vendor 依赖。这个子模块提供了 Agent 类型定义(AgentType、AgentConfig)和 Agent 检测逻辑(detectInstalledAgents())。
构建时通过 tsdown 的 noExternal: [/vendor\/skills/] 配置将 vendor 代码内联打包到最终产物中,实现了:
- 类型共享:复用上游标准类型而非自己定义
- 检测逻辑复用:不重复实现 Agent 特定目录的检测
- 版本可控:通过 submodule 的 commit hash 精确锁定上游版本
这是一种「编译时依赖,运行时零依赖 」的策略 ------ 用户不需要安装 vercel-labs/skills,但 skills-npm 的代码中包含了它的逻辑。
八、工作区检测:移植自 Vite 的成熟实践
工作区根目录的检测(utils/workspace.ts)直接移植自 Vite 的源码,通过递归向上搜索来定位 Monorepo 根目录:
typescript
const ROOT_FILES = ['pnpm-workspace.yaml', 'lerna.json']
export function searchForWorkspaceRoot(current: string, root: string): string {
if (hasRootFile(current)) return current // 找到标志文件
if (hasWorkspacePackageJSON(current)) return current // package.json 有 workspaces
const dir = dirname(current)
if (!dir || dir === current) return root // 到达文件系统根
return searchForWorkspaceRoot(dir, root) // 继续向上
}
这种「向上探测」的模式在 Node.js 工具链中非常常见(ESLint、Prettier 也是类似策略),skills-npm 选择复用 Vite 经过大规模验证的实现,是明智的工程决策。
九、设计精华与技术创新
回顾整个项目,有几个设计决策值得特别关注:
9.1 理念创新:基础设施复用
skills-npm 没有构建新的分发渠道、注册中心或版本管理系统。它的创新在于 认知层面 ------ 将 Agent Skills 的分发问题映射到已有的 npm 生态上。npm 的版本语义化、lockfile 锁定、私有仓库、CI/CD 集成、Monorepo 支持......这些经过十多年打磨的基础设施,skills-npm 全部白嫖。
9.2 幂等设计:prepare 脚本友好
skills-npm 被设计为可以安全地放入 package.json 的 prepare 脚本中:
json
{
"scripts": {
"prepare": "skills-npm --yes"
}
}
每次 npm install 后自动执行。幂等性确保了重复运行不会产生副作用,缓存机制避免了不必要的扫描开销。
9.3 模块化极致
14 个源文件,平均每个文件不超过 100 行。每个文件的命名就是它的职责:scan.ts 只做扫描,symlink.ts 只做链接,cache.ts 只做缓存。类型定义集中在 types.ts,常量集中在 constants.ts。这种极致的关注点分离让代码的可维护性和可测试性都达到了很高的水平。
十、改进空间与未来展望
作为 v0.1.0 阶段的项目,skills-npm 仍有不少进化空间:
| 方面 | 现状 | 潜在改进 |
|---|---|---|
| 扫描性能 | 包逐个串行扫描 | 使用 Promise.all 或 p-limit 并行扫描 |
| 错误恢复 | 大量 catch 块静默忽略 | 增加 --verbose 模式暴露调试信息 |
| 缓存粒度 | lockfile 级别(任何依赖变化都失效) | 可按包级别做增量缓存 |
| 链接清理 | 无自动清理功能 | 增加 --clean 命令清除失效链接 |
| 实时更新 | 无 watch 模式 | 监听 node_modules 变化自动更新 |
| 嵌套发现 | 不扫描嵌套 node_modules |
适配非 hoisted 模式的包管理策略 |
十一、总结
skills-npm 是一个典型的「小工具,大理念」项目。1,400 行代码背后,是对 AI Agent 工具链生态的深刻观察:Skills 的分发不需要新的基础设施,npm 已经准备好了一切。
从技术实现来看,项目的代码质量极高:TypeScript 类型系统运用规范、模块职责划分清晰、边界处理(符号链接的幂等性、ELOOP 防护、Windows 兼容)无可挑剔。技术栈选型也体现了 Anthony Fu 一贯的品味 ------ cac、picocolors、unconfig、tsdown ------ 每一个都是轻量级、无依赖的精品工具。
在 AI 编码助手快速普及的今天,skills-npm 很可能成为 Agent Skills 分发领域的事实标准。毕竟,最好的基础设施,是让你感觉不到它存在的基础设施。