2026-06-11 · skills

第 5 章:skill 进阶(hooks 与 slash-command)

给 skill 加上参数和工具权限,再用 hooks 与 slash-command 接成一套可重复的审查工作流。

小满 · 机关廊

光有手艺还不够。今天你给小满装上会自己触发的机关。

草稿章节。跑通格式用的第一版,正式索引前会再打磨。

本章目标

把第 4 章那个被动触发的单一 skill,升级成一套可重复的工作流,做三件事。第一,给 skill 加参数,让调用方能限定范围(审查某个文件,还是相对某个基准分支的整个 diff)。第二,限制它能用哪些工具,让审查只能读、永远不能写。第三,把它绑到一个可按名触发的 slash-command,和一个在确定时机自动触发的 hook 上。这么做的好处是:同一套审查流程,三条路都能跑到。手动用 /pr-reviewer,模型在相关时自动调起,以及在工作流固定节点经由 hook 确定性地触发。

这里有个关键区别要记住:skill 是一段流程,slash-command 是调起它的一种方式,hook 是一个不依赖模型做任何决定的确定性触发器。skill 是概率性的,要模型来选;hook 是有保证的,由 harness 来跑。两个你都需要。

前置准备

  • 完成第 4 章:一个 agent 已能发现的、可用的 pr-reviewer skill。
  • 你的 agent 支持 skill frontmatter 字段、slash-command 和 hooks。下面用到的字段名只是照文档形态举的例子,会随版本变,最终以官方文档为准。
  • 一个可以安全测试自动触发、又不会刷屏真实 PR 的仓库。
  • hook 那一步需要 jq 来解析 hook 的 JSON 输入。

动手做

1. 给 skill 加参数

SKILL.md,让指令接受调用方给出的明确范围,而不是每次都审查全部。skill 支持参数替换:$ARGUMENTS 展开为命令名之后输入的全部内容,$1$2(或具名参数)取出对应位置。声明一个 argument-hint,让自动补全告诉调用方该传什么。

---
name: pr-reviewer
description: >-
  审查一个 pull request 或 diff 的正确性、测试、安全与清晰度。
  当用户要求 review 一个 PR、审查改动、或检查 diff 时使用。
argument-hint: "[路径或基准分支]"
allowed-tools: Read, Grep, Bash(git diff *), Bash(gh pr diff *)
---

# PR 审查助手

从参数判断范围:
- 若 $1 看起来像路径,只审查该文件的改动。
- 若 $1 看起来像分支(如 main),审查相对它的 diff。
- 若 $ARGUMENTS 为空,审查当前 PR 的完整 diff。

## 待审 diff
!`git diff ${1:-HEAD}`

(然后对上面的 diff 跑第 4 章那份四维清单)

2. 把工具权限收到最小

审查只读,绝不能写、推送或合并。在 frontmatter 的 allowed-tools 字段里,只列出 skill 可以不经询问就使用的工具。这里有个容易踩的点:allowed-tools 是预批准所列工具,让 agent 不停下来问你,但它本身并不会移除其它工具。要真正封掉危险操作,得配合权限设置里的 deny 规则,或者在 disallowed-tools 里把它们列出来。

# SKILL.md frontmatter 里:只预批准读取类工具
allowed-tools: Read, Grep, Bash(git diff *), Bash(gh pr diff *)
// .claude/settings.json 里:全局硬禁写/合并(示意)
{
  "permissions": {
    "deny": [
      "Bash(git push *)",
      "Bash(git commit *)",
      "Bash(gh pr merge *)"
    ]
  }
}

这里收紧权限不是走形式。一个根本推不了代码的审查助手,你才敢让它自动跑而不用一直盯着。

3. 加一个 slash-command

因为文件夹名就是命令,你放在 .claude/skills/pr-reviewer/SKILL.md 的 skill 已经能用 /pr-reviewer 调起。队友一句话就能跑起完全相同的审查,把范围作为参数传入。若想让命令只能手动触发(模型永不自动触发),加上 disable-model-invocation: true

/pr-reviewer                 # 审查当前整个 diff
/pr-reviewer src/auth.py     # 只审查某个文件的改动
/pr-reviewer main            # 审查相对 main 的 diff

4. 接一个 hook,做确定性的自动审查

hook 在某个确定的生命周期时机运行,与模型怎么决定无关。在 settings.json 里按你关心的事件配置它。一个常见形态:在 git 提交工具的 PreToolUse 上,跑一个脚本,在提交落地前触发审查。hook 通过退出码(exit 0 放行,exit 2 阻止)或 stdout 上的结构化 JSON 回传结果。

// .claude/settings.json(示意;事件/字段名以文档为准)
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/review-before-commit.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# review-before-commit.sh:审查发现阻塞项就拦下提交(示意)
DIFF=$(git diff --cached)
if [ -z "$DIFF" ]; then exit 0; fi   # 没有暂存内容,没什么可审

# 对暂存的 diff 跑你的审查助手;假设它写出一份结论文件
# 若发现阻塞项,拒绝提交并告诉模型原因
if grep -q "REQUEST CHANGES" review-verdict.txt; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "审查发现阻塞项。提交前先修掉。"
    }
  }'
else
  exit 0
fi

如果你是用 SDK 在进程内跑这个 agent,同样这道护栏可以直接用代码挂上去,不用走外部脚本。Python 用 HookMatcher 把回调注册到 PreToolUse 事件上,返回 permissionDecision: "deny" 来拦截;TypeScript 在 hooks.PreToolUse 里挂一个函数,返回 { decision: "block", continue: false }

