手搓 10 个 Skill 后,我把重复劳动收敛成了一套零依赖 CLI 工具

背景

如果团队里已经有 5 个、10 个 Skill,每次还要从空目录开始,重新搭建目录结构、复制公共工具、手写 SKILL.md,再手动同步到 Agent 的运行目录吗?

过去半年,我陆陆续续写了大概 10 个 Agent Skill:

  • 游戏中台 SSO 登录
  • D2C 自动化
  • 营销活动管理
  • ......

单看一个 Skill,其实不复杂:一份 SKILL.md 定义规则,再配一段脚本,Agent 就能把任务跑起来。

但 Skill 一多,问题就不再是"这个脚本能不能写出来",而是:那些和业务无关、却又每次都会出现的工程问题,能不能被稳定收敛? 目录结构、HTTP 等工具、SKILL.md 校验、产物同步,写到第 3、4 个的时候,这些事会占据一定的精力。

把这类重复劳动尽量前置、收敛、固化,这就是 skill-kits 想做的事情。

先看它实际解决了什么:

指标 重构前:手搓 Skill 重构后:基于 skill-kits
新建一个 Skill 每次重做一遍工程决策 pnpm new <name> 一行命令
HTTP / 错误处理等工具 每个 Skill 复制一份 从 runtime import,零依赖内联
代码改动同步到 Agent cp -r / 手动同步 pnpm dev 一条命令 watch + 同步
SKILL.md 质量检验 全靠肉眼 review pnpm build 自动检查

下面我就按踩坑的顺序,聊聊这套东西是怎么长出来的。

一、手搓 Skill 时,我反复踩的几个坑

1.1 一段 fetch,在 3 个 Skill 里维护了 3 份

写到第 3 个 Skill 的时候,我发现已经复制了三套 HTTP 封装。

ts 复制代码
// skill1/scripts/http.ts ------ Cookie 鉴权 + 错误详情提取
async function request<T>(
  method: "GET" | "POST",
  domain: string,
  path: string,
  token: string,
  options?: { params?: Record<string, string>; body?: unknown },
) {
  const url = new URL(`${domain}/gms_api${path}`);
  if (options?.params) {
    Object.entries(options.params).forEach(([k, v]) => {
      url.searchParams.set(k, v);
    });
  }

  const res = await fetch(url.toString(), {
    method,
    headers: {
      "Content-Type": "application/json",
      Cookie: `x-token=${token}`,
    },
    body: options?.body ? JSON.stringify(options.body) : undefined,
  });

  if (!res.ok) {
    const detail = await res.text().catch(() => "");
    throw new Error(`HTTP ${res.status}: ${res.statusText}\n${detail}`);
  }

  return res.json() as Promise<ApiResponse<T>>;
}

// skill2/scripts/http.ts ------ Bearer Token + 网络异常兜底 + 响应解析容错
async function postJson<T>(url: string, body: unknown, token?: string) {
  let response: Response;

  try {
    response = await fetch(url, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify(body),
    });
  } catch (error) {
    return {
      httpOk: false,
      status: 0,
      statusText: `NetworkError: ${error instanceof Error ? error.message : String(error)}`,
      data: null,
    };
  }

  const raw = await response.text();
  let data: unknown = null;
  try {
    data = JSON.parse(raw);
  } catch {
    data = { raw };
  }

  return { httpOk: response.ok, status: response.status, data: data as T };
}

// skill3/scripts/http.ts ------ 又是另一套(省略)

三个 Skill,三份 http.ts,哪天鉴权从 Cookie 切到 Bearer,需要把同一段逻辑修改 3 次,这无疑抬高了维护成本。

1.2 每次新建 Skill,都要重新考虑工程问题

