Claude Code Skills怎么写:SKILL.md格式、自动触发和命令注入的3个坑

  发布时间:2026-11-04 10:09:51   作者:aiks_yuan   我要评论
想给团队写Claude Code Skill却不知从何下手,本文手把手教你写一个Java后端实用的MyBatis SQL注入检查Skill,实测自动触发与手动调用,并揭露一个让Claude误报“没问题”的隐蔽大坑,写Skill前必看,需要的朋友可以参考下

导读:知乎上"有哪些好的 Claude Code Skills 推荐"有 12 万多浏览,但别人的 Skill 多半是通用的,真正省事的是把自己团队反复交代的那套检查写成 Skill。这篇从零写一个 Java 后端用得上的 Skill:检查 MyBatis Mapper XML 里的 ${} 有没有 SQL 注入风险。在 Claude Code 2.1.299 上实测了自动触发、手动调用、命令注入,其中有一个坑会让 Claude 给出"没发现问题"的错误结论,写 Skill 的人最好都看一眼。

一、先说清楚:Skill 是什么,什么时候值得写

一句白话:Skill 就是一份写给 Claude 的操作手册,放在固定目录里,需要的时候它自己会翻出来看。

打个比方,CLAUDE.md 像是贴在工位上的团队规范,每次对话都会读;Skill 像是放在抽屉里的专项手册,平时 Claude 只看得到封面上的一句简介(description),等你问到相关的事,或者你直接敲 /手册名,它才把整本拿出来照着做。

对比CLAUDE.mdSkill
什么时候进上下文每次会话都加载平时只加载一句简介,用到时才加载全文
适合放什么人人都要遵守的通用规范某一类任务的具体步骤、脚本、参考资料
能不能带脚本不能可以,放在 Skill 目录里一起分发
怎么触发自动Claude 自动判断,或你敲 /skill-name

不写会怎样:同一套要求每次都要在对话里重新交代一遍,换个同事就交代得不一样;或者全塞进 CLAUDE.md,越写越长,每次对话都在为用不上的内容付 token。

什么时候值得写:一件事你已经跟 Claude 交代过两三次、步骤基本固定,就该写成 Skill。比如提交前查 SQL、按团队模板生成 CRUD、发版前的检查清单。

二、最小的 Skill 长什么样

2.1 放在哪

位置路径谁能用
个人~/.claude/skills/<skill名>/SKILL.md你在这台机器上的所有项目
项目.claude/skills/<skill名>/SKILL.md这个仓库里的会话,提交后团队都能用
插件<插件>/skills/<skill名>/SKILL.md装了这个插件的人,命令名带插件前缀

团队规范类的放项目目录,随代码提交;个人习惯类的放 ~/.claude/skills/。

2.2 SKILL.md 的两部分

一个目录、一个 SKILL.md 就是一个 Skill。文件开头 --- 之间是 YAML 格式的元信息(frontmatter),下面是给 Claude 的正文:

---
name: api-conventions
description: 本项目的接口设计规范。写 Controller、定义接口出入参、设计错误码时使用。
---

写接口时:
- 统一返回 Result<T>,分页返回 PageVO<T>
- 入参用 @Valid 校验,必填字段加 @NotBlank / @NotNull
- Long 类型 ID 序列化成字符串,避免前端精度丢失

最常用的几个字段:

字段作用
name命令名,不写就用目录名。上面这个可以用 /api-conventions 调用
description最重要。Claude 靠它判断什么时候自动加载这个 Skill
disable-model-invocation设成 true 后只有你敲命令才会触发,Claude 不会自己调用
allowed-tools这个 Skill 运行的那一轮里,哪些工具不用你确认就能用
context: fork放到独立的子代理里跑,不占主对话的上下文

官方文档特别提醒:字段名写错(比如 disable_model_invocation 用了下划线),Claude Code 不报错,直接忽略;YAML 格式写坏了,Skill 照样能用 / 调用,但 description 失效,Claude 不会自动触发它。排查时用 claude --debug 看解析错误。

三、实战:写一个检查 MyBatis${}的 Skill

