From 6da6f0c1491efcc52dd9943f615a8fd96db7af1d Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 20 Feb 2026 12:12:16 +0100 Subject: [PATCH 1/7] Remove "already enabled" early exit from `entire enable` Previously, running `entire enable` when already set up would print "Already enabled. Everything looks good." and exit immediately. This prevented users from modifying their agent selection on subsequent runs. Remove the isFullyEnabled() early return so the command always proceeds to agent selection. Simplify isFullyEnabled() to return just bool since the extra return values are no longer needed. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: d1999de90630 --- cmd/entire/cli/setup.go | 60 +++++------------------------------- cmd/entire/cli/setup_test.go | 6 ++-- 2 files changed, 10 insertions(+), 56 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index b8ec5dc53..1f467b52c 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -98,22 +98,6 @@ Strategies: manual-commit (default), auto-commit`, } return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, strategyFlag, localDev, forceHooks, skipPushSessions, telemetry) } - // Check if already fully enabled before prompting for agents. - // Only applies to interactive path (no --strategy flag) with no config flags. - if strategyFlag == "" { - hasConfigFlags := forceHooks || skipPushSessions || !telemetry || useLocalSettings || useProjectSettings || localDev - if !hasConfigFlags { - if fullyEnabled, agentDesc, configPath := isFullyEnabled(); fullyEnabled { - w := cmd.OutOrStdout() - fmt.Fprintln(w, "Already enabled. Everything looks good.") - fmt.Fprintln(w) - fmt.Fprintf(w, " Agent: %s\n", agentDesc) - fmt.Fprintf(w, " Config: %s\n", configPath) - return nil - } - } - } - // Detect or prompt for agents agents, err := detectOrSelectAgent(cmd.OutOrStdout(), nil) if err != nil { @@ -195,48 +179,20 @@ To completely remove Entire integrations from this repository, use --uninstall: } // isFullyEnabled checks whether Entire is already fully set up. -// Returns whether it's fully enabled, and if so, the agent type display name and config file path. -func isFullyEnabled() (enabled bool, agentDesc string, configPath string) { - // Check settings exist and Enabled == true +// Returns true when settings are enabled, agent hooks are installed, +// git hooks are installed, and the .entire directory exists. +func isFullyEnabled() bool { s, err := LoadEntireSettings() if err != nil || !s.Enabled { - return false, "", "" + return false } - - // Check any agent hooks installed (not just Claude Code — works with Gemini too) - installedAgents := GetAgentsWithHooksInstalled() - if len(installedAgents) == 0 { - return false, "", "" + if len(GetAgentsWithHooksInstalled()) == 0 { + return false } - - // Check git hooks installed if !strategy.IsGitHookInstalled() { - return false, "", "" - } - - // Check .entire directory exists - if !checkEntireDirExists() { - return false, "", "" - } - - // Determine agent description from first installed agent - desc := string(installedAgents[0]) // fallback to agent name - if ag, err := agent.Get(installedAgents[0]); err == nil { - desc = string(ag.Type()) - } - - // Determine config path - check if local settings exists, otherwise show project settings - entireDirAbs, err := paths.AbsPath(paths.EntireDir) - if err != nil { - entireDirAbs = paths.EntireDir - } - configDisplay := configDisplayProject - localSettingsPath := filepath.Join(entireDirAbs, "settings.local.json") - if _, err := os.Stat(localSettingsPath); err == nil { - configDisplay = configDisplayLocal + return false } - - return true, desc, configDisplay + return checkEntireDirExists() } // runEnableWithStrategy enables Entire with a specified strategy (non-interactive). diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 1d0a820f5..7d41e3859 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -553,8 +553,7 @@ func TestIsFullyEnabled_NotEnabled(t *testing.T) { setupTestDir(t) // No settings, no hooks, no directory - should not be fully enabled - enabled, _, _ := isFullyEnabled() - if enabled { + if isFullyEnabled() { t.Error("isFullyEnabled() should return false when nothing is set up") } } @@ -563,8 +562,7 @@ func TestIsFullyEnabled_SettingsDisabled(t *testing.T) { setupTestDir(t) writeSettings(t, testSettingsDisabled) - enabled, _, _ := isFullyEnabled() - if enabled { + if isFullyEnabled() { t.Error("isFullyEnabled() should return false when settings have enabled=false") } } From e3f11d5ffd0cb1001f3663bfe12d136253bc176c Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 20 Feb 2026 12:13:28 +0100 Subject: [PATCH 2/7] Add re-run awareness to agent selection in `entire enable` On subsequent runs (hooks already installed), detectOrSelectAgent now always shows the interactive multi-select prompt instead of auto-using the single detected agent. Agents with hooks already installed are pre-selected alongside any newly detected agents. On first run (no hooks installed), behavior is unchanged: single detected agent auto-uses, multiple/none shows the prompt. Non-interactive fallback (no TTY) on re-run keeps the currently installed agents rather than falling back to detection only. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: f9a59793ef8e --- cmd/entire/cli/setup.go | 75 ++++++++++++++++++++------------ cmd/entire/cli/setup_test.go | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 27 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 1f467b52c..42ae622fb 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -470,40 +470,59 @@ func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { / // detectOrSelectAgent tries to auto-detect agents, or prompts the user to select. // Returns the detected/selected agents and any error. -// When exactly one agent is detected, it is used automatically. -// When multiple agents are detected, the user is prompted to confirm. -// If no agent is detected and no TTY is available, falls back to the default agent. +// +// On first run (no hooks installed): +// - Single detected agent: used automatically +// - Multiple/no detected agents: interactive multi-select prompt +// +// On re-run (hooks already installed): +// - Always shows the interactive multi-select +// - Pre-selects agents that have hooks installed + any newly detected agents // // selectFn overrides the interactive prompt for testing. When nil, the real form is used. // It receives available agent names and returns the selected names. func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]string, error)) ([]agent.Agent, error) { - // Try auto-detection first + // Check for agents with hooks already installed (re-run detection) + installedAgentNames := GetAgentsWithHooksInstalled() + isReRun := len(installedAgentNames) > 0 + + // Try auto-detection detected := agent.DetectAll() - switch { - case len(detected) == 1: - // Single agent detected — use it directly - fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type()) - return detected, nil - - case len(detected) > 1: - // Multiple agents detected — prompt the user to confirm which to enable - agentTypes := make([]string, 0, len(detected)) - for _, ag := range detected { - agentTypes = append(agentTypes, string(ag.Type())) + // First run: use existing auto-detect shortcuts + if !isReRun { + switch { + case len(detected) == 1: + fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type()) + return detected, nil + + case len(detected) > 1: + agentTypes := make([]string, 0, len(detected)) + for _, ag := range detected { + agentTypes = append(agentTypes, string(ag.Type())) + } + fmt.Fprintf(w, "Detected multiple agents: %s\n", strings.Join(agentTypes, ", ")) + fmt.Fprintln(w) } - fmt.Fprintf(w, "Detected multiple agents: %s\n", strings.Join(agentTypes, ", ")) - fmt.Fprintln(w) - // Fall through to the interactive multi-select below } - // No agent detected (or multiple detected) — check if we can prompt interactively + // Check if we can prompt interactively if !canPromptInteractively() { + if isReRun { + // Re-run without TTY — keep currently installed agents + agents := make([]agent.Agent, 0, len(installedAgentNames)) + for _, name := range installedAgentNames { + ag, err := agent.Get(name) + if err != nil { + continue + } + agents = append(agents, ag) + } + return agents, nil + } if len(detected) > 0 { - // Multiple agents detected but no TTY — use all of them return detected, nil } - // No TTY available (e.g., running in CI or tests) - fall back to default agent defaultAgent := agent.Default() if defaultAgent == nil { return nil, errors.New("no default agent available") @@ -512,17 +531,19 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin return []agent.Agent{defaultAgent}, nil } - if len(detected) == 0 { - // Show message only when nothing was detected + if !isReRun && len(detected) == 0 { fmt.Fprintln(w, "No agent configuration detected (e.g., .claude or .gemini directory).") fmt.Fprintln(w, "This is normal - some agents don't require a config directory.") fmt.Fprintln(w) } - // Build a set of detected agent names for pre-selection - detectedSet := make(map[agent.AgentName]struct{}, len(detected)) + // Build pre-selection set: installed agents (always) + detected agents + preSelectedSet := make(map[agent.AgentName]struct{}) + for _, name := range installedAgentNames { + preSelectedSet[name] = struct{}{} + } for _, ag := range detected { - detectedSet[ag.Name()] = struct{}{} + preSelectedSet[ag.Name()] = struct{}{} } // Build options from registered agents @@ -542,7 +563,7 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin label += " (default)" } opt := huh.NewOption(label, string(name)) - if _, isDetected := detectedSet[name]; isDetected { + if _, isPreSelected := preSelectedSet[name]; isPreSelected { opt = opt.Selected(true) } options = append(options, opt) diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 7d41e3859..d2ed18066 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -1152,3 +1152,87 @@ func TestDetectOrSelectAgent_BothDirectoriesExist_NoTTY_UsesAll(t *testing.T) { t.Errorf("detectOrSelectAgent() returned %d agents, want 2", len(agents)) } } + +// writeClaudeHooksFixture writes a minimal .claude/settings.json with Entire hooks installed. +func writeClaudeHooksFixture(t *testing.T) { + t.Helper() + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatalf("Failed to create .claude directory: %v", err) + } + hooksJSON := `{ + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": "entire hooks claude-code stop"}]}] + } + }` + if err := os.WriteFile(".claude/settings.json", []byte(hooksJSON), 0o644); err != nil { + t.Fatalf("Failed to write .claude/settings.json: %v", err) + } +} + +func TestDetectOrSelectAgent_ReRun_AlwaysPromptsWithInstalledPreSelected(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir and t.Setenv + setupTestRepo(t) + t.Setenv("ENTIRE_TEST_TTY", "1") + + // Install Claude Code hooks (simulates a previous `entire enable` run) + writeClaudeHooksFixture(t) + + // Verify hooks are detected as installed + installed := GetAgentsWithHooksInstalled() + if len(installed) == 0 { + t.Fatal("Expected Claude Code hooks to be detected as installed") + } + + // Track what the selector receives + var receivedAvailable []string + selectFn := func(available []string) ([]string, error) { + receivedAvailable = available + // User keeps claude-code selected + return []string{string(agent.AgentNameClaudeCode)}, nil + } + + var buf bytes.Buffer + agents, err := detectOrSelectAgent(&buf, selectFn) + if err != nil { + t.Fatalf("detectOrSelectAgent() error = %v", err) + } + + // Should have been prompted (selectFn called) even though only one agent is detected + if len(receivedAvailable) == 0 { + t.Fatal("Expected interactive prompt to be shown on re-run, but selectFn was not called") + } + + // Should return the selected agent + if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode { + t.Errorf("Expected [claude-code], got %v", agents) + } + + // Should NOT contain "Detected agent:" (the auto-use message for first run) + output := buf.String() + if strings.Contains(output, "Detected agent:") { + t.Errorf("Re-run should not auto-use agent, but got: %s", output) + } +} + +func TestDetectOrSelectAgent_ReRun_NoTTY_KeepsInstalled(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir and t.Setenv + setupTestRepo(t) + t.Setenv("ENTIRE_TEST_TTY", "0") // No TTY available + + // Install Claude Code hooks + writeClaudeHooksFixture(t) + + var buf bytes.Buffer + agents, err := detectOrSelectAgent(&buf, nil) + if err != nil { + t.Fatalf("detectOrSelectAgent() error = %v", err) + } + + // Should keep currently installed agents without prompting + if len(agents) != 1 { + t.Fatalf("Expected 1 agent, got %d", len(agents)) + } + if agents[0].Name() != agent.AgentNameClaudeCode { + t.Errorf("Expected claude-code, got %v", agents[0].Name()) + } +} From 44dd933108514e391724e3c8b4e12e30835b0ce1 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 20 Feb 2026 12:14:40 +0100 Subject: [PATCH 3/7] Uninstall hooks for deselected agents on re-enable When a user re-runs `entire enable` and deselects a previously active agent, the agent's hooks are now removed. This is handled by the new uninstallDeselectedAgentHooks helper which compares currently installed agents against the new selection. Called from both runEnableInteractive and runEnableWithStrategy before installing hooks for newly selected agents. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 1430640d8159 --- cmd/entire/cli/setup.go | 47 +++++++++++++++++++++++++++- cmd/entire/cli/setup_test.go | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 42ae622fb..bafcc5f65 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -212,6 +212,11 @@ func runEnableWithStrategy(w io.Writer, agents []agent.Agent, selectedStrategy s return fmt.Errorf("unknown strategy: %s (use manual-commit or auto-commit)", selectedStrategy) } + // Uninstall hooks for agents that were previously active but are no longer selected + if err := uninstallDeselectedAgentHooks(w, agents); err != nil { + return fmt.Errorf("failed to clean up deselected agents: %w", err) + } + // Setup agent hooks for all selected agents for _, ag := range agents { if _, err := setupAgentHooks(ag, localDev, forceHooks); err != nil { @@ -294,8 +299,12 @@ func runEnableWithStrategy(w io.Writer, agents []agent.Agent, selectedStrategy s // runEnableInteractive runs the interactive enable flow. // agents must be provided by the caller (via detectOrSelectAgent). -// The isFullyEnabled check is handled by the caller before agent detection. func runEnableInteractive(w io.Writer, agents []agent.Agent, localDev, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry bool) error { + // Uninstall hooks for agents that were previously active but are no longer selected + if err := uninstallDeselectedAgentHooks(w, agents); err != nil { + return fmt.Errorf("failed to clean up deselected agents: %w", err) + } + // Setup agent hooks for all selected agents for _, ag := range agents { if _, err := setupAgentHooks(ag, localDev, forceHooks); err != nil { @@ -452,6 +461,42 @@ func checkDisabledGuard(w io.Writer) bool { return false } +// uninstallDeselectedAgentHooks removes hooks for agents that were previously +// installed but are not in the selected list. This handles the case where a user +// re-runs `entire enable` and deselects an agent. +func uninstallDeselectedAgentHooks(w io.Writer, selectedAgents []agent.Agent) error { + installedNames := GetAgentsWithHooksInstalled() + if len(installedNames) == 0 { + return nil + } + + selectedSet := make(map[agent.AgentName]struct{}, len(selectedAgents)) + for _, ag := range selectedAgents { + selectedSet[ag.Name()] = struct{}{} + } + + var errs []error + for _, name := range installedNames { + if _, selected := selectedSet[name]; selected { + continue + } + ag, err := agent.Get(name) + if err != nil { + continue + } + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + continue + } + if err := hookAgent.UninstallHooks(); err != nil { + errs = append(errs, fmt.Errorf("failed to uninstall %s hooks: %w", ag.Type(), err)) + } else { + fmt.Fprintf(w, "Removed %s hooks\n", ag.Type()) + } + } + return errors.Join(errs...) +} + // setupAgentHooks sets up hooks for a given agent. // Returns the number of hooks installed (0 if already installed). func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { //nolint:unparam // return value used by setupAgentHooksNonInteractive diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index d2ed18066..6615403a2 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -1236,3 +1236,63 @@ func TestDetectOrSelectAgent_ReRun_NoTTY_KeepsInstalled(t *testing.T) { t.Errorf("Expected claude-code, got %v", agents[0].Name()) } } + +func TestUninstallDeselectedAgentHooks(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir + setupTestRepo(t) + + // Install Claude Code hooks + writeClaudeHooksFixture(t) + + // Verify hooks are installed + if !checkClaudeCodeHooksInstalled() { + t.Fatal("Expected Claude Code hooks to be installed before test") + } + + // Call uninstallDeselectedAgentHooks with an empty selection (deselect claude-code) + var buf bytes.Buffer + err := uninstallDeselectedAgentHooks(&buf, []agent.Agent{}) + if err != nil { + t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err) + } + + // Hooks should be uninstalled + if checkClaudeCodeHooksInstalled() { + t.Error("Expected Claude Code hooks to be uninstalled after deselection") + } + + output := buf.String() + if !strings.Contains(output, "Removed") { + t.Errorf("Expected output to mention removal, got: %s", output) + } +} + +func TestUninstallDeselectedAgentHooks_KeepsSelectedAgents(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir + setupTestRepo(t) + + // Install Claude Code hooks + writeClaudeHooksFixture(t) + + // Call uninstallDeselectedAgentHooks with claude-code still selected + claudeAgent, err := agent.Get(agent.AgentNameClaudeCode) + if err != nil { + t.Fatalf("Failed to get claude-code agent: %v", err) + } + + var buf bytes.Buffer + err = uninstallDeselectedAgentHooks(&buf, []agent.Agent{claudeAgent}) + if err != nil { + t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err) + } + + // Hooks should still be installed + if !checkClaudeCodeHooksInstalled() { + t.Error("Expected Claude Code hooks to remain installed when still selected") + } + + output := buf.String() + if strings.Contains(output, "Removed") { + t.Errorf("Should not mention removal when agent is still selected, got: %s", output) + } +} From 232956794ddee5b0c1d66e145a9fca504738e103 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 20 Feb 2026 12:21:26 +0100 Subject: [PATCH 4/7] Address review feedback: remove dead code, improve tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback addressed: - Remove dead isFullyEnabled() function (no production callers after removing the early exit) - Document that --agent flag intentionally skips deselection cleanup (it's a targeted single-agent operation, not a full re-selection) - Rename isReRun → hasInstalledHooks for clarity - Add writeGeminiHooksFixture helper for multi-agent test scenarios - Add test: multi-agent installed, deselect one (keeps other) - Add test: newly detected agent is pre-selected on re-run - Add test: empty selection on re-run returns error - Add comments to fixture helpers explaining minimal hook choice Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: f59e5a0d07ad --- cmd/entire/cli/setup.go | 28 ++----- cmd/entire/cli/setup_test.go | 139 ++++++++++++++++++++++++++++++----- 2 files changed, 128 insertions(+), 39 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index bafcc5f65..9033bb8b6 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -96,6 +96,9 @@ Strategies: manual-commit (default), auto-commit`, printWrongAgentError(cmd.ErrOrStderr(), agentName) return NewSilentError(errors.New("wrong agent name")) } + // --agent is a targeted operation: set up this specific agent without + // affecting other agents. Unlike the interactive path, it does not + // uninstall hooks for other previously-enabled agents. return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, strategyFlag, localDev, forceHooks, skipPushSessions, telemetry) } // Detect or prompt for agents @@ -178,23 +181,6 @@ To completely remove Entire integrations from this repository, use --uninstall: return cmd } -// isFullyEnabled checks whether Entire is already fully set up. -// Returns true when settings are enabled, agent hooks are installed, -// git hooks are installed, and the .entire directory exists. -func isFullyEnabled() bool { - s, err := LoadEntireSettings() - if err != nil || !s.Enabled { - return false - } - if len(GetAgentsWithHooksInstalled()) == 0 { - return false - } - if !strategy.IsGitHookInstalled() { - return false - } - return checkEntireDirExists() -} - // runEnableWithStrategy enables Entire with a specified strategy (non-interactive). // The selectedStrategy can be either a display name (manual-commit, auto-commit) // or an internal name (manual-commit, auto-commit). @@ -529,13 +515,13 @@ func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { / func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]string, error)) ([]agent.Agent, error) { // Check for agents with hooks already installed (re-run detection) installedAgentNames := GetAgentsWithHooksInstalled() - isReRun := len(installedAgentNames) > 0 + hasInstalledHooks := len(installedAgentNames) > 0 // Try auto-detection detected := agent.DetectAll() // First run: use existing auto-detect shortcuts - if !isReRun { + if !hasInstalledHooks { switch { case len(detected) == 1: fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type()) @@ -553,7 +539,7 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin // Check if we can prompt interactively if !canPromptInteractively() { - if isReRun { + if hasInstalledHooks { // Re-run without TTY — keep currently installed agents agents := make([]agent.Agent, 0, len(installedAgentNames)) for _, name := range installedAgentNames { @@ -576,7 +562,7 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin return []agent.Agent{defaultAgent}, nil } - if !isReRun && len(detected) == 0 { + if !hasInstalledHooks && len(detected) == 0 { fmt.Fprintln(w, "No agent configuration detected (e.g., .claude or .gemini directory).") fmt.Fprintln(w, "This is normal - some agents don't require a config directory.") fmt.Fprintln(w) diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 6615403a2..950c4e447 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -549,24 +549,6 @@ func TestCheckEntireDirExists(t *testing.T) { } } -func TestIsFullyEnabled_NotEnabled(t *testing.T) { - setupTestDir(t) - - // No settings, no hooks, no directory - should not be fully enabled - if isFullyEnabled() { - t.Error("isFullyEnabled() should return false when nothing is set up") - } -} - -func TestIsFullyEnabled_SettingsDisabled(t *testing.T) { - setupTestDir(t) - writeSettings(t, testSettingsDisabled) - - if isFullyEnabled() { - t.Error("isFullyEnabled() should return false when settings have enabled=false") - } -} - func TestCountSessionStates(t *testing.T) { setupTestRepo(t) @@ -1154,6 +1136,7 @@ func TestDetectOrSelectAgent_BothDirectoriesExist_NoTTY_UsesAll(t *testing.T) { } // writeClaudeHooksFixture writes a minimal .claude/settings.json with Entire hooks installed. +// Only the Stop hook is needed — AreHooksInstalled() checks for it first. func writeClaudeHooksFixture(t *testing.T) { t.Helper() if err := os.MkdirAll(".claude", 0o755); err != nil { @@ -1169,6 +1152,24 @@ func writeClaudeHooksFixture(t *testing.T) { } } +// writeGeminiHooksFixture writes a minimal .gemini/settings.json with Entire hooks installed. +// AreHooksInstalled() checks for any hook command starting with "entire ". +func writeGeminiHooksFixture(t *testing.T) { + t.Helper() + if err := os.MkdirAll(".gemini", 0o755); err != nil { + t.Fatalf("Failed to create .gemini directory: %v", err) + } + hooksJSON := `{ + "hooks": { + "enabled": true, + "SessionStart": [{"hooks": [{"type": "command", "command": "entire hooks gemini session-start"}]}] + } + }` + if err := os.WriteFile(".gemini/settings.json", []byte(hooksJSON), 0o644); err != nil { + t.Fatalf("Failed to write .gemini/settings.json: %v", err) + } +} + func TestDetectOrSelectAgent_ReRun_AlwaysPromptsWithInstalledPreSelected(t *testing.T) { // Cannot use t.Parallel() because we use t.Chdir and t.Setenv setupTestRepo(t) @@ -1296,3 +1297,105 @@ func TestUninstallDeselectedAgentHooks_KeepsSelectedAgents(t *testing.T) { t.Errorf("Should not mention removal when agent is still selected, got: %s", output) } } + +func TestUninstallDeselectedAgentHooks_MultipleInstalled_DeselectOne(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir + setupTestRepo(t) + + // Install both Claude Code and Gemini hooks + writeClaudeHooksFixture(t) + writeGeminiHooksFixture(t) + + // Verify both are installed + installed := GetAgentsWithHooksInstalled() + if len(installed) < 2 { + t.Fatalf("Expected at least 2 agents installed, got %d", len(installed)) + } + + // Keep only Claude Code selected (deselect Gemini) + claudeAgent, err := agent.Get(agent.AgentNameClaudeCode) + if err != nil { + t.Fatalf("Failed to get claude-code agent: %v", err) + } + + var buf bytes.Buffer + err = uninstallDeselectedAgentHooks(&buf, []agent.Agent{claudeAgent}) + if err != nil { + t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err) + } + + // Claude Code hooks should remain + if !checkClaudeCodeHooksInstalled() { + t.Error("Expected Claude Code hooks to remain installed") + } + + // Gemini hooks should be removed + if checkGeminiCLIHooksInstalled() { + t.Error("Expected Gemini CLI hooks to be uninstalled after deselection") + } + + output := buf.String() + if !strings.Contains(output, "Removed") { + t.Errorf("Expected output to mention removal, got: %s", output) + } +} + +func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentPreSelected(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir and t.Setenv + setupTestRepo(t) + t.Setenv("ENTIRE_TEST_TTY", "1") + + // Simulate: Claude Code hooks installed from a previous run + writeClaudeHooksFixture(t) + + // Simulate: user added .gemini directory since last enable + if err := os.MkdirAll(".gemini", 0o755); err != nil { + t.Fatalf("Failed to create .gemini directory: %v", err) + } + + // Track which agents the selector receives + var receivedAvailable []string + selectFn := func(available []string) ([]string, error) { + receivedAvailable = available + // Accept all available agents + return available, nil + } + + var buf bytes.Buffer + agents, err := detectOrSelectAgent(&buf, selectFn) + if err != nil { + t.Fatalf("detectOrSelectAgent() error = %v", err) + } + + // Should have prompted (re-run always prompts) + if len(receivedAvailable) == 0 { + t.Fatal("Expected interactive prompt on re-run") + } + + // Should return both agents (installed + newly detected) + if len(agents) < 2 { + t.Errorf("Expected at least 2 agents (installed + detected), got %d", len(agents)) + } +} + +func TestDetectOrSelectAgent_ReRun_EmptySelection_ReturnsError(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir and t.Setenv + setupTestRepo(t) + t.Setenv("ENTIRE_TEST_TTY", "1") + + // Install Claude Code hooks (re-run scenario) + writeClaudeHooksFixture(t) + + selectFn := func(_ []string) ([]string, error) { + return []string{}, nil // user deselected everything + } + + var buf bytes.Buffer + _, err := detectOrSelectAgent(&buf, selectFn) + if err == nil { + t.Fatal("Expected error when no agents selected on re-run") + } + if !strings.Contains(err.Error(), "no agents selected") { + t.Errorf("Expected 'no agents selected' error, got: %v", err) + } +} From a463fb4bec38df625d8e7d2ee48de2d0685246e3 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 20 Feb 2026 12:44:46 +0100 Subject: [PATCH 5/7] Fix deselected agents reappearing as pre-selected on re-enable On re-run, detected-but-not-installed agents were added to the pre-selection set, causing previously deselected agents to appear checked again. Now only agents with hooks installed are pre-selected on re-run; detected agents still appear as options but unchecked. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 00fe8c62aefe --- cmd/entire/cli/setup.go | 19 ++++++++++++------- cmd/entire/cli/setup_test.go | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 9033bb8b6..faaec128a 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -508,7 +508,7 @@ func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { / // // On re-run (hooks already installed): // - Always shows the interactive multi-select -// - Pre-selects agents that have hooks installed + any newly detected agents +// - Pre-selects only agents that have hooks installed (respects prior deselection) // // selectFn overrides the interactive prompt for testing. When nil, the real form is used. // It receives available agent names and returns the selected names. @@ -568,13 +568,18 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin fmt.Fprintln(w) } - // Build pre-selection set: installed agents (always) + detected agents + // Build pre-selection set. + // On re-run: only pre-select agents with hooks installed (respect prior deselection). + // On first run: pre-select all detected agents. preSelectedSet := make(map[agent.AgentName]struct{}) - for _, name := range installedAgentNames { - preSelectedSet[name] = struct{}{} - } - for _, ag := range detected { - preSelectedSet[ag.Name()] = struct{}{} + if hasInstalledHooks { + for _, name := range installedAgentNames { + preSelectedSet[name] = struct{}{} + } + } else { + for _, ag := range detected { + preSelectedSet[ag.Name()] = struct{}{} + } } // Build options from registered agents diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 950c4e447..f15d14d0a 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -1340,7 +1340,7 @@ func TestUninstallDeselectedAgentHooks_MultipleInstalled_DeselectOne(t *testing. } } -func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentPreSelected(t *testing.T) { +func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentAvailableNotPreSelected(t *testing.T) { // Cannot use t.Parallel() because we use t.Chdir and t.Setenv setupTestRepo(t) t.Setenv("ENTIRE_TEST_TTY", "1") @@ -1348,7 +1348,7 @@ func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentPreSelected(t *testing.T) { // Simulate: Claude Code hooks installed from a previous run writeClaudeHooksFixture(t) - // Simulate: user added .gemini directory since last enable + // Simulate: user added .gemini directory since last enable (detected but not installed) if err := os.MkdirAll(".gemini", 0o755); err != nil { t.Fatalf("Failed to create .gemini directory: %v", err) } @@ -1357,8 +1357,8 @@ func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentPreSelected(t *testing.T) { var receivedAvailable []string selectFn := func(available []string) ([]string, error) { receivedAvailable = available - // Accept all available agents - return available, nil + // Only select the installed agent (simulate user not checking the new one) + return []string{string(agent.AgentNameClaudeCode)}, nil } var buf bytes.Buffer @@ -1372,9 +1372,14 @@ func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentPreSelected(t *testing.T) { t.Fatal("Expected interactive prompt on re-run") } - // Should return both agents (installed + newly detected) - if len(agents) < 2 { - t.Errorf("Expected at least 2 agents (installed + detected), got %d", len(agents)) + // Newly detected agent should be available as an option + if len(receivedAvailable) < 2 { + t.Errorf("Expected at least 2 available agents (detected agent should be an option), got %d", len(receivedAvailable)) + } + + // Only the installed agent should be returned (user didn't select the new one) + if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode { + t.Errorf("Expected only [claude-code], got %v", agents) } } From e95bfe0d32145bbf0d7ce290e908cf1fafa7d2ef Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 20 Feb 2026 12:46:56 +0100 Subject: [PATCH 6/7] Remove "(default)" label from agent selection prompt Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: ceeb3ba1f535 --- cmd/entire/cli/setup.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index faaec128a..b3be97db9 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -594,11 +594,7 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin if _, ok := ag.(agent.HookSupport); !ok { continue } - label := string(ag.Type()) - if name == agent.DefaultAgentName { - label += " (default)" - } - opt := huh.NewOption(label, string(name)) + opt := huh.NewOption(string(ag.Type()), string(name)) if _, isPreSelected := preSelectedSet[name]; isPreSelected { opt = opt.Selected(true) } From cd949282e61278912025677b3ccab7147883a4ff Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 20 Feb 2026 12:58:24 +0100 Subject: [PATCH 7/7] Add inline validation to agent multi-select prompt Instead of crashing with a hard error after submission, the form now shows an inline message and keeps the prompt open when no agents are selected. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: e924fd03ad4e --- cmd/entire/cli/setup.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index b3be97db9..05df68fc9 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -618,6 +618,9 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin if err != nil { return nil, err } + if len(selectedAgentNames) == 0 { + return nil, errors.New("no agents selected") + } } else { form := NewAccessibleForm( huh.NewGroup( @@ -625,6 +628,12 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin Title("Which agents are you using?"). Description("Use space to select, enter to confirm."). Options(options...). + Validate(func(selected []string) error { + if len(selected) == 0 { + return errors.New("please select at least one agent") + } + return nil + }). Value(&selectedAgentNames), ), ) @@ -633,10 +642,6 @@ func detectOrSelectAgent(w io.Writer, selectFn func(available []string) ([]strin } } - if len(selectedAgentNames) == 0 { - return nil, errors.New("no agents selected") - } - selectedAgents := make([]agent.Agent, 0, len(selectedAgentNames)) for _, name := range selectedAgentNames { selectedAgent, err := agent.Get(agent.AgentName(name))