这个坑不一定在第一个 Skill 就冒出来,但到第三四个的时候会特别明显,开发中可能要花不少时间思考"这个 Skill 到底该长成什么样"。反复出现的,基本就是这几类问题:

  • 入口和目录怎么定 :脚本入口放哪,references/assets/ 怎么组织,最后 Agent 到底执行哪个文件?这事如果第一次没定住,后面每个 Skill 都会长得不一样。
  • 产物怎么交付 :本地想用 TS 写,但交给 Agent 时最好是一个 Node 直接能跑的 main.mjs。不然可能就是一堆 .js、依赖和构建约定跟着走,迁一次目录都容易出问题。
  • 改完怎么联调 :写完一个命令,不是本地跑通就结束了,还得让 Agent 下一次调用拿到最新版本。没有 watch 和同步时,最常见的动作就是反复 build -> cp -r -> 再试一次
  • 定义和实现怎么对齐SKILL.md 里的 namedescription、references 路径、脚本入口,只要有一个地方没对齐,就会出现"能构建但触发不到",或者"触发了却找不到文件"。

1.3 JS 没类型补全,TS 又不想把运行环境搞复杂

Skill 脚本的执行可以很简单,Agent 直接跑下面的命令即可:

bash 复制代码
node scripts/main.mjs

但纯 JS 没有类型补全、没有类型检查,开发体验会比较差。

我试过走两条路:

  • bun 直接跑 TS:本地开发体验确实好,但 Agent 环境不一定装了 bun,需要进行嗅探
  • npx tsc 兜底:确实能直接跑 TS,但不管是 bun 还是 npx tsc,SKILL.md 里都得额外声明运行时------"用 bun 执行"或"用 npx tsc 执行"------规则一多,Agent 执行就容易出错;而且 npx 本身也有冷启动开销,每次执行都要等一下

1.4 SKILL.md 写得"对",不等于真的"好用"

这一点我一开始其实没太当回事,后来被现实教育了。

遇到过两个相关的坑:

  • 触发不准description 写得抽象、没有覆盖某些场景,LLM 不知道什么时候该用它,得反复调关键词
  • 内容太"全"反而难用 :AI 生成的 SKILL.md 内容很全,但我想自己动手改时,感觉无从下手

二、我最后把它做成了什么样

这几个坑拆开看都不算大,但当它们在 5 个、10 个 Skill 上反复出现时,指向的是同一件事:Skill 开发缺一套可复用的工程边界。skill-kits 做的事,就是把这层边界落成一个尽量简单的工具链:

  • 源码侧:当成一个正常的 TypeScript 项目来写,有 src/main.tssrc/commands/references/assets/SKILL.md
  • 构建侧:统一打成单文件 main.mjs,把 runtime 和 @skills/shared 内联进去,产物零依赖
  • 交付侧:Agent 只认 node scripts/main.mjs 这一种形态

用一张图看,会更直观一点:

text 复制代码
源码(TypeScript + runtime import)

┌──────────────────┐
│  src/main.ts     │
│  src/commands/   │
│  references/     │
│  assets/         │
│  SKILL.md        │
└──────────────────┘
           │
     build (esbuild)
           │
           ▼
┌──────────────────┐
│  dist/<skill>/   │
│  ├── SKILL.md    │
│  ├── scripts/    │
│  │   └── main.mjs│
│  ├── references/ │
│  └── assets/     │
└──────────────────┘

产物:单文件 ESM,零依赖
Agent 侧直接执行:node scripts/main.mjs

平时常用的命令也就这几条:

bash 复制代码
npx skill-kits init my-skills
cd my-skills && pnpm install

pnpm new daily-report
pnpm dev daily-report --out ~/.agent/skills
pnpm build daily-report

三、Runtime:解决掉执行层的重复劳动

我之前在另一篇文章里聊过 Skill 的运行原理。简单来说,Skill 拆开看就是两层:

  • 一层是结构化的 Prompt,即 SKILL.md,负责定义规则
  • 一层是 Tool,即脚本,负责真正执行

