Skip to content
Merged
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
65 changes: 52 additions & 13 deletions cmd/entire/cli/agent/geminicli/hooks.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package geminicli

import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

Expand Down Expand Up @@ -87,6 +90,11 @@ func (g *GeminiCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
rawHooks = make(map[string]json.RawMessage)
}

// Strip non-array values from hooks (removes legacy fields like "enabled": true
// that old Entire versions wrote directly into hooks, which Gemini CLI 0.33+
// rejects because hooks.additionalProperties requires arrays).
cleanupDone := stripNonArrayHookFields(ctx, rawHooks)

// Enable hooks via hooksConfig
// hooksConfig.Enabled must be true for Gemini CLI to execute hooks
hooksConfig.Enabled = true
Expand Down Expand Up @@ -115,13 +123,20 @@ func (g *GeminiCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
parseGeminiHookType(rawHooks, "PreCompress", &preCompress)
parseGeminiHookType(rawHooks, "Notification", &notification)

// Check for idempotency BEFORE removing hooks
// If the exact same hook command already exists, return 0 (no changes needed)
// Check for idempotency BEFORE removing hooks.
// If the exact same hook command already exists, hooks are already installed.
// When cleanupDone, we still need to write the file to persist the cleanup,
// but we return 0 (not 12) so callers know no hooks were added.
if !force {
existingCmd := getFirstEntireHookCommand(sessionStart)
expectedCmd := cmdPrefix + "session-start"
if existingCmd == expectedCmd {
return 0, nil // Already installed with same mode
if !cleanupDone {
return 0, nil // Already installed with same mode, nothing to write
}
// Cleanup needed but hooks already installed — write cleaned rawHooks
// without running the full remove+add cycle.
return 0, writeGeminiSettingsFile(rawSettings, rawHooks, hooksConfig, settingsPath)
}
}

Expand Down Expand Up @@ -190,35 +205,56 @@ func (g *GeminiCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
marshalGeminiHookType(rawHooks, "PreCompress", preCompress)
marshalGeminiHookType(rawHooks, "Notification", notification)

// Marshal hooksConfig back to raw settings
if err := writeGeminiSettingsFile(rawSettings, rawHooks, hooksConfig, settingsPath); err != nil {
return 0, err
}
return count, nil
}

// stripNonArrayHookFields removes non-array values from rawHooks (e.g., legacy
// "enabled": true that old Entire versions wrote directly into hooks, which
// Gemini CLI 0.33+ rejects because hooks.additionalProperties requires arrays).
// Returns true if any fields were removed.
func stripNonArrayHookFields(ctx context.Context, rawHooks map[string]json.RawMessage) bool {
var cleaned bool
for key, val := range rawHooks {
trimmed := bytes.TrimSpace(val)
if len(trimmed) == 0 || trimmed[0] != '[' {
delete(rawHooks, key)
logging.Debug(ctx, "removed non-array field from hooks", slog.String("key", key))
cleaned = true
}
}
return cleaned
}

// writeGeminiSettingsFile marshals rawHooks and hooksConfig back into rawSettings and writes to disk.
func writeGeminiSettingsFile(rawSettings map[string]json.RawMessage, rawHooks map[string]json.RawMessage, hooksConfig GeminiHooksConfig, settingsPath string) error {
hooksConfigJSON, err := json.Marshal(hooksConfig)
if err != nil {
return 0, fmt.Errorf("failed to marshal hooksConfig: %w", err)
return fmt.Errorf("failed to marshal hooksConfig: %w", err)
}
rawSettings["hooksConfig"] = hooksConfigJSON

// Marshal hooks back to raw settings (preserving unknown hook types)
hooksJSON, err := json.Marshal(rawHooks)
if err != nil {
return 0, fmt.Errorf("failed to marshal hooks: %w", err)
return fmt.Errorf("failed to marshal hooks: %w", err)
}
rawSettings["hooks"] = hooksJSON

// Write back to file
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil {
return 0, fmt.Errorf("failed to create .gemini directory: %w", err)
return fmt.Errorf("failed to create .gemini directory: %w", err)
}

output, err := json.MarshalIndent(rawSettings, "", " ")
if err != nil {
return 0, fmt.Errorf("failed to marshal settings: %w", err)
return fmt.Errorf("failed to marshal settings: %w", err)
}

if err := os.WriteFile(settingsPath, output, 0o600); err != nil {
return 0, fmt.Errorf("failed to write settings.json: %w", err)
return fmt.Errorf("failed to write settings.json: %w", err)
}

return count, nil
return nil
}

// parseGeminiHookType parses a specific hook type from rawHooks into the target slice.
Expand Down Expand Up @@ -273,6 +309,9 @@ func (g *GeminiCLIAgent) UninstallHooks(ctx context.Context) error {
rawHooks = make(map[string]json.RawMessage)
}

// Strip non-array values from hooks (same migration as InstallHooks)
stripNonArrayHookFields(ctx, rawHooks)

// Parse only the hook types we need to modify
var sessionStart, sessionEnd, beforeAgent, afterAgent []GeminiHookMatcher
var beforeModel, afterModel, beforeToolSelection []GeminiHookMatcher
Expand Down
201 changes: 198 additions & 3 deletions cmd/entire/cli/agent/geminicli/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import (
"github.com/entireio/cli/cmd/entire/cli/agent/testutil"
)

const testMatcherStartup = "startup"
const testHookNameMyHook = "my-hook"

func TestInstallHooks_FreshInstall(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)
Expand Down Expand Up @@ -198,9 +201,9 @@ func TestInstallHooks_PreservesUserHooks(t *testing.T) {
// Verify user hook is still there
foundUserHook := false
for _, matcher := range settings.Hooks.SessionStart {
if matcher.Matcher == "startup" {
if matcher.Matcher == testMatcherStartup {
for _, hook := range matcher.Hooks {
if hook.Name == "my-hook" {
if hook.Name == testHookNameMyHook {
foundUserHook = true
}
}
Expand Down Expand Up @@ -439,7 +442,7 @@ func TestUninstallHooks_PreservesUserHooks(t *testing.T) {
}

// Verify it's the user hook
if settings.Hooks.SessionStart[0].Matcher != "startup" {
if settings.Hooks.SessionStart[0].Matcher != testMatcherStartup {
t.Error("user hook was removed during uninstall")
}
}
Expand Down Expand Up @@ -496,6 +499,198 @@ func TestHookNames(t *testing.T) {
}
}

func TestInstallHooks_RemovesLegacyEnabledField(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

// Simulate settings.json written by old Entire that put "enabled": true inside hooks
writeGeminiSettings(t, tempDir, `{
"hooks": {
"enabled": true,
"SessionStart": [
{
"matcher": "startup",
"hooks": [{"name": "my-hook", "type": "command", "command": "echo user-startup-hook"}]
}
]
}
}`)

agent := &GeminiCLIAgent{}
_, err := agent.InstallHooks(context.Background(), false, false)
if err != nil {
t.Fatalf("InstallHooks() error = %v", err)
}

// Verify "enabled" boolean is gone from hooks
rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini")
if _, ok := rawHooks["enabled"]; ok {
t.Error("legacy hooks.enabled field should have been removed")
}

// Verify the user hook in SessionStart is still present
settings := readGeminiSettings(t, tempDir)
foundUserHook := false
for _, matcher := range settings.Hooks.SessionStart {
if matcher.Matcher == testMatcherStartup {
for _, hook := range matcher.Hooks {
if hook.Name == testHookNameMyHook {
foundUserHook = true
}
}
}
}
if !foundUserHook {
t.Error("user hook 'my-hook' should be preserved after legacy cleanup")
}
}

func TestInstallHooks_RemovesLegacyEnabledField_WhenAlreadyInstalled(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

// Hooks already installed but legacy "enabled": true is also present
writeGeminiSettings(t, tempDir, `{
"hooks": {
"enabled": true,
"SessionStart": [
{
"hooks": [{"name": "entire-session-start", "type": "command", "command": "entire hooks gemini session-start"}]
}
]
}
}`)

agent := &GeminiCLIAgent{}
n, err := agent.InstallHooks(context.Background(), false, false)
if err != nil {
t.Fatalf("InstallHooks() error = %v", err)
}

// Hooks were already installed — cleanup-only run should return 0, not 12.
if n != 0 {
t.Errorf("InstallHooks() count = %d, want 0 (hooks already installed, only cleanup occurred)", n)
}

// Verify "enabled" boolean is gone even though idempotency would have fired
rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini")
if _, ok := rawHooks["enabled"]; ok {
t.Error("legacy hooks.enabled field should have been removed even when hooks were already installed")
}
}

func TestInstallHooks_RemovesMultipleLegacyFields(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

// Multiple non-array legacy fields in hooks
writeGeminiSettings(t, tempDir, `{
"hooks": {
"enabled": true,
"version": "1.0",
"debug": false,
"SessionStart": [
{
"matcher": "startup",
"hooks": [{"name": "my-hook", "type": "command", "command": "echo user-startup-hook"}]
}
]
}
}`)

agent := &GeminiCLIAgent{}
_, err := agent.InstallHooks(context.Background(), false, false)
if err != nil {
t.Fatalf("InstallHooks() error = %v", err)
}

rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini")
for _, key := range []string{"enabled", "version", "debug"} {
if _, ok := rawHooks[key]; ok {
t.Errorf("legacy field %q should have been removed", key)
}
}

// Verify user hook survived
settings := readGeminiSettings(t, tempDir)
foundUserHook := false
for _, matcher := range settings.Hooks.SessionStart {
if matcher.Matcher == testMatcherStartup {
for _, hook := range matcher.Hooks {
if hook.Name == testHookNameMyHook {
foundUserHook = true
}
}
}
}
if !foundUserHook {
t.Error("user hook 'my-hook' should be preserved after legacy cleanup")
}
}

func TestInstallHooks_ForceWithLegacyFields(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

// Legacy field present with existing hooks, force reinstall
writeGeminiSettings(t, tempDir, `{
"hooks": {
"enabled": true,
"SessionStart": [
{
"hooks": [{"name": "entire-session-start", "type": "command", "command": "entire hooks gemini session-start"}]
}
]
}
}`)

agent := &GeminiCLIAgent{}
count, err := agent.InstallHooks(context.Background(), false, true)
if err != nil {
t.Fatalf("InstallHooks() error = %v", err)
}

// Force should reinstall all 12 hooks
if count != 12 {
t.Errorf("InstallHooks() count = %d, want 12 (force reinstall)", count)
}

// Legacy field should be gone
rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini")
if _, ok := rawHooks["enabled"]; ok {
t.Error("legacy hooks.enabled field should have been removed on force reinstall")
}
}

func TestUninstallHooks_RemovesLegacyEnabledField(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

// Simulate legacy settings with "enabled": true inside hooks plus an Entire hook
writeGeminiSettings(t, tempDir, `{
"hooks": {
"enabled": true,
"SessionStart": [
{
"hooks": [{"name": "entire-session-start", "type": "command", "command": "entire hooks gemini session-start"}]
}
]
}
}`)

agent := &GeminiCLIAgent{}
err := agent.UninstallHooks(context.Background())
if err != nil {
t.Fatalf("UninstallHooks() error = %v", err)
}

// Verify "enabled" boolean is gone from hooks
rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini")
if _, ok := rawHooks["enabled"]; ok {
t.Error("legacy hooks.enabled field should have been removed by UninstallHooks")
}
}

// Helper functions

func readGeminiSettings(t *testing.T, tempDir string) GeminiSettings {
Expand Down
13 changes: 13 additions & 0 deletions cmd/entire/cli/agent/geminicli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,21 @@ import (
var (
_ agent.TranscriptAnalyzer = (*GeminiCLIAgent)(nil)
_ agent.TokenCalculator = (*GeminiCLIAgent)(nil)
_ agent.HookResponseWriter = (*GeminiCLIAgent)(nil)
)

// WriteHookResponse outputs a JSON hook response to stdout.
// Gemini CLI reads this JSON and displays the systemMessage to the user.
func (g *GeminiCLIAgent) WriteHookResponse(message string) error {
resp := struct {
SystemMessage string `json:"systemMessage,omitempty"`
}{SystemMessage: message}
if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil {
return fmt.Errorf("failed to encode hook response: %w", err)
}
return nil
}

// HookNames returns the hook verbs Gemini CLI supports.
// These become subcommands: entire hooks gemini <verb>
func (g *GeminiCLIAgent) HookNames() []string {
Expand Down
Loading