跳转至

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} &lt;{input.author.email}&gt;</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

配套资源