Skip to content
Open
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
53 changes: 26 additions & 27 deletions cmd/entire/cli/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"slices"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
Expand Down Expand Up @@ -123,13 +122,13 @@ func (c *ClaudeCodeAgent) InstallHooks(ctx context.Context, localDev bool, force
postTaskCmd = "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-task"
postTodoCmd = "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-todo"
} else {
sessionStartCmd = "entire hooks claude-code session-start"
sessionEndCmd = "entire hooks claude-code session-end"
stopCmd = "entire hooks claude-code stop"
userPromptSubmitCmd = "entire hooks claude-code user-prompt-submit"
preTaskCmd = "entire hooks claude-code pre-task"
postTaskCmd = "entire hooks claude-code post-task"
postTodoCmd = "entire hooks claude-code post-todo"
sessionStartCmd = agent.WrapProductionJSONWarningHookCommand("entire hooks claude-code session-start", agent.WarningFormatMultiLine)
sessionEndCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code session-end")
stopCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code stop")
userPromptSubmitCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code user-prompt-submit")
preTaskCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code pre-task")
postTaskCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code post-task")
postTodoCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code post-todo")
}

count := 0
Expand Down Expand Up @@ -195,14 +194,14 @@ func (c *ClaudeCodeAgent) InstallHooks(ctx context.Context, localDev bool, force
marshalHookType(rawHooks, "PostToolUse", postToolUse)

// Marshal hooks and update raw settings
hooksJSON, err := json.Marshal(rawHooks)
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
if err != nil {
return 0, fmt.Errorf("failed to marshal hooks: %w", err)
}
rawSettings["hooks"] = hooksJSON

// Marshal permissions and update raw settings
permJSON, err := json.Marshal(rawPermissions)
permJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawPermissions)
if err != nil {
return 0, fmt.Errorf("failed to marshal permissions: %w", err)
}
Expand Down Expand Up @@ -241,7 +240,7 @@ func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, match
delete(rawHooks, hookType)
return
}
data, err := json.Marshal(matchers)
data, err := jsonutil.MarshalWithNoHTMLEscape(matchers)
if err != nil {
return // Silently ignore marshal errors (shouldn't happen)
}
Expand Down Expand Up @@ -336,7 +335,7 @@ func (c *ClaudeCodeAgent) UninstallHooks(ctx context.Context) error {

// If permissions is empty, remove it entirely
if len(rawPermissions) > 0 {
permJSON, err := json.Marshal(rawPermissions)
permJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawPermissions)
if err == nil {
rawSettings["permissions"] = permJSON
}
Expand All @@ -347,7 +346,7 @@ func (c *ClaudeCodeAgent) UninstallHooks(ctx context.Context) error {

// Marshal hooks back (preserving unknown hook types)
if len(rawHooks) > 0 {
hooksJSON, err := json.Marshal(rawHooks)
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
if err != nil {
return fmt.Errorf("failed to marshal hooks: %w", err)
}
Expand Down Expand Up @@ -385,14 +384,8 @@ func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) bool {
return false
}

// Check for at least one of our hooks (new or old format)
return hookCommandExists(settings.Hooks.Stop, "entire hooks claude-code stop") ||
hookCommandExists(settings.Hooks.Stop, "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code stop") ||
// Backwards compatibility: check for old hook formats
hookCommandExists(settings.Hooks.Stop, "entire hooks claudecode stop") ||
hookCommandExists(settings.Hooks.Stop, "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claudecode stop") ||
hookCommandExists(settings.Hooks.Stop, "entire rewind claude-hook --stop") ||
hookCommandExists(settings.Hooks.Stop, "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go rewind claude-hook --stop")
// Check for at least one of our hooks (new, wrapped, or legacy format)
return hasEntireHook(settings.Hooks.Stop)
}

// Helper functions for hook management
Expand All @@ -408,6 +401,17 @@ func hookCommandExists(matchers []ClaudeHookMatcher, command string) bool {
return false
}

func hasEntireHook(matchers []ClaudeHookMatcher) bool {
for _, matcher := range matchers {
for _, hook := range matcher.Hooks {
if isEntireHook(hook.Command) {
return true
}
}
}
return false
}

func hookCommandExistsWithMatcher(matchers []ClaudeHookMatcher, matcherName, command string) bool {
for _, matcher := range matchers {
if matcher.Matcher == matcherName {
Expand Down Expand Up @@ -457,12 +461,7 @@ func addHookToMatcher(matchers []ClaudeHookMatcher, matcherName, command string)

// isEntireHook checks if a command is an Entire hook (old or new format)
func isEntireHook(command string) bool {
for _, prefix := range entireHookPrefixes {
if strings.HasPrefix(command, prefix) {
return true
}
}
return false
return agent.IsManagedHookCommand(command, entireHookPrefixes)
}

// removeEntireHooks removes all Entire hooks from a list of matchers (for simple hooks like Stop)
Expand Down
9 changes: 5 additions & 4 deletions cmd/entire/cli/agent/claudecode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"slices"
"testing"

agentpkg "github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/testutil"
)

Expand Down Expand Up @@ -478,7 +479,7 @@ func TestInstallHooks_PreservesUserHooksOnSameType(t *testing.T) {
t.Fatalf("failed to parse Stop hooks: %v", err)
}
assertHookExists(t, matchers, "", "echo user stop hook", "user Stop hook")
assertHookExists(t, matchers, "", "entire hooks claude-code stop", "Entire Stop hook")
assertHookExists(t, matchers, "", agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code stop"), "Entire Stop hook")
})

t.Run("SessionStart", func(t *testing.T) {
Expand All @@ -488,7 +489,7 @@ func TestInstallHooks_PreservesUserHooksOnSameType(t *testing.T) {
t.Fatalf("failed to parse SessionStart hooks: %v", err)
}
assertHookExists(t, matchers, "", "echo user session start", "user SessionStart hook")
assertHookExists(t, matchers, "", "entire hooks claude-code session-start", "Entire SessionStart hook")
assertHookExists(t, matchers, "", agentpkg.WrapProductionJSONWarningHookCommand("entire hooks claude-code session-start", agentpkg.WarningFormatMultiLine), "Entire SessionStart hook")
})

t.Run("PostToolUse", func(t *testing.T) {
Expand All @@ -498,8 +499,8 @@ func TestInstallHooks_PreservesUserHooksOnSameType(t *testing.T) {
t.Fatalf("failed to parse PostToolUse hooks: %v", err)
}
assertHookExists(t, matchers, "Write", "echo user wrote file", "user Write hook")
assertHookExists(t, matchers, "Task", "entire hooks claude-code post-task", "Entire Task hook")
assertHookExists(t, matchers, "TodoWrite", "entire hooks claude-code post-todo", "Entire TodoWrite hook")
assertHookExists(t, matchers, "Task", agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code post-task"), "Entire Task hook")
assertHookExists(t, matchers, "TodoWrite", agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code post-todo"), "Entire TodoWrite hook")
})
}

Expand Down
21 changes: 12 additions & 9 deletions cmd/entire/cli/agent/codex/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/paths"
)
Expand Down Expand Up @@ -78,8 +79,15 @@ func (c *CodexAgent) InstallHooks(ctx context.Context, localDev bool, force bool
cmdPrefix = "entire hooks codex "
}
sessionStartCmd := cmdPrefix + "session-start"
if !localDev {
sessionStartCmd = agent.WrapProductionJSONWarningHookCommand(sessionStartCmd, agent.WarningFormatSingleLine)
}
userPromptSubmitCmd := cmdPrefix + "user-prompt-submit"
stopCmd := cmdPrefix + "stop"
if !localDev {
userPromptSubmitCmd = agent.WrapProductionSilentHookCommand(userPromptSubmitCmd)
stopCmd = agent.WrapProductionSilentHookCommand(stopCmd)
}

count := 0

Expand Down Expand Up @@ -116,7 +124,7 @@ func (c *CodexAgent) InstallHooks(ctx context.Context, localDev bool, force bool
// Re-parse the original file to preserve all top-level keys
_ = json.Unmarshal(existingData, &topLevel) //nolint:errcheck // best-effort preservation
}
hooksJSON, err := json.Marshal(rawHooks)
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
if err != nil {
return 0, fmt.Errorf("failed to marshal hooks: %w", err)
}
Expand Down Expand Up @@ -193,7 +201,7 @@ func (c *CodexAgent) UninstallHooks(ctx context.Context) error {
marshalHookType(rawHooks, "Stop", stop)

if len(rawHooks) > 0 {
hooksJSON, err := json.Marshal(rawHooks)
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
if err != nil {
return fmt.Errorf("failed to marshal hooks: %w", err)
}
Expand Down Expand Up @@ -251,7 +259,7 @@ func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, group
delete(rawHooks, hookType)
return
}
data, err := json.Marshal(groups)
data, err := jsonutil.MarshalWithNoHTMLEscape(groups)
if err != nil {
return
}
Expand Down Expand Up @@ -290,12 +298,7 @@ func addHook(groups []MatcherGroup, command string) []MatcherGroup {
}

func isEntireHook(command string) bool {
for _, prefix := range entireHookPrefixes {
if strings.HasPrefix(command, prefix) {
return true
}
}
return false
return agent.IsManagedHookCommand(command, entireHookPrefixes)
}

func hasEntireHook(groups []MatcherGroup) bool {
Expand Down
58 changes: 55 additions & 3 deletions cmd/entire/cli/agent/codex/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package codex

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"

agentpkg "github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/stretchr/testify/require"
)

Expand All @@ -31,9 +33,13 @@ func TestInstallHooks_CreatesConfig(t *testing.T) {
hooksPath := filepath.Join(tempDir, ".codex", HooksFileName)
data, err := os.ReadFile(hooksPath)
require.NoError(t, err)
require.Contains(t, string(data), "entire hooks codex session-start")
require.Contains(t, string(data), "entire hooks codex user-prompt-submit")
require.Contains(t, string(data), "entire hooks codex stop")

var hooksFile HooksFile
require.NoError(t, json.Unmarshal(data, &hooksFile))

assertHookCommand(t, hooksFile.Hooks.SessionStart, agentpkg.WrapProductionJSONWarningHookCommand("entire hooks codex session-start", agentpkg.WarningFormatSingleLine), "SessionStart")
assertHookCommand(t, hooksFile.Hooks.UserPromptSubmit, agentpkg.WrapProductionSilentHookCommand("entire hooks codex user-prompt-submit"), "UserPromptSubmit")
assertHookCommand(t, hooksFile.Hooks.Stop, agentpkg.WrapProductionSilentHookCommand("entire hooks codex stop"), "Stop")

// Verify project-level config.toml enables codex_hooks feature (per-repo)
projectConfig := filepath.Join(tempDir, ".codex", configFileName)
Expand Down Expand Up @@ -98,6 +104,39 @@ func TestUninstallHooks(t *testing.T) {
require.False(t, ag.AreHooksInstalled(context.Background()))
}

func TestUninstallHooks_PreservesUserHookContainingEntireSubstring(t *testing.T) {
tempDir := setupTestEnv(t)

codexDir := filepath.Join(tempDir, ".codex")
require.NoError(t, os.MkdirAll(codexDir, 0o750))
existingConfig := `{
"hooks": {
"Stop": [
{
"matcher": null,
"hooks": [
{"type": "command", "command": "echo \"the entire workflow finished\""}
]
}
]
}
}`
hooksPath := filepath.Join(codexDir, HooksFileName)
require.NoError(t, os.WriteFile(hooksPath, []byte(existingConfig), 0o600))

ag := &CodexAgent{}
_, err := ag.InstallHooks(context.Background(), false, false)
require.NoError(t, err)

err = ag.UninstallHooks(context.Background())
require.NoError(t, err)

data, readErr := os.ReadFile(hooksPath)
require.NoError(t, readErr)
require.Contains(t, string(data), `echo \"the entire workflow finished\"`)
require.NotContains(t, string(data), "entire hooks codex stop")
}

func TestAreHooksInstalled_NoFile(t *testing.T) {
setupTestEnv(t)

Expand Down Expand Up @@ -238,3 +277,16 @@ func TestInstallHooks_DoesNotModifyUserConfig(t *testing.T) {
require.Contains(t, string(configData), "model = \"gpt-4.1\"")
require.NotContains(t, string(configData), `trust_level = "trusted"`)
}

// assertHookCommand verifies that one of the hook entries in groups contains the expected command.
func assertHookCommand(t *testing.T, groups []MatcherGroup, expectedCmd, label string) {
t.Helper()
for _, g := range groups {
for _, h := range g.Hooks {
if h.Command == expectedCmd {
return
}
}
}
t.Errorf("%s: expected hook command not found: %s", label, expectedCmd)
}
17 changes: 7 additions & 10 deletions cmd/entire/cli/agent/copilotcli/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
Expand Down Expand Up @@ -112,6 +111,9 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
// Add hooks that don't already exist
for _, hookName := range c.HookNames() {
cmd := cmdPrefix + hookName
if !localDev {
cmd = agent.WrapProductionSilentHookCommand(cmd)
}
entries := hookEntries[hookName]
if !hookBashExists(entries, cmd) {
entries = append(entries, CopilotHookEntry{
Expand All @@ -137,7 +139,7 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
}

// Marshal hooks and update raw file
hooksJSON, err := json.Marshal(rawHooks)
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
if err != nil {
return 0, fmt.Errorf("failed to marshal hooks: %w", err)
}
Expand Down Expand Up @@ -206,7 +208,7 @@ func (c *CopilotCLIAgent) UninstallHooks(ctx context.Context) error {

// Marshal hooks back (preserving unknown hook types)
if len(rawHooks) > 0 {
hooksJSON, err := json.Marshal(rawHooks)
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
if err != nil {
return fmt.Errorf("failed to marshal hooks: %w", err)
}
Expand Down Expand Up @@ -292,7 +294,7 @@ func marshalCopilotHookType(rawHooks map[string]json.RawMessage, hookType string
delete(rawHooks, hookType)
return nil
}
data, err := json.Marshal(entries)
data, err := jsonutil.MarshalWithNoHTMLEscape(entries)
if err != nil {
return fmt.Errorf("failed to marshal hook type %s: %w", hookType, err)
}
Expand All @@ -312,12 +314,7 @@ func hookBashExists(entries []CopilotHookEntry, bash string) bool {

// isEntireHook checks if a hook entry's bash command belongs to Entire.
func isEntireHook(bash string) bool {
for _, prefix := range entireHookPrefixes {
if strings.HasPrefix(bash, prefix) {
return true
}
}
return false
return agent.IsManagedHookCommand(bash, entireHookPrefixes)
}

// hasEntireHook checks if any entry in the slice is an Entire hook.
Expand Down
Loading
Loading