Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/entire/cli/agent/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ type Event struct {
ModifiedFiles []string

// FilePath is the path to a file that was edited (for FileEdit events).
// Populated by agents from tool_input.file_path. May be absolute — the
// framework normalizes to repo-relative before persisting.
// Populated by agents from tool input (field name varies by agent).
// May be absolute — the framework normalizes to repo-relative before persisting.
FilePath string

// ResponseMessage is an optional message to display to the user via the agent.
Expand Down
12 changes: 10 additions & 2 deletions cmd/entire/cli/agent/geminicli/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Comment on lines +164 to +168
Copy link

Copilot AI Mar 6, 2026

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

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 addGeminiHook appends 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 enable won'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.


// 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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InstallHooks() returns a hard-coded hook count (16). Since this now depends on FileModificationTools, it’s easy for the count/comment/tests to drift if that list changes. Consider computing the return count from a base constant plus len(FileModificationTools) (or counting the hooks you add) to keep it correct automatically.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — all agents currently hardcode their count and this matches the existing pattern. The comment enumerates every hook. A dynamic count is a nice-to-have but low priority given the pattern is consistent across agents.


// Marshal modified hook types back to rawHooks
marshalGeminiHookType(rawHooks, "SessionStart", sessionStart)
Expand Down
27 changes: 17 additions & 10 deletions cmd/entire/cli/agent/geminicli/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ func TestInstallHooks_FreshInstall(t *testing.T) {
t.Fatalf("InstallHooks() error = %v", err)
}

// 12 hooks: SessionStart, SessionEnd (exit+logout), BeforeAgent, AfterAgent,
// BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool, PreCompress, Notification
if count != 12 {
t.Errorf("InstallHooks() count = %d, want 12", count)
// 16 hooks: SessionStart, SessionEnd (exit+logout), BeforeAgent, AfterAgent,
// BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool,
// PostFileEdit (write_file, edit_file, save_file, replace), PreCompress, Notification
if count != 16 {
t.Errorf("InstallHooks() count = %d, want 16", count)
}

// Verify settings.json was created with hooks
Expand Down Expand Up @@ -51,8 +52,9 @@ func TestInstallHooks_FreshInstall(t *testing.T) {
if len(settings.Hooks.BeforeTool) != 1 {
t.Errorf("BeforeTool hooks = %d, want 1", len(settings.Hooks.BeforeTool))
}
if len(settings.Hooks.AfterTool) != 1 {
t.Errorf("AfterTool hooks = %d, want 1", len(settings.Hooks.AfterTool))
// AfterTool has 5 matchers: * (all tools) + 4 file-edit tools
if len(settings.Hooks.AfterTool) != 5 {
t.Errorf("AfterTool hooks = %d, want 5 (* + 4 file-edit tools)", len(settings.Hooks.AfterTool))
}
if len(settings.Hooks.BeforeModel) != 1 {
t.Errorf("BeforeModel hooks = %d, want 1", len(settings.Hooks.BeforeModel))
Expand Down Expand Up @@ -81,6 +83,10 @@ func TestInstallHooks_FreshInstall(t *testing.T) {
verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", "entire hooks gemini before-tool-selection")
verifyHookCommand(t, settings.Hooks.BeforeTool, "*", "entire hooks gemini before-tool")
verifyHookCommand(t, settings.Hooks.AfterTool, "*", "entire hooks gemini after-tool")
verifyHookCommand(t, settings.Hooks.AfterTool, "write_file", "entire hooks gemini post-file-edit")
verifyHookCommand(t, settings.Hooks.AfterTool, "edit_file", "entire hooks gemini post-file-edit")
verifyHookCommand(t, settings.Hooks.AfterTool, "save_file", "entire hooks gemini post-file-edit")
verifyHookCommand(t, settings.Hooks.AfterTool, "replace", "entire hooks gemini post-file-edit")
verifyHookCommand(t, settings.Hooks.PreCompress, "", "entire hooks gemini pre-compress")
verifyHookCommand(t, settings.Hooks.Notification, "", "entire hooks gemini notification")
}
Expand Down Expand Up @@ -121,8 +127,8 @@ func TestInstallHooks_Idempotent(t *testing.T) {
if err != nil {
t.Fatalf("first InstallHooks() error = %v", err)
}
if count1 != 12 {
t.Errorf("first InstallHooks() count = %d, want 12", count1)
if count1 != 16 {
t.Errorf("first InstallHooks() count = %d, want 16", count1)
}

// Second install should add 0 hooks
Expand Down Expand Up @@ -161,8 +167,8 @@ func TestInstallHooks_Force(t *testing.T) {
if err != nil {
t.Fatalf("force InstallHooks() error = %v", err)
}
if count != 12 {
t.Errorf("force InstallHooks() count = %d, want 12", count)
if count != 16 {
t.Errorf("force InstallHooks() count = %d, want 16", count)
}
}

Expand Down Expand Up @@ -483,6 +489,7 @@ func TestHookNames(t *testing.T) {
HookNameAfterTool,
HookNamePreCompress,
HookNameNotification,
HookNamePostFileEdit,
}

if len(names) != len(expected) {
Expand Down
55 changes: 54 additions & 1 deletion cmd/entire/cli/agent/geminicli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseFileEdit silently drops events when tool_input can’t be unmarshaled, which makes it hard to diagnose schema mismatches (e.g., tool_input missing/null or using an unexpected structure). Consider adding a Debug log (similar to ClaudeCode’s parseFileEdit) that includes session_id and tool_name when Unmarshal fails, while still fail-opening (returning nil event).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debug log was already there (lines 212-215 in the diff). Added tool_name to it in e1ae412 for better diagnostics.

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 {
Expand Down
106 changes: 106 additions & 0 deletions cmd/entire/cli/agent/geminicli/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,107 @@ func TestParseHookEvent_BeforeModel_EmptyModel_ReturnsNil(t *testing.T) {
}
}

func TestParseHookEvent_FileEdit(t *testing.T) {
t.Parallel()

ag := &GeminiCLIAgent{}
input := `{
"session_id": "gem-sess-1",
"transcript_path": "/tmp/gem.json",
"tool_name": "write_file",
"tool_input": {"file_path": "docs/hello.md", "content": "hello"}
}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePostFileEdit, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event == nil {
t.Fatal("expected event, got nil")
}
if event.Type != agent.FileEdit {
t.Errorf("expected event type %v, got %v", agent.FileEdit, event.Type)
}
if event.SessionID != "gem-sess-1" {
t.Errorf("expected session_id 'gem-sess-1', got %q", event.SessionID)
}
if event.FilePath != "docs/hello.md" {
t.Errorf("expected file_path 'docs/hello.md', got %q", event.FilePath)
}
}

func TestParseHookEvent_FileEdit_FallbackPath(t *testing.T) {
t.Parallel()

ag := &GeminiCLIAgent{}
// Some Gemini tools use "path" instead of "file_path"
input := `{
"session_id": "gem-sess-2",
"transcript_path": "/tmp/gem.json",
"tool_name": "replace",
"tool_input": {"path": "src/main.go", "old_string": "a", "new_string": "b"}
}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePostFileEdit, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event == nil {
t.Fatal("expected event, got nil")
}
if event.FilePath != "src/main.go" {
t.Errorf("expected file_path 'src/main.go', got %q", event.FilePath)
}
}

func TestParseHookEvent_FileEdit_FallbackFilename(t *testing.T) {
t.Parallel()

ag := &GeminiCLIAgent{}
// Some Gemini tools use "filename" instead of "file_path" or "path"
input := `{
"session_id": "gem-sess-4",
"transcript_path": "/tmp/gem.json",
"tool_name": "save_file",
"tool_input": {"filename": "config.yaml", "content": "key: value"}
}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePostFileEdit, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event == nil {
t.Fatal("expected event, got nil")
}
if event.FilePath != "config.yaml" {
t.Errorf("expected file_path 'config.yaml', got %q", event.FilePath)
}
}

func TestParseHookEvent_FileEdit_NoFilePath(t *testing.T) {
t.Parallel()

ag := &GeminiCLIAgent{}
input := `{
"session_id": "gem-sess-3",
"transcript_path": "/tmp/gem.json",
"tool_name": "write_file",
"tool_input": {"content": "no path here"}
}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePostFileEdit, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event != nil {
t.Errorf("expected nil event for missing file_path, got %+v", event)
}
}

func TestParseHookEvent_PassThroughHooks_ReturnNil(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -318,6 +419,11 @@ func TestParseHookEvent_AllLifecycleHooks(t *testing.T) {
expectNil: true,
inputTemplate: `{"session_id": "s7", "transcript_path": "/t"}`,
},
{
hookName: HookNamePostFileEdit,
expectedType: agent.FileEdit,
inputTemplate: `{"session_id": "s7b", "transcript_path": "/t", "tool_name": "write_file", "tool_input": {"file_path": "f.txt"}}`,
},
{
hookName: HookNameBeforeModel,
expectedType: agent.ModelUpdate,
Expand Down
11 changes: 11 additions & 0 deletions cmd/entire/cli/agent/geminicli/types.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package geminicli

import "encoding/json"

// GeminiSettings represents the .gemini/settings.json structure
type GeminiSettings struct {
HooksConfig GeminiHooksConfig `json:"hooksConfig,omitempty"`
Expand Down Expand Up @@ -73,6 +75,15 @@ type beforeModelRaw struct {
} `json:"llm_request"`
}

// afterToolHookInputRaw is the JSON structure from AfterTool hooks.
// Gemini CLI sends tool_name and tool_input for the tool that just executed.
type afterToolHookInputRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
}

// Tool names used in Gemini CLI that modify files
// Note: Gemini CLI uses different names in different contexts:
// - Internal/transcript names: write_file, replace
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/hook_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getHookType() classifies "post-file-edit" hooks by referencing claudecode.HookNamePostFileEdit, which couples generic hook behavior to the Claude Code agent package. Since Gemini now defines HookNamePostFileEdit too, consider using the string literal "post-file-edit" (or a shared constant in a common package) in the switch to avoid cross-agent dependency and keep the comment accurate.

Suggested change
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

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

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.

return "tool"
default:
return "agent"
Expand Down
13 changes: 7 additions & 6 deletions cmd/entire/cli/integration_test/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,10 +478,11 @@ func TestGeminiCLIHookInstallation(t *testing.T) {
t.Fatalf("InstallHooks() error = %v", err)
}

// Should install 12 hooks: SessionStart, SessionEnd (exit+logout), BeforeAgent, AfterAgent,
// BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool, PreCompress, Notification
if count != 12 {
t.Errorf("InstallHooks() count = %d, want 12", count)
// Should install 16 hooks: SessionStart, SessionEnd (exit+logout), BeforeAgent, AfterAgent,
// BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool,
// PostFileEdit (write_file, edit_file, save_file, replace), PreCompress, Notification
if count != 16 {
t.Errorf("InstallHooks() count = %d, want 16", count)
}

// Verify hooks are installed
Expand Down Expand Up @@ -665,8 +666,8 @@ func TestGeminiCLIHookInstallation(t *testing.T) {
if err != nil {
t.Fatalf("force InstallHooks() error = %v", err)
}
if count != 12 {
t.Errorf("force InstallHooks() count = %d, want 12", count)
if count != 16 {
t.Errorf("force InstallHooks() count = %d, want 16", count)
}
})
}
Expand Down
Loading