Walkthrough | 设计一个新工具:GitCommitTool(阶段 5 练习答案)¶
对应练习:phase-05-tools.md 5.13 练习任务 2 关联:topics/bash-security-model.md 的 4 层防御
目标¶
从零设计一个 GitCommitTool:
- 让 LLM 调用来"git commit"
- 包含 message 字符串
- UI 渲染 commit message
- 要求用户授权
- 执行后返回 commit hash
遵循 Claude Code 工具系统的 7 个文件结构。
步骤 1:设计工具接口¶
// 输入 schema
type GitCommitInput = {
message: string // 必填,commit message
files?: string[] // 可选,指定要 add 的文件
amend?: boolean // 可选,是否 amend
noVerify?: boolean // 可选,跳过 pre-commit hook
author?: { // 可选,自定义 author
name: string
email: string
}
}
步骤 2:7 个文件结构¶
src/tools/GitCommitTool/
├── GitCommitTool.tsx 主实现
├── UI.tsx 渲染组件
├── prompt.ts LLM 看的工具描述
├── types.ts 工具私有类型
├── utils.ts 工具私有工具函数
├── constants.ts 工具相关常量
└── commitValidation.ts 输入校验(特殊)
参考 FileEditTool/ 的 7 个文件结构。
步骤 3:主实现 GitCommitTool.tsx¶
import { z } from 'zod/v4'
import { spawn } from 'child_process'
import { buildTool, type ToolDef } from '../../Tool.js'
import type { ToolUseContext } from '../../Tool.js'
import { GitCommitUI } from './UI.js'
import { validateCommitMessage } from './commitValidation.js'
import { getCwd } from '../../utils/cwd.js'
// 输入 schema (zod)
const inputSchema = z.object({
message: z.string().min(1).max(5000),
files: z.array(z.string()).optional(),
amend: z.boolean().default(false),
noVerify: z.boolean().default(false),
author: z.object({
name: z.string(),
email: z.string().email(),
}).optional(),
})
// 工具定义
export const GitCommitTool: ToolDef<typeof inputSchema> = buildTool({
name: 'GitCommit',
description: 'Create a git commit with the given message...',
inputSchema,
isReadOnly: false, // 有副作用
isConcurrencySafe: false, // 不能并发
// UI 渲染(用户看到的)
renderToolUseMessage: GitCommitUI,
// 输入校验(在 execute 之前)
validateInput: async (input, ctx) => {
// 1. 校验 message
const messageCheck = validateCommitMessage(input.message)
if (!messageCheck.ok) {
return {
result: false,
errorMessage: `Invalid commit message: ${messageCheck.error}`,
}
}
// 2. 校验在 git repo
const cwd = getCwd()
const isGitRepo = await checkIsGitRepo(cwd)
if (!isGitRepo) {
return {
result: false,
errorMessage: `Not in a git repository: ${cwd}`,
}
}
// 3. 校验有 staged 改动(除非 amend)
if (!input.amend) {
const hasStaged = await checkHasStagedChanges(cwd)
if (!hasStaged) {
return {
result: false,
errorMessage: 'No staged changes to commit',
}
}
}
return { result: true }
},
// 工具执行
async *call(input, context) {
// 1. 构造命令
const args = ['commit', '-m', input.message]
if (input.amend) args.push('--amend')
if (input.noVerify) args.push('--no-verify')
if (input.author) {
args.push('--author', `${input.author.name} <${input.author.email}>`)
}
yield {
type: 'progress',
data: { stage: 'git_add', message: input.files ? `Adding ${input.files.length} files` : 'Staging all' },
}
// 2. 先 add(如果指定了 files)
if (input.files && input.files.length > 0) {
yield* executeGitCommand(['add', ...input.files], context)
}
// 3. 跑 commit
yield {
type: 'progress',
data: { stage: 'git_commit', message: 'Creating commit' },
}
const result = yield* executeGitCommand(args, context)
// 4. 解析 commit hash
const hashMatch = result.stdout.match(/\[[\w-]+ ([a-f0-9]+)\]/)
const commitHash = hashMatch?.[1]
return {
content: `Committed: ${commitHash}\n${input.message}`,
is_error: result.exitCode !== 0,
metadata: { commitHash },
}
},
})
// 内部:执行 git 命令
async function* executeGitCommand(
args: string[],
context: ToolUseContext,
): AsyncGenerator<ToolProgressEvent, GitCommandResult> {
const cwd = getCwd()
return new Promise((resolve) => {
const child = spawn('git', args, { cwd })
let stdout = ''
let stderr = ''
child.stdout.on('data', (data) => {
stdout += data.toString()
// 实时 yield 给消费者
})
child.stderr.on('data', (data) => {
stderr += data.toString()
})
child.on('close', (exitCode) => {
resolve({ stdout, stderr, exitCode: exitCode ?? 0 })
})
// 消费者取消时 kill 子进程
context.abortController.signal.addEventListener('abort', () => {
child.kill('SIGTERM')
})
})
}
步骤 4:UI 渲染 UI.tsx¶
import { Box, Text } from '../../ink.js'
export function GitCommitUI({ input }: { input: GitCommitInput }) {
return (
<Box flexDirection="column">
{/* 头部:工具名 */}
<Box flexDirection="row">
<Text color="cyan">⎇ git commit</Text>
</Box>
{/* Commit message(突出显示) */}
<Box
flexDirection="column"
borderStyle="single"
borderColor="green"
paddingX={1}
marginY={1}
>
<Text dimColor>Message:</Text>
<Text>{input.message}</Text>
</Box>
{/* 附加信息 */}
{input.amend && (
<Box>
<Text color="yellow">⚠ Amending previous commit</Text>
</Box>
)}
{input.noVerify && (
<Box>
<Text color="yellow">⚠ Skipping pre-commit hooks</Text>
</Box>
)}
{input.author && (
<Box>
<Text dimColor>Author: {input.author.name} <{input.author.email}></Text>
</Box>
)}
{input.files && input.files.length > 0 && (
<Box flexDirection="column">
<Text dimColor>Files to add:</Text>
{input.files.map((f) => (
<Text key={f}> + {f}</Text>
))}
</Box>
)}
{/* 键盘提示 */}
<Box marginTop={1}>
<Text dimColor>[Enter] Approve [Esc] Deny</Text>
</Box>
</Box>
)
}
步骤 5:LLM 看的 prompt prompt.ts¶
export const GIT_COMMIT_TOOL_PROMPT = `
# GitCommit
Create a git commit with the specified message.
## When to use
- User explicitly asks to commit
- After staging changes, as part of a multi-step task
- Use \`amend\` only when user explicitly says "amend"
## When NOT to use
- No staged changes (inform user first)
- Not in a git repo (use Bash to check \`git status\`)
- Unclear commit message (ask user first via AskUserQuestion)
## Input format
\`\`\`json
{
"message": "string (required, 1-5000 chars)",
"files": ["string (optional)"],
"amend": "boolean (default false)",
"noVerify": "boolean (default false)",
"author": { "name": "string", "email": "string" }
}
\`\`\`
## Best practices
1. **Write clear commit messages**: 50 char subject + blank line + 72 char body
2. **Use present tense**: "Add feature" not "Added feature"
3. **Reference issues**: "Fix #123" if applicable
4. **Don't amend public commits** (warns user if pushed)
5. **Don't bypass hooks** (noVerify) unless user explicitly asks
## Examples
✓ Good message: \`Fix race condition in agent loop\`
✗ Bad message: \`fixed bug\` (too vague)
✗ Bad message: \`WIP\` (incomplete)
## Error handling
- If no staged changes, return error (don't try to commit empty)
- If pre-commit hook fails, report hook output to user
- If push protected branch, warn and ask user
`
步骤 6:输入校验 commitValidation.ts¶
export type ValidationResult = { ok: true } | { ok: false, error: string }
export function validateCommitMessage(message: string): ValidationResult {
// 1. 不能为空
if (!message.trim()) {
return { ok: false, error: 'Message cannot be empty' }
}
// 2. 长度检查
if (message.length < 10) {
return { ok: false, error: 'Message too short (< 10 chars), please be more descriptive' }
}
// 3. subject 行长度
const subject = message.split('\n')[0]
if (subject.length > 72) {
return { ok: false, error: `Subject too long (${subject.length} > 72 chars)` }
}
// 4. 检测常见反模式
const antiPatterns = [
/^wip$/i,
/^fix$/i,
/^update$/i,
/^test$/i,
/^temp$/i,
]
for (const pattern of antiPatterns) {
if (pattern.test(subject)) {
return { ok: false, error: `Subject "${subject}" is too vague, please be more specific` }
}
}
// 5. 不能包含敏感信息(防泄漏)
const sensitivePatterns = [
/-----BEGIN [A-Z]+ PRIVATE KEY-----/,
/api[_-]?key\s*[:=]\s*['"]\w+/i,
/password\s*[:=]\s*['"]\w+/i,
]
for (const pattern of sensitivePatterns) {
if (pattern.test(message)) {
return { ok: false, error: 'Message contains potentially sensitive information' }
}
}
return { ok: true }
}
步骤 7:utils.ts 工具函数¶
import { spawn } from 'child_process'
// 检查是否是 git repo
export async function checkIsGitRepo(cwd: string): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('git', ['rev-parse', '--git-dir'], { cwd })
child.on('close', (code) => resolve(code === 0))
child.on('error', () => resolve(false))
})
}
// 检查是否有 staged 改动
export async function checkHasStagedChanges(cwd: string): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('git', ['diff', '--cached', '--quiet'], { cwd })
child.on('close', (code) => resolve(code !== 0)) // code=0 means no changes
child.on('error', () => resolve(false))
})
}
// 获取当前 branch(用于警告 pushed branch)
export async function getCurrentBranch(cwd: string): Promise<string | null> {
return new Promise((resolve) => {
const child = spawn('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
let out = ''
child.stdout.on('data', (d) => (out += d.toString()))
child.on('close', () => resolve(out.trim() || null))
child.on('error', () => resolve(null))
})
}
步骤 8:types.ts 私有类型¶
export type GitCommitInput = {
message: string
files?: string[]
amend?: boolean
noVerify?: boolean
author?: {
name: string
email: string
}
}
export type GitCommitResult = {
commitHash?: string
message: string
branch: string
isError: boolean
errorOutput?: string
}
export type GitCommandResult = {
stdout: string
stderr: string
exitCode: number
}
步骤 9:constants.ts 常量¶
export const MAX_MESSAGE_LENGTH = 5000
export const MAX_SUBJECT_LENGTH = 72
export const MIN_MESSAGE_LENGTH = 10
export const PROTECTED_BRANCHES = [
'main',
'master',
'production',
'release',
'prod',
]
步骤 10:注册到 tools.ts¶
// src/tools.ts
import { GitCommitTool } from './tools/GitCommitTool/GitCommitTool.js'
// 添加到工具列表
const allTools: Tools = [
BashTool,
FileEditTool,
GitCommitTool, // ← 加这里
// ... 其他 40+ 工具
]
安全模型:BashTool 的 4 层防御¶
GitCommitTool 内部用了 spawn('git', ...) —— 绕过了 BashTool 的 4 层防御。这是危险的。
正确做法:
// 方案 1:复用 BashTool(最安全)
async function* call(input, context) {
const args = ['git', 'commit', '-m', input.message]
yield* callBashTool(args, context) // 走 BashTool 4 层防御
}
// 方案 2:GitCommitTool 自带安全检查
async function* call(input, context) {
// 1. ⭐ 危险命令检测(参考 bashSecurity.ts)
if (input.message.includes('; rm -rf')) {
return { content: 'Suspicious commit message', is_error: true }
}
// 2. ⭐ 路径校验(防 commit 不在 cwd 的文件)
if (input.files?.some(f => f.startsWith('/etc'))) {
return { content: 'Cannot commit system files', is_error: true }
}
// 3. ⭐ 沙箱(用 shouldUseSandbox 跑)
if (shouldUseSandbox('git commit', context)) {
// 走沙箱
}
// 4. ⭐ 权限检查
const decision = await canUseTool('GitCommit', input, context)
if (decision.behavior === 'deny') {
return { content: 'Denied by user', is_error: true }
}
// ...
}
实战:Claude Code 实际做法是 方案 1(复用 BashTool)。
GitCommitTool 这样的"专门工具"主要价值是 UI + LLM prompt 优化,安全层用 BashTool 的就够了。
关键洞察¶
1. 工具的 7 个文件结构 = 完整业务封装¶
- 主实现 + UI + prompt + 校验 + 工具 + 常量 + 类型
- 每个文件单一职责
2. 工具的 async *call 是核心¶
- yield 进度 + 中间结果
- return 最终结果
- throw 异常
3. LLM 看到的 prompt 决定工具使用频率¶
- 写得好 → LLM 经常用
- 写得差 → LLM 忽略
4. 复用 BashTool 比自建安全层更好¶
- "专门工具"的价值在 UI + prompt,不在安全
- 安全层永远用 BashTool 的 4 层防御
5. zod schema 既是校验又是"LLM 看的 schema"¶
- 一次定义,两个用途
- Claude Code 大量用
zod/v4
配套资源¶
- 真实参照:
src/tools/FileEditTool/(7 文件) - 安全模型:topics/bash-security-model.md
- buildTool API:reference/api-quickref.md 第 4 节