MyBatis 里 #{} 是预编译参数,安全;${} 是字符串直接拼进 SQL,用户能控制这个值就有注入风险。但 ORDER BY 字段名、排序方向这类地方又只能用 ${}。所以这件事适合交给 Claude:先用脚本把所有 ${} 找全,再让 Claude 逐条判断是该改 #{} 还是该加白名单。

3.1 测试项目

在 scratchpad 临时目录建了一个最小项目,OrderMapper.xml 里埋了 3 处 ${}:

另外一个 UserMapper.xml 只用了 #{},作为对照。

3.2 目录结构

.claude/skills/mybatis-sql-check/
├── SKILL.md
└── scripts/
    └── scan_mapper.py

脚本跟着 Skill 一起放,提交到仓库后团队拉下来就能用,不用每个人自己装。

3.3 扫描脚本

找 ${} 这种机械活交给脚本,比让 Claude 自己 grep 稳定:

#!/usr/bin/env python3
# List every ${...} in MyBatis mapper XML files; exit 1 when any is found
import pathlib, re, sys
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "src/main/resources")
if not root.is_dir():
    print(f"目录不存在:{root},没有扫描")
    sys.exit(2)
hits = 0
for xml in sorted(root.rglob("*.xml")):
    for no, line in enumerate(xml.read_text(encoding="utf-8").splitlines(), 1):
        for m in re.finditer(r"\$\{([^}]+)\}", line):
            hits += 1
            print(f"{xml}:{no}: ${{{m.group(1)}}}  | {line.strip()}")
print(f"共扫描 {len(list(root.rglob('*.xml')))} 个 XML,{hits} 处 ${{}}")
sys.exit(1 if hits else 0)

在项目根目录单独跑一下,3 处都找到了:

# 先脱离 Claude Code 验证脚本本身
python3 .claude/skills/mybatis-sql-check/scripts/scan_mapper.py src/main/resources

输出:

src/main/resources/mapper/OrderMapper.xml:8: ${orderBy}  | SELECT * FROM t_order WHERE user_id = #{userId} ORDER BY ${orderBy} ${sortDir}
src/main/resources/mapper/OrderMapper.xml:8: ${sortDir}  | SELECT * FROM t_order WHERE user_id = #{userId} ORDER BY ${orderBy} ${sortDir}
src/main/resources/mapper/OrderMapper.xml:11: ${keyword}  | SELECT * FROM t_order WHERE order_no LIKE '%${keyword}%'
共扫描 2 个 XML,3 处 ${}

目录不存在时打印一句话并退出 2,这是踩坑之后才加的,第四节会讲为什么。

3.4 SKILL.md(最终版)

---
name: mybatis-sql-check
description: 检查 MyBatis Mapper XML 里的 ${} 拼接,判断有没有 SQL 注入风险并给出 #{} 或白名单改法。用户提到 SQL 注入、Mapper XML 检查、${} 和 #{} 区别、提交前查 SQL 时使用。
allowed-tools: Read Grep Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scan_mapper.py *)
---

## 扫描结果

!`python3 ${CLAUDE_SKILL_DIR}/scripts/scan_mapper.py ${CLAUDE_PROJECT_DIR}/src/main/resources || true`

## 要做的事

扫描结果里如果写着"目录不存在",先告诉用户,不要给结论。

逐条判断每一处 `${}`:

1. 来自用户输入的值(关键字、ID、名称):必须改成 `#{}`,LIKE 用 `CONCAT('%', #{keyword}, '%')`
2. ORDER BY 字段、排序方向、表名这类不能用 `#{}` 的:保留 `${}`,要求在 Java 层用白名单校验,给出校验代码
3. 输出一张表:文件:行号 / 变量 / 风险 / 改法

只给改法,不要直接修改文件。

几处写法说明:

写法作用
!`命令`动态注入:Claude Code 先在本机执行命令,用输出替换这一行,再把整段内容交给 Claude。Claude 拿到的是扫描结果,不是命令
${CLAUDE_SKILL_DIR}Skill 自己所在的目录。脚本路径用它,不管从哪个目录启动都能找到
${CLAUDE_PROJECT_DIR}项目根目录
allowed-tools 里的 Bash(...)预先放行这条脚本命令。注入命令不会弹窗询问,没放行的会直接让 Skill 中止
`true`脚本发现问题时退出码是 1,不加这个 Skill 会中止(第四节坑 2)

