77 "os"
88 "path/filepath"
99 "slices"
10+ "strings"
1011
1112 "github.com/entireio/cli/cmd/entire/cli/agent"
1213 "github.com/entireio/cli/cmd/entire/cli/jsonutil"
@@ -400,8 +401,9 @@ func (c *ClaudeCodeAgent) UninstallHooks(ctx context.Context) error {
400401 return nil
401402}
402403
403- // AreHooksInstalled checks if Entire hooks are installed.
404- func (c * ClaudeCodeAgent ) AreHooksInstalled (ctx context.Context ) bool {
404+ // loadClaudeSettings reads and parses .claude/settings.json from the repo root.
405+ // Returns ok=false when the file is missing or unparseable.
406+ func loadClaudeSettings (ctx context.Context ) (ClaudeSettings , bool ) {
405407 // Use repo root to find .claude directory when run from a subdirectory
406408 repoRoot , err := paths .WorktreeRoot (ctx )
407409 if err != nil {
@@ -410,18 +412,61 @@ func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) bool {
410412 settingsPath := filepath .Join (repoRoot , ".claude" , ClaudeSettingsFileName )
411413 data , err := os .ReadFile (settingsPath ) //nolint:gosec // path is constructed from repo root + fixed path
412414 if err != nil {
413- return false
415+ return ClaudeSettings {}, false
414416 }
415417
416418 var settings ClaudeSettings
417419 if err := json .Unmarshal (data , & settings ); err != nil {
418- return false
420+ return ClaudeSettings {}, false
419421 }
422+ return settings , true
423+ }
420424
425+ // AreHooksInstalled checks if Entire hooks are installed.
426+ func (c * ClaudeCodeAgent ) AreHooksInstalled (ctx context.Context ) bool {
427+ settings , ok := loadClaudeSettings (ctx )
428+ if ! ok {
429+ return false
430+ }
421431 // Check for at least one of our hooks (new, wrapped, or legacy format)
422432 return hasEntireHook (settings .Hooks .Stop )
423433}
424434
435+ // HookConfigState describes how Entire's Claude Code hooks compare to what
436+ // InstallHooks would write today.
437+ type HookConfigState int
438+
439+ const (
440+ // HooksAbsent means Entire hooks are not installed in this repo.
441+ HooksAbsent HookConfigState = iota
442+ // HooksCurrent means the installed hooks match the current config.
443+ HooksCurrent
444+ // HooksOutdated means Entire hooks are installed but the current tool-use
445+ // matchers no longer carry them (e.g. an older CLI wrote them under the now
446+ // non-firing "Task"/"TodoWrite" matchers). Fix: `entire enable --force`.
447+ HooksOutdated
448+ )
449+
450+ // CheckHookConfig reports whether Entire's Claude Code hooks are absent,
451+ // current, or outdated. It is a read-only diagnostic used by `entire status`
452+ // and `entire doctor`; it never modifies settings. Outdated is detected on the
453+ // positive spec: Entire is installed (Stop hook present) yet one of the current
454+ // tool-use matchers does not carry its Entire hook.
455+ func CheckHookConfig (ctx context.Context ) HookConfigState {
456+ settings , ok := loadClaudeSettings (ctx )
457+ if ! ok || ! hasEntireHook (settings .Hooks .Stop ) {
458+ return HooksAbsent
459+ }
460+ subagentTools := splitMatcherTools (subagentToolMatcher )
461+ taskTools := splitMatcherTools (taskToolMatcher )
462+ if ! hasEntireHookCoveringTools (settings .Hooks .PreToolUse , subagentTools ) ||
463+ ! hasEntireHookCoveringTools (settings .Hooks .PostToolUse , subagentTools ) ||
464+ ! hasEntireHookCoveringTools (settings .Hooks .PostToolUse , taskTools ) {
465+ return HooksOutdated
466+ }
467+ return HooksCurrent
468+ }
469+
425470// Helper functions for hook management
426471
427472func hookCommandExists (matchers []ClaudeHookMatcher , command string ) bool {
@@ -446,6 +491,47 @@ func hasEntireHook(matchers []ClaudeHookMatcher) bool {
446491 return false
447492}
448493
494+ // splitMatcherTools splits a Claude Code tool matcher into its exact tool
495+ // names. Matchers that InstallHooks writes are `|`-separated lists (Claude Code
496+ // also accepts `,`); whitespace around separators is ignored. Returns the tools
497+ // in order, dropping empties.
498+ func splitMatcherTools (matcher string ) []string {
499+ parts := strings .FieldsFunc (matcher , func (r rune ) bool { return r == '|' || r == ',' })
500+ tools := make ([]string , 0 , len (parts ))
501+ for _ , p := range parts {
502+ if t := strings .TrimSpace (p ); t != "" {
503+ tools = append (tools , t )
504+ }
505+ }
506+ return tools
507+ }
508+
509+ // hasEntireHookCoveringTools reports whether an Entire hook is installed under a
510+ // matcher that covers every tool in want. A widened matcher still counts: a
511+ // matcher of "TaskCreate|TaskUpdate|TaskGet" covers {TaskCreate, TaskUpdate},
512+ // so users who broaden a matcher aren't falsely flagged as outdated.
513+ func hasEntireHookCoveringTools (matchers []ClaudeHookMatcher , want []string ) bool {
514+ for _ , matcher := range matchers {
515+ have := splitMatcherTools (matcher .Matcher )
516+ coversAll := true
517+ for _ , w := range want {
518+ if ! slices .Contains (have , w ) {
519+ coversAll = false
520+ break
521+ }
522+ }
523+ if ! coversAll {
524+ continue
525+ }
526+ for _ , hook := range matcher .Hooks {
527+ if isEntireHook (hook .Command ) {
528+ return true
529+ }
530+ }
531+ }
532+ return false
533+ }
534+
449535func hookCommandExistsWithMatcher (matchers []ClaudeHookMatcher , matcherName , command string ) bool {
450536 for _ , matcher := range matchers {
451537 if matcher .Matcher == matcherName {
0 commit comments