Files
gemini-cli/docs/hooks/writing-hooks.md
Roaim cc52839f19 Docs (#15103)
Co-authored-by: Abhi <abhipatel@google.com>
2025-12-16 21:51:47 +00:00

1027 lines
24 KiB
Markdown

# Writing hooks for Gemini CLI
This guide will walk you through creating hooks for Gemini CLI, from a simple
logging hook to a comprehensive workflow assistant that demonstrates all hook
events working together.
## Prerequisites
Before you start, make sure you have:
- Gemini CLI installed and configured
- Basic understanding of shell scripting or JavaScript/Node.js
- Familiarity with JSON for hook input/output
## Quick start
Let's create a simple hook that logs all tool executions to understand the
basics.
### Step 1: Create your hook script
Create a directory for hooks and a simple logging script:
```bash
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
```
### Step 2: Configure the hook
Add the hook configuration to `.gemini/settings.json`:
```json
{
"hooks": {
"AfterTool": [
{
"matcher": "*",
"hooks": [
{
"name": "tool-logger",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/log-tools.sh",
"description": "Log all tool executions"
}
]
}
]
}
}
```
### Step 3: Test your hook
Run Gemini CLI and execute any command that uses tools:
```
> Read the README.md file
[Agent uses read_file tool]
Logged: read_file
```
Check `.gemini/tool-log.txt` to see the logged tool executions.
## Practical examples
### Security: Block secrets in commits
Prevent committing files containing API keys or passwords.
**`.gemini/hooks/block-secrets.sh`:**
```bash
#!/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`:**
```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"
}
]
}
]
}
}
```
### Auto-testing after code changes
Automatically run tests when code files are modified.
**`.gemini/hooks/auto-test.sh`:**
```bash
#!/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`:**
```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"
}
]
}
]
}
}
```
### Dynamic context injection
Add relevant project context before each agent interaction.
**`.gemini/hooks/inject-context.sh`:**
```bash
#!/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`:**
```json
{
"hooks": {
"BeforeAgent": [
{
"matcher": "*",
"hooks": [
{
"name": "git-context",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/inject-context.sh",
"description": "Inject git commit history"
}
]
}
]
}
}
```
## Advanced features
### RAG-based tool filtering
Use `BeforeToolSelection` to intelligently reduce the tool space based on the
current task. Instead of sending all 100+ tools to the model, filter to the most
relevant ~15 tools using semantic search or keyword matching.
This improves:
- **Model accuracy:** Fewer similar tools reduce confusion
- **Response speed:** Smaller tool space is faster to process
- **Cost efficiency:** Less tokens used per request
### Cross-session memory
Use `SessionStart` and `SessionEnd` hooks to maintain persistent knowledge
across sessions:
- **SessionStart:** Load relevant memories from previous sessions
- **AfterModel:** Record important interactions during the session
- **SessionEnd:** Extract learnings and store for future use
This enables the assistant to learn project conventions, remember important
decisions, and share knowledge across team members.
### Hook chaining
Multiple hooks for the same event run in the order declared. Each hook can build
upon previous hooks' outputs:
```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"
}
]
}
]
}
}
```
## Complete example: Smart Development Workflow Assistant
This comprehensive example demonstrates all hook events working together with
two advanced features:
- **RAG-based tool selection:** Reduces 100+ tools to ~15 relevant ones per task
- **Cross-session memory:** Learns and persists project knowledge
### Architecture
```
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
```
### Installation
**Prerequisites:**
- Node.js 18+
- Gemini CLI installed
**Setup:**
```bash
# 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
```
### Configuration
**`.gemini/settings.json`:**
```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"
}
]
}
]
}
}
```
### Hook scripts
#### 1. Initialize (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');
// 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. Inject memories (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;
}
// 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 tool filter (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 || [];
// 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. Security validation (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:
'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. Auto-test (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;
}
// 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. Record interaction (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 });
// 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. Consolidate memories (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;
}
// 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
```
> gemini
🧠 3 memories loaded
> Fix the authentication bug in login.ts
💭 2 memories recalled:
- [convention] Use middleware pattern for auth
- [gotcha] Remember to update token types
🎯 Filtered 127 → 15 tools
[Agent reads login.ts and proposes fix]
✅ Tests passed: login.ts
---
> Add error logging to API endpoints
💭 3 memories recalled:
- [convention] Use middleware pattern for auth
- [pattern] Centralized error handling in middleware
- [decision] Log errors to CloudWatch
🎯 Filtered 127 → 18 tools
[Agent implements error logging]
> /exit
🧠 2 new learnings saved for future sessions
```
### 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
- 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)
### Customization
**Adjust memory relevance:**
```javascript
// In inject-memories.js, change nResults
const results = await collection.query({
queryEmbeddings: [result.embedding.values],
nResults: 5, // More memories
});
```
**Modify tool filter count:**
```javascript
// In rag-filter.js, adjust the limit
allowedFunctionNames: filtered.slice(0, 30), // More tools
```
**Add custom security patterns:**
```javascript
// In security.js, add patterns
const SECRET_PATTERNS = [
// ... existing patterns
/private[_-]?key/i,
/auth[_-]?token/i,
];
```
## Learn more
- [Hooks Reference](index.md) - Complete API reference and configuration
- [Best Practices](best-practices.md) - Security, performance, and debugging
- [Configuration](../cli/configuration.md) - Gemini CLI settings
- [Custom Commands](../cli/custom-commands.md) - Create custom commands