mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
475 lines
11 KiB
Markdown
475 lines
11 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.
|
|
|
|
## 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.
|
|
|
|
**Crucial Rule:** Always write logs to `stderr`. Write only the final JSON to
|
|
`stdout`.
|
|
|
|
### Step 1: Create your hook script
|
|
|
|
Create a directory for hooks and a simple logging script.
|
|
|
|
> **Note**:
|
|
>
|
|
> This example uses `jq` to parse JSON. If you don't have it installed, you can
|
|
> perform similar logic using Node.js or Python.
|
|
|
|
**macOS/Linux**
|
|
|
|
```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 (requires jq)
|
|
tool_name=$(echo "$input" | jq -r '.tool_name')
|
|
|
|
# Log to stderr (visible in terminal if hook fails, or captured in logs)
|
|
echo "Logging tool: $tool_name" >&2
|
|
|
|
# Log to file
|
|
echo "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt
|
|
|
|
# Return success (exit 0) with empty JSON
|
|
echo "{}"
|
|
exit 0
|
|
EOF
|
|
|
|
chmod +x .gemini/hooks/log-tools.sh
|
|
```
|
|
|
|
**Windows (PowerShell)**
|
|
|
|
```powershell
|
|
New-Item -ItemType Directory -Force -Path ".gemini\hooks"
|
|
@"
|
|
# Read hook input from stdin
|
|
`$inputJson = `$input | Out-String | ConvertFrom-Json
|
|
|
|
# Extract tool name
|
|
`$toolName = `$inputJson.tool_name
|
|
|
|
# Log to stderr (visible in terminal if hook fails, or captured in logs)
|
|
[Console]::Error.WriteLine("Logging tool: `$toolName")
|
|
|
|
# Log to file
|
|
"[`$(Get-Date -Format 'o')] Tool executed: `$toolName" | Out-File -FilePath ".gemini\tool-log.txt" -Append -Encoding utf8
|
|
|
|
# Return success with empty JSON
|
|
"{}"
|
|
"@ | Out-File -FilePath ".gemini\hooks\log-tools.ps1" -Encoding utf8
|
|
```
|
|
|
|
## Exit Code Strategies
|
|
|
|
There are two ways to control or block an action in Gemini CLI:
|
|
|
|
| Strategy | Exit Code | Implementation | Best For |
|
|
| :------------------------- | :-------- | :----------------------------------------------------------------- | :---------------------------------------------------------- |
|
|
| **Structured (Idiomatic)** | `0` | Return a JSON object like `{"decision": "deny", "reason": "..."}`. | Production hooks, custom user feedback, and complex logic. |
|
|
| **Emergency Brake** | `2` | Print the error message to `stderr` and exit. | Simple security gates, script errors, or rapid prototyping. |
|
|
|
|
## Practical examples
|
|
|
|
### Security: Block secrets in commits
|
|
|
|
Prevent committing files containing API keys or passwords. Note that we use
|
|
**Exit Code 0** to provide a structured denial message to the agent.
|
|
|
|
**`.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
|
|
# Log to stderr
|
|
echo "Blocked potential secret" >&2
|
|
|
|
# Return structured denial to stdout
|
|
cat <<EOF
|
|
{
|
|
"decision": "deny",
|
|
"reason": "Security Policy: Potential secret detected in content.",
|
|
"systemMessage": "🔒 Security scanner blocked operation"
|
|
}
|
|
EOF
|
|
exit 0
|
|
fi
|
|
|
|
# Allow
|
|
echo '{"decision": "allow"}'
|
|
exit 0
|
|
```
|
|
|
|
### Dynamic context injection (Git History)
|
|
|
|
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
|
|
```
|
|
|
|
### RAG-based Tool Filtering (BeforeToolSelection)
|
|
|
|
Use `BeforeToolSelection` to intelligently reduce the tool space. This example
|
|
uses a Node.js script to check the user's prompt and allow only relevant tools.
|
|
|
|
**`.gemini/hooks/filter-tools.js`:**
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
|
|
async function main() {
|
|
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
|
const { llm_request } = input;
|
|
|
|
// Decoupled API: Access messages from llm_request
|
|
const messages = llm_request.messages || [];
|
|
const lastUserMessage = messages
|
|
.slice()
|
|
.reverse()
|
|
.find((m) => m.role === 'user');
|
|
|
|
if (!lastUserMessage) {
|
|
console.log(JSON.stringify({})); // Do nothing
|
|
return;
|
|
}
|
|
|
|
const text = lastUserMessage.content;
|
|
const allowed = ['write_todos']; // Always allow memory
|
|
|
|
// Simple keyword matching
|
|
if (text.includes('read') || text.includes('check')) {
|
|
allowed.push('read_file', 'list_directory');
|
|
}
|
|
if (text.includes('test')) {
|
|
allowed.push('run_shell_command');
|
|
}
|
|
|
|
// If we found specific intent, filter tools. Otherwise allow all.
|
|
if (allowed.length > 1) {
|
|
console.log(
|
|
JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'BeforeToolSelection',
|
|
toolConfig: {
|
|
mode: 'ANY', // Force usage of one of these tools (or AUTO)
|
|
allowedFunctionNames: allowed,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
} else {
|
|
console.log(JSON.stringify({}));
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
```
|
|
|
|
**`.gemini/settings.json`:**
|
|
|
|
```json
|
|
{
|
|
"hooks": {
|
|
"BeforeToolSelection": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [
|
|
{
|
|
"name": "intent-filter",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/filter-tools.js"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
> **TIP**
|
|
>
|
|
> **Union Aggregation Strategy**: `BeforeToolSelection` is unique in that it
|
|
> combines the results of all matching hooks. If you have multiple filtering
|
|
> hooks, the agent will receive the **union** of all whitelisted tools. Only
|
|
> using `mode: "NONE"` will override other hooks to disable all tools.
|
|
|
|
## Complete example: Smart Development Workflow Assistant
|
|
|
|
This comprehensive example demonstrates all hook events working together. We
|
|
will build a system that maintains memory, filters tools, and checks for
|
|
security.
|
|
|
|
### Architecture
|
|
|
|
1. **SessionStart**: Load project memories.
|
|
2. **BeforeAgent**: Inject memories into context.
|
|
3. **BeforeToolSelection**: Filter tools based on intent.
|
|
4. **BeforeTool**: Scan for secrets.
|
|
5. **AfterModel**: Record interactions.
|
|
6. **AfterAgent**: Validate final response quality (Retry).
|
|
7. **SessionEnd**: Consolidate memories.
|
|
|
|
### Configuration (`.gemini/settings.json`)
|
|
|
|
```json
|
|
{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"matcher": "startup",
|
|
"hooks": [
|
|
{
|
|
"name": "init",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/init.js"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"BeforeAgent": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [
|
|
{
|
|
"name": "memory",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/inject-memories.js"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"BeforeToolSelection": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [
|
|
{
|
|
"name": "filter",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/rag-filter.js"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"BeforeTool": [
|
|
{
|
|
"matcher": "write_file",
|
|
"hooks": [
|
|
{
|
|
"name": "security",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/security.js"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"AfterModel": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [
|
|
{
|
|
"name": "record",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/record.js"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"AfterAgent": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [
|
|
{
|
|
"name": "validate",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/validate.js"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"SessionEnd": [
|
|
{
|
|
"matcher": "exit",
|
|
"hooks": [
|
|
{
|
|
"name": "save",
|
|
"type": "command",
|
|
"command": "node .gemini/hooks/consolidate.js"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
### Hook Scripts
|
|
|
|
> **Note**: For brevity, these scripts use `console.error` for logging and
|
|
> standard `console.log` for JSON output.
|
|
|
|
#### 1. Initialize (`init.js`)
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
// Initialize DB or resources
|
|
console.error('Initializing assistant...');
|
|
|
|
// Output to user
|
|
console.log(
|
|
JSON.stringify({
|
|
systemMessage: '🧠 Smart Assistant Loaded',
|
|
}),
|
|
);
|
|
```
|
|
|
|
#### 2. Inject Memories (`inject-memories.js`)
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
|
|
async function main() {
|
|
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
|
// Assume we fetch memories from a DB here
|
|
const memories = '- [Memory] Always use TypeScript for this project.';
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'BeforeAgent',
|
|
additionalContext: `\n## Relevant Memories\n${memories}`,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
main();
|
|
```
|
|
|
|
#### 3. Security Check (`security.js`)
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
const input = JSON.parse(fs.readFileSync(0));
|
|
const content = input.tool_input.content || '';
|
|
|
|
if (content.includes('SECRET_KEY')) {
|
|
console.log(
|
|
JSON.stringify({
|
|
decision: 'deny',
|
|
reason: 'Found SECRET_KEY in content',
|
|
systemMessage: '🚨 Blocked sensitive commit',
|
|
}),
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log(JSON.stringify({ decision: 'allow' }));
|
|
```
|
|
|
|
#### 4. Record Interaction (`record.js`)
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const input = JSON.parse(fs.readFileSync(0));
|
|
const { llm_request, llm_response } = input;
|
|
const logFile = path.join(
|
|
process.env.GEMINI_PROJECT_DIR,
|
|
'.gemini/memory/session.jsonl',
|
|
);
|
|
|
|
fs.appendFileSync(
|
|
logFile,
|
|
JSON.stringify({
|
|
request: llm_request,
|
|
response: llm_response,
|
|
timestamp: new Date().toISOString(),
|
|
}) + '\n',
|
|
);
|
|
|
|
console.log(JSON.stringify({}));
|
|
```
|
|
|
|
#### 5. Validate Response (`validate.js`)
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
const input = JSON.parse(fs.readFileSync(0));
|
|
const response = input.prompt_response;
|
|
|
|
// Example: Check if the agent forgot to include a summary
|
|
if (!response.includes('Summary:')) {
|
|
console.log(
|
|
JSON.stringify({
|
|
decision: 'block', // Triggers an automatic retry turn
|
|
reason: 'Your response is missing a Summary section. Please add one.',
|
|
systemMessage: '🔄 Requesting missing summary...',
|
|
}),
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log(JSON.stringify({ decision: 'allow' }));
|
|
```
|
|
|
|
#### 6. Consolidate Memories (`consolidate.js`)
|
|
|
|
```javascript
|
|
#!/usr/bin/env node
|
|
// Logic to save final session state
|
|
console.error('Consolidating memories for session end...');
|
|
```
|
|
|
|
## Packaging as an extension
|
|
|
|
While project-level hooks are great for specific repositories, you can share
|
|
your hooks across multiple projects by packaging them as a
|
|
[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md).
|
|
This provides version control, easy distribution, and centralized management.
|