-
Notifications
You must be signed in to change notification settings - Fork 252
feat: wire Gemini CLI post-file-edit hook #648
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: refactoring-transcript-reads
Are you sure you want to change the base?
Changes from all commits
3c3ed7a
a806c55
e568ab8
e1ae412
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ const ( | |
| HookNameAfterTool = "after-tool" | ||
| HookNamePreCompress = "pre-compress" | ||
| HookNameNotification = "notification" | ||
| HookNamePostFileEdit = "post-file-edit" | ||
| ) | ||
|
|
||
| // GeminiSettingsFileName is the settings file used by Gemini CLI. | ||
|
|
@@ -160,22 +161,29 @@ func (g *GeminiCLIAgent) InstallHooks(ctx context.Context, localDev bool, force | |
| beforeTool = addGeminiHook(beforeTool, "*", "entire-before-tool", cmdPrefix+"before-tool") | ||
| afterTool = addGeminiHook(afterTool, "*", "entire-after-tool", cmdPrefix+"after-tool") | ||
|
|
||
| // File-edit hooks (AfterTool with specific matchers for file-modifying tools) | ||
| postFileEditCmd := cmdPrefix + "post-file-edit" | ||
| for _, toolName := range FileModificationTools { | ||
| afterTool = addGeminiHook(afterTool, toolName, "entire-post-file-edit-"+toolName, postFileEditCmd) | ||
| } | ||
|
|
||
| // Compression hook (before chat history compression) | ||
| preCompress = addGeminiHook(preCompress, "", "entire-pre-compress", cmdPrefix+"pre-compress") | ||
|
|
||
| // Notification hook (errors, warnings, info) | ||
| notification = addGeminiHook(notification, "", "entire-notification", cmdPrefix+"notification") | ||
|
|
||
| // 12 hooks total: | ||
| // 16 hooks total: | ||
| // - session-start (1) | ||
| // - session-end exit + logout (2) | ||
| // - before-agent, after-agent (2) | ||
| // - before-model, after-model (2) | ||
| // - before-tool-selection (1) | ||
| // - before-tool, after-tool (2) | ||
| // - post-file-edit: write_file, edit_file, save_file, replace (4) | ||
| // - pre-compress (1) | ||
| // - notification (1) | ||
| count := 12 | ||
| count := 16 | ||
|
||
|
|
||
| // Marshal modified hook types back to rawHooks | ||
| marshalGeminiHookType(rawHooks, "SessionStart", sessionStart) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,10 +5,12 @@ import ( | |
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "log/slog" | ||
| "os" | ||
| "time" | ||
|
|
||
| "github.com/entireio/cli/cmd/entire/cli/agent" | ||
| "github.com/entireio/cli/cmd/entire/cli/logging" | ||
| ) | ||
|
|
||
| // Compile-time interface assertions for new interfaces. | ||
|
|
@@ -32,12 +34,13 @@ func (g *GeminiCLIAgent) HookNames() []string { | |
| HookNameAfterTool, | ||
| HookNamePreCompress, | ||
| HookNameNotification, | ||
| HookNamePostFileEdit, | ||
| } | ||
| } | ||
|
|
||
| // ParseHookEvent translates a Gemini CLI hook into a normalized lifecycle Event. | ||
| // Returns nil if the hook has no lifecycle significance (e.g., pass-through hooks). | ||
| func (g *GeminiCLIAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { | ||
| func (g *GeminiCLIAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { | ||
| switch hookName { | ||
| case HookNameSessionStart: | ||
| return g.parseSessionStart(stdin) | ||
|
|
@@ -51,6 +54,8 @@ func (g *GeminiCLIAgent) ParseHookEvent(_ context.Context, hookName string, stdi | |
| return g.parseCompaction(stdin) | ||
| case HookNameBeforeModel: | ||
| return g.parseBeforeModel(stdin) | ||
| case HookNamePostFileEdit: | ||
| return g.parseFileEdit(ctx, stdin) | ||
| case HookNameBeforeTool, HookNameAfterTool, | ||
| HookNameAfterModel, HookNameBeforeToolSelection, HookNameNotification: | ||
| // Acknowledged hooks with no lifecycle action | ||
|
|
@@ -177,6 +182,54 @@ func (g *GeminiCLIAgent) parseBeforeModel(stdin io.Reader) (*agent.Event, error) | |
| }, nil | ||
| } | ||
|
|
||
| // fileEditToolInput extracts the edited file's path from Gemini CLI tool input. | ||
| // Different tools use different field names: file_path, path, or filename. | ||
| type fileEditToolInput struct { | ||
| FilePath string `json:"file_path"` | ||
| Path string `json:"path"` | ||
| Filename string `json:"filename"` | ||
| } | ||
|
|
||
| // filePath returns the first non-empty path field. | ||
| func (f fileEditToolInput) filePath() string { | ||
| if f.FilePath != "" { | ||
| return f.FilePath | ||
| } | ||
| if f.Path != "" { | ||
| return f.Path | ||
| } | ||
| return f.Filename | ||
| } | ||
|
|
||
| func (g *GeminiCLIAgent) parseFileEdit(ctx context.Context, stdin io.Reader) (*agent.Event, error) { | ||
| raw, err := agent.ReadAndParseHookInput[afterToolHookInputRaw](stdin) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var toolInput fileEditToolInput | ||
| if err := json.Unmarshal(raw.ToolInput, &toolInput); err != nil { | ||
| logCtx := logging.WithComponent(ctx, "agent.geminicli") | ||
| logging.Debug(logCtx, "parseFileEdit: unexpected tool_input format", | ||
| slog.String("tool_name", raw.ToolName), | ||
| slog.String("error", err.Error()), | ||
| ) | ||
| return nil, nil //nolint:nilnil // skip event but don't block agent | ||
| } | ||
|
Comment on lines
+210
to
+218
|
||
| path := toolInput.filePath() | ||
| if path == "" { | ||
| return nil, nil //nolint:nilnil // no file path = skip | ||
| } | ||
|
|
||
| return &agent.Event{ | ||
| Type: agent.FileEdit, | ||
| SessionID: raw.SessionID, | ||
| SessionRef: raw.TranscriptPath, | ||
| FilePath: path, | ||
| Timestamp: time.Now(), | ||
| }, nil | ||
| } | ||
|
|
||
| func (g *GeminiCLIAgent) parseCompaction(stdin io.Reader) (*agent.Event, error) { | ||
| raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) | ||
| if err != nil { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -80,7 +80,7 @@ func getHookType(hookName string) string { | |||||
| case claudecode.HookNamePreTask, claudecode.HookNamePostTask, claudecode.HookNamePostTodo: | ||||||
| return "subagent" | ||||||
| case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool, | ||||||
| claudecode.HookNamePostFileEdit: | ||||||
| claudecode.HookNamePostFileEdit: // all agents use "post-file-edit"; only one constant needed (Go disallows duplicate case values) | ||||||
|
||||||
| claudecode.HookNamePostFileEdit: // all agents use "post-file-edit"; only one constant needed (Go disallows duplicate case values) | |
| "post-file-edit": // all agents use the "post-file-edit" hook name; match by literal to avoid cross-agent dependency |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reference predates this PR — it was introduced in #637 (the upstack). All agents define the same "post-file-edit" string value so Go's switch doesn't allow duplicates. A shared constant in a common package would be the cleaner long-term fix; will address in the base PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
InstallHooks() can return 0 (idempotent) based solely on the existing SessionStart hook command, which means repos that already had the older Gemini hook set installed won’t get these newly-added post-file-edit AfterTool matchers unless users pass --force. Consider updating the idempotency check to verify that all required AfterTool matchers (including the new file-edit tool matchers) are present before early-returning 0, so upgrades happen automatically.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The idempotency check uses the SessionStart command string — if the binary path matches, it early-returns. But
addGeminiHookappends new matchers to the existing array regardless, so existing installs do get the new matchers on re-enable.That said, there's a valid broader concern: users who never re-run
entire enablewon't get new hooks. We've noted this as future work — need a hook auto-update mechanism (version check or count comparison during session-start). Tracking separately from the per-agent rollout.