diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md new file mode 100644 index 0000000000..9678744b75 --- /dev/null +++ b/docs/hooks/best-practices.md @@ -0,0 +1,806 @@ +# Hooks on Gemini CLI: Best practices + +This guide covers security considerations, performance optimization, debugging +techniques, and privacy considerations for developing and deploying hooks in +Gemini CLI. + +## Security considerations + +### Validate all inputs + +Never trust data from hooks without validation. Hook inputs may contain +user-provided data that could be malicious: + +```bash +#!/usr/bin/env bash +input=$(cat) + +# Validate JSON structure +if ! echo "$input" | jq empty 2>/dev/null; then + echo "Invalid JSON input" >&2 + exit 1 +fi + +# Validate required fields +tool_name=$(echo "$input" | jq -r '.tool_name // empty') +if [ -z "$tool_name" ]; then + echo "Missing tool_name field" >&2 + exit 1 +fi +``` + +### Use timeouts + +Set reasonable timeouts to prevent hooks from hanging indefinitely: + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "*", + "hooks": [ + { + "name": "slow-validator", + "type": "command", + "command": "./hooks/validate.sh", + "timeout": 5000 + } + ] + } + ] + } +} +``` + +**Recommended timeouts:** + +- Fast validation: 1000-5000ms +- Network requests: 10000-30000ms +- Heavy computation: 30000-60000ms + +### Limit permissions + +Run hooks with minimal required permissions: + +```bash +#!/usr/bin/env bash +# Don't run as root +if [ "$EUID" -eq 0 ]; then + echo "Hook should not run as root" >&2 + exit 1 +fi + +# Check file permissions before writing +if [ -w "$file_path" ]; then + # Safe to write +else + echo "Insufficient permissions" >&2 + exit 1 +fi +``` + +### Scan for secrets + +Use `BeforeTool` hooks to prevent committing sensitive data: + +```javascript +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 access key + /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token + /sk-[a-zA-Z0-9]{48}/, // OpenAI API key +]; + +function containsSecret(content) { + return SECRET_PATTERNS.some((pattern) => pattern.test(content)); +} +``` + +### Review external scripts + +Always review hook scripts from untrusted sources before enabling them: + +```bash +# Review before installing +cat third-party-hook.sh | less + +# Check for suspicious patterns +grep -E 'curl|wget|ssh|eval' third-party-hook.sh + +# Verify hook source +ls -la third-party-hook.sh +``` + +### Sandbox untrusted hooks + +For maximum security, consider running untrusted hooks in isolated environments: + +```bash +# Run hook in Docker container +docker run --rm \ + -v "$GEMINI_PROJECT_DIR:/workspace:ro" \ + -i untrusted-hook-image \ + /hook-script.sh < input.json +``` + +## Performance + +### Keep hooks fast + +Hooks run synchronously—slow hooks delay the agent loop. Optimize for speed by +using parallel operations: + +```javascript +// Sequential operations are slower +const data1 = await fetch(url1).then((r) => r.json()); +const data2 = await fetch(url2).then((r) => r.json()); +const data3 = await fetch(url3).then((r) => r.json()); + +// Prefer parallel operations for better performance +const [data1, data2, data3] = await Promise.all([ + fetch(url1).then((r) => r.json()), + fetch(url2).then((r) => r.json()), + fetch(url3).then((r) => r.json()), +]); +``` + +### Cache expensive operations + +Store results between invocations to avoid repeated computation: + +```javascript +const fs = require('fs'); +const path = require('path'); + +const CACHE_FILE = '.gemini/hook-cache.json'; + +function readCache() { + try { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + } catch { + return {}; + } +} + +function writeCache(data) { + fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); +} + +async function main() { + const cache = readCache(); + const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache + + if (cache[cacheKey]) { + console.log(JSON.stringify(cache[cacheKey])); + return; + } + + // Expensive operation + const result = await computeExpensiveResult(); + cache[cacheKey] = result; + writeCache(cache); + + console.log(JSON.stringify(result)); +} +``` + +### Use appropriate events + +Choose hook events that match your use case to avoid unnecessary execution. +`AfterAgent` fires once per agent loop completion, while `AfterModel` fires +after every LLM call (potentially multiple times per loop): + +```json +// If checking final completion, use AfterAgent instead of AfterModel +{ + "hooks": { + "AfterAgent": [ + { + "matcher": "*", + "hooks": [ + { + "name": "final-checker", + "command": "./check-completion.sh" + } + ] + } + ] + } +} +``` + +### Filter with matchers + +Use specific matchers to avoid unnecessary hook execution. Instead of matching +all tools with `*`, specify only the tools you need: + +```json +{ + "matcher": "WriteFile|Edit", + "hooks": [ + { + "name": "validate-writes", + "command": "./validate.sh" + } + ] +} +``` + +### Optimize JSON parsing + +For large inputs, use streaming JSON parsers to avoid loading everything into +memory: + +```javascript +// Standard approach: parse entire input +const input = JSON.parse(await readStdin()); +const content = input.tool_input.content; + +// For very large inputs: stream and extract only needed fields +const { createReadStream } = require('fs'); +const JSONStream = require('JSONStream'); + +const stream = createReadStream(0).pipe(JSONStream.parse('tool_input.content')); +let content = ''; +stream.on('data', (chunk) => { + content += chunk; +}); +``` + +## Debugging + +### Log to files + +Write debug information to dedicated log files: + +```bash +#!/usr/bin/env bash +LOG_FILE=".gemini/hooks/debug.log" + +# Log with timestamp +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" +} + +input=$(cat) +log "Received input: ${input:0:100}..." + +# Hook logic here + +log "Hook completed successfully" +``` + +### Use stderr for errors + +Error messages on stderr are surfaced appropriately based on exit codes: + +```javascript +try { + const result = dangerousOperation(); + console.log(JSON.stringify({ result })); +} catch (error) { + console.error(`Hook error: ${error.message}`); + process.exit(2); // Blocking error +} +``` + +### Test hooks independently + +Run hook scripts manually with sample JSON input: + +```bash +# Create test input +cat > test-input.json << 'EOF' +{ + "session_id": "test-123", + "cwd": "/tmp/test", + "hook_event_name": "BeforeTool", + "tool_name": "WriteFile", + "tool_input": { + "file_path": "test.txt", + "content": "Test content" + } +} +EOF + +# Test the hook +cat test-input.json | .gemini/hooks/my-hook.sh + +# Check exit code +echo "Exit code: $?" +``` + +### Check exit codes + +Ensure your script returns the correct exit code: + +```bash +#!/usr/bin/env bash +set -e # Exit on error + +# Hook logic +process_input() { + # ... +} + +if process_input; then + echo "Success message" + exit 0 +else + echo "Error message" >&2 + exit 2 +fi +``` + +### Enable telemetry + +Hook execution is logged when `telemetry.logPrompts` is enabled: + +```json +{ + "telemetry": { + "logPrompts": true + } +} +``` + +View hook telemetry in logs to debug execution issues. + +### Use hook panel + +The `/hooks panel` command shows execution status and recent output: + +```bash +/hooks panel +``` + +Check for: + +- Hook execution counts +- Recent successes/failures +- Error messages +- Execution timing + +## Development + +### Start simple + +Begin with basic logging hooks before implementing complex logic: + +```bash +#!/usr/bin/env bash +# Simple logging hook to understand input structure +input=$(cat) +echo "$input" >> .gemini/hook-inputs.log +echo "Logged input" +``` + +### Use JSON libraries + +Parse JSON with proper libraries instead of text processing: + +**Bad:** + +```bash +# Fragile text parsing +tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+') +``` + +**Good:** + +```bash +# Robust JSON parsing +tool_name=$(echo "$input" | jq -r '.tool_name') +``` + +### Make scripts executable + +Always make hook scripts executable: + +```bash +chmod +x .gemini/hooks/*.sh +chmod +x .gemini/hooks/*.js +``` + +### Version control + +Commit hooks to share with your team: + +```bash +git add .gemini/hooks/ +git add .gemini/settings.json +git commit -m "Add project hooks for security and testing" +``` + +**`.gitignore` considerations:** + +```gitignore +# Ignore hook cache and logs +.gemini/hook-cache.json +.gemini/hook-debug.log +.gemini/memory/session-*.jsonl + +# Keep hook scripts +!.gemini/hooks/*.sh +!.gemini/hooks/*.js +``` + +### Document behavior + +Add descriptions to help others understand your hooks: + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "WriteFile|Edit", + "hooks": [ + { + "name": "secret-scanner", + "type": "command", + "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", + "description": "Scans code changes for API keys, passwords, and other secrets before writing" + } + ] + } + ] + } +} +``` + +Add comments in hook scripts: + +```javascript +#!/usr/bin/env node +/** + * RAG Tool Filter Hook + * + * This hook reduces the tool space from 100+ tools to ~15 relevant ones + * by extracting keywords from the user's request and filtering tools + * based on semantic similarity. + * + * Performance: ~500ms average, cached tool embeddings + * Dependencies: @google/generative-ai + */ +``` + +## Troubleshooting + +### Hook not executing + +**Check hook name in `/hooks panel`:** + +```bash +/hooks panel +``` + +Verify the hook appears in the list and is enabled. + +**Verify matcher pattern:** + +```bash +# Test regex pattern +echo "WriteFile" | grep -E "Write.*|Edit" +``` + +**Check disabled list:** + +```json +{ + "hooks": { + "disabled": ["my-hook-name"] + } +} +``` + +**Ensure script is executable:** + +```bash +ls -la .gemini/hooks/my-hook.sh +chmod +x .gemini/hooks/my-hook.sh +``` + +**Verify script path:** + +```bash +# Check path expansion +echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" + +# Verify file exists +test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists" +``` + +### Hook timing out + +**Check configured timeout:** + +```json +{ + "name": "slow-hook", + "timeout": 60000 +} +``` + +**Optimize slow operations:** + +```javascript +// Before: Sequential operations (slow) +for (const item of items) { + await processItem(item); +} + +// After: Parallel operations (fast) +await Promise.all(items.map((item) => processItem(item))); +``` + +**Use caching:** + +```javascript +const cache = new Map(); + +async function getCachedData(key) { + if (cache.has(key)) { + return cache.get(key); + } + const data = await fetchData(key); + cache.set(key, data); + return data; +} +``` + +**Consider splitting into multiple faster hooks:** + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "WriteFile", + "hooks": [ + { + "name": "quick-check", + "command": "./quick-validation.sh", + "timeout": 1000 + } + ] + }, + { + "matcher": "WriteFile", + "hooks": [ + { + "name": "deep-check", + "command": "./deep-analysis.sh", + "timeout": 30000 + } + ] + } + ] + } +} +``` + +### Invalid JSON output + +**Validate JSON before outputting:** + +```bash +#!/usr/bin/env bash +output='{"decision": "allow"}' + +# Validate JSON +if echo "$output" | jq empty 2>/dev/null; then + echo "$output" +else + echo "Invalid JSON generated" >&2 + exit 1 +fi +``` + +**Ensure proper quoting and escaping:** + +```javascript +// Bad: Unescaped string interpolation +const message = `User said: ${userInput}`; +console.log(JSON.stringify({ message })); + +// Good: Automatic escaping +console.log(JSON.stringify({ message: `User said: ${userInput}` })); +``` + +**Check for binary data or control characters:** + +```javascript +function sanitizeForJSON(str) { + return str.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // Remove control chars +} + +const cleanContent = sanitizeForJSON(content); +console.log(JSON.stringify({ content: cleanContent })); +``` + +### Exit code issues + +**Verify script returns correct codes:** + +```bash +#!/usr/bin/env bash +set -e # Exit on error + +# Processing logic +if validate_input; then + echo "Success" + exit 0 +else + echo "Validation failed" >&2 + exit 2 +fi +``` + +**Check for unintended errors:** + +```bash +#!/usr/bin/env bash +# Don't use 'set -e' if you want to handle errors explicitly +# set -e + +if ! command_that_might_fail; then + # Handle error + echo "Command failed but continuing" >&2 +fi + +# Always exit explicitly +exit 0 +``` + +**Use trap for cleanup:** + +```bash +#!/usr/bin/env bash + +cleanup() { + # Cleanup logic + rm -f /tmp/hook-temp-* +} + +trap cleanup EXIT + +# Hook logic here +``` + +### Environment variables not available + +**Check if variable is set:** + +```bash +#!/usr/bin/env bash + +if [ -z "$GEMINI_PROJECT_DIR" ]; then + echo "GEMINI_PROJECT_DIR not set" >&2 + exit 1 +fi + +if [ -z "$CUSTOM_VAR" ]; then + echo "Warning: CUSTOM_VAR not set, using default" >&2 + CUSTOM_VAR="default-value" +fi +``` + +**Debug available variables:** + +```bash +#!/usr/bin/env bash + +# List all environment variables +env > .gemini/hook-env.log + +# Check specific variables +echo "GEMINI_PROJECT_DIR: $GEMINI_PROJECT_DIR" >> .gemini/hook-env.log +echo "GEMINI_SESSION_ID: $GEMINI_SESSION_ID" >> .gemini/hook-env.log +echo "GEMINI_API_KEY: ${GEMINI_API_KEY:+}" >> .gemini/hook-env.log +``` + +**Use .env files:** + +```bash +#!/usr/bin/env bash + +# Load .env file if it exists +if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then + source "$GEMINI_PROJECT_DIR/.env" +fi +``` + +## Privacy considerations + +Hook inputs and outputs may contain sensitive information. Gemini CLI respects +the `telemetry.logPrompts` setting for hook data logging. + +### What data is collected + +Hook telemetry may include: + +- **Hook inputs:** User prompts, tool arguments, file contents +- **Hook outputs:** Hook responses, decision reasons, added context +- **Standard streams:** stdout and stderr from hook processes +- **Execution metadata:** Hook name, event type, duration, success/failure + +### Privacy settings + +**Enabled (default):** + +Full hook I/O is logged to telemetry. Use this when: + +- Developing and debugging hooks +- Telemetry is redirected to a trusted enterprise system +- You understand and accept the privacy implications + +**Disabled:** + +Only metadata is logged (event name, duration, success/failure). Hook inputs and +outputs are excluded. Use this when: + +- Sending telemetry to third-party systems +- Working with sensitive data +- Privacy regulations require minimizing data collection + +### Configuration + +**Disable PII logging in settings:** + +```json +{ + "telemetry": { + "logPrompts": false + } +} +``` + +**Disable via environment variable:** + +```bash +export GEMINI_TELEMETRY_LOG_PROMPTS=false +``` + +### Sensitive data in hooks + +If your hooks process sensitive data: + +1. **Minimize logging:** Don't write sensitive data to log files +2. **Sanitize outputs:** Remove sensitive data before outputting +3. **Use secure storage:** Encrypt sensitive data at rest +4. **Limit access:** Restrict hook script permissions + +**Example sanitization:** + +```javascript +function sanitizeOutput(data) { + const sanitized = { ...data }; + + // Remove sensitive fields + delete sanitized.apiKey; + delete sanitized.password; + + // Redact sensitive strings + if (sanitized.content) { + sanitized.content = sanitized.content.replace( + /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi, + '[REDACTED]', + ); + } + + return sanitized; +} + +console.log(JSON.stringify(sanitizeOutput(hookOutput))); +``` + +## Learn more + +- [Hooks Reference](index.md) - Complete API reference +- [Writing Hooks](writing-hooks.md) - Tutorial and examples +- [Configuration](../cli/configuration.md) - Gemini CLI settings +- [Hooks Design Document](../hooks-design.md) - Technical architecture diff --git a/docs/hooks/index.md b/docs/hooks/index.md new file mode 100644 index 0000000000..d982d16c0a --- /dev/null +++ b/docs/hooks/index.md @@ -0,0 +1,560 @@ +# Gemini CLI hooks + +Hooks are scripts or programs that Gemini CLI executes at specific points in the +agentic loop, allowing you to intercept and customize behavior without modifying +the CLI's source code. + +See [writing hooks guide](writing-hooks.md) for a tutorial on creating your +first hook and a comprehensive example. + +See [best practices](best-practices.md) for guidelines on security, performance, +and debugging. + +## What are hooks? + +With hooks, you can: + +- **Add context:** Inject relevant information before the model processes a + request +- **Validate actions:** Review and block potentially dangerous operations +- **Enforce policies:** Implement security and compliance requirements +- **Log interactions:** Track tool usage and model responses +- **Optimize behavior:** Dynamically adjust tool selection or model parameters + +Hooks run synchronously as part of the agent loop—when a hook event fires, +Gemini CLI waits for all matching hooks to complete before continuing. + +## Core concepts + +### Hook events + +Hooks are triggered by specific events in Gemini CLI's lifecycle. The following +table lists all available hook events: + +| Event | When It Fires | Common Use Cases | +| --------------------- | --------------------------------------------- | ------------------------------------------ | +| `SessionStart` | When a session begins | Initialize resources, load context | +| `SessionEnd` | When a session ends | Clean up, save state | +| `BeforeAgent` | After user submits prompt, before planning | Add context, validate prompts | +| `AfterAgent` | When agent loop ends | Review output, force continuation | +| `BeforeModel` | Before sending request to LLM | Modify prompts, add instructions | +| `AfterModel` | After receiving LLM response | Filter responses, log interactions | +| `BeforeToolSelection` | Before LLM selects tools (after BeforeModel) | Filter available tools, optimize selection | +| `BeforeTool` | Before a tool executes | Validate arguments, block dangerous ops | +| `AfterTool` | After a tool executes | Process results, run tests | +| `PreCompress` | Before context compression | Save state, notify user | +| `Notification` | When a notification occurs (e.g., permission) | Auto-approve, log decisions | + +### Hook types + +Gemini CLI currently supports **command hooks** that run shell commands or +scripts: + +```json +{ + "type": "command", + "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh", + "timeout": 30000 +} +``` + +**Note:** Plugin hooks (npm packages) are planned for a future release. + +### Matchers + +For tool-related events (`BeforeTool`, `AfterTool`), you can filter which tools +trigger the hook: + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "WriteFile|Edit", + "hooks": [ + /* hooks for write operations */ + ] + } + ] + } +} +``` + +**Matcher patterns:** + +- **Exact match:** `"ReadFile"` matches only `ReadFile` +- **Regex:** `"Write.*|Edit"` matches `WriteFile`, `WriteBinary`, `Edit` +- **Wildcard:** `"*"` or `""` matches all tools + +**Session event matchers:** + +- **SessionStart:** `startup`, `resume`, `clear` +- **SessionEnd:** `exit`, `clear`, `logout`, `prompt_input_exit` +- **PreCompress:** `manual`, `auto` +- **Notification:** `ToolPermission` + +## Hook input/output contract + +### Command hook communication + +Hooks communicate via: + +- **Input:** JSON on stdin +- **Output:** Exit code + stdout/stderr + +### Exit codes + +- **0:** Success - stdout shown to user (or injected as context for some events) +- **2:** Blocking error - stderr shown to agent/user, operation may be blocked +- **Other:** Non-blocking warning - logged but execution continues + +### Common input fields + +Every hook receives these base fields: + +```json +{ + "session_id": "abc123", + "cwd": "/path/to/project", + "hook_event_name": "BeforeTool", + "timestamp": "2025-12-01T10:30:00Z" + // ... event-specific fields +} +``` + +### Event-specific fields + +#### BeforeTool + +**Input:** + +```json +{ + "tool_name": "WriteFile", + "tool_input": { + "file_path": "/path/to/file.ts", + "content": "..." + } +} +``` + +**Output (JSON on stdout):** + +```json +{ + "decision": "allow|deny|ask|block", + "reason": "Explanation shown to agent", + "systemMessage": "Message shown to user" +} +``` + +Or simple exit codes: + +- Exit 0 = allow (stdout shown to user) +- Exit 2 = deny (stderr shown to agent) + +#### AfterTool + +**Input:** + +```json +{ + "tool_name": "ReadFile", + "tool_input": { "file_path": "..." }, + "tool_response": "file contents..." +} +``` + +**Output:** + +```json +{ + "decision": "allow|deny", + "hookSpecificOutput": { + "hookEventName": "AfterTool", + "additionalContext": "Extra context for agent" + } +} +``` + +#### BeforeAgent + +**Input:** + +```json +{ + "prompt": "Fix the authentication bug" +} +``` + +**Output:** + +```json +{ + "decision": "allow|deny", + "hookSpecificOutput": { + "hookEventName": "BeforeAgent", + "additionalContext": "Recent project decisions: ..." + } +} +``` + +#### BeforeModel + +**Input:** + +```json +{ + "llm_request": { + "model": "gemini-2.0-flash-exp", + "messages": [{ "role": "user", "content": "Hello" }], + "config": { "temperature": 0.7 }, + "toolConfig": { + "functionCallingConfig": { + "mode": "AUTO", + "allowedFunctionNames": ["ReadFile", "WriteFile"] + } + } + } +} +``` + +**Output:** + +```json +{ + "decision": "allow", + "hookSpecificOutput": { + "hookEventName": "BeforeModel", + "llm_request": { + "messages": [ + { "role": "system", "content": "Additional instructions..." }, + { "role": "user", "content": "Hello" } + ] + } + } +} +``` + +#### AfterModel + +**Input:** + +```json +{ + "llm_request": { + "model": "gemini-2.0-flash-exp", + "messages": [ + /* ... */ + ], + "config": { + /* ... */ + }, + "toolConfig": { + /* ... */ + } + }, + "llm_response": { + "text": "string", + "candidates": [ + { + "content": { + "role": "model", + "parts": ["array of content parts"] + }, + "finishReason": "STOP" + } + ] + } +} +``` + +**Output:** + +```json +{ + "hookSpecificOutput": { + "hookEventName": "AfterModel", + "llm_response": { + "candidate": { + /* modified response */ + } + } + } +} +``` + +#### BeforeToolSelection + +**Input:** + +```json +{ + "llm_request": { + "model": "gemini-2.0-flash-exp", + "messages": [ + /* ... */ + ], + "toolConfig": { + "functionCallingConfig": { + "mode": "AUTO", + "allowedFunctionNames": [ + /* 100+ tools */ + ] + } + } + } +} +``` + +**Output:** + +```json +{ + "hookSpecificOutput": { + "hookEventName": "BeforeToolSelection", + "toolConfig": { + "functionCallingConfig": { + "mode": "ANY", + "allowedFunctionNames": ["ReadFile", "WriteFile", "Edit"] + } + } + } +} +``` + +Or simple output (comma-separated tool names sets mode to ANY): + +```bash +echo "ReadFile,WriteFile,Edit" +``` + +#### SessionStart + +**Input:** + +```json +{ + "source": "startup|resume|clear" +} +``` + +**Output:** + +```json +{ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "Loaded 5 project memories" + } +} +``` + +#### SessionEnd + +**Input:** + +```json +{ + "reason": "exit|clear|logout|prompt_input_exit|other" +} +``` + +No structured output expected (but stdout/stderr logged). + +#### PreCompress + +**Input:** + +```json +{ + "trigger": "manual|auto" +} +``` + +**Output:** + +```json +{ + "systemMessage": "Compression starting..." +} +``` + +#### Notification + +**Input:** + +```json +{ + "notification_type": "ToolPermission", + "message": "string", + "details": { + /* notification details */ + } +} +``` + +**Output:** + +```json +{ + "systemMessage": "Notification logged" +} +``` + +## Configuration + +Hook definitions are configured in `settings.json` files using the `hooks` +object. Configuration can be specified at multiple levels with defined +precedence rules. + +### Configuration layers + +Hook configurations are applied in the following order of precedence (higher +numbers override lower numbers): + +1. **System defaults:** Built-in default settings (lowest precedence) +2. **User settings:** `~/.gemini/settings.json` +3. **Project settings:** `.gemini/settings.json` in your project directory +4. **System settings:** `/etc/gemini-cli/settings.json` (highest precedence) + +Within each level, hooks run in the order they are declared in the +configuration. + +### Configuration schema + +```json +{ + "hooks": { + "EventName": [ + { + "matcher": "pattern", + "hooks": [ + { + "name": "hook-identifier", + "type": "command", + "command": "./path/to/script.sh", + "description": "What this hook does", + "timeout": 30000 + } + ] + } + ] + } +} +``` + +**Configuration properties:** + +- **`name`** (string, required): Unique identifier for the hook used in + `/hooks enable/disable` commands +- **`type`** (string, required): Hook type - currently only `"command"` is + supported +- **`command`** (string, required): Path to the script or command to execute +- **`description`** (string, optional): Human-readable description shown in + `/hooks panel` +- **`timeout`** (number, optional): Timeout in milliseconds (default: 60000) +- **`matcher`** (string, optional): Pattern to filter when hook runs (event + matchers only) + +### Environment variables + +Hooks have access to: + +- `GEMINI_PROJECT_DIR`: Project root directory +- `GEMINI_SESSION_ID`: Current session ID +- `GEMINI_API_KEY`: Gemini API key (if configured) +- All other environment variables from the parent process + +## Managing hooks + +### View registered hooks + +Use the `/hooks panel` command to view all registered hooks: + +```bash +/hooks panel +``` + +This command displays: + +- All active hooks organized by event +- Hook source (user, project, system) +- Hook type (command or plugin) +- Execution status and recent output + +### Enable and disable hooks + +You can temporarily enable or disable individual hooks using commands: + +```bash +/hooks enable hook-name +/hooks disable hook-name +``` + +These commands allow you to control hook execution without editing configuration +files. The hook name should match the `name` field in your hook configuration. + +### Disabled hooks configuration + +To permanently disable hooks, add them to the `hooks.disabled` array in your +`settings.json`: + +```json +{ + "hooks": { + "disabled": ["secret-scanner", "auto-test"] + } +} +``` + +**Note:** The `hooks.disabled` array uses a UNION merge strategy. Disabled hooks +from all configuration levels (user, project, system) are combined and +deduplicated, meaning a hook disabled at any level remains disabled. + +## Migration from Claude Code + +If you have hooks configured for Claude Code, you can migrate them: + +```bash +gemini hooks migrate --from-claude +``` + +This command: + +- Reads `.claude/settings.json` +- Converts event names (`PreToolUse` → `BeforeTool`, etc.) +- Translates tool names (`Bash` → `RunShellCommand`, `Edit` → `Edit`) +- Updates matcher patterns +- Writes to `.gemini/settings.json` + +### Event name mapping + +| Claude Code | Gemini CLI | +| ------------------ | -------------- | +| `PreToolUse` | `BeforeTool` | +| `PostToolUse` | `AfterTool` | +| `UserPromptSubmit` | `BeforeAgent` | +| `Stop` | `AfterAgent` | +| `Notification` | `Notification` | +| `SessionStart` | `SessionStart` | +| `SessionEnd` | `SessionEnd` | +| `PreCompact` | `PreCompress` | + +### Tool name mapping + +| Claude Code | Gemini CLI | +| ----------- | ----------------- | +| `Bash` | `RunShellCommand` | +| `Edit` | `Edit` | +| `Read` | `ReadFile` | +| `Write` | `WriteFile` | + +## Learn more + +- [Writing Hooks](writing-hooks.md) - Tutorial and comprehensive example +- [Best Practices](best-practices.md) - Security, performance, and debugging +- [Custom Commands](../cli/custom-commands.md) - Create reusable prompt + shortcuts +- [Configuration](../cli/configuration.md) - Gemini CLI configuration options +- [Hooks Design Document](../hooks-design.md) - Technical architecture details diff --git a/docs/hooks/writing-hooks.md b/docs/hooks/writing-hooks.md new file mode 100644 index 0000000000..04b79566f5 --- /dev/null +++ b/docs/hooks/writing-hooks.md @@ -0,0 +1,1026 @@ +# 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 ReadFile tool] + +Logged: ReadFile +``` + +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": "WriteFile|Edit", + "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": "WriteFile|Edit", + "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 < { + 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 = ['ReadFile', 'WriteFile', 'Edit', 'RunShellCommand']; + 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 diff --git a/docs/index.md b/docs/index.md index 7ca176cda1..0c6e5b78d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,6 +102,15 @@ This documentation is organized into the following sections: - **[Extension releasing](./extensions/extension-releasing.md):** How to release Gemini CLI extensions. +### Hooks + +- **[Hooks](./hooks/index.md):** Intercept and customize Gemini CLI behavior at + key lifecycle points. +- **[Writing Hooks](./hooks/writing-hooks.md):** Learn how to create your first + hook with a comprehensive example. +- **[Best Practices](./hooks/best-practices.md):** Security, performance, and + debugging guidelines for hooks. + ### IDE integration - **[Introduction to IDE integration](./ide-integration/index.md):** Connect the diff --git a/docs/sidebar.json b/docs/sidebar.json index 399c8d0284..c1453b9f37 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -193,6 +193,23 @@ } ] }, + { + "label": "Hooks", + "items": [ + { + "label": "Introduction", + "slug": "docs/hooks" + }, + { + "label": "Writing hooks", + "slug": "docs/hooks/writing-hooks" + }, + { + "label": "Best practices", + "slug": "docs/hooks/best-practices" + } + ] + }, { "label": "IDE integration", "items": [