import os
import anyio
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher

async def review_before_commit(input_data, tool_use_id, context):
    if input_data["tool_name"] != "Bash":
        return {}
    command = input_data["tool_input"].get("command", "")
    if "git commit" not in command:
        return {}   # 只在 git commit 上出手,不一刀切拦所有 Bash
    verdict = open("review-verdict.txt").read() if os.path.exists("review-verdict.txt") else ""
    if "REQUEST CHANGES" in verdict:
        return {
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "deny",
                "permissionDecisionReason": "审查发现阻塞项。提交前先修掉。",
            }
        }
    return {}   # 干净,放行提交

options = ClaudeAgentOptions(
    allowed_tools=["Read", "Grep", "Bash"],
    hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[review_before_commit])]},
)

async def main():
    async with ClaudeSDKClient(options=options) as client:
        await client.query("修一下退款的差一错误并提交。")
        async for msg in client.receive_response():
            print(msg)

anyio.run(main)
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { HookJSONOutput } from "@anthropic-ai/claude-agent-sdk";
import { existsSync, readFileSync } from "fs";

const q = query({
  prompt: "修一下退款的差一错误并提交。",
  options: {
    allowedTools: ["Read", "Grep", "Bash"],
    hooks: {
      PreToolUse: [
        {
          matcher: "Bash",
          hooks: [
            async (input: any): Promise<HookJSONOutput> => {
              const command = input.tool_input?.command ?? "";
              if (input.tool_name !== "Bash" || !command.includes("git commit")) {
                return { continue: true }; // 只在 git commit 上出手
              }
              const verdict = existsSync("review-verdict.txt")
                ? readFileSync("review-verdict.txt", "utf8")
                : "";
              if (verdict.includes("REQUEST CHANGES")) {
                return {
                  decision: "block",
                  stopReason: "审查发现阻塞项。提交前先修掉。",
                  continue: false,
                };
              }
              return { continue: true }; // 干净,放行提交
            },
          ],
        },
      ],
    },
  },
});

for await (const message of q) {
  console.log(message);
}

5. 让它可重复,并确认各条路都落到同一个 skill

这样组合起来,好处是只有一个事实来源。slash-command、模型的自动调起、hook 都应该驱动同一份 SKILL.md 流程,所以不管从哪条路起,审查结果都一样。拿一处真实改动,从每条路各走一遍,比对一下输出长什么样。

路 A:输入 /pr-reviewer main      -> 同一份清单,范围是相对 main 的 diff
路 B:说"审查我的改动"            -> 模型加载 skill,同一份清单
路 C:尝试 git commit             -> hook 触发,同一份清单,可拦截

习得「条件反射」你没开口说审查,小满也会在你 git commit 那一刻被 hook 自动叫起来跑同一份审查,发现阻塞项就拦下提交,不用你每次记得手动喊它。

如何验证

  • 调起 /pr-reviewer src/auth.py,确认审查只覆盖该文件的改动,证明参数限定了范围。
  • 观察一次审查中的工具调用,确认只有读取类工具触发。尝试一次写入,确认 deny 规则拦下它。
  • 暂存一个带故意阻塞项的改动并尝试提交。确认 hook 不用提示就触发,并以原因文本拦下提交。
  • 暂存一个干净改动并提交。确认 hook 放行(exit 0),证明触发是被限定的,而非一刀切地全拦。

习得「确认收放有度」你能暂存一个带阻塞项的改动看 hook 拦下提交,再暂存一个干净改动看它放行,确认这个自动拦截只在该拦的时候拦,不会把每一次提交都拦下来。

小结

你的审查助手现在可以拼装了。参数限定范围,allowed-tools 加 deny 规则定好边界,文件夹名给你一个 slash-command,PreToolUse hook 确定性地把它自动跑起来。记住这个心智模型:skill 是那唯一的流程;slash-command 和模型调起是通向它的两条概率性入口;hook 是接在生命周期事件上、有保证会触发的那条。正是把这几样分开,它才成了一套工作流,而不是一次性的提示词。下一章升级到 subagent 与编排,会讲清楚多 agent 什么时候帮你、什么时候坑你。

常见坑

  • 权限给太宽。 预批准所有工具,或者忘了 allowed-tools 并不会移除工具,结果一个”审查助手”也能推送或合并。把最小权限的 allowed-tools 和显式 deny 规则配在一起用。
  • hook 触发太频。 matcher 太松的 hook 会在每次工具调用时都跑一遍审查,烦死你。用 if 条件把它限定到具体的工具和命令(Bash(git commit *)),而不是整个 Bash
  • 参数藏着或没传。 如果 skill 假设了一个调用方从来不设的范围,结果就会跑偏。用 $ARGUMENTS / $1 把范围写明,再用 argument-hint 标出来。
  • 拦截的方式不对。 一个非零退出、但消息没什么用的 hook,只会把 agent 弄糊涂。用 exit 2,或者一个带清楚 permissionDecisionReason 的结构化 deny 决定,让模型知道该修什么。

装上 hook,小满第一次在你没开口时自动做了件事,pre-commit 替你拦了一手,像宠物学会你一进门就叼来拖鞋。又惊喜,又有点失控。机关廊,亮了。

刚点亮 机关廊 · 地图已点亮 6 / 16

活越来越多,它一个人忙不过来,看了你一眼,欲言又止。下一站:分身回廊。

来源

  1. Anthropic Agent Skills 官方文档 · official
  2. Claude Code hooks 参考文档 · official
下一章 · 第 6 章 subagent 与编排(以及何时别用)