Skip to content

为 Gemini CLI 编写钩子

本指南将引导你为 Gemini CLI 创建钩子,从简单的日志钩子到展示所有钩子事件协同工作的综合工作流助手。

前提条件

在开始之前,请确保你已经:

  • 安装并配置了 Gemini CLI
  • 基本了解 shell 脚本或 JavaScript/Node.js
  • 熟悉用于钩子输入/输出的 JSON

快速开始

让我们创建一个简单的钩子来记录所有工具执行,以了解基础知识。

步骤 1:创建钩子脚本

创建钩子目录和简单的日志脚本:

bash
mkdir -p .gemini/hooks
cat > .gemini/hooks/log-tools.sh << 'EOF'
#!/usr/bin/env bash
# 从 stdin 读取钩子输入
input=$(cat)

# 提取工具名称
tool_name=$(echo "$input" | jq -r '.tool_name')

# 记录到文件
echo "[$(date)] 工具已执行: $tool_name" >> .gemini/tool-log.txt

# 返回成功(退出 0)- 输出在转录模式下显示给用户
echo "已记录: $tool_name"
EOF

chmod +x .gemini/hooks/log-tools.sh

步骤 2:配置钩子

将钩子配置添加到 .gemini/settings.json

json
{
  "hooks": {
    "AfterTool": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "tool-logger",
            "type": "command",
            "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/log-tools.sh",
            "description": "记录所有工具执行"
          }
        ]
      }
    ]
  }
}

步骤 3:测试钩子

运行 Gemini CLI 并执行任何使用工具的命令:

> 读取 README.md 文件

[代理使用 read_file 工具]

已记录: read_file

检查 .gemini/tool-log.txt 查看记录的工具执行。

实用示例

安全:阻止提交中的密钥

防止提交包含 API 密钥或密码的文件。

.gemini/hooks/block-secrets.sh

bash
#!/usr/bin/env bash
input=$(cat)

# 提取正在写入的内容
content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')

# 检查密钥
if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then
  echo '{"decision":"deny","reason":"检测到潜在密钥"}' >&2
  exit 2
fi

exit 0

.gemini/settings.json

json
{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "secret-scanner",
            "type": "command",
            "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
            "description": "防止提交密钥"
          }
        ]
      }
    ]
  }
}

代码更改后自动测试

在修改代码文件时自动运行测试。

.gemini/hooks/auto-test.sh

bash
#!/usr/bin/env bash
input=$(cat)

file_path=$(echo "$input" | jq -r '.tool_input.file_path')

# 只测试 .ts 文件
if [[ ! "$file_path" =~ \.ts$ ]]; then
  exit 0
fi

# 查找对应的测试文件
test_file="${file_path%.ts}.test.ts"

if [ ! -f "$test_file" ]; then
  echo "⚠️ 未找到测试文件"
  exit 0
fi

# 运行测试
if npx vitest run "$test_file" --silent 2>&1 | head -20; then
  echo "✅ 测试通过"
else
  echo "❌ 测试失败"
fi

exit 0

.gemini/settings.json

json
{
  "hooks": {
    "AfterTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "auto-test",
            "type": "command",
            "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.sh",
            "description": "代码更改后运行测试"
          }
        ]
      }
    ]
  }
}

动态上下文注入

在每次代理交互之前添加相关项目上下文。

.gemini/hooks/inject-context.sh

bash
#!/usr/bin/env bash

# 获取最近的 git 提交作为上下文
context=$(git log -5 --oneline 2>/dev/null || echo "无 git 历史")

# 返回 JSON
cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "BeforeAgent",
    "additionalContext": "最近提交:\n$context"
  }
}
EOF

.gemini/settings.json

json
{
  "hooks": {
    "BeforeAgent": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "git-context",
            "type": "command",
            "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/inject-context.sh",
            "description": "注入 git 提交历史"
          }
        ]
      }
    ]
  }
}

高级功能

基于 RAG 的工具过滤

使用 BeforeToolSelection 根据当前任务智能减少工具空间。不是将所有 100+ 工具发送给模型,而是使用语义搜索或关键词匹配过滤到最相关的约 15 个工具。

这可以改善:

  • 模型准确性: 更少的相似工具减少混淆
  • 响应速度: 更小的工具空间处理更快
  • 成本效率: 每个请求使用更少的 token

