主题
为 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
跨会话记忆
使用 SessionStart 和 SessionEnd 钩子在会话之间维护持久知识:
- 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,
];