diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index b8ec5dc53..05df68fc9 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -96,24 +96,11 @@ 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) } - // 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 { @@ -194,51 +181,6 @@ To completely remove Entire integrations from this repository, use --uninstall: return cmd } -// 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 - s, err := LoadEntireSettings() - if err != nil || !s.Enabled { - return false, "", "" - } - - // Check any agent hooks installed (not just Claude Code — works with Gemini too) - installedAgents := GetAgentsWithHooksInstalled() - if len(installedAgents) == 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 true, desc, configDisplay -} - // 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). @@ -256,6 +198,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 { @@ -338,8 +285,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 { @@ -496,6 +447,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 @@ -514,40 +501,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 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. 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() + hasInstalledHooks := 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 !hasInstalledHooks { + 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 hasInstalledHooks { + // 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") @@ -556,17 +562,24 @@ 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 !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) } - // Build a set of detected agent names for pre-selection - detectedSet := make(map[agent.AgentName]struct{}, len(detected)) - for _, ag := range detected { - detectedSet[ag.Name()] = struct{}{} + // 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{}) + if hasInstalledHooks { + for _, name := range installedAgentNames { + preSelectedSet[name] = struct{}{} + } + } else { + for _, ag := range detected { + preSelectedSet[ag.Name()] = struct{}{} + } } // Build options from registered agents @@ -581,12 +594,8 @@ 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)) - if _, isDetected := detectedSet[name]; isDetected { + opt := huh.NewOption(string(ag.Type()), string(name)) + if _, isPreSelected := preSelectedSet[name]; isPreSelected { opt = opt.Selected(true) } options = append(options, opt) @@ -609,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( @@ -616,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), ), ) @@ -624,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)) diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 1d0a820f5..f15d14d0a 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -549,26 +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 - enabled, _, _ := isFullyEnabled() - if enabled { - t.Error("isFullyEnabled() should return false when nothing is set up") - } -} - -func TestIsFullyEnabled_SettingsDisabled(t *testing.T) { - setupTestDir(t) - writeSettings(t, testSettingsDisabled) - - enabled, _, _ := isFullyEnabled() - if enabled { - t.Error("isFullyEnabled() should return false when settings have enabled=false") - } -} - func TestCountSessionStates(t *testing.T) { setupTestRepo(t) @@ -1154,3 +1134,273 @@ 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. +// 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 { + 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) + } +} + +// 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) + 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()) + } +} + +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) + } +} + +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_NewlyDetectedAgentAvailableNotPreSelected(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 (detected but not installed) + 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 + // Only select the installed agent (simulate user not checking the new one) + 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 prompted (re-run always prompts) + if len(receivedAvailable) == 0 { + t.Fatal("Expected interactive prompt on re-run") + } + + // 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) + } +} + +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) + } +}