Skip to content

Commit 5deb7a4

Browse files
authored
Merge pull request #929 from entireio/warn-entire-enable-but-not-installed
Handle missing Entire CLI in agent hooks
2 parents b366d29 + 61b16ce commit 5deb7a4

File tree

19 files changed

+494
-166
lines changed

19 files changed

+494
-166
lines changed

cmd/entire/cli/agent/claudecode/hooks.go

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"os"
88
"path/filepath"
99
"slices"
10-
"strings"
1110

1211
"github.com/entireio/cli/cmd/entire/cli/agent"
1312
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
@@ -123,13 +122,13 @@ func (c *ClaudeCodeAgent) InstallHooks(ctx context.Context, localDev bool, force
123122
postTaskCmd = "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-task"
124123
postTodoCmd = "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-todo"
125124
} else {
126-
sessionStartCmd = "entire hooks claude-code session-start"
127-
sessionEndCmd = "entire hooks claude-code session-end"
128-
stopCmd = "entire hooks claude-code stop"
129-
userPromptSubmitCmd = "entire hooks claude-code user-prompt-submit"
130-
preTaskCmd = "entire hooks claude-code pre-task"
131-
postTaskCmd = "entire hooks claude-code post-task"
132-
postTodoCmd = "entire hooks claude-code post-todo"
125+
sessionStartCmd = agent.WrapProductionJSONWarningHookCommand("entire hooks claude-code session-start", agent.WarningFormatMultiLine)
126+
sessionEndCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code session-end")
127+
stopCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code stop")
128+
userPromptSubmitCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code user-prompt-submit")
129+
preTaskCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code pre-task")
130+
postTaskCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code post-task")
131+
postTodoCmd = agent.WrapProductionSilentHookCommand("entire hooks claude-code post-todo")
133132
}
134133

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

197196
// Marshal hooks and update raw settings
198-
hooksJSON, err := json.Marshal(rawHooks)
197+
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
199198
if err != nil {
200199
return 0, fmt.Errorf("failed to marshal hooks: %w", err)
201200
}
202201
rawSettings["hooks"] = hooksJSON
203202

204203
// Marshal permissions and update raw settings
205-
permJSON, err := json.Marshal(rawPermissions)
204+
permJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawPermissions)
206205
if err != nil {
207206
return 0, fmt.Errorf("failed to marshal permissions: %w", err)
208207
}
@@ -241,7 +240,7 @@ func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, match
241240
delete(rawHooks, hookType)
242241
return
243242
}
244-
data, err := json.Marshal(matchers)
243+
data, err := jsonutil.MarshalWithNoHTMLEscape(matchers)
245244
if err != nil {
246245
return // Silently ignore marshal errors (shouldn't happen)
247246
}
@@ -336,7 +335,7 @@ func (c *ClaudeCodeAgent) UninstallHooks(ctx context.Context) error {
336335

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

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

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

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

404+
func hasEntireHook(matchers []ClaudeHookMatcher) bool {
405+
for _, matcher := range matchers {
406+
for _, hook := range matcher.Hooks {
407+
if isEntireHook(hook.Command) {
408+
return true
409+
}
410+
}
411+
}
412+
return false
413+
}
414+
411415
func hookCommandExistsWithMatcher(matchers []ClaudeHookMatcher, matcherName, command string) bool {
412416
for _, matcher := range matchers {
413417
if matcher.Matcher == matcherName {
@@ -457,12 +461,7 @@ func addHookToMatcher(matchers []ClaudeHookMatcher, matcherName, command string)
457461

458462
// isEntireHook checks if a command is an Entire hook (old or new format)
459463
func isEntireHook(command string) bool {
460-
for _, prefix := range entireHookPrefixes {
461-
if strings.HasPrefix(command, prefix) {
462-
return true
463-
}
464-
}
465-
return false
464+
return agent.IsManagedHookCommand(command, entireHookPrefixes)
466465
}
467466

468467
// removeEntireHooks removes all Entire hooks from a list of matchers (for simple hooks like Stop)

cmd/entire/cli/agent/claudecode/hooks_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"slices"
99
"testing"
1010

11+
agentpkg "github.com/entireio/cli/cmd/entire/cli/agent"
1112
"github.com/entireio/cli/cmd/entire/cli/agent/testutil"
1213
)
1314

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

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

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

cmd/entire/cli/agent/codex/hooks.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99
"strings"
1010

11+
"github.com/entireio/cli/cmd/entire/cli/agent"
1112
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
1213
"github.com/entireio/cli/cmd/entire/cli/paths"
1314
)
@@ -78,8 +79,15 @@ func (c *CodexAgent) InstallHooks(ctx context.Context, localDev bool, force bool
7879
cmdPrefix = "entire hooks codex "
7980
}
8081
sessionStartCmd := cmdPrefix + "session-start"
82+
if !localDev {
83+
sessionStartCmd = agent.WrapProductionJSONWarningHookCommand(sessionStartCmd, agent.WarningFormatSingleLine)
84+
}
8185
userPromptSubmitCmd := cmdPrefix + "user-prompt-submit"
8286
stopCmd := cmdPrefix + "stop"
87+
if !localDev {
88+
userPromptSubmitCmd = agent.WrapProductionSilentHookCommand(userPromptSubmitCmd)
89+
stopCmd = agent.WrapProductionSilentHookCommand(stopCmd)
90+
}
8391

8492
count := 0
8593

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

195203
if len(rawHooks) > 0 {
196-
hooksJSON, err := json.Marshal(rawHooks)
204+
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
197205
if err != nil {
198206
return fmt.Errorf("failed to marshal hooks: %w", err)
199207
}
@@ -251,7 +259,7 @@ func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, group
251259
delete(rawHooks, hookType)
252260
return
253261
}
254-
data, err := json.Marshal(groups)
262+
data, err := jsonutil.MarshalWithNoHTMLEscape(groups)
255263
if err != nil {
256264
return
257265
}
@@ -290,12 +298,7 @@ func addHook(groups []MatcherGroup, command string) []MatcherGroup {
290298
}
291299

292300
func isEntireHook(command string) bool {
293-
for _, prefix := range entireHookPrefixes {
294-
if strings.HasPrefix(command, prefix) {
295-
return true
296-
}
297-
}
298-
return false
301+
return agent.IsManagedHookCommand(command, entireHookPrefixes)
299302
}
300303

301304
func hasEntireHook(groups []MatcherGroup) bool {

cmd/entire/cli/agent/codex/hooks_test.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package codex
22

33
import (
44
"context"
5+
"encoding/json"
56
"os"
67
"path/filepath"
78
"testing"
89

10+
agentpkg "github.com/entireio/cli/cmd/entire/cli/agent"
911
"github.com/stretchr/testify/require"
1012
)
1113

@@ -31,9 +33,13 @@ func TestInstallHooks_CreatesConfig(t *testing.T) {
3133
hooksPath := filepath.Join(tempDir, ".codex", HooksFileName)
3234
data, err := os.ReadFile(hooksPath)
3335
require.NoError(t, err)
34-
require.Contains(t, string(data), "entire hooks codex session-start")
35-
require.Contains(t, string(data), "entire hooks codex user-prompt-submit")
36-
require.Contains(t, string(data), "entire hooks codex stop")
36+
37+
var hooksFile HooksFile
38+
require.NoError(t, json.Unmarshal(data, &hooksFile))
39+
40+
assertHookCommand(t, hooksFile.Hooks.SessionStart, agentpkg.WrapProductionJSONWarningHookCommand("entire hooks codex session-start", agentpkg.WarningFormatSingleLine), "SessionStart")
41+
assertHookCommand(t, hooksFile.Hooks.UserPromptSubmit, agentpkg.WrapProductionSilentHookCommand("entire hooks codex user-prompt-submit"), "UserPromptSubmit")
42+
assertHookCommand(t, hooksFile.Hooks.Stop, agentpkg.WrapProductionSilentHookCommand("entire hooks codex stop"), "Stop")
3743

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

107+
func TestUninstallHooks_PreservesUserHookContainingEntireSubstring(t *testing.T) {
108+
tempDir := setupTestEnv(t)
109+
110+
codexDir := filepath.Join(tempDir, ".codex")
111+
require.NoError(t, os.MkdirAll(codexDir, 0o750))
112+
existingConfig := `{
113+
"hooks": {
114+
"Stop": [
115+
{
116+
"matcher": null,
117+
"hooks": [
118+
{"type": "command", "command": "echo \"the entire workflow finished\""}
119+
]
120+
}
121+
]
122+
}
123+
}`
124+
hooksPath := filepath.Join(codexDir, HooksFileName)
125+
require.NoError(t, os.WriteFile(hooksPath, []byte(existingConfig), 0o600))
126+
127+
ag := &CodexAgent{}
128+
_, err := ag.InstallHooks(context.Background(), false, false)
129+
require.NoError(t, err)
130+
131+
err = ag.UninstallHooks(context.Background())
132+
require.NoError(t, err)
133+
134+
data, readErr := os.ReadFile(hooksPath)
135+
require.NoError(t, readErr)
136+
require.Contains(t, string(data), `echo \"the entire workflow finished\"`)
137+
require.NotContains(t, string(data), "entire hooks codex stop")
138+
}
139+
101140
func TestAreHooksInstalled_NoFile(t *testing.T) {
102141
setupTestEnv(t)
103142

@@ -238,3 +277,16 @@ func TestInstallHooks_DoesNotModifyUserConfig(t *testing.T) {
238277
require.Contains(t, string(configData), "model = \"gpt-4.1\"")
239278
require.NotContains(t, string(configData), `trust_level = "trusted"`)
240279
}
280+
281+
// assertHookCommand verifies that one of the hook entries in groups contains the expected command.
282+
func assertHookCommand(t *testing.T, groups []MatcherGroup, expectedCmd, label string) {
283+
t.Helper()
284+
for _, g := range groups {
285+
for _, h := range g.Hooks {
286+
if h.Command == expectedCmd {
287+
return
288+
}
289+
}
290+
}
291+
t.Errorf("%s: expected hook command not found: %s", label, expectedCmd)
292+
}

cmd/entire/cli/agent/copilotcli/hooks.go

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"fmt"
88
"os"
99
"path/filepath"
10-
"strings"
1110

1211
"github.com/entireio/cli/cmd/entire/cli/agent"
1312
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
@@ -112,6 +111,9 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
112111
// Add hooks that don't already exist
113112
for _, hookName := range c.HookNames() {
114113
cmd := cmdPrefix + hookName
114+
if !localDev {
115+
cmd = agent.WrapProductionSilentHookCommand(cmd)
116+
}
115117
entries := hookEntries[hookName]
116118
if !hookBashExists(entries, cmd) {
117119
entries = append(entries, CopilotHookEntry{
@@ -137,7 +139,7 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
137139
}
138140

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

207209
// Marshal hooks back (preserving unknown hook types)
208210
if len(rawHooks) > 0 {
209-
hooksJSON, err := json.Marshal(rawHooks)
211+
hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
210212
if err != nil {
211213
return fmt.Errorf("failed to marshal hooks: %w", err)
212214
}
@@ -292,7 +294,7 @@ func marshalCopilotHookType(rawHooks map[string]json.RawMessage, hookType string
292294
delete(rawHooks, hookType)
293295
return nil
294296
}
295-
data, err := json.Marshal(entries)
297+
data, err := jsonutil.MarshalWithNoHTMLEscape(entries)
296298
if err != nil {
297299
return fmt.Errorf("failed to marshal hook type %s: %w", hookType, err)
298300
}
@@ -312,12 +314,7 @@ func hookBashExists(entries []CopilotHookEntry, bash string) bool {
312314

313315
// isEntireHook checks if a hook entry's bash command belongs to Entire.
314316
func isEntireHook(bash string) bool {
315-
for _, prefix := range entireHookPrefixes {
316-
if strings.HasPrefix(bash, prefix) {
317-
return true
318-
}
319-
}
320-
return false
317+
return agent.IsManagedHookCommand(bash, entireHookPrefixes)
321318
}
322319

323320
// hasEntireHook checks if any entry in the slice is an Entire hook.

0 commit comments

Comments
 (0)