3.5 实测:两种触发方式

手动调用,在项目目录里执行(交互模式下直接输入 /mybatis-sql-check 一样):

# --permission-mode default:按默认权限模式跑,后面解释为什么要指定
claude -p "/mybatis-sql-check" --model haiku --permission-mode default

Claude 拿到扫描结果后读了一遍 XML 核对,输出一张表:${keyword} 判为高风险,改成 CONCAT('%', #{keyword}, '%');${orderBy}、${sortDir} 保留 ${},给了 Java 白名单代码,最后注明没有修改文件。

自动触发,不提 Skill 名,只说需求:

claude -p "帮我看看这个项目的 mapper 有没有 SQL 注入风险,只给结论和改法,不要改文件" --model haiku --permission-mode default

用 --output-format stream-json --verbose 看工具调用,第一步就是:

TOOL_USE Skill {"skill": "mybatis-sql-check", "args": "检查当前项目 Mapper XML 的 ${} 拼接注入风险,只给结论和改法,不修改文件"}

Claude 根据 description 自己选中了这个 Skill。作为对照,问一个无关的问题"src/main/resources 下面有几个 xml 文件",它直接用 find 数了,没有调用 Skill。

本文的用例为了省钱都用 --model haiku 跑,每种情况跑了 1~2 次。会不会自动触发是模型判断的,换模型、换问法结果可能不同,你自己的 Skill 要用平时用的模型多试几种问法。

description 里要把用户会说的词写进去:这里写了"SQL 注入""Mapper XML 检查""${} 和 #{} 区别""提交前查 SQL"。官方文档的说法是把关键用例放在最前面,description 加 when_to_use 合起来超过 1536 个字符会被截断。

四、实测踩到的 3 个坑

坑 1:注入命令没放行,Skill 直接中止

第一版 allowed-tools 只写了 Read Grep,没放行脚本。默认权限模式下一调用就中止,claude -p 的输出是空的,费用是 0,stream-json 里能看到:

Shell command permission check failed for pattern "!`python3 .../scripts/scan_mapper.py src/main/resources`": This command requires approval

注入命令在渲染 Skill 时执行,不会弹窗问你,没被放行就直接中止。解决办法就是 3.4 节那样,在 allowed-tools 里放行这条命令。

本机全局设置的默认权限模式是 auto,同一个 Skill 在 auto 模式下没有中止:Claude 收到"先执行这条命令"的提示,自己用 Bash 工具跑了一遍。官方文档对 auto 模式的描述也是这样。所以 Skill 在你机器上能跑,不代表在同事的默认模式下也能跑,测 Skill 时加 --permission-mode default。

坑 2:脚本退出码非 0,整个 Skill 中止

放行之后再跑,还是中止,这次的报错是:

Shell command failed for pattern "!`python3 .../scripts/scan_mapper.py src/main/resources`": [stderr]
src/main/resources/mapper/OrderMapper.xml:8: ${orderBy}  | ...

脚本找到问题时 exit 1,Claude Code 把非 0 退出码当作命令失败,整个 Skill 中止,Claude 一个字都看不到。官方文档写明了:除了 grep、diff 这类搜索和比较命令的退出码 1,其余非 0 都算失败。检查类脚本"发现问题就返回非 0"是常见习惯,在 Skill 里要在命令后加 || true。

坑 3:把$ARGUMENTS传进注入命令,可能静默给出错误结论

这个坑最隐蔽。第一版的注入命令是这样写的,想让用户能传目录:

!`python3 ${CLAUDE_SKILL_DIR}/scripts/scan_mapper.py $ARGUMENTS || true`

手动调用 /mybatis-sql-check src/main/resources 没问题。但 Claude 自动触发时,传进来的参数是它自己写的一句话:

检查当前项目 Mapper XML 的 ${} 拼接注入风险,只给结论和改法,不修改文件

我分别用两种参数复现了一下:

传给 Skill 的参数结果
检查 ${} 拼接(带 ${})Skill 中止:A variable in braces in this command can't be checked before it runs
检查当前项目的注入风险(普通一句话)脚本把这句话当目录名去扫,扫到 0 个 XML,输出"共 0 处";Claude 据此回答"未发现需要改造的 ${}"

第二种最危险:没有报错,结论是错的,看上去还很像回事。

改法有两处:

  1. 注入命令里不用 $ARGUMENTS,目录写死成 ${CLAUDE_PROJECT_DIR}/src/main/resources,需要可变的部分让 Claude 在正文里按参数自己处理
  2. 脚本对输入做校验:目录不存在就明确说"没有扫描",并在 SKILL.md 里要求 Claude 遇到这句话先告诉用户、不给结论

改完后两种参数都正常:带 ${} 的参数不再中止,自动触发时扫描结果是 3 处。

五、控制谁能触发

写法你能 / 调用Claude 能自动调用适合
默认✅✅检查、规范、参考资料类
disable-model-invocation: true✅❌发版、提交、发消息这类有副作用的动作
user-invocable: false❌✅纯背景知识,比如老系统的业务说明

我在 mybatis-sql-check 上加了 disable-model-invocation: true 测了一下:同样问"mapper 有没有 SQL 注入风险",Claude 没有调用 Skill,自己用 grep 和 cat 查了一遍;直接敲 /mybatis-sql-check 仍然正常。

我的建议:会改文件、调外部系统、发版的 Skill 一律加 disable-model-invocation: true,什么时候跑由人决定;只读的检查类 Skill 保持默认,让它在你提到相关问题时自己出来。

allowed-tools 不受工作区信任限制,claude -p 在没信任过的目录里也会生效。别人仓库里带的 Skill,先看一眼它的 allowed-tools 放行了什么再跑。

六、几条写法建议

  • 正文写"做什么",不写"为什么"。Skill 一旦加载,内容会一直留在对话里,每一行都是持续的 token 开销
  • 机械活交给脚本,判断交给 Claude。找全 ${}、统计行数、跑检查用脚本,结果注入进去;哪个该改、怎么改让 Claude 判断
  • 长资料拆成附属文件。参考文档、示例放在 Skill 目录里,在 SKILL.md 里写清楚"什么情况读哪个文件",用到时才加载
  • 改完先用默认权限模式跑一遍,再用一句不提 Skill 名的话试试能不能自动触发
  • 项目级 Skill 提交到仓库,团队拿到的是同一份;个人偏好放 ~/.claude/skills/

七、速查表

想做什么怎么写
新建 Skill.claude/skills/<名字>/SKILL.md
让 Claude 知道什么时候用description 写清用途和用户会说的词
只允许手动触发disable-model-invocation: true
运行时不弹确认allowed-tools: Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scan.py *)
把命令输出塞给 Claude`` !`命令true` ``
引用 Skill 自带脚本${CLAUDE_SKILL_DIR}/scripts/...
接收参数正文里用 $ARGUMENTS、$0、$1,别放进注入命令
放到子代理里跑context: fork
排查不触发claude --debug 看 frontmatter 解析错误

八、常见问题

Q:Skill 和以前的 .claude/commands/ 有什么区别?

commands 下的单个 md 文件是老格式,现在还能用,frontmatter 也基本一样。官方建议新写的用 Skill,因为 Skill 是一个目录,可以带脚本和参考文件。

Q:改了 SKILL.md 要重启会话吗?

按官方文档,不用。Claude Code 会监听 ~/.claude/skills/ 和项目 .claude/skills/ 的变化,改了 SKILL.md 当前会话就能用上;会话开始时还不存在的顶层 skills 目录要执行一次 /reload-skills。本文的测试每次都是新起的 claude -p,这一条没有实测。

Q:写好的 Skill 能给 claude.ai 或 API 用吗?

能用的字段有限制。按官方文档,上传到 claude.ai 或走 Skills API 只认 name、description、license、compatibility、metadata、allowed-tools 六个字段,多了会直接报错;!`命令` 注入也只在 Claude Code 里生效。本文没测这条路径。

以上就是Claude Code Skills怎么写:SKILL.md格式、自动触发和命令注入的3个坑的详细内容,更多关于Claude Code Skills格式、自动触发和命令注入的资料请关注脚本之家其它相关文章!

相关文章

最新评论