跨会话记忆

使用 SessionStartSessionEnd 钩子在会话之间维护持久知识:

  • SessionStart: 从之前的会话加载相关记忆
  • AfterModel: 在会话期间记录重要交互
  • SessionEnd: 提取学习内容并存储以供将来使用

这使助手能够学习项目约定、记住重要决策,并在团队成员之间共享知识。

钩子链

同一事件的多个钩子按声明顺序运行。每个钩子可以基于前一个钩子的输出构建:

json
{
  "hooks": {
    "BeforeAgent": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "load-memories",
            "type": "command",
            "command": "./hooks/load-memories.sh"
          },
          {
            "name": "analyze-sentiment",
            "type": "command",
            "command": "./hooks/analyze-sentiment.sh"
          }
        ]
      }
    ]
  }
}

完整示例:智能开发工作流助手

这个综合示例展示了所有钩子事件协同工作的两个高级功能:

  • 基于 RAG 的工具选择: 将 100+ 工具减少到每个任务约 15 个相关工具
  • 跨会话记忆: 学习并持久化项目知识

架构

SessionStart → 初始化记忆和索引工具

BeforeAgent → 注入相关记忆

BeforeModel → 添加系统指令

BeforeToolSelection → 通过 RAG 过滤工具

BeforeTool → 验证安全性

AfterTool → 运行自动测试

AfterModel → 记录交互

SessionEnd → 提取并存储记忆

安装

前提条件:

  • Node.js 18+
  • 已安装 Gemini CLI

设置:

bash
# 创建钩子目录
mkdir -p .gemini/hooks .gemini/memory

# 安装依赖
npm install --save-dev chromadb @google/generative-ai

