本教程带你从零构建一个生成式自定义 Skill —— conv-commit:它读取暂存区的 git diff,自动产出符合 Conventional Commits 规范的提交信息并完成提交。读完你将掌握 Skill 的三层渐进式披露结构、SKILL.md frontmatter 写法、确定性脚本与模型判断的职责切分,以及触发器描述的调优技巧。适合已经能用 Claude Code 跑通基础任务、想把重复工作流沉淀为可复用 Skill 的开发者。
先明确一个判断标准:"这个任务我会重复 3 次以上吗?" 如果会,就值得做成 Skill。
写提交信息是高频痛点。每次 git commit 都要:看 diff、归类改动、回忆规范(是 feat 还是 fix?要不要 scope?有没有 BREAKING CHANGE)、再手工敲一行。同一个改动,团队成员写出来的提交信息五花八门,后续无法自动生成 changelog、无法用语义化版本号。
把它做成 Skill 的好处在于:diff 是客观事实,提交规范是确定性规则,只有"归类"这一步需要模型的语义判断。这三者的切分,恰好是练习 Skill 设计的最佳素材。
claude --version 可正常运行)git、python3(≥3.8)、bash 的 Linux/macOS 机器<type>[scope]: <description>动手之前,先在脑子里建好模型。Skill 不是一个大文件,而是一个文件夹,按"加载成本"分三层:
graph LR
A["第1层:frontmatter<br/>~100 tokens · 始终常驻"] -->|触发匹配成功| B["第2层:SKILL.md 正文<br/>建议 < 5000 tokens · 触发时加载"]
B -->|需要细则时| C["第3层:scripts/ references/<br/>无上限 · 按需读取"]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#e8f5e9
关键认知:第 1 层永远在上下文里,哪怕你不触发这个 Skill,它也在消耗 token。所以 description 是整个 Skill 最重要的一段——它决定 Claude 何时调用。第 2 层正文只在被触发后才加载,因此"何时使用"必须写在 description 里,写在正文里 Claude 根本看不到。
第 3 层是重资料:大段规则、API 文档、脚本,都放这里,按需读取,不占常驻预算。这就是渐进式披露(Progressive Disclosure),是 Skill 设计的灵魂。
SKILL.md 骨架在任意工作目录下创建 Skill 文件夹。Claude Code 的个人级 Skill 默认放在 ~/.claude/skills/,项目级放在 <repo>/.claude/skills/。本教程用项目级,方便随仓库分发。
mkdir -p .claude/skills/conv-commit/scripts
mkdir -p .claude/skills/conv-commit/references
写入 .claude/skills/conv-commit/SKILL.md:
---
name: conv-commit
description: Analyzes the staged git diff and produces a Conventional Commits message (feat/fix/docs/refactor/perf/test/build/ci/chore), then commits. Use when the user wants to commit changes, says "提交" / "commit" / "写提交信息" / "整理这次的改动", or asks for a 规范化的 commit message.
---
# Conventional Commits 提交助手
## 工作流
1. 运行 `python3 scripts/analyze_diff.py`,拿到暂存区变更的结构化事实(JSON)。
- 若 `has_staged` 为 false,先提示用户 `git add` 暂存相关文件,不要自动全量 `git add -A`。
2. 读取 `references/type_mapping.md`,依据事实判定 `type` / `scope` / `breaking` / `description`。
3. 组装提交信息并调用 `scripts/render_commit.sh <type> <scope> <breaking> <description> [body_file]` 完成提交。
## description 写作规则
- 主体 ≤ 50 字符,祈使句,小写开头,句末不加句号。
- 仅当改动确实聚焦于一个模块时才写 scope。
- breaking=true 时给 header 加 `!`,并在 body 中以 `BREAKING CHANGE: <原因>` 说明。
## 自由度
本 Skill 的语义判断(选 type、写描述)交给模型;格式化、行数统计、文件归类交给脚本——后者必须 100% 可复现。
预期结果:保存后用 /skills 命令应能看到 conv-commit 出现在列表里,状态为可用。
常见问题:
| 现象 | 原因 | 解决 |
|---|---|---|
/skills 看不到该 Skill |
frontmatter YAML 缩进/冒号错误 | 用 python3 -c "import yaml,sys;print(yaml.safe_load(open('.claude/skills/conv-commit/SKILL.md').read().split('---')[1]))" 验证可解析 |
| 触发了但行为不对 | description 没写清触发词 | 在 description 里补足用户可能的自然语言表达(中英文都要) |
| 报 "name 含保留字" | name 写了 claude 或 anthropic |
改名,这两个词是保留字 |
analyze_diff.py这是本 Skill 的核心设计点:把所有可机械判定的事实交给脚本,把需要语义理解的归类交给模型。脚本只回答"改了什么",模型回答"算哪类"。
写入 .claude/skills/conv-commit/scripts/analyze_diff.py:
#!/usr/bin/env python3
"""analyze_diff.py — 把暂存区 diff 解析成机器可读的变更事实。
仅做确定性提取,不做 type 判断。输出 JSON 供模型消费。"""
import subprocess, json, os, sys
from collections import defaultdict
def git(*args):
return subprocess.run(["git", *args], capture_output=True, text=True).stdout
def classify(path):
"""按路径启发式分桶,给模型一个粗粒度的归类信号。"""
base = os.path.basename(path).lower()
if (base.startswith("test") or ".test." in base or ".spec." in base
or "__test__" in path or "/tests/" in path or "/test/" in path):
return "tests"
if base in {"package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
"go.mod", "go.sum", "requirements.txt", "pyproject.toml",
"cargo.toml", "cargo.lock", "pom.xml", "gemfile", "gemfile.lock"}:
return "deps"
if base.endswith((".md", ".rst")) or "/docs/" in path or "/doc/" in path:
return "docs"
if base.endswith((".yml", ".yaml")) and ("/.github/" in path or "ci" in path):
return "ci"
if base.startswith("dockerfile") or base.endswith((".dockerfile", "makefile")) or base == "cmakelists.txt":
return "build"
return "source"
def main():
name_status = git("diff", "--cached", "--name-status")
numstat = git("diff", "--cached", "--numstat")
added = deleted = 0
for line in numstat.splitlines():
p = line.split("\t")
if len(p) == 3 and p[0].lstrip("-").isdigit():
added += int(p[0]); deleted += int(p[1])
files, buckets = [], defaultdict(int)
for line in name_status.splitlines():
if not line.strip():
continue
code, _, path = line.partition("\t")
status = code[0] # A=新增 M=修改 D=删除 R=重命名 ...
files.append({"status": status, "path": path})
buckets[classify(path)] += 1
result = {
"has_staged": len(files) > 0,
"file_count": len(files),
"added_lines": added,
"deleted_lines": deleted,
"buckets": dict(buckets), # 例如 {"source": 2, "tests": 1}
"files": files,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
为什么用脚本而不是写进 SKILL.md 正文? 因为行数统计、文件分桶这类操作要求"每次输入相同就输出相同"。如果用自然语言让模型去数行数,不同次调用结果可能差几行——而提交信息的客观事实必须精确。这就是"确定性处理脚本化"原则:需要 100% 可复现的,写成脚本;需要上下文理解的,留给模型。
验证一下(在一个有暂存改动的仓库里执行):
python3 .claude/skills/conv-commit/scripts/analyze_diff.py
预期输出(示意):
{
"has_staged": true,
"file_count": 3,
"added_lines": 47,
"deleted_lines": 9,
"buckets": { "source": 2, "tests": 1 },
"files": [
{ "status": "M", "path": "src/auth/token.py" },
{ "status": "M", "path": "src/auth/session.py" },
{ "status": "A", "path": "tests/auth/test_token_expire.py" }
]
}
render_commit.sh 格式化提交模型负责决定字段值,脚本负责按规范拼装并真正提交——同样为了格式 100% 一致。
写入 .claude/skills/conv-commit/scripts/render_commit.sh:
#!/usr/bin/env bash
# render_commit.sh — 组装 Conventional Commits 头并提交。不做事语义判断。
set -euo pipefail
type="${1:?usage: render_commit.sh <type> <scope> <breaking> <description> [body_file]}"
scope="${2:-}"
breaking="${3:-false}"
description="${4:?description required}"
body_file="${5:-}"
scope_part=""; [[ -n "$scope" ]] && scope_part="($scope)"
bang=""; [[ "$breaking" == "true" ]] && bang="!"
header="${type}${scope_part}${bang}: ${description}"
if [[ -n "$body_file" && -f "$body_file" ]]; then
git commit -m "$header" -F "$body_file"
else
git commit -m "$header"
fi
echo "✓ committed: $header"
赋权:
chmod +x .claude/skills/conv-commit/scripts/render_commit.sh
type_mapping.md这是第 3 层资料——放完整的归类规则,平时不加载,模型需要时才读。把它和 SKILL.md 正文分开,是为了把正文 token 控制在预算内。
写入 .claude/skills/conv-commit/references/type_mapping.md:
# Commit Type 判定规则(基于 @commitlint/config-angular)
## 类型清单
feat 新功能(→ SemVer MINOR)
fix 修 bug(→ SemVer PATCH)
docs 仅文档
style 不影响代码含义(空格/格式)
refactor 既非修 bug 也非加功能
perf 提升性能
test 补/改测试
build 构建系统/外部依赖(webpack/npm/go.mod 等)
ci CI 配置(.github/workflows、GitLab CI)
chore 杂务(不修 bug 也不加功能的琐事,如改 .gitignore)
revert 回滚某个提交
## 判定优先级(结合 analyze_diff.py 的 buckets)
1. buckets 只含 docs → docs
2. buckets 只含/主导 tests → test(除非连源码一起改,则以源码为准)
3. buckets 只含 deps/build/ci → build / ci
4. source 中含全新公开能力
(新增导出符号、新路由、新页面、新 CLI 命令)→ feat
5. source 改动针对已知缺陷
(加边界判断、修空指针、纠正逻辑)→ fix
6. 仅结构调整、无行为变化 → refactor
7. 命中性能优化(缓存、算法)→ perf
## Breaking Change 信号(→ 加 !,并在 body 写 BREAKING CHANGE:)
- 删除或重命名公开导出符号
- 函数签名参数变化/移除
- 配置项被移除
- 文件迁移导致 import 路径变化
- 数据库 schema 的不可逆迁移
## scope 写法
- 仅当改动聚焦单一模块时使用,取模块/包名:feat(auth):、fix(parser):
- 跨多模块的大改不要硬凑 scope,留空更诚实
完整的目录结构应该是:
graph TD
R["conv-commit/"] --> S["SKILL.md<br/>frontmatter + 工作流"]
R --> D["scripts/"]
R --> E["references/"]
D --> D1["analyze_diff.py<br/>确定性事实提取"]
D --> D2["render_commit.sh<br/>格式化并提交"]
E --> E1["type_mapping.md<br/>归类规则资料"]
style S fill:#fff3e0
style D fill:#e8f5e9
style E fill:#e1f5fe
在测试仓库里制造一次暂存改动,然后对 Claude Code 说:
"帮我整理这次的改动并提交"
预期行为链:
commit/提交 意图,触发 conv-commit(因为 description 里写了触发词)。analyze_diff.py,读到的 JSON 没有 has_staged——它会先问你"是否需要 git add 这几个文件?"。type_mapping.md 判定 type/scope。render_commit.sh feat auth false "修复 token 过期判断" 完成提交。如果没触发,十有八九是 description 不够好。调试方法:把当时的真实用户原话加进 description。比如你习惯说"commit 一下",那 description 里就必须出现 commit 一下 这几个字。
准备一个最小 Flask 项目:
mkdir demo && cd demo && git init
cat > app.py <<'EOF'
def auth(user, token):
return user == "admin" and token != ""
EOF
git add app.py && git commit -m "init"
现在新增一个"修复 token 空字符串也能通过"的逻辑:
cat > app.py <<'EOF'
def auth(user, token):
if not token:
return False
return user == "admin"
EOF
cat > test_app.py <<'EOF'
def test_empty_token_rejected():
assert auth("admin", "") is False
EOF
git add app.py test_app.py
对 Claude Code 说:"提交一下这次的改动"。
理想情况下,Skill 跑完 analyze_diff.py,看到 buckets={"source":1,"tests":1},test_app.py 是新增测试、app.py 改了判断逻辑(从宽松变严格),判定为 fix,scope 取 auth,最终提交:
fix(auth): reject empty token in auth check
这就是一个完整的"diff 秒变提交信息"闭环。
Skill 跑通后,两个值得加的增强:
1. 提交前本地校验。在 render_commit.sh 末尾、git commit 之前插入 commitlint 检查,不合规就拒绝提交、把错误回吐给模型让它修正:
if command -v commitlint >/dev/null 2>&1; then
echo "$header" | commitlint || { echo "✗ commitlint 拒绝,请修正"; exit 1; }
fi
2. 团队分发。把 .claude/skills/conv-commit/ 整个目录提交到仓库。团队成员 clone 后自动获得该 Skill——这是项目级 Skill 的天然优势,比口头约定"请按规范写提交"靠谱得多。如果要跨项目复用,则放进 ~/.claude/skills/(个人级)。
保持诚实:一次性任务别做 Skill,维护成本会超过收益;频繁变动的流程更适合写成 Memory 或项目说明;主要在和外部 API 交互的任务,MCP 服务器比 Skill 更合适。本案例之所以适合,是因为"提交"是每个仓库、每一天都会重复的高频操作。
/plugin marketplace add anthropics/skills 后 /plugin install example-skills@anthropic-agent-skills。@commitlint/config-angular 实现,是业界事实标准,type_mapping.md 的类型清单即源自此处。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
