为 Gemini CLI 编写钩子
为 Gemini CLI 编写钩子
Section titled “为 Gemini CLI 编写钩子”本指南将引导您创建 Gemini CLI 的钩子,从简单的日志钩子到一个展示所有钩子事件协同工作的全面工作流助手。
在开始之前,请确保您具备以下条件:
- 已安装并配置了 Gemini CLI
- 对 shell 脚本或 JavaScript/Node.js 有基本了解
- 熟悉用于钩子输入/输出的 JSON
让我们创建一个简单的钩子,记录所有工具的执行情况,以了解基础知识。
步骤 1:创建您的钩子脚本
Section titled “步骤 1:创建您的钩子脚本”为钩子创建一个目录和一个简单的日志脚本:
mkdir -p .gemini/hookscat > .gemini/hooks/log-tools.sh << 'EOF'#!/usr/bin/env bash# Read hook input from stdininput=$(cat)
# Extract tool nametool_name=$(echo "$input" | jq -r '.tool_name')
# Log to fileecho "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt
# Return success (exit 0) - output goes to user in transcript modeecho "Logged: $tool_name"EOF
chmod +x .gemini/hooks/log-tools.sh步骤 2:配置钩子
Section titled “步骤 2:配置钩子”在 .gemini/settings.json 中添加钩子配置:
{ "hooks": { "AfterTool": [ { "matcher": "*", "hooks": [ { "name": "tool-logger", "type": "command", "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/log-tools.sh", "description": "Log all tool executions" } ] } ] }}步骤 3:测试您的钩子
Section titled “步骤 3:测试您的钩子”运行 Gemini CLI 并执行任何使用工具的命令:
> Read the README.md file
[Agent uses read_file tool]
Logged: read_file检查 .gemini/tool-log.txt 以查看记录的工具执行情况。
安全:阻止提交中的机密信息
Section titled “安全:阻止提交中的机密信息”防止提交包含 API 密钥或密码的文件。
.gemini/hooks/block-secrets.sh:
#!/usr/bin/env bashinput=$(cat)
# Extract content being writtencontent=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')
# Check for secretsif echo "$content" | grep -qE 'api[_-]?key|password|secret'; then echo '{"decision":"deny","reason":"Potential secret detected"}' >&2 exit 2fi
exit 0.gemini/settings.json:
{ "hooks": { "BeforeTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "secret-scanner", "type": "command", "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", "description": "Prevent committing secrets" } ] } ] }}代码更改后的自动测试
Section titled “代码更改后的自动测试”代码文件修改后自动运行测试。
.gemini/hooks/auto-test.sh:
#!/usr/bin/env bashinput=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
# Only test .ts filesif [[ ! "$file_path" =~ \.ts$ ]]; then exit 0fi
# Find corresponding test filetest_file="${file_path%.ts}.test.ts"
if [ ! -f "$test_file" ]; then echo "⚠️ No test file found" exit 0fi
# Run testsif npx vitest run "$test_file" --silent 2>&1 | head -20; then echo "✅ Tests passed"else echo "❌ Tests failed"fi
exit 0.gemini/settings.json:
{ "hooks": { "AfterTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "auto-test", "type": "command", "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.sh", "description": "Run tests after code changes" } ] } ] }}动态上下文注入
Section titled “动态上下文注入”在每次代理交互前添加相关项目上下文。
.gemini/hooks/inject-context.sh:
#!/usr/bin/env bash
# Get recent git commits for contextcontext=$(git log -5 --oneline 2>/dev/null || echo "No git history")
# Return as JSONcat <<EOF{ "hookSpecificOutput": { "hookEventName": "BeforeAgent", "additionalContext": "Recent commits:\n$context" }}EOF.gemini/settings.json:
{ "hooks": { "BeforeAgent": [ { "matcher": "*", "hooks": [ { "name": "git-context", "type": "command", "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/inject-context.sh", "description": "Inject git commit history" } ] } ] }}基于RAG的工具筛选
Section titled “基于RAG的工具筛选”使用 BeforeToolSelection 智能地根据当前任务减少工具空间。不是将所有100多个工具发送给模型,而是使用语义搜索或关键词匹配筛选出最相关的约15个工具。
这改进了:
- 模型准确性: 更少的相似工具减少了混淆
- 响应速度: 较小的工具空间处理速度更快
- 成本效益: 每个请求使用的令牌更少
使用 SessionStart 和 SessionEnd 钩子可以在会话之间保持持久的知识:
- 会话开始(SessionStart):从上一个会话加载相关记忆
- 模型之后(AfterModel):记录会话期间的重要交互
- 会话结束(SessionEnd):提取学习成果并存储以供将来使用
这使得助手能够学习项目约定,记住重要决策,并在团队成员之间共享知识。
对于同一事件的多个钩子将按照声明的顺序运行。每个钩子都可以基于前一个钩子的输出来构建:
{ "hooks": { "BeforeAgent": [ { "matcher": "*", "hooks": [ { "name": "load-memories", "type": "command", "command": "./hooks/load-memories.sh" }, { "name": "analyze-sentiment", "type": "command", "command": "./hooks/analyze-sentiment.sh" } ] } ] }}完整示例:智能开发工作流助手
Section titled “完整示例:智能开发工作流助手”这个全面的示例展示了所有钩子事件协同工作,并包含两个高级功能:
- 基于RAG的工具选择:将100多个工具减少到每个任务约15个相关工具
- 跨会话记忆:学习并持久化项目知识
SessionStart → Initialize memory & index tools ↓BeforeAgent → Inject relevant memories ↓BeforeModel → Add system instructions ↓BeforeToolSelection → Filter tools via RAG ↓BeforeTool → Validate security ↓AfterTool → Run auto-tests ↓AfterModel → Record interaction ↓SessionEnd → Extract and store memories先决条件:
- Node.js 18+
- 已安装 Gemini CLI
设置:
# Create hooks directorymkdir -p .gemini/hooks .gemini/memory
# Install dependenciesnpm install --save-dev chromadb @google/generative-ai
# Copy hook scripts (shown below)# Make them executablechmod +x .gemini/hooks/*.js.gemini/settings.json:
{ "hooks": { "SessionStart": [ { "matcher": "startup", "hooks": [ { "name": "init-assistant", "type": "command", "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/init.js", "description": "Initialize Smart Workflow Assistant" } ] } ], "BeforeAgent": [ { "matcher": "*", "hooks": [ { "name": "inject-memories", "type": "command", "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/inject-memories.js", "description": "Inject relevant project memories" } ] } ], "BeforeToolSelection": [ { "matcher": "*", "hooks": [ { "name": "rag-filter", "type": "command", "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/rag-filter.js", "description": "Filter tools using RAG" } ] } ], "BeforeTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "security-check", "type": "command", "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/security.js", "description": "Prevent committing secrets" } ] } ], "AfterTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "auto-test", "type": "command", "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.js", "description": "Run tests after code changes" } ] } ], "AfterModel": [ { "matcher": "*", "hooks": [ { "name": "record-interaction", "type": "command", "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/record.js", "description": "Record interaction for learning" } ] } ], "SessionEnd": [ { "matcher": "exit|logout", "hooks": [ { "name": "consolidate-memories", "type": "command", "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/consolidate.js", "description": "Extract and store session learnings" } ] } ] }}1. 初始化(SessionStart)
Section titled “1. 初始化(SessionStart)”.gemini/hooks/init.js:
#!/usr/bin/env nodeconst { 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');
// Ensure chroma directory exists fs.mkdirSync(chromaPath, { recursive: true });
const client = new ChromaClient({ path: chromaPath });
// Initialize memory collection await client.getOrCreateCollection({ name: 'project_memories', metadata: { 'hnsw:space': 'cosine' }, });
// Count existing memories const collection = await client.getCollection({ name: 'project_memories' }); const memoryCount = await collection.count();
console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `Smart Workflow Assistant initialized with ${memoryCount} project memories.`, }, systemMessage: `🧠 ${memoryCount} memories loaded`, }), );}
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)
Section titled “2. 注入记忆(BeforeAgent)”.gemini/hooks/inject-memories.js:
#!/usr/bin/env nodeconst { 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; }
// Embed the prompt const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const model = genai.getGenerativeModel({ model: 'text-embedding-004' }); const result = await model.embedContent(prompt);
// Search memories 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## Relevant Project Context\n\n${memories}\n`, }, systemMessage: `💭 ${results.documents[0].length} memories recalled`, }), ); } 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)
Section titled “3. RAG工具筛选(BeforeToolSelection)”.gemini/hooks/rag-filter.js:
#!/usr/bin/env nodeconst { 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 || [];
// Skip if already filtered if (candidateTools.length <= 20) { console.log(JSON.stringify({})); return; }
// Extract recent user messages const recentMessages = llm_request.messages .slice(-3) .filter((m) => m.role === 'user') .map((m) => m.content) .join('\n');
// Use fast model to extract task keywords const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });
const result = await model.generateContent( `Extract 3-5 keywords describing needed tool capabilities from this request:\n\n${recentMessages}\n\nKeywords (comma-separated):`, );
const keywords = result.response .text() .toLowerCase() .split(',') .map((k) => k.trim());
// Simple keyword-based filtering + core tools 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: `🎯 Filtered ${candidateTools.length} → ${Math.min(filtered.length, 20)} tools`, }), );}
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)
Section titled “4. 安全验证(BeforeTool)”.gemini/hooks/security.js:
#!/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: 'Potential secret detected in code. Please remove sensitive data.', systemMessage: '🚨 Secret scanner blocked operation', }), ); 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)
Section titled “5. 自动测试(AfterTool)”.gemini/hooks/auto-test.js:
#!/usr/bin/env nodeconst { 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; }
// Find test file 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: `⚠️ No test file: ${path.basename(testFile)}`, }), ); return; }
// Run tests try { execSync(`npx vitest run ${testFile} --silent`, { encoding: 'utf8', stdio: 'pipe', timeout: 30000, });
console.log( JSON.stringify({ systemMessage: `✅ Tests passed: ${path.basename(filePath)}`, }), ); } catch (error) { console.log( JSON.stringify({ systemMessage: `❌ Tests failed: ${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)
Section titled “6. 记录交互(AfterModel)”.gemini/hooks/record.js:
#!/usr/bin/env nodeconst 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 });
// Extract user message and model response 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), // Truncate for storage 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)
Section titled “7. 整合记忆(SessionEnd)”.gemini/hooks/consolidate.js:
#!/usr/bin/env nodeconst 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; }
// Read interactions 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; }
// Extract memories using LLM const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });
const prompt = `Extract important project learnings from this session.Focus on: decisions, conventions, gotchas, patterns.Return JSON array with: category, summary, keywords
Session interactions:${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);
// Store in 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} new learnings saved for future sessions`, }), ); } catch (error) { console.error('Error consolidating memories:', 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);Example session
Section titled “Example session”CODE_BLOCK_21
What makes this example special
Section titled “What makes this example special”RAG-based tool selection:
- Traditional: Send all 100+ tools causing confusion and context overflow
- This example: Extract intent, filter to ~15 relevant tools
- Benefits: Faster responses, better selection, lower costs
Cross-session memory:
- Traditional: Each session starts fresh
- This example: Learns conventions, decisions, gotchas, patterns
- Benefits: Shared knowledge across team members, persistent learnings
All hook events integrated:
Demonstrates every hook event with practical use cases in a cohesive workflow.
Cost efficiency
Section titled “Cost efficiency”- Uses
gemini-2.0-flash-expfor intent extraction (fast, cheap) - Uses
text-embedding-004for RAG (inexpensive) - Caches tool descriptions (one-time cost)
- Minimal overhead per request (<500ms typically)
Customization
Section titled “Customization”Adjust memory relevance:
CODE_BLOCK_22
Modify tool filter count:
CODE_BLOCK_23
Add custom security patterns:
CODE_BLOCK_24
Packaging as an extension
Section titled “Packaging as an extension”While project-level hooks are great for specific repositories, you might want to share your hooks across multiple projects or with other users. You can do this by packaging your hooks as a Gemini CLI extension.
Packaging as an extension provides:
- Easy distribution: Share hooks via a git repository or GitHub release.
- Centralized management: Install, update, and disable hooks using
gemini扩展commands. - Version control: Manage hook versions separately from your project code.
- Variable substitution: Use
${extensionPath}and${process.execPath}用于可移植的跨平台脚本。
要将以下 Markdown 文档片段翻译为简体中文:
作为扩展打包钩子,请遵循扩展钩子文档。