前者天然灵活,怎么写都可能不一样;但后者会有很多相似之处,比如:

  • 命令路由
  • 参数校验
  • stdout / stderr 输出协议
  • HTTP 请求样板
  • 错误码
  • 长轮询心跳

也正因为这块重复得太明显,我把它们收成了一个 runtime。

3.1 命令路由:简化 ifswitch 的堆叠写法

我在写营销活动管理 Skill 的时候,7 个子命令,main.ts 基本就是一大坨 parseArgs + switch + usage + 参数校验

ts 复制代码
// ❌ 手搓版:parseArgs + switch,约 250 行
function parseArgs(): CliOptions {
  const args = process.argv.slice(2);
  const opts: Partial<CliOptions> = {};

  for (let i = 1; i < args.length; i++) {
    switch (args[i]) {
      case "--domain":
        opts.domain = args[++i];
        break;
      case "--app-id":
        opts.appId = args[++i];
        break;
      case "--token":
        opts.token = args[++i];
        break;
      case "--activity-id":
        opts.activityId = args[++i];
        break;
      case "--body":
        opts.body = args[++i];
        break;
      // ... 还有十几个 case
    }
  }

  if (!opts.domain) {
    console.error("❌ 缺少 --domain");
    process.exit(1);
  }
  if (!opts.appId) {
    console.error("❌ 缺少 --app-id");
    process.exit(1);
  }
  if (!opts.token) {
    console.error("❌ 缺少 --token");
    process.exit(1);
  }

  return opts as CliOptions;
}

async function main() {
  const opts = parseArgs();

  switch (opts.command) {
    case "get_activity":
      if (!opts.activityId) {
        console.error("❌ 需要 --activity-id");
        process.exit(1);
      }
      await getActivity(opts.domain, opts.appId, opts.token, opts.activityId);
      break;
    case "create_activity":
      if (!opts.body) {
        console.error("❌ 需要 --body");
        process.exit(1);
      }
      await createActivity(opts.domain, opts.appId, opts.token, opts.body);
      break;
    // ... 其余 case
  }
}

这种代码最大的问题不是代码长,而是逻辑分散。参数解析、校验、USAGE、错误处理分布在不同地方,加一个命令得改好几处,特别容易漏。

后来我把这块收成了 createRouter

ts 复制代码
import { createRouter, writeResult } from "skill-kits/runtime";

const router = createRouter({
  name: "redbrick-activity",
  description: "...",
  commonArgs: {
    domain: { type: "string", required: true, desc: "平台域名" },
    appId: { type: "string", required: true, desc: "应用 ID" },
    token: { type: "string", required: true, desc: "SSO token" },
  },
});

router.command({
  name: "get-activity",
  description: "查询活动详情",
  args: {
    activityId: { type: "string", required: true, desc: "活动 ID" },
  },
  async handler({ domain, appId, token, activityId }) {
    writeResult(await getActivity(domain, appId, token, activityId));
  },
});

router.command({
  name: "create-activity",
  description: "创建活动",
  args: {
    body: { type: "json", required: true, desc: "活动字段 JSON" },
  },
  async handler({ domain, appId, token, body }) {
    writeResult(await createActivity(domain, appId, token, body));
  },
});

router.run(process.argv.slice(2));

这一层抽完之后,思路会清爽很多:我不需要再盯着"参数是怎么 parse 的",只需要关心"这个命令需要什么参数,拿到参数后做什么事"。

3.2 输出协议:stdout 和 stderr 分家

之前写 Skill 需要打印信息时,除了错误用 console.error,其他都用 console.log 一把梭。

其实落到实践上,更好的做法是:stdout 输出 JSON 结果,stderr 输出进度文案;失败时通过非 0 退出码让 Agent 识别到 status: failed,这比让 LLM 去解析 stderr 里的错误文本可靠得多。

skill-kits 提供了几个简单的输出函数,便于使用:

ts 复制代码
writeResult(payload);                          // stdout:单行 JSON,给 Agent 用
writeError(errorOrMessage, { code?, extra? }); // stderr:结构化错误 + exitCode=1
notify("正在拉取数据...");                      // stderr:进度日志

3.3 HTTP:一层薄的 fetch 封装

HTTP 这一块,我一开始打算做一个"全功能 HttpClient":

  • 内置鉴权
  • 内置 baseURL
  • 内置重试
  • 最好再顺手把业务错误也兜一下

但实际写下来发现,不同 Skill 处理 HTTP 的差异很大:

  • 有的 Skill 用 Cookie 鉴权
  • 有的 Skill 用 Bearer Token 鉴权
  • 不同业务的错误码也不能通用

这时候再硬做一个大而全的客户端就不合适了,目前设计上只保留"少写一点 fetch 样板"这件事:

ts 复制代码
import { httpGet, HttpError } from "skill-kits/runtime";

const res = await httpGet<UserInfo>("https://api.example.com/me", {
  headers: { authorization: `Bearer ${token}` },
  query: { fields: "id,name" },
  timeoutMs: 10_000,
});

if (!res.ok) {
  throw new HttpError(res.status, url, res.statusText);
}

剩下那层跟业务强相关的封装,就交给各个 Skill 自己包。

3.4 长轮询心跳:别让 Agent 以为你挂了

D2C 生码、SSO 登录回调这类场景,最大的问题往往不是"真的超时",而是"看起来像超时"。

比如你直接睡 60 秒:

ts 复制代码
await new Promise((resolve) => setTimeout(resolve, 60_000));

进程在这 60 秒内没有任何输出,Agent 很可能会认为进程已经卡死而将其终止。

所以我后来补充了 sleepWithHeartbeat

ts 复制代码
import { sleepWithHeartbeat } from "skill-kits/runtime";

await sleepWithHeartbeat(60_000, {
  message: (rem) => `等待生码... 剩余 ${rem}s`,
});

它每 5 秒往 stderr 输出一次心跳,让 Agent 知道进程还在运行,避免因长时间无输出而被误判为卡死。

3.5 错误体系:能靠 code 说清楚,就别靠猜 message

最后是错误体系,我把常见错误收进了统一的 code 里:

code 典型场景
UserInputError USER_INPUT_ERROR 参数缺失 / 格式错误
AuthError AUTH_ERROR Token 过期 / 权限不足
HttpError HTTP_ERROR 上游 HTTP 非 2xx
BusinessApiError BIZ_<code> HTTP 200 但业务 code ≠ 0

以下是几个典型用法和输出示例:

ts 复制代码
import {
  SkillError,
  UserInputError,
  BusinessApiError,
} from "skill-kits/runtime";

// UserInputError:参数校验失败
throw new UserInputError("activityId 不能为空", { field: "activityId" });
// stderr → {"ok":false,"code":"USER_INPUT_ERROR","error":"activityId 不能为空","details":{"field":"activityId"}}

// 自定义 BusinessApiError 错误
throw new BusinessApiError(-10000, "token 过期", {
  hintMap: { [-10000]: "请重新登录", [-14]: "记录不存在" },
});
// stderr → {"ok":false,"code":"BIZ_-10000","error":"[code=-10000] token 过期(请重新登录)"}

// 自定义业务错误:继承 SkillError,code 自由命名
class RateLimitError extends SkillError {
  constructor(retryAfterSec?: number) {
    super("RATE_LIMIT", "请求过于频繁", { retryAfterSec });
  }
}
throw new RateLimitError(30);
// stderr → {"ok":false,"code":"RATE_LIMIT","error":"请求过于频繁","details":{"retryAfterSec":30}}

这样无论是人工排查,还是 LLM 后续做分支处理,都无需再从冗长的 message 中猜测错误含义。

四、业务公共模块:第二层抽象

runtime 解决的是基础设施层的重复,还有一层其实也很值得抽:业务公共模块。

例如:

  • 内部 API 客户端
  • 域名常量
  • 签名工具
  • 多个 Skill 之间会反复出现的小工具

所以 skill-kits init 生成的 workspace 默认会带一个 packages/shared

text 复制代码
my-skills/
├── pnpm-workspace.yaml
├── package.json
└── packages/
    ├── shared/        # 跨 Skill 复用的业务公共模块 (@skills/shared)
    └── skills/
        └── daily-report/

用的时候也很直接:

ts 复制代码
import { signRequest, BIZ_DOMAINS } from "@skills/shared";

构建时 esbuild 会把用到的部分内联进产物,最后还是单文件、零依赖。

五、Lint:SKILL.md 也该有一层最低限度的工程化

前面说的那些都属于 Tool。SKILL.md 这东西当然不可能像代码一样完全标准化,可它也不是完全没法校验。至少这些问题,应该在本地就拦住:

  • name 和目录名不一致
  • body 太长,把上下文窗口塞爆
  • references 引错了路径
  • description 太短,看不出触发场景

这里补了一套围绕 SKILL.md 的 lint 规则,pnpm build 默认先跑:

  • name-matches-dirname 必须等于父目录名
  • body-line-limit:body 超过 500 行直接报错
  • body-line-soft:超过 400 行给 warning,建议拆到 references/
  • description-length:description 太短给 warning
  • description-trigger / description-negative:检查有没有写清楚"何时触发"和"不要触发"

如果对阈值有自己的习惯,也可以配 .skillkitrc.json

json 复制代码
{
  "lint": {
    "triggerHints": ["何时", "trigger", "use when"],
    "negativeHints": ["不要", "do not"],
    "descriptionMinChars": 80,
    "bodyLinesWarn": 400,
    "bodyLinesFail": 500
  }
}

六、Dev 模式:边写边同步,不想再手动 cp -r

开发 Skill 时,需要将其同步到 Agent 的本地 Skill 目录以便快速验证。

以前这一步基本都是手动复制:

bash 复制代码
cp -r dist/xxx ~/.agent/skills

次数多了难免繁琐,因此我添加了 dev 模式:

bash 复制代码
pnpm dev daily-report --out ~/.agent/skills

它会同时做两件事:

  1. 用 esbuild watch src/.ts 文件变化触发重编
  2. 监听 SKILL.md / references/ / assets/,资源变化直接同步到 --out 指定目录

这样我本地改完,Agent 下次调用拿到的就是最新版本。

写在最后

skill-kits 不替你写脚本,也不替你做业务抽象。它做的事情很明确:把入口、产物、运行时、定义校验这些横切问题收进一层护栏里,让你在新建第 5 个、第 10 个 Skill 时,脑子里想的依然是业务逻辑怎么写。

如果你也在写 Agent Skill,欢迎试试:

bash 复制代码
npx skill-kits init my-skills

GitHub 地址:github.com/weijhfly/sk...

相关推荐
罗超驿1 小时前
13.JavaScript 新手入门指南:语法、变量、流程控制全解析
开发语言·javascript
IT_陈寒1 小时前
Python的线程池居然把我坑在了垃圾回收这块
前端·人工智能·后端
刘一说1 小时前
AI科技热点日报 | 2026年6月1日
人工智能·科技
阿里云大数据AI技术1 小时前
性能提升20倍:阿里云 Milvus 深度优化磁盘索引,重新定义亿级向量检索
人工智能
包子BI大数据1 小时前
3.openclaw小龙虾简单版安装教程
人工智能·python·ai
ct9782 小时前
Three.js 性能优化(测量-定位-优化)
javascript·性能优化·three
研☆香2 小时前
es6新特性功能介绍(一)
前端·ecmascript·es6
zhangfeng11332 小时前
超算/曙光DCU集群 昆山站 根目录文件夹逐项释义(HTC调度集群环境、国产DCU算力节点)
人工智能·pytorch·机器学习