gemini-cli icon indicating copy to clipboard operation
gemini-cli copied to clipboard

Hooks implementation & automatic Claude Code hooks migration

Open rickycambrian opened this issue 1 month ago • 36 comments

Summary

Implemented a comprehensive hook system for Gemini CLI, introducing feature parity with Claude Code hooks. This allows developers to intercept and modify key lifecycle events (like session start, user prompts, tool execution, and model requests) using external scripts or plugins.

Details

This PR adds the core infrastructure and CLI commands to support hooks:

  • Core Architecture:

    • HookRegistry: Loads and manages hook definitions from settings.json.
    • HookRunner: Executes command-based hooks via child processes, handling the JSON-over-stdin protocol compatible with Claude Code.
    • Events: Defined types for SessionStart, SessionEnd, BeforeAgent, AfterAgent, BeforeTool, AfterTool, BeforeModel, and more.
  • CLI Integration:

    • Integrated hooks into gemini.tsx (Session lifecycle).
    • Integrated into useGeminiStream.ts and nonInteractiveCli.ts (Agent loop and Model interaction).
    • Integrated into CoreToolScheduler.ts (Tool execution and blocking).
  • New Commands:

    • gemini hooks migrate: Automatically converts existing Claude Code configuration (~/.claude/settings.json) to Gemini format (~/.gemini/settings.json), remapping paths and event names.
    • gemini hooks list: Displays currently active hooks.

Related Issues

#9140, #9139, #9138, #9137, #9135, #9134, #9133, #9132, #9111, #9109, #9107, #9103, #9098, #9096, #9093, #9091, #9089, #9086, #9083, #9081, #9079, #9077, #9071

How to Validate

  1. Build the project:

    npm run build
    
  2. Migrate existing Claude hooks (if available):

    node packages/cli/dist/index.js hooks migrate
    

    Expected Result: Output confirming migration and the number of enabled events. Check ~/.gemini/settings.json to see the hooks section.

  3. List active hooks:

    node packages/cli/dist/index.js hooks list
    

    Expected Result: A list of events (e.g., SessionStart, BeforeAgent) and the scripts configured for them.

  4. Verify execution:

    • Start a session: node packages/cli/dist/index.js
    • Verification: If you have a SessionStart hook (like session-start.sh), verify it ran (e.g., check your logs or database).
    • Send a prompt.
    • Verification: BeforeAgent hooks should fire.
    • Use a tool.
    • Verification: BeforeTool and AfterTool hooks should fire.

Pre-Merge Checklist

  • [x] Updated relevant documentation and README (if needed)
  • [x] Added/updated tests (if needed)
  • [ ] Noted breaking changes (if any)
  • [x] Validated on required platforms/methods:
    • [x] MacOS
      • [x] npm run
      • [ ] npx
      • [ ] Docker
      • [ ] Podman
      • [ ] Seatbelt
    • [ ] Windows
      • [ ] npm run
      • [ ] npx
      • [ ] Docker
    • [ ] Linux
      • [ ] npm run
      • [ ] npx
      • [ ] Docker

rickycambrian avatar Nov 22 '25 13:11 rickycambrian

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

google-cla[bot] avatar Nov 22 '25 13:11 google-cla[bot]

Summary of Changes

Hello @rickycambrian, I'm Gemini Code Assist[^1]! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a powerful and flexible hook system to the Gemini CLI, enabling developers to intercept and modify various stages of the CLI's operation. By providing feature parity with Claude Code's hook functionality and an automated migration path, it significantly enhances the customizability and integration capabilities for users, allowing them to extend the CLI's behavior with external scripts and plugins.

Highlights

  • Comprehensive Hook System: Implemented a new, extensible hook system for the Gemini CLI, allowing developers to intercept and modify key lifecycle events.
  • Claude Code Hooks Parity & Migration: Achieved feature parity with Claude Code hooks and introduced a gemini hooks migrate command for automatic conversion of existing Claude Code configurations to the Gemini format.
  • New CLI Commands: Added gemini hooks list to display active hooks and gemini hooks migrate for configuration transfer, enhancing CLI manageability.
  • Extensive Integration: Hooks are integrated across critical CLI components, including session lifecycle (SessionStart, SessionEnd), agent interactions (BeforeAgent, AfterAgent), tool execution (BeforeTool, AfterTool), and model requests (BeforeModel).
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with :thumbsup: and :thumbsdown: on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

[^1]: Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

gemini-code-assist[bot] avatar Nov 22 '25 13:11 gemini-code-assist[bot]

Each of the comments by @gemini-code-assist have been addressed, but I'm not sure how to get its feedback again. Everything seems to be working well for me locally: image

rickycambrian avatar Nov 22 '25 15:11 rickycambrian

Hello @rickycambrian! I'm glad to hear that the previous comments were helpful and that things are working well for you locally. To get new feedback and trigger another review of your pull request, please use the command /gemini review in a new comment. This will prompt me to perform a fresh code review based on the current state of your pull request.

gemini-code-assist[bot] avatar Nov 22 '25 15:11 gemini-code-assist[bot]

/gemini review

rickycambrian avatar Nov 22 '25 15:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 16:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 16:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 17:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 17:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 17:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 17:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 22:11 rickycambrian

/gemini review

rickycambrian avatar Nov 22 '25 23:11 rickycambrian

/gemini review

rickycambrian avatar Nov 23 '25 00:11 rickycambrian

/gemini review

rickycambrian avatar Nov 23 '25 04:11 rickycambrian

/gemini review

rickycambrian avatar Nov 23 '25 13:11 rickycambrian

/gemini review

rickycambrian avatar Nov 23 '25 15:11 rickycambrian

I've been testing this PR locally. A very simple hook

{
  "security": {
    "auth": {
      "selectedType": "oauth-personal"
    }
  },
  "general": {
    "previewFeatures": true
  },
  "hooks": [ 
    {
      "event": "BeforeAgent", 
      "command": "/home/me/.gemini/hooks/touch-test.sh",
      "match": ".*"
    }
  ],
  "tools": {
    "enableHooks": true
  }
}
#!/bin/bash
touch /home/me/gemini_agent_hook_fired.log

However, even with the correct configuration and an absolute path to an executable script, no hooks (neither SessionStart nor BeforeAgent) are actually being executed when the CLI runs. What am i missing?

KristofStroobants avatar Nov 23 '25 19:11 KristofStroobants

Thank you for testing @KristofStroobants sorry about that, will look into this. I originally did have it work, but I should have tested more after all this continued iteration with the Gemini bot, sorry about that, I will take a closer look and provide an example hook for testing. Apologies

rickycambrian avatar Nov 23 '25 20:11 rickycambrian

Quote reply

Just ping me, if I have some time i will redo my tests.

KristofStroobants avatar Nov 24 '25 11:11 KristofStroobants

Thank you @KristofStroobants, very much appreciated! Was on a super long flight and was taking a look and this example should work. I'm still on the road for a better follow-up, but tried different types of hooks that seem to be working. Does this example work on your end?

/tmp/test_hook.sh:

#!/bin/bash
# Save as /tmp/test_hook.sh and run: chmod +x /tmp/test_hook.sh

LOG_FILE="/tmp/gemini_hook_test.log"
EVENT_NAME=${1:-"UnknownEvent"}

echo "✅ [$(date)] Hook Fired: $EVENT_NAME" >> "$LOG_FILE"

home folder (on mac) .gemini/settings.json configuration:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/tmp/test_hook.sh SessionStart"
          }
        ]
      }
    ],
    "BeforeAgent": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "/tmp/test_hook.sh BeforeAgent"
          }
        ]
      }
    ]
  },
  "tools": {
    "enableHooks": true,
    "enableMessageBusIntegration": true
  }
}

Curious if this is working on your side as a starting point since it is for me. Thank you for testing, much appreciated!

rickycambrian avatar Nov 24 '25 16:11 rickycambrian

Thank you @KristofStroobants, very much appreciated! Was on a super long flight and was taking a look and this example should work. I'm still on the road for a better follow-up, but tried different types of hooks that seem to be working. Does this example work on your end?

/tmp/test_hook.sh:

#!/bin/bash
# Save as /tmp/test_hook.sh and run: chmod +x /tmp/test_hook.sh

LOG_FILE="/tmp/gemini_hook_test.log"
EVENT_NAME=${1:-"UnknownEvent"}

echo "✅ [$(date)] Hook Fired: $EVENT_NAME" >> "$LOG_FILE"

home folder (on mac) .gemini/settings.json configuration:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/tmp/test_hook.sh SessionStart"
          }
        ]
      }
    ],
    "BeforeAgent": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "/tmp/test_hook.sh BeforeAgent"
          }
        ]
      }
    ]
  },
  "tools": {
    "enableHooks": true,
    "enableMessageBusIntegration": true
  }
}

Curious if this is working on your side as a starting point since it is for me. Thank you for testing, much appreciated!

I rebuild yours: this works:

kst@agent7:/tmp$ cat gemini_hook_test.log ✅ [Mon Nov 24 05:30:54 PM CET 2025] Hook Fired: BeforeAgent

But then when i place the .SH in a different folder, it breaks (sort of).

 "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/home/kristof/projects/Auxilium/.auxilium/hooks/test_hook.sh"
          }
        ]
      }
    ],
    "BeforeAgent": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "/home/kst/projects/Auxilium/.auxilium/hooks/test_hook.sh"
          }
        ]
      }
    ]
  },
  "tools": {
    "enableHooks": true,
    "enableMessageBusIntegration": true
  }

this gives me :

kst@agent7:/tmp$ cat gemini_hook_test.log
✅ [Mon Nov 24 05:30:54 PM CET 2025] Hook Fired: BeforeAgent
✅ [Mon Nov 24 07:40:42 PM CET 2025] Hook Fired: BeforeAgent
✅ [Mon Nov 24 07:42:58 PM CET 2025] Hook Fired from Auxilium: UnknownEvent
✅ [Mon Nov 24 07:43:31 PM CET 2025] Hook Fired from Auxilium: UnknownEvent
✅ [Mon Nov 24 07:43:52 PM CET 2025] Hook Fired from Auxilium: UnknownEvent

KristofStroobants avatar Nov 24 '25 16:11 KristofStroobants

Hey @KristofStroobants thank you for the follow-up 🙏 Sorry for things being poorly documented and hard to use. In the context of the example script, I think the event name should be read from stdin json, here is what I think should work, lmk if it doesn't for you:

#!/bin/bash
# Fixed Gemini CLI Test Hook
# Reads event name from stdin JSON (the proper way)

LOG_FILE="/tmp/gemini_hook_test.log"

# Read JSON input from stdin
INPUT=$(cat)

# Extract event name from JSON (NOT from command args!)
EVENT_NAME=$(echo "$INPUT" | jq -r '.hook_event_name // "MISSING_EVENT"' 2>/dev/null)

# Fallback: if jq fails or field is missing, check CLI arg (for backward compat)
if [[ "$EVENT_NAME" == "MISSING_EVENT" || -z "$EVENT_NAME" ]]; then
    EVENT_NAME=${1:-"UnknownEvent"}
fi

# Log the event
echo "✅ [$(date)] Hook Fired: $EVENT_NAME" >> "$LOG_FILE"

# Return valid JSON response
echo '{"continue": true}'

exit 0

@KristofStroobants I made some documentation here which I think should help a lot, let me know if you disagree or have issues: https://github.com/google-gemini/gemini-cli/blob/9f8935c7a715f1f31351f1a657f737f4badf622e/docs/cli/hooks.md Here as an attachment too: hooks.md

rickycambrian avatar Nov 25 '25 11:11 rickycambrian

Last part since my last comment was just to remove Claude Code co-authorship on one commit which was breaking the CLA check, not planning to make further changes from my side here

rickycambrian avatar Nov 25 '25 12:11 rickycambrian

This is awesome will get a maintainer to take a pass at a review here in the next day or two 👏

jackwotherspoon avatar Nov 25 '25 13:11 jackwotherspoon

"command": "/home/kst/projects/Auxilium/.auxilium/hooks/test_hook.sh"

Hi,

I just ran a test with the same path as before "command": "/home/kst/projects/Auxilium/.auxilium/hooks/test_hook.sh" but with your new script. It worked 👍🏼

✅ [Tue Nov 25 07:31:49 PM CET 2025] Hook Fired: BeforeAgent ✅ [Tue Nov 25 07:31:56 PM CET 2025] Hook Fired: BeforeAgent ✅ [Tue Nov 25 07:33:49 PM CET 2025] Hook Fired from aux: BeforeAgent

I will go over the documentation, see what i can learn.

KristofStroobants avatar Nov 25 '25 18:11 KristofStroobants

Hey @KristofStroobants thank you for the follow-up 🙏 Sorry for things being poorly documented and hard to use. In the context of the example script, I think the event name should be read from stdin json, here is what I think should work, lmk if it doesn't for you:

#!/bin/bash
# Fixed Gemini CLI Test Hook
# Reads event name from stdin JSON (the proper way)

LOG_FILE="/tmp/gemini_hook_test.log"

# Read JSON input from stdin
INPUT=$(cat)

# Extract event name from JSON (NOT from command args!)
EVENT_NAME=$(echo "$INPUT" | jq -r '.hook_event_name // "MISSING_EVENT"' 2>/dev/null)

# Fallback: if jq fails or field is missing, check CLI arg (for backward compat)
if [[ "$EVENT_NAME" == "MISSING_EVENT" || -z "$EVENT_NAME" ]]; then
    EVENT_NAME=${1:-"UnknownEvent"}
fi

# Log the event
echo "✅ [$(date)] Hook Fired: $EVENT_NAME" >> "$LOG_FILE"

# Return valid JSON response
echo '{"continue": true}'

exit 0

@KristofStroobants I made some documentation here which I think should help a lot, let me know if you disagree or have issues: https://github.com/google-gemini/gemini-cli/blob/9f8935c7a715f1f31351f1a657f737f4badf622e/docs/cli/hooks.md Here as an attachment too: hooks.md

I went over the docs, they are good. Questions:

  1. Does a hook-script need to be .SH? IF not, what other languages does it support?
  2. You focus on ~/.gemini/ so each time you open gemini CLI, they are at your disposal, that's the idea i suppose? But that would mean your logs etc would always write there too. So one of the things i would like to build is a context-log. At [timestamp] the AI did [read-file][filename], at [timestamp] user [prompt] 'user asked this silly question' So that when i reopen the project, i can tell the CLI, go and read the context-log and it is 70 up to speed, saving me (thats the goal) tokens. But is it saves its files outside of the project, that won't work.
  3. A few more practical examples use cases might be helpful to promote their purpose.

Thanks for the work, looking forward to start using these tbh.

KristofStroobants avatar Nov 25 '25 18:11 KristofStroobants

Thank you both for taking a look!

@KristofStroobants for your questions:

  1. Does a hook-script need to be .sh?

No, any executable that can:

  1. Read JSON from stdin
  2. Output JSON to stdout
  3. Exit with appropriate code (0=success, 1=warning, 2=block)

Works with bash, python, node, ruby, go, rust, etc...

Python example, tested as working:

#!/usr/bin/env python3
import sys
import json

# Read JSON from stdin
input_data = json.load(sys.stdin)

event = input_data.get('hook_event_name', 'unknown')
prompt = input_data.get('prompt', '')

# Log to file
with open('/tmp/gemini_hook.log', 'a') as f:
    f.write(f"Event: {event}, Prompt: {prompt[:50]}...\n")

# Return JSON response
print(json.dumps({"continue": True}))

Node.js example, tested as working:

#!/usr/bin/env node
const fs = require('fs');

let input = '';
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
  const data = JSON.parse(input);
  fs.appendFileSync('/tmp/hook.log', `${data.hook_event_name}\n`);
  console.log(JSON.stringify({ continue: true }));
});
  1. Global (~/.gemini/) vs project-specific hooks?

Both work. For example could look like this:

/home/kst/projects/Auxilium/
├── .gemini/
│   ├── settings.json      
│   └── hooks/
│       └── context-log.sh
├── .gemini-context.log    

Project settings.json:

{
  "hooks": {
    "BeforeAgent": [
      {
        "hooks": [{
          "type": "command",
          "command": ".gemini/hooks/context-log.sh"
        }]
      }
    ],
    "AfterTool": [
      {
        "hooks": [{
          "type": "command",
          "command": ".gemini/hooks/context-log.sh"
        }],
        "matcher": ".*"
      }
    ]
  }
}

I didn't test this one, but might be helpful:

#!/bin/bash
# .gemini/hooks/context-log.sh

PROJECT_DIR="${GEMINI_PROJECT_DIR:-.}"
LOG_FILE="$PROJECT_DIR/.gemini-context.log"

INPUT=$(cat)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"')
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

case "$EVENT" in
  "BeforeAgent")
    PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' | head -c 200)
    echo "[$TIMESTAMP] USER: $PROMPT" >> "$LOG_FILE"
    ;;
  "AfterTool")
    TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
    case "$TOOL" in
      "read_file")
        FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // "unknown"')
        echo "[$TIMESTAMP] READ: $FILE" >> "$LOG_FILE"
        ;;
      "replace"|"write_file")
        FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // "unknown"')
        echo "[$TIMESTAMP] EDIT: $FILE" >> "$LOG_FILE"
        ;;
      "run_shell_command")
        CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""' | head -c 100)
        echo "[$TIMESTAMP] SHELL: $CMD" >> "$LOG_FILE"
        ;;
    esac
    ;;
esac

echo '{"continue": true}'

Output example:

[2025-11-25 19:31:49] USER: Help me refactor the auth module
[2025-11-25 19:31:52] READ: src/auth/login.ts
[2025-11-25 19:32:01] EDIT: src/auth/login.ts
[2025-11-25 19:33:00] USER: Now add unit tests

Then to recover context: "Read .gemini-context.log to see what we worked on"

  1. A few more practical examples use cases might be helpful to promote their purpose.

Probably the most helpful starting point is looking at the Claude Code documentation for hooks: https://code.claude.com/docs/en/hooks Everything in this PR was aimed at creating a direct translation of what Claude Code makes available rather than being opinionated around what should exist. Within the context of our organization we have hooks for each one in order to track all activity in our shared knowledge base.

That being said, below are some examples in case helpful:

Use Case Hook Event What It Does
Context Logger BeforeAgent, AfterTool Log activity for session recovery (your idea)
Timestamp Injection BeforeAgent Add current time to prompts so AI knows "now"
File Version Tracking AfterTool (replace|write_file) Store file versions/diffs to database
Git Operations Logger AfterTool (run_shell_command) Track commits, pushes, branch changes
Todo Persistence AfterTool (write_todos) Sync todos to external task manager
Conversation Archive BeforeAgent, AfterAgent Save all prompts/responses to database
Session Analytics SessionStart, SessionEnd Track session duration, file counts
Lint/Test Guard AfterTool (replace) Auto-run tests after edits, warn on failures
Secrets Scanner BeforeTool (run_shell_command) Block commands containing API keys
Team Notifications SessionEnd Post summary to Slack when session ends

rickycambrian avatar Nov 26 '25 16:11 rickycambrian

Tagging @edilmo and @abhipatel12 who should review this from the Google side. 👍

jackwotherspoon avatar Nov 26 '25 16:11 jackwotherspoon

Thank you both for taking a look!

@KristofStroobants for your questions:

  1. Does a hook-script need to be .sh?

No, any executable that can:

  1. Read JSON from stdin
  2. Output JSON to stdout
  3. Exit with appropriate code (0=success, 1=warning, 2=block)

Works with bash, python, node, ruby, go, rust, etc...

Python example, tested as working:

#!/usr/bin/env python3
import sys
import json

# Read JSON from stdin
input_data = json.load(sys.stdin)

event = input_data.get('hook_event_name', 'unknown')
prompt = input_data.get('prompt', '')

# Log to file
with open('/tmp/gemini_hook.log', 'a') as f:
    f.write(f"Event: {event}, Prompt: {prompt[:50]}...\n")

# Return JSON response
print(json.dumps({"continue": True}))

Node.js example, tested as working:

#!/usr/bin/env node
const fs = require('fs');

let input = '';
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
  const data = JSON.parse(input);
  fs.appendFileSync('/tmp/hook.log', `${data.hook_event_name}\n`);
  console.log(JSON.stringify({ continue: true }));
});
  1. Global (~/.gemini/) vs project-specific hooks?

Both work. For example could look like this:

/home/kst/projects/Auxilium/
├── .gemini/
│   ├── settings.json      
│   └── hooks/
│       └── context-log.sh
├── .gemini-context.log    

Project settings.json:

{
  "hooks": {
    "BeforeAgent": [
      {
        "hooks": [{
          "type": "command",
          "command": ".gemini/hooks/context-log.sh"
        }]
      }
    ],
    "AfterTool": [
      {
        "hooks": [{
          "type": "command",
          "command": ".gemini/hooks/context-log.sh"
        }],
        "matcher": ".*"
      }
    ]
  }
}

So lets see if i understand this correctly. It first reads the .gemini in the home directory and then it overwrites whatever is in there with (if it exisits) the .gemini in the projects folder?

Thanks for your help btw. I will try out your logger script. Happy to hear it can be any language. My main language is PowerShell. Been postponing learning python for a while now.

KristofStroobants avatar Nov 27 '25 11:11 KristofStroobants