跳转到内容

为 Gemini CLI 编写钩子

本指南将引导您创建 Gemini CLI 的钩子,从简单的日志钩子到一个展示所有钩子事件协同工作的全面工作流助手。

在开始之前,请确保您具备以下条件:

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

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

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

mkdir -p .gemini/hooks
cat > .gemini/hooks/log-tools.sh << 'EOF'
#!/usr/bin/env bash
# Read hook input from stdin
input=$(cat)
# Extract tool name
tool_name=$(echo "$input" | jq -r '.tool_name')
# Log to file
echo "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt
# Return success (exit 0) - output goes to user in transcript mode
echo "Logged: $tool_name"
EOF
chmod +x .gemini/hooks/log-tools.sh

.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"
}
]
}
]
}
}

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

> Read the README.md file
[Agent uses read_file tool]
Logged: read_file

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

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

.gemini/hooks/block-secrets.sh:

#!/usr/bin/env bash
input=$(cat)
# Extract content being written
content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')
# Check for secrets
if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then
echo '{"decision":"deny","reason":"Potential secret detected"}' >&2
exit 2
fi
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"
}
]
}
]
}
}

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

.gemini/hooks/auto-test.sh:

#!/usr/bin/env bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
# Only test .ts files
if [[ ! "$file_path" =~ \.ts$ ]]; then
exit 0
fi
# Find corresponding test file
test_file="${file_path%.ts}.test.ts"
if [ ! -f "$test_file" ]; then
echo "⚠️ No test file found"
exit 0
fi
# Run tests
if 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"
}
]
}
]
}
}

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

.gemini/hooks/inject-context.sh:

#!/usr/bin/env bash
# Get recent git commits for context
context=$(git log -5 --oneline 2>/dev/null || echo "No git history")
# Return as JSON
cat <<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"
}
]
}
]
}
}

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

这改进了:

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

使用 SessionStartSessionEnd 钩子可以在会话之间保持持久的知识:

  • 会话开始(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

设置:

Terminal window
# Create hooks directory
mkdir -p .gemini/hooks .gemini/memory
# Install dependencies
npm install --save-dev chromadb @google/generative-ai
# Copy hook scripts (shown below)
# Make them executable
chmod +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"
}
]
}
]
}
}

.gemini/hooks/init.js:

#!/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');
// 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);

.gemini/hooks/inject-memories.js:

#!/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;
}
// 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 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 || [];
// 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);

.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);

.gemini/hooks/auto-test.js:

#!/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;
}
// 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);

.gemini/hooks/record.js:

#!/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 });
// 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);

.gemini/hooks/consolidate.js:

#!/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;
}
// 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);

CODE_BLOCK_21

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.

  • Uses gemini-2.0-flash-exp for intent extraction (fast, cheap)
  • Uses text-embedding-004 for RAG (inexpensive)
  • Caches tool descriptions (one-time cost)
  • Minimal overhead per request (<500ms typically)

Adjust memory relevance:

CODE_BLOCK_22

Modify tool filter count:

CODE_BLOCK_23

Add custom security patterns:

CODE_BLOCK_24

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 文档片段翻译为简体中文:


作为扩展打包钩子,请遵循扩展钩子文档