# 复制钩子脚本(如下所示)
# 使它们可执行
chmod +x .gemini/hooks/*.js

配置

.gemini/settings.json

json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "name": "init-assistant",
            "type": "command",
            "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/init.js",
            "description": "初始化智能工作流助手"
          }
        ]
      }
    ],
    "BeforeAgent": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "inject-memories",
            "type": "command",
            "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/inject-memories.js",
            "description": "注入相关项目记忆"
          }
        ]
      }
    ],
    "BeforeToolSelection": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "rag-filter",
            "type": "command",
            "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/rag-filter.js",
            "description": "使用 RAG 过滤工具"
          }
        ]
      }
    ],
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "security-check",
            "type": "command",
            "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/security.js",
            "description": "防止提交密钥"
          }
        ]
      }
    ],
    "AfterTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "auto-test",
            "type": "command",
            "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.js",
            "description": "代码更改后运行测试"
          }
        ]
      }
    ],
    "AfterModel": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "record-interaction",
            "type": "command",
            "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/record.js",
            "description": "记录交互以供学习"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "matcher": "exit|logout",
        "hooks": [
          {
            "name": "consolidate-memories",
            "type": "command",
            "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/consolidate.js",
            "description": "提取并存储会话学习内容"
          }
        ]
      }
    ]
  }
}

钩子脚本

1. 初始化(SessionStart)

.gemini/hooks/init.js

javascript
#!/usr/bin/env node
const { ChromaClient } = require('chromadb');
const path = require('path');
const fs = require('fs');

async function main() {
  const projectDir = process.env.GEMINI_PROJECT_DIR;
  const chromaPath = path.join(projectDir, '.gemini', 'chroma');

  // 确保 chroma 目录存在
  fs.mkdirSync(chromaPath, { recursive: true });

  const client = new ChromaClient({ path: chromaPath });

  // 初始化记忆集合
  await client.getOrCreateCollection({
    name: 'project_memories',
    metadata: { 'hnsw:space': 'cosine' },
  });

  // 计算现有记忆数量
  const collection = await client.getCollection({ name: 'project_memories' });
  const memoryCount = await collection.count();

  console.log(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: 'SessionStart',
        additionalContext: `智能工作流助手已初始化,包含 ${memoryCount} 个项目记忆。`,
      },
      systemMessage: `🧠 已加载 ${memoryCount} 个记忆`,
    }),
  );
}

function readStdin() {
  return new Promise((resolve) => {
    const chunks = [];
    process.stdin.on('data', (chunk) => chunks.push(chunk));
    process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString()));
  });
}

readStdin().then(main).catch(console.error);

2. 注入记忆(BeforeAgent)

.gemini/hooks/inject-memories.js

javascript
#!/usr/bin/env node
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { ChromaClient } = require('chromadb');
const path = require('path');

async function main() {
  const input = JSON.parse(await readStdin());
  const { prompt } = input;

  if (!prompt?.trim()) {
    console.log(JSON.stringify({}));
    return;
  }

  // 嵌入提示
  const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
  const model = genai.getGenerativeModel({ model: 'text-embedding-004' });
  const result = await model.embedContent(prompt);

  // 搜索记忆
  const projectDir = process.env.GEMINI_PROJECT_DIR;
  const client = new ChromaClient({
    path: path.join(projectDir, '.gemini', 'chroma'),
  });

  try {
    const collection = await client.getCollection({ name: 'project_memories' });
    const results = await collection.query({
      queryEmbeddings: [result.embedding.values],
      nResults: 3,
    });

    if (results.documents[0]?.length > 0) {
      const memories = results.documents[0]
        .map((doc, i) => {
          const meta = results.metadatas[0][i];
          return `- [${meta.category}] ${meta.summary}`;
        })
        .join('\n');

      console.log(
        JSON.stringify({
          hookSpecificOutput: {
            hookEventName: 'BeforeAgent',
            additionalContext: `\n## 相关项目上下文\n\n${memories}\n`,
          },
          systemMessage: `💭 已召回 ${results.documents[0].length} 个记忆`,
        }),
      );
    } else {
      console.log(JSON.stringify({}));
    }
  } catch (error) {
    console.log(JSON.stringify({}));
  }
}

function readStdin() {
  return new Promise((resolve) => {
    const chunks = [];
    process.stdin.on('data', (chunk) => chunks.push(chunk));
    process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString()));
  });
}

readStdin().then(main).catch(console.error);

3. RAG 工具过滤(BeforeToolSelection)

.gemini/hooks/rag-filter.js

javascript
#!/usr/bin/env node
const { GoogleGenerativeAI } = require('@google/generative-ai');

async function main() {
  const input = JSON.parse(await readStdin());
  const { llm_request } = input;
  const candidateTools =
    llm_request.toolConfig?.functionCallingConfig?.allowedFunctionNames || [];

  // 如果已经过滤则跳过
  if (candidateTools.length <= 20) {
    console.log(JSON.stringify({}));
    return;
  }

  // 提取最近的用户消息
  const recentMessages = llm_request.messages
    .slice(-3)
    .filter((m) => m.role === 'user')
    .map((m) => m.content)
    .join('\n');

  // 使用快速模型提取任务关键词
  const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
  const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });

  const result = await model.generateContent(
    `从此请求中提取 3-5 个描述所需工具能力的关键词:\n\n${recentMessages}\n\n关键词(逗号分隔):`,
  );

  const keywords = result.response
    .text()
    .toLowerCase()
    .split(',')
    .map((k) => k.trim());

  // 简单的基于关键词的过滤 + 核心工具
  const coreTools = ['read_file', 'write_file', 'replace', 'run_shell_command'];
  const filtered = candidateTools.filter((tool) => {
    if (coreTools.includes(tool)) return true;
    const toolLower = tool.toLowerCase();
    return keywords.some(
      (kw) => toolLower.includes(kw) || kw.includes(toolLower),
    );
  });

  console.log(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: 'BeforeToolSelection',
        toolConfig: {
          functionCallingConfig: {
            mode: 'ANY',
            allowedFunctionNames: filtered.slice(0, 20),
          },
        },
      },
      systemMessage: `🎯 已过滤 ${candidateTools.length} → ${Math.min(filtered.length, 20)} 个工具`,
    }),
  );
}

function readStdin() {
  return new Promise((resolve) => {
    const chunks = [];
    process.stdin.on('data', (chunk) => chunks.push(chunk));
    process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString()));
  });
}

readStdin().then(main).catch(console.error);

4. 安全验证(BeforeTool)

.gemini/hooks/security.js

javascript
#!/usr/bin/env node

const SECRET_PATTERNS = [
  /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
  /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i,
  /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
  /AKIA[0-9A-Z]{16}/, // AWS
  /ghp_[a-zA-Z0-9]{36}/, // GitHub
];

async function main() {
  const input = JSON.parse(await readStdin());
  const { tool_input } = input;

  const content = tool_input.content || tool_input.new_string || '';

  for (const pattern of SECRET_PATTERNS) {
    if (pattern.test(content)) {
      console.log(
        JSON.stringify({
          decision: 'deny',
          reason:
            '在代码中检测到潜在密钥。请删除敏感数据。',
          systemMessage: '🚨 密钥扫描器已阻止操作',
        }),
      );
      process.exit(2);
    }
  }

  console.log(JSON.stringify({ decision: 'allow' }));
}

function readStdin() {
  return new Promise((resolve) => {
    const chunks = [];
    process.stdin.on('data', (chunk) => chunks.push(chunk));
    process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString()));
  });
}

readStdin().then(main).catch(console.error);

5. 自动测试(AfterTool)

.gemini/hooks/auto-test.js

javascript
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');

async function main() {
  const input = JSON.parse(await readStdin());
  const { tool_input } = input;
  const filePath = tool_input.file_path;

  if (!filePath?.match(/\.(ts|js|tsx|jsx)$/)) {
    console.log(JSON.stringify({}));
    return;
  }

  // 查找测试文件
  const ext = path.extname(filePath);
  const base = filePath.slice(0, -ext.length);
  const testFile = `${base}.test${ext}`;

  if (!fs.existsSync(testFile)) {
    console.log(
      JSON.stringify({
        systemMessage: `⚠️ 无测试文件: ${path.basename(testFile)}`,
      }),
    );
    return;
  }

  // 运行测试
  try {
    execSync(`npx vitest run ${testFile} --silent`, {
      encoding: 'utf8',
      stdio: 'pipe',
      timeout: 30000,
    });

    console.log(
      JSON.stringify({
        systemMessage: `✅ 测试通过: ${path.basename(filePath)}`,
      }),
    );
  } catch (error) {
    console.log(
      JSON.stringify({
        systemMessage: `❌ 测试失败: ${path.basename(filePath)}`,
      }),
    );
  }
}

function readStdin() {
  return new Promise((resolve) => {
    const chunks = [];
    process.stdin.on('data', (chunk) => chunks.push(chunk));
    process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString()));
  });
}

readStdin().then(main).catch(console.error);

6. 记录交互(AfterModel)

.gemini/hooks/record.js

javascript
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');

async function main() {
  const input = JSON.parse(await readStdin());
  const { llm_request, llm_response } = input;
  const projectDir = process.env.GEMINI_PROJECT_DIR;
  const sessionId = process.env.GEMINI_SESSION_ID;

  const tempFile = path.join(
    projectDir,
    '.gemini',
    'memory',
    `session-${sessionId}.jsonl`,
  );

  fs.mkdirSync(path.dirname(tempFile), { recursive: true });

  // 提取用户消息和模型响应
  const userMsg = llm_request.messages
    ?.filter((m) => m.role === 'user')
    .slice(-1)[0]?.content;

  const modelMsg = llm_response.candidates?.[0]?.content?.parts
    ?.map((p) => p.text)
    .filter(Boolean)
    .join('');

  if (userMsg && modelMsg) {
    const interaction = {
      timestamp: new Date().toISOString(),
      user: process.env.USER || 'unknown',
      request: userMsg.slice(0, 500), // 截断以便存储
      response: modelMsg.slice(0, 500),
    };

    fs.appendFileSync(tempFile, JSON.stringify(interaction) + '\n');
  }

  console.log(JSON.stringify({}));
}

function readStdin() {
  return new Promise((resolve) => {
    const chunks = [];
    process.stdin.on('data', (chunk) => chunks.push(chunk));
    process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString()));
  });
}

readStdin().then(main).catch(console.error);

7. 整合记忆(SessionEnd)

.gemini/hooks/consolidate.js

javascript
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { ChromaClient } = require('chromadb');

async function main() {
  const input = JSON.parse(await readStdin());
  const projectDir = process.env.GEMINI_PROJECT_DIR;
  const sessionId = process.env.GEMINI_SESSION_ID;

  const tempFile = path.join(
    projectDir,
    '.gemini',
    'memory',
    `session-${sessionId}.jsonl`,
  );

  if (!fs.existsSync(tempFile)) {
    console.log(JSON.stringify({}));
    return;
  }

  // 读取交互
  const interactions = fs
    .readFileSync(tempFile, 'utf8')
    .trim()
    .split('\n')
    .filter(Boolean)
    .map((line) => JSON.parse(line));

  if (interactions.length === 0) {
    fs.unlinkSync(tempFile);
    console.log(JSON.stringify({}));
    return;
  }

  // 使用 LLM 提取记忆
  const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
  const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });

  const prompt = `从此会话中提取重要的项目学习内容。
关注:决策、约定、注意事项、模式。
返回 JSON 数组,包含:category、summary、keywords

会话交互:
${JSON.stringify(interactions, null, 2)}

JSON:`;

  try {
    const result = await model.generateContent(prompt);
    const text = result.response.text().replace(/```json\n?|\n?```/g, '');
    const memories = JSON.parse(text);

    // 存储到 ChromaDB
    const client = new ChromaClient({
      path: path.join(projectDir, '.gemini', 'chroma'),
    });
    const collection = await client.getCollection({ name: 'project_memories' });
    const embedModel = genai.getGenerativeModel({
      model: 'text-embedding-004',
    });

    for (const memory of memories) {
      const memoryText = `${memory.category}: ${memory.summary}`;
      const embedding = await embedModel.embedContent(memoryText);
      const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;

      await collection.add({
        ids: [id],
        embeddings: [embedding.embedding.values],
        documents: [memoryText],
        metadatas: [
          {
            category: memory.category || 'general',
            summary: memory.summary,
            keywords: (memory.keywords || []).join(','),
            timestamp: new Date().toISOString(),
          },
        ],
      });
    }

    fs.unlinkSync(tempFile);

    console.log(
      JSON.stringify({
        systemMessage: `🧠 已保存 ${memories.length} 个新学习内容供未来会话使用`,
      }),
    );
  } catch (error) {
    console.error('整合记忆时出错:', error);
    fs.unlinkSync(tempFile);
    console.log(JSON.stringify({}));
  }
}

function readStdin() {
  return new Promise((resolve) => {
    const chunks = [];
    process.stdin.on('data', (chunk) => chunks.push(chunk));
    process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString()));
  });
}

readStdin().then(main).catch(console.error);

示例会话

> gemini

🧠 已加载 3 个记忆

> 修复 login.ts 中的认证 bug

💭 已召回 2 个记忆:
  - [convention] 使用中间件模式进行认证
  - [gotcha] 记得更新 token 类型

🎯 已过滤 127 → 15 个工具

[代理读取 login.ts 并提出修复]

✅ 测试通过: login.ts

---

> 为 API 端点添加错误日志

💭 已召回 3 个记忆:
  - [convention] 使用中间件模式进行认证
  - [pattern] 在中间件中集中错误处理
  - [decision] 将错误记录到 CloudWatch

🎯 已过滤 127 → 18 个工具

[代理实现错误日志]

> /exit

🧠 已保存 2 个新学习内容供未来会话使用

这个示例的特别之处

基于 RAG 的工具选择:

  • 传统方式:发送所有 100+ 工具导致混淆和上下文溢出
  • 本示例:提取意图,过滤到约 15 个相关工具
  • 好处:更快的响应、更好的选择、更低的成本

跨会话记忆:

  • 传统方式:每个会话从头开始
  • 本示例:学习约定、决策、注意事项、模式
  • 好处:团队成员之间共享知识、持久学习

所有钩子事件集成:

在一个连贯的工作流中展示每个钩子事件的实际用例。

成本效率

  • 使用 gemini-2.0-flash-exp 进行意图提取(快速、便宜)
  • 使用 text-embedding-004 进行 RAG(便宜)
  • 缓存工具描述(一次性成本)
  • 每个请求的开销最小(通常 <500ms)

自定义

调整记忆相关性:

javascript
// 在 inject-memories.js 中,更改 nResults
const results = await collection.query({
  queryEmbeddings: [result.embedding.values],
  nResults: 5, // 更多记忆
});

修改工具过滤数量:

javascript
// 在 rag-filter.js 中,调整限制
allowedFunctionNames: filtered.slice(0, 30), // 更多工具

添加自定义安全模式:

javascript
// 在 security.js 中,添加模式
const SECRET_PATTERNS = [
  // ... 现有模式
  /private[_-]?key/i,
  /auth[_-]?token/i,
];

了解更多

aicodex 文档网站