From b33e6aac7b399e8f2e0366044deb1d83587fda14 Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Tue, 10 Feb 2026 15:48:57 -0500 Subject: [PATCH 01/12] feat: validate agent installation before enabling - Add IsInstalled() to Agent interface; implement for Claude Code and Gemini CLI - Check agent binary in PATH when using entire enable (all flows) - Show helpful error and install URL when agent is missing - Auto-detect installed agents in interactive/strategy flows instead of hardcoding Claude Code - Add unit tests for IsInstalled() and getInstallURL --- cmd/entire/cli/agent/agent.go | 5 + cmd/entire/cli/agent/agent_test.go | 1 + cmd/entire/cli/agent/claudecode/claude.go | 13 ++ .../cli/agent/claudecode/claude_test.go | 15 +++ cmd/entire/cli/agent/geminicli/gemini.go | 13 ++ cmd/entire/cli/agent/geminicli/gemini_test.go | 15 +++ cmd/entire/cli/setup.go | 115 +++++++++++++----- cmd/entire/cli/setup_test.go | 62 ++++++++++ 8 files changed, 210 insertions(+), 29 deletions(-) diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 385357be9..64066b47a 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -59,6 +59,11 @@ type Agent interface { // FormatResumeCommand returns command to resume a session FormatResumeCommand(sessionID string) string + + // IsInstalled checks if the agent's CLI binary is available in PATH. + // Returns (true, nil) if found, (false, nil) if not found, + // and (false, err) only for unexpected OS errors. + IsInstalled() (bool, error) } // HookSupport is implemented by agents with lifecycle hooks. diff --git a/cmd/entire/cli/agent/agent_test.go b/cmd/entire/cli/agent/agent_test.go index 6b85b09c1..b2c43555c 100644 --- a/cmd/entire/cli/agent/agent_test.go +++ b/cmd/entire/cli/agent/agent_test.go @@ -34,6 +34,7 @@ func (m *mockAgent) GetSessionDir(_ string) (string, error) { return "", n func (m *mockAgent) ReadSession(_ *HookInput) (*AgentSession, error) { return nil, nil } func (m *mockAgent) WriteSession(_ *AgentSession) error { return nil } func (m *mockAgent) FormatResumeCommand(_ string) string { return "" } +func (m *mockAgent) IsInstalled() (bool, error) { return false, nil } // mockHookSupport implements both Agent and HookSupport interfaces. type mockHookSupport struct { diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index 1c08eaa64..1b4b2f987 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "regexp" "time" @@ -70,6 +71,18 @@ func (c *ClaudeCodeAgent) DetectPresence() (bool, error) { return false, nil } +// IsInstalled checks if the `claude` binary is available in PATH. +func (c *ClaudeCodeAgent) IsInstalled() (bool, error) { + _, err := exec.LookPath("claude") + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return false, nil + } + return false, fmt.Errorf("look path claude: %w", err) + } + return true, nil +} + // GetHookConfigPath returns the path to Claude's hook config file. func (c *ClaudeCodeAgent) GetHookConfigPath() string { return ".claude/settings.json" diff --git a/cmd/entire/cli/agent/claudecode/claude_test.go b/cmd/entire/cli/agent/claudecode/claude_test.go index a468f20f8..0c51b1adf 100644 --- a/cmd/entire/cli/agent/claudecode/claude_test.go +++ b/cmd/entire/cli/agent/claudecode/claude_test.go @@ -7,6 +7,21 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" ) +func TestIsInstalled(t *testing.T) { + t.Parallel() + + c := &ClaudeCodeAgent{} + installed, err := c.IsInstalled() + + // Should not return an error regardless of whether claude is installed + if err != nil { + t.Fatalf("IsInstalled() error = %v", err) + } + + // installed is environment-dependent, just verify it's a valid bool + _ = installed +} + func TestParseHookInput_UserPromptSubmit(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/geminicli/gemini.go b/cmd/entire/cli/agent/geminicli/gemini.go index 9a584d492..620f9b899 100644 --- a/cmd/entire/cli/agent/geminicli/gemini.go +++ b/cmd/entire/cli/agent/geminicli/gemini.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "os" + "os/exec" "path/filepath" "regexp" "time" @@ -71,6 +72,18 @@ func (g *GeminiCLIAgent) DetectPresence() (bool, error) { return false, nil } +// IsInstalled checks if the `gemini` binary is available in PATH. +func (g *GeminiCLIAgent) IsInstalled() (bool, error) { + _, err := exec.LookPath("gemini") + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return false, nil + } + return false, fmt.Errorf("look path gemini: %w", err) + } + return true, nil +} + // GetHookConfigPath returns the path to Gemini's hook config file. func (g *GeminiCLIAgent) GetHookConfigPath() string { return ".gemini/settings.json" diff --git a/cmd/entire/cli/agent/geminicli/gemini_test.go b/cmd/entire/cli/agent/geminicli/gemini_test.go index db7843cbe..c83d33659 100644 --- a/cmd/entire/cli/agent/geminicli/gemini_test.go +++ b/cmd/entire/cli/agent/geminicli/gemini_test.go @@ -15,6 +15,21 @@ import ( // Test constants const testSessionID = "abc123" +func TestIsInstalled(t *testing.T) { + t.Parallel() + + ag := &GeminiCLIAgent{} + installed, err := ag.IsInstalled() + + // Should not return an error regardless of whether gemini is installed + if err != nil { + t.Fatalf("IsInstalled() error = %v", err) + } + + // installed is environment-dependent, just verify it's a valid bool + _ = installed +} + func TestNewGeminiCLIAgent(t *testing.T) { ag := NewGeminiCLIAgent() if ag == nil { diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 435881ac9..136b75a48 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -88,6 +88,19 @@ Strategies: manual-commit (default), auto-commit`, printWrongAgentError(cmd.ErrOrStderr(), agentName) return NewSilentError(errors.New("wrong agent name")) } + + // Check if the agent binary is installed + installed, err := ag.IsInstalled() + if err != nil { + return fmt.Errorf("error checking if %s is installed: %w", agentName, err) + } + if !installed { + fmt.Fprintf(cmd.ErrOrStderr(), + "%s is not installed or not found in PATH.\nInstall it from: %s\n", + agentName, getInstallURL(agentName)) + return NewSilentError(errors.New("agent not installed")) + } + return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, strategyFlag, localDev, forceHooks, skipPushSessions, telemetry) } // If strategy is specified via flag, skip interactive selection @@ -226,17 +239,16 @@ func runEnableWithStrategy(w io.Writer, selectedStrategy string, localDev, _, us return fmt.Errorf("unknown strategy: %s (use manual-commit or auto-commit)", selectedStrategy) } - // Detect default agent - ag := agent.Default() - agentType := string(agent.AgentTypeClaudeCode) - if ag != nil { - agentType = string(ag.Type()) + // Find an installed agent + ag, err := findInstalledAgent(w) + if err != nil { + return err } - fmt.Fprintf(w, "Agent: %s (use --agent to change)\n\n", agentType) + fmt.Fprintf(w, "Agent: %s (use --agent to change)\n\n", ag.Type()) - // Setup Claude Code hooks (agent hooks don't depend on settings) - if _, err := setupClaudeCodeHook(localDev, forceHooks); err != nil { - return fmt.Errorf("failed to setup Claude Code hooks: %w", err) + // Setup agent hooks (agent hooks don't depend on settings) + if _, err := setupAgentHooks(ag, localDev, forceHooks); err != nil { + return fmt.Errorf("failed to setup agent hooks: %w", err) } // Setup .entire directory @@ -326,17 +338,16 @@ func runEnableInteractive(w io.Writer, localDev, _, useLocalSettings, useProject } } - // Detect default agent - ag := agent.Default() - agentType := string(agent.AgentTypeClaudeCode) - if ag != nil { - agentType = string(ag.Type()) + // Find an installed agent + ag, err := findInstalledAgent(w) + if err != nil { + return err } - fmt.Fprintf(w, "Agent: %s (use --agent to change)\n\n", agentType) + fmt.Fprintf(w, "Agent: %s (use --agent to change)\n\n", ag.Type()) - // Setup Claude Code hooks (agent hooks don't depend on settings) - if _, err := setupClaudeCodeHook(localDev, forceHooks); err != nil { - return fmt.Errorf("failed to setup Claude Code hooks: %w", err) + // Setup agent hooks (agent hooks don't depend on settings) + if _, err := setupAgentHooks(ag, localDev, forceHooks); err != nil { + return fmt.Errorf("failed to setup agent hooks: %w", err) } // Setup .entire directory @@ -487,28 +498,74 @@ func checkDisabledGuard(w io.Writer) bool { return false } -// setupClaudeCodeHook sets up Claude Code hooks. -// This is a convenience wrapper that uses the agent package. -// Returns the number of hooks installed (0 if already installed). -func setupClaudeCodeHook(localDev, forceHooks bool) (int, error) { //nolint:unparam // already present in codebase - ag, err := agent.Get(agent.AgentNameClaudeCode) - if err != nil { - return 0, fmt.Errorf("failed to get claude-code agent: %w", err) +// findInstalledAgent returns the first installed agent that supports hooks. +// It tries the default agent first, then checks all registered agents. +// Returns the agent and nil error if found, or nil agent and an error with +// helpful install instructions if no agents are installed. +func findInstalledAgent(w io.Writer) (agent.Agent, error) { + // Try the default agent first + ag := agent.Default() + if ag != nil { + installed, err := ag.IsInstalled() + if err == nil && installed { + return ag, nil + } } + // Check all registered agents + for _, name := range agent.List() { + a, err := agent.Get(name) + if err != nil { + continue + } + installed, err := a.IsInstalled() + if err == nil && installed { + return a, nil + } + } + + // No agents found - provide helpful error + fmt.Fprintln(w, "No AI agents found in PATH.") + fmt.Fprintln(w) + fmt.Fprintln(w, "Please install one of the following:") + for _, name := range agent.List() { + a, err := agent.Get(name) + if err != nil { + continue + } + fmt.Fprintf(w, " - %s: %s\n", a.Description(), getInstallURL(string(name))) + } + return nil, NewSilentError(errors.New("no agents installed")) +} + +// setupAgentHooks installs hooks for the given agent if it supports them. +// Returns the number of hooks installed. +// +//nolint:unparam // count is returned for API consistency with InstallHooks +func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { hookAgent, ok := ag.(agent.HookSupport) if !ok { - return 0, errors.New("claude-code agent does not support hooks") + return 0, nil // Agent doesn't support hooks, not an error } - count, err := hookAgent.InstallHooks(localDev, forceHooks) if err != nil { - return 0, fmt.Errorf("failed to install claude-code hooks: %w", err) + return 0, fmt.Errorf("failed to install hooks for %s: %w", ag.Name(), err) } - return count, nil } +// getInstallURL returns the installation documentation URL for an agent. +func getInstallURL(agentName string) string { + switch agentName { + case string(agent.AgentNameClaudeCode): + return "https://docs.anthropic.com/en/docs/claude-code" + case string(agent.AgentNameGemini): + return "https://github.com/google-gemini/gemini-cli" + default: + return "https://github.com/entireio/cli#requirements" + } +} + // printAgentError writes an error message followed by available agents and usage. func printAgentError(w io.Writer, message string) { agents := agent.List() diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 41c5bb719..7fa65cd5f 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -878,3 +878,65 @@ func TestEnableCmd_AgentFlagEmptyValue(t *testing.T) { t.Error("should not contain default cobra/pflag error message") } } + +func TestGetInstallURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agent string + wantURL string + }{ + { + name: "claude-code", + agent: "claude-code", + wantURL: "https://docs.anthropic.com/en/docs/claude-code", + }, + { + name: "gemini", + agent: "gemini", + wantURL: "https://github.com/google-gemini/gemini-cli", + }, + { + name: "unknown agent", + agent: "unknown", + wantURL: "https://github.com/entireio/cli#requirements", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := getInstallURL(tt.agent) + if got != tt.wantURL { + t.Errorf("getInstallURL(%q) = %q, want %q", tt.agent, got, tt.wantURL) + } + }) + } +} + +func TestFindInstalledAgent_ReturnsAgent(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ag, err := findInstalledAgent(&buf) + + // This test is environment-dependent: + // - If claude or gemini is installed, ag should be non-nil + // - If neither is installed, err should be non-nil with helpful message + if ag != nil { + // Agent was found - verify it has a valid name + if ag.Name() == "" { + t.Error("findInstalledAgent() returned agent with empty name") + } + } else { + // No agent found - verify helpful error message was printed + if err == nil { + t.Error("findInstalledAgent() returned nil agent and nil error") + } + output := buf.String() + if !strings.Contains(output, "No AI agents found") { + t.Errorf("expected helpful error message, got: %s", output) + } + } +} From fa1c9129a4c7d3e431d206a17bfb3608f3de2f9e Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Tue, 10 Feb 2026 16:13:44 -0500 Subject: [PATCH 02/12] fix: require HookSupport in findInstalledAgent for consistent enable behavior findInstalledAgent only checked IsInstalled() and never verified HookSupport, so enable could complete without installing any agent hooks when a non-hook agent was returned. setupAgentHooksNonInteractive (--agent path) correctly errors in that case. Add agentHasHooksAndInstalled helper and use it in findInstalledAgent so only installed agents that support hooks are returned. Co-authored-by: Cursor Entire-Checkpoint: f3dedc0ac0b7 --- cmd/entire/cli/setup.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 136b75a48..21363bdeb 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -498,18 +498,27 @@ func checkDisabledGuard(w io.Writer) bool { return false } +// agentHasHooksAndInstalled returns true if the agent is installed (binary in PATH) +// and implements HookSupport, so we can install hooks for it. +func agentHasHooksAndInstalled(ag agent.Agent) bool { + if _, ok := ag.(agent.HookSupport); !ok { + return false + } + installed, err := ag.IsInstalled() + return err == nil && installed +} + // findInstalledAgent returns the first installed agent that supports hooks. // It tries the default agent first, then checks all registered agents. +// Only agents that are both installed (binary in PATH) and implement HookSupport +// are returned, so the enable flow can always install hooks. // Returns the agent and nil error if found, or nil agent and an error with -// helpful install instructions if no agents are installed. +// helpful install instructions if no such agents are installed. func findInstalledAgent(w io.Writer) (agent.Agent, error) { // Try the default agent first ag := agent.Default() - if ag != nil { - installed, err := ag.IsInstalled() - if err == nil && installed { - return ag, nil - } + if ag != nil && agentHasHooksAndInstalled(ag) { + return ag, nil } // Check all registered agents @@ -518,14 +527,13 @@ func findInstalledAgent(w io.Writer) (agent.Agent, error) { if err != nil { continue } - installed, err := a.IsInstalled() - if err == nil && installed { + if agentHasHooksAndInstalled(a) { return a, nil } } - // No agents found - provide helpful error - fmt.Fprintln(w, "No AI agents found in PATH.") + // No hook-supporting agents found - provide helpful error + fmt.Fprintln(w, "No AI agents with hook support found in PATH.") fmt.Fprintln(w) fmt.Fprintln(w, "Please install one of the following:") for _, name := range agent.List() { From d6cf6570670227155a35a2032027e9a84e4bc23e Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Tue, 10 Feb 2026 16:26:27 -0500 Subject: [PATCH 03/12] fix: assert correct error message in TestFindInstalledAgent_ReturnsAgent findInstalledAgent writes 'No AI agents with hook support found in PATH.' not 'No AI agents found'; update the test to check for the actual message so it passes when no agent is installed (e.g. in CI). Co-authored-by: Cursor Entire-Checkpoint: 9232141d2019 --- cmd/entire/cli/setup_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 7fa65cd5f..0f656c6ae 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -935,7 +935,7 @@ func TestFindInstalledAgent_ReturnsAgent(t *testing.T) { t.Error("findInstalledAgent() returned nil agent and nil error") } output := buf.String() - if !strings.Contains(output, "No AI agents found") { + if !strings.Contains(output, "No AI agents with hook support found in PATH") { t.Errorf("expected helpful error message, got: %s", output) } } From 77ddb66f4cabdd0dc785cd2ff458e884f2ca30de Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Tue, 10 Feb 2026 16:49:08 -0500 Subject: [PATCH 04/12] Address PR feedback: stderr for errors, InstallURL on Agent, rename helper - findInstalledAgent now accepts (stdout, errW) and writes the 'no agents' error to errW (stderr); runEnableWithStrategy/runEnableInteractive pass cmd.ErrOrStderr() so diagnostics go to stderr like other enable errors - Rename agentHasHooksAndInstalled to isInstalledWithHookSupport - Add InstallURL() to Agent interface; implement in claudecode and geminicli, remove getInstallURL from setup.go and use ag.InstallURL() at call sites - Tests: TestAgentInstallURL via agent.Get().InstallURL(); findInstalledAgent test asserts stderr buffer; runEnableWithStrategy tests pass two writers Co-authored-by: Cursor Entire-Checkpoint: 9232141d2019 --- cmd/entire/cli/agent/agent.go | 3 ++ cmd/entire/cli/agent/agent_test.go | 1 + cmd/entire/cli/agent/claudecode/claude.go | 5 +++ cmd/entire/cli/agent/geminicli/gemini.go | 5 +++ cmd/entire/cli/setup.go | 48 +++++++++-------------- cmd/entire/cli/setup_test.go | 35 ++++++++--------- 6 files changed, 49 insertions(+), 48 deletions(-) diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 64066b47a..f936f47c7 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -64,6 +64,9 @@ type Agent interface { // Returns (true, nil) if found, (false, nil) if not found, // and (false, err) only for unexpected OS errors. IsInstalled() (bool, error) + + // InstallURL returns the installation documentation URL for this agent. + InstallURL() string } // HookSupport is implemented by agents with lifecycle hooks. diff --git a/cmd/entire/cli/agent/agent_test.go b/cmd/entire/cli/agent/agent_test.go index b2c43555c..b5277263d 100644 --- a/cmd/entire/cli/agent/agent_test.go +++ b/cmd/entire/cli/agent/agent_test.go @@ -35,6 +35,7 @@ func (m *mockAgent) ReadSession(_ *HookInput) (*AgentSession, error) { return ni func (m *mockAgent) WriteSession(_ *AgentSession) error { return nil } func (m *mockAgent) FormatResumeCommand(_ string) string { return "" } func (m *mockAgent) IsInstalled() (bool, error) { return false, nil } +func (m *mockAgent) InstallURL() string { return "" } // mockHookSupport implements both Agent and HookSupport interfaces. type mockHookSupport struct { diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index 1b4b2f987..787ccfa38 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -83,6 +83,11 @@ func (c *ClaudeCodeAgent) IsInstalled() (bool, error) { return true, nil } +// InstallURL returns the installation documentation URL for Claude Code. +func (c *ClaudeCodeAgent) InstallURL() string { + return "https://docs.anthropic.com/en/docs/claude-code" +} + // GetHookConfigPath returns the path to Claude's hook config file. func (c *ClaudeCodeAgent) GetHookConfigPath() string { return ".claude/settings.json" diff --git a/cmd/entire/cli/agent/geminicli/gemini.go b/cmd/entire/cli/agent/geminicli/gemini.go index 620f9b899..e41510129 100644 --- a/cmd/entire/cli/agent/geminicli/gemini.go +++ b/cmd/entire/cli/agent/geminicli/gemini.go @@ -84,6 +84,11 @@ func (g *GeminiCLIAgent) IsInstalled() (bool, error) { return true, nil } +// InstallURL returns the installation documentation URL for Gemini CLI. +func (g *GeminiCLIAgent) InstallURL() string { + return "https://github.com/google-gemini/gemini-cli" +} + // GetHookConfigPath returns the path to Gemini's hook config file. func (g *GeminiCLIAgent) GetHookConfigPath() string { return ".gemini/settings.json" diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 21363bdeb..4b91d655c 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -97,7 +97,7 @@ Strategies: manual-commit (default), auto-commit`, if !installed { fmt.Fprintf(cmd.ErrOrStderr(), "%s is not installed or not found in PATH.\nInstall it from: %s\n", - agentName, getInstallURL(agentName)) + agentName, ag.InstallURL()) return NewSilentError(errors.New("agent not installed")) } @@ -105,9 +105,9 @@ Strategies: manual-commit (default), auto-commit`, } // If strategy is specified via flag, skip interactive selection if strategyFlag != "" { - return runEnableWithStrategy(cmd.OutOrStdout(), strategyFlag, localDev, ignoreUntracked, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry) + return runEnableWithStrategy(cmd.OutOrStdout(), cmd.ErrOrStderr(), strategyFlag, localDev, ignoreUntracked, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry) } - return runEnableInteractive(cmd.OutOrStdout(), localDev, ignoreUntracked, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry) + return runEnableInteractive(cmd.OutOrStdout(), cmd.ErrOrStderr(), localDev, ignoreUntracked, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry) }, } @@ -226,7 +226,7 @@ func isFullyEnabled() (enabled bool, agentDesc string, configPath string) { // 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). -func runEnableWithStrategy(w io.Writer, selectedStrategy string, localDev, _, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry bool) error { +func runEnableWithStrategy(w, errW io.Writer, selectedStrategy string, localDev, _, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry bool) error { // Map the strategy to internal name if it's a display name internalStrategy := selectedStrategy if mapped, ok := strategyDisplayToInternal[selectedStrategy]; ok { @@ -240,7 +240,7 @@ func runEnableWithStrategy(w io.Writer, selectedStrategy string, localDev, _, us } // Find an installed agent - ag, err := findInstalledAgent(w) + ag, err := findInstalledAgent(w, errW) if err != nil { return err } @@ -324,7 +324,7 @@ func runEnableWithStrategy(w io.Writer, selectedStrategy string, localDev, _, us } // runEnableInteractive runs the interactive enable flow. -func runEnableInteractive(w io.Writer, localDev, _, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry bool) error { +func runEnableInteractive(w, errW io.Writer, localDev, _, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry bool) error { // Check if already fully enabled — show summary and return early. // Skip early return if any configuration flags are set (user wants to reconfigure). hasConfigFlags := forceHooks || skipPushSessions || !telemetry || useLocalSettings || useProjectSettings || localDev @@ -339,7 +339,7 @@ func runEnableInteractive(w io.Writer, localDev, _, useLocalSettings, useProject } // Find an installed agent - ag, err := findInstalledAgent(w) + ag, err := findInstalledAgent(w, errW) if err != nil { return err } @@ -498,9 +498,9 @@ func checkDisabledGuard(w io.Writer) bool { return false } -// agentHasHooksAndInstalled returns true if the agent is installed (binary in PATH) +// isInstalledWithHookSupport returns true if the agent is installed (binary in PATH) // and implements HookSupport, so we can install hooks for it. -func agentHasHooksAndInstalled(ag agent.Agent) bool { +func isInstalledWithHookSupport(ag agent.Agent) bool { if _, ok := ag.(agent.HookSupport); !ok { return false } @@ -513,11 +513,11 @@ func agentHasHooksAndInstalled(ag agent.Agent) bool { // Only agents that are both installed (binary in PATH) and implement HookSupport // are returned, so the enable flow can always install hooks. // Returns the agent and nil error if found, or nil agent and an error with -// helpful install instructions if no such agents are installed. -func findInstalledAgent(w io.Writer) (agent.Agent, error) { +// helpful install instructions written to errW (stderr). +func findInstalledAgent(stdout, errW io.Writer) (agent.Agent, error) { // Try the default agent first ag := agent.Default() - if ag != nil && agentHasHooksAndInstalled(ag) { + if ag != nil && isInstalledWithHookSupport(ag) { return ag, nil } @@ -527,21 +527,21 @@ func findInstalledAgent(w io.Writer) (agent.Agent, error) { if err != nil { continue } - if agentHasHooksAndInstalled(a) { + if isInstalledWithHookSupport(a) { return a, nil } } - // No hook-supporting agents found - provide helpful error - fmt.Fprintln(w, "No AI agents with hook support found in PATH.") - fmt.Fprintln(w) - fmt.Fprintln(w, "Please install one of the following:") + // No hook-supporting agents found - provide helpful error to stderr + fmt.Fprintln(errW, "No AI agents with hook support found in PATH.") + fmt.Fprintln(errW) + fmt.Fprintln(errW, "Please install one of the following:") for _, name := range agent.List() { a, err := agent.Get(name) if err != nil { continue } - fmt.Fprintf(w, " - %s: %s\n", a.Description(), getInstallURL(string(name))) + fmt.Fprintf(errW, " - %s: %s\n", a.Description(), a.InstallURL()) } return nil, NewSilentError(errors.New("no agents installed")) } @@ -562,18 +562,6 @@ func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { return count, nil } -// getInstallURL returns the installation documentation URL for an agent. -func getInstallURL(agentName string) string { - switch agentName { - case string(agent.AgentNameClaudeCode): - return "https://docs.anthropic.com/en/docs/claude-code" - case string(agent.AgentNameGemini): - return "https://github.com/google-gemini/gemini-cli" - default: - return "https://github.com/entireio/cli#requirements" - } -} - // printAgentError writes an error message followed by available agents and usage. func printAgentError(w io.Writer, message string) { agents := agent.List() diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 0f656c6ae..7eb22b144 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -358,8 +358,8 @@ func TestRunEnableWithStrategy_PreservesExistingSettings(t *testing.T) { writeSettings(t, initialSettings) // Run enable with a different strategy - var stdout bytes.Buffer - err := runEnableWithStrategy(&stdout, "auto-commit", false, false, false, true, false, false, false) + var stdout, stderr bytes.Buffer + err := runEnableWithStrategy(&stdout, &stderr, "auto-commit", false, false, false, true, false, false, false) if err != nil { t.Fatalf("runEnableWithStrategy() error = %v", err) } @@ -402,8 +402,8 @@ func TestRunEnableWithStrategy_PreservesLocalSettings(t *testing.T) { writeLocalSettings(t, localSettings) // Run enable with --local flag - var stdout bytes.Buffer - err := runEnableWithStrategy(&stdout, "auto-commit", false, false, true, false, false, false, false) + var stdout, stderr bytes.Buffer + err := runEnableWithStrategy(&stdout, &stderr, "auto-commit", false, false, true, false, false, false, false) if err != nil { t.Fatalf("runEnableWithStrategy() error = %v", err) } @@ -879,7 +879,7 @@ func TestEnableCmd_AgentFlagEmptyValue(t *testing.T) { } } -func TestGetInstallURL(t *testing.T) { +func TestAgentInstallURL(t *testing.T) { t.Parallel() tests := []struct { @@ -897,19 +897,18 @@ func TestGetInstallURL(t *testing.T) { agent: "gemini", wantURL: "https://github.com/google-gemini/gemini-cli", }, - { - name: "unknown agent", - agent: "unknown", - wantURL: "https://github.com/entireio/cli#requirements", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := getInstallURL(tt.agent) + ag, err := agent.Get(agent.AgentName(tt.agent)) + if err != nil { + t.Fatalf("agent.Get(%q): %v", tt.agent, err) + } + got := ag.InstallURL() if got != tt.wantURL { - t.Errorf("getInstallURL(%q) = %q, want %q", tt.agent, got, tt.wantURL) + t.Errorf("InstallURL() = %q, want %q", got, tt.wantURL) } }) } @@ -918,25 +917,25 @@ func TestGetInstallURL(t *testing.T) { func TestFindInstalledAgent_ReturnsAgent(t *testing.T) { t.Parallel() - var buf bytes.Buffer - ag, err := findInstalledAgent(&buf) + var stdoutBuf, stderrBuf bytes.Buffer + ag, err := findInstalledAgent(&stdoutBuf, &stderrBuf) // This test is environment-dependent: // - If claude or gemini is installed, ag should be non-nil - // - If neither is installed, err should be non-nil with helpful message + // - If neither is installed, err should be non-nil with helpful message on stderr if ag != nil { // Agent was found - verify it has a valid name if ag.Name() == "" { t.Error("findInstalledAgent() returned agent with empty name") } } else { - // No agent found - verify helpful error message was printed + // No agent found - verify helpful error message was printed to stderr if err == nil { t.Error("findInstalledAgent() returned nil agent and nil error") } - output := buf.String() + output := stderrBuf.String() if !strings.Contains(output, "No AI agents with hook support found in PATH") { - t.Errorf("expected helpful error message, got: %s", output) + t.Errorf("expected helpful error message on stderr, got: %s", output) } } } From efffb0ba84318b3cf34c09318812c2c3c2caef67 Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Tue, 10 Feb 2026 17:01:30 -0500 Subject: [PATCH 05/12] fix: remove unused stdout from findInstalledAgent, propagate IsInstalled OS errors - findInstalledAgent: drop unused stdout parameter; callers now pass only errW - isInstalledWithHookSupport: return (bool, error) and propagate IsInstalled() errors - findInstalledAgent: when no agent is found but an OS error occurred, return that error instead of generic 'No AI agents with hook support found in PATH' (matches --agent flag behavior) Entire-Checkpoint: 9232141d2019 --- cmd/entire/cli/setup.go | 43 +++++++++++++++++++++++++++--------- cmd/entire/cli/setup_test.go | 4 ++-- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 4b91d655c..0ba7a2082 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -240,7 +240,7 @@ func runEnableWithStrategy(w, errW io.Writer, selectedStrategy string, localDev, } // Find an installed agent - ag, err := findInstalledAgent(w, errW) + ag, err := findInstalledAgent(errW) if err != nil { return err } @@ -339,7 +339,7 @@ func runEnableInteractive(w, errW io.Writer, localDev, _, useLocalSettings, useP } // Find an installed agent - ag, err := findInstalledAgent(w, errW) + ag, err := findInstalledAgent(errW) if err != nil { return err } @@ -500,12 +500,17 @@ func checkDisabledGuard(w io.Writer) bool { // isInstalledWithHookSupport returns true if the agent is installed (binary in PATH) // and implements HookSupport, so we can install hooks for it. -func isInstalledWithHookSupport(ag agent.Agent) bool { +// Returns (false, err) when IsInstalled() returns an unexpected OS error, so callers +// can propagate it instead of showing a generic "not found" message. +func isInstalledWithHookSupport(ag agent.Agent) (bool, error) { if _, ok := ag.(agent.HookSupport); !ok { - return false + return false, nil } installed, err := ag.IsInstalled() - return err == nil && installed + if err != nil { + return false, err + } + return installed, nil } // findInstalledAgent returns the first installed agent that supports hooks. @@ -513,12 +518,20 @@ func isInstalledWithHookSupport(ag agent.Agent) bool { // Only agents that are both installed (binary in PATH) and implement HookSupport // are returned, so the enable flow can always install hooks. // Returns the agent and nil error if found, or nil agent and an error with -// helpful install instructions written to errW (stderr). -func findInstalledAgent(stdout, errW io.Writer) (agent.Agent, error) { +// helpful install instructions written to errW (stderr). OS errors from +// IsInstalled() (e.g. permission errors on PATH) are propagated to the caller. +func findInstalledAgent(errW io.Writer) (agent.Agent, error) { + var firstErr error + // Try the default agent first ag := agent.Default() - if ag != nil && isInstalledWithHookSupport(ag) { - return ag, nil + if ag != nil { + ok, err := isInstalledWithHookSupport(ag) + if err != nil { + firstErr = err + } else if ok { + return ag, nil + } } // Check all registered agents @@ -527,11 +540,21 @@ func findInstalledAgent(stdout, errW io.Writer) (agent.Agent, error) { if err != nil { continue } - if isInstalledWithHookSupport(a) { + ok, err := isInstalledWithHookSupport(a) + if err != nil { + if firstErr == nil { + firstErr = err + } + } else if ok { return a, nil } } + // If we hit an OS error while checking, propagate it + if firstErr != nil { + return nil, fmt.Errorf("error checking if agent is installed: %w", firstErr) + } + // No hook-supporting agents found - provide helpful error to stderr fmt.Fprintln(errW, "No AI agents with hook support found in PATH.") fmt.Fprintln(errW) diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 7eb22b144..53d2dd9a4 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -917,8 +917,8 @@ func TestAgentInstallURL(t *testing.T) { func TestFindInstalledAgent_ReturnsAgent(t *testing.T) { t.Parallel() - var stdoutBuf, stderrBuf bytes.Buffer - ag, err := findInstalledAgent(&stdoutBuf, &stderrBuf) + var stderrBuf bytes.Buffer + ag, err := findInstalledAgent(&stderrBuf) // This test is environment-dependent: // - If claude or gemini is installed, ag should be non-nil From c5f1794b3e24fffc22738bf73113c93ee77545ca Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Tue, 10 Feb 2026 17:23:14 -0500 Subject: [PATCH 06/12] ci: add stub claude and gemini to PATH so enable tests pass The agent-installation-validation feature requires at least one agent (claude or gemini) to be in PATH for `entire enable` and findInstalledAgent(). CI runners do not install these CLIs, so unit tests (runEnableWithStrategy) and integration tests (enable disable/re-enable, default strategy, setup hooks) were failing. Add a workflow step that creates minimal executable stubs for `claude` and `gemini` in $HOME/entire-test-bin and appends that directory to GITHUB_PATH. The enable flow only needs LookPath to succeed; the stubs are never executed. Co-authored-by: Cursor --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71a16793d..1a0e4a58e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,5 +11,12 @@ jobs: steps: - uses: actions/checkout@v6 - uses: jdx/mise-action@v3 + - name: Setup stub agents for tests + run: | + mkdir -p "$HOME/entire-test-bin" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$HOME/entire-test-bin/claude" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$HOME/entire-test-bin/gemini" + chmod +x "$HOME/entire-test-bin/claude" "$HOME/entire-test-bin/gemini" + echo "$HOME/entire-test-bin" >> "$GITHUB_PATH" - name: Tests run: mise run test:ci From 34a1122c22aa408607a99571b298df5befb29dc2 Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Tue, 10 Feb 2026 17:25:26 -0500 Subject: [PATCH 07/12] fix: wrap IsInstalled error in isInstalledWithHookSupport Co-authored-by: Cursor --- cmd/entire/cli/setup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 0ba7a2082..31b926b4f 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -508,7 +508,7 @@ func isInstalledWithHookSupport(ag agent.Agent) (bool, error) { } installed, err := ag.IsInstalled() if err != nil { - return false, err + return false, fmt.Errorf("IsInstalled failed: %w", err) } return installed, nil } From acd9aca7c0ab7c8cf0aa04c2afb5d98b5e267071 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 11 Feb 2026 13:04:42 +0100 Subject: [PATCH 08/12] use struct approach to mock binary checks Entire-Checkpoint: 537a3bbe9ff5 --- .github/workflows/ci.yml | 7 --- cmd/entire/cli/agent/agent_test.go | 2 +- cmd/entire/cli/agent/claudecode/claude.go | 12 ++++- .../cli/agent/claudecode/claude_test.go | 54 +++++++++++++++++-- cmd/entire/cli/agent/geminicli/gemini.go | 12 ++++- cmd/entire/cli/agent/geminicli/gemini_test.go | 54 +++++++++++++++++-- 6 files changed, 119 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a0e4a58e..71a16793d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,12 +11,5 @@ jobs: steps: - uses: actions/checkout@v6 - uses: jdx/mise-action@v3 - - name: Setup stub agents for tests - run: | - mkdir -p "$HOME/entire-test-bin" - printf '%s\n' '#!/bin/sh' 'exit 0' > "$HOME/entire-test-bin/claude" - printf '%s\n' '#!/bin/sh' 'exit 0' > "$HOME/entire-test-bin/gemini" - chmod +x "$HOME/entire-test-bin/claude" "$HOME/entire-test-bin/gemini" - echo "$HOME/entire-test-bin" >> "$GITHUB_PATH" - name: Tests run: mise run test:ci diff --git a/cmd/entire/cli/agent/agent_test.go b/cmd/entire/cli/agent/agent_test.go index 44d8bc414..dffc7c40b 100644 --- a/cmd/entire/cli/agent/agent_test.go +++ b/cmd/entire/cli/agent/agent_test.go @@ -39,7 +39,7 @@ func (m *mockAgent) ReadSession(_ *HookInput) (*AgentSession, error) { return ni func (m *mockAgent) WriteSession(_ *AgentSession) error { return nil } func (m *mockAgent) FormatResumeCommand(_ string) string { return "" } func (m *mockAgent) IsInstalled() (bool, error) { return false, nil } -func (m *mockAgent) InstallURL() string { return "" } +func (m *mockAgent) InstallURL() string { return "" } // mockHookSupport implements both Agent and HookSupport interfaces. type mockHookSupport struct { diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index 9b686181b..5a5698eb1 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -26,7 +26,11 @@ func init() { // ClaudeCodeAgent implements the Agent interface for Claude Code. // //nolint:revive // ClaudeCodeAgent is clearer than Agent in this context -type ClaudeCodeAgent struct{} +type ClaudeCodeAgent struct { + // lookPath checks if a binary exists in PATH. Defaults to exec.LookPath when nil. + // Exported for testing. + LookPath func(file string) (string, error) +} // NewClaudeCodeAgent creates a new Claude Code agent instance. func NewClaudeCodeAgent() agent.Agent { @@ -73,7 +77,11 @@ func (c *ClaudeCodeAgent) DetectPresence() (bool, error) { // IsInstalled checks if the `claude` binary is available in PATH. func (c *ClaudeCodeAgent) IsInstalled() (bool, error) { - _, err := exec.LookPath("claude") + lookPath := c.LookPath + if lookPath == nil { + lookPath = exec.LookPath + } + _, err := lookPath("claude") if err != nil { if errors.Is(err, exec.ErrNotFound) { return false, nil diff --git a/cmd/entire/cli/agent/claudecode/claude_test.go b/cmd/entire/cli/agent/claudecode/claude_test.go index 01f3138e9..4e5ddf719 100644 --- a/cmd/entire/cli/agent/claudecode/claude_test.go +++ b/cmd/entire/cli/agent/claudecode/claude_test.go @@ -1,25 +1,69 @@ package claudecode import ( + "errors" + "os/exec" "strings" "testing" "github.com/entireio/cli/cmd/entire/cli/agent" ) -func TestIsInstalled(t *testing.T) { +func TestIsInstalled_Found(t *testing.T) { t.Parallel() - c := &ClaudeCodeAgent{} + c := &ClaudeCodeAgent{ + LookPath: func(file string) (string, error) { + if file == "claude" { + return "/usr/bin/claude", nil + } + return "", exec.ErrNotFound + }, + } + installed, err := c.IsInstalled() + + if err != nil { + t.Fatalf("IsInstalled() error = %v", err) + } + if !installed { + t.Error("IsInstalled() = false, want true") + } +} + +func TestIsInstalled_NotFound(t *testing.T) { + t.Parallel() + + c := &ClaudeCodeAgent{ + LookPath: func(_ string) (string, error) { + return "", exec.ErrNotFound + }, + } installed, err := c.IsInstalled() - // Should not return an error regardless of whether claude is installed if err != nil { t.Fatalf("IsInstalled() error = %v", err) } + if installed { + t.Error("IsInstalled() = true, want false") + } +} + +func TestIsInstalled_OSError(t *testing.T) { + t.Parallel() - // installed is environment-dependent, just verify it's a valid bool - _ = installed + c := &ClaudeCodeAgent{ + LookPath: func(_ string) (string, error) { + return "", errors.New("permission denied") + }, + } + installed, err := c.IsInstalled() + + if err == nil { + t.Fatal("IsInstalled() should return error for OS errors") + } + if installed { + t.Error("IsInstalled() = true, want false on error") + } } func TestResolveSessionFile(t *testing.T) { diff --git a/cmd/entire/cli/agent/geminicli/gemini.go b/cmd/entire/cli/agent/geminicli/gemini.go index e760b5099..05f5103b6 100644 --- a/cmd/entire/cli/agent/geminicli/gemini.go +++ b/cmd/entire/cli/agent/geminicli/gemini.go @@ -28,7 +28,11 @@ func init() { // GeminiCLIAgent implements the Agent interface for Gemini CLI. // //nolint:revive // GeminiCLIAgent is clearer than Agent in this context -type GeminiCLIAgent struct{} +type GeminiCLIAgent struct { + // LookPath checks if a binary exists in PATH. Defaults to exec.LookPath when nil. + // Exported for testing. + LookPath func(file string) (string, error) +} func NewGeminiCLIAgent() agent.Agent { return &GeminiCLIAgent{} @@ -74,7 +78,11 @@ func (g *GeminiCLIAgent) DetectPresence() (bool, error) { // IsInstalled checks if the `gemini` binary is available in PATH. func (g *GeminiCLIAgent) IsInstalled() (bool, error) { - _, err := exec.LookPath("gemini") + lookPath := g.LookPath + if lookPath == nil { + lookPath = exec.LookPath + } + _, err := lookPath("gemini") if err != nil { if errors.Is(err, exec.ErrNotFound) { return false, nil diff --git a/cmd/entire/cli/agent/geminicli/gemini_test.go b/cmd/entire/cli/agent/geminicli/gemini_test.go index c5e24e4a6..8b0e6d078 100644 --- a/cmd/entire/cli/agent/geminicli/gemini_test.go +++ b/cmd/entire/cli/agent/geminicli/gemini_test.go @@ -3,8 +3,10 @@ package geminicli import ( "bytes" "encoding/json" + "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -15,19 +17,61 @@ import ( // Test constants const testSessionID = "abc123" -func TestIsInstalled(t *testing.T) { +func TestIsInstalled_Found(t *testing.T) { t.Parallel() - ag := &GeminiCLIAgent{} + ag := &GeminiCLIAgent{ + LookPath: func(file string) (string, error) { + if file == "gemini" { + return "/usr/bin/gemini", nil + } + return "", exec.ErrNotFound + }, + } installed, err := ag.IsInstalled() - // Should not return an error regardless of whether gemini is installed if err != nil { t.Fatalf("IsInstalled() error = %v", err) } + if !installed { + t.Error("IsInstalled() = false, want true") + } +} + +func TestIsInstalled_NotFound(t *testing.T) { + t.Parallel() + + ag := &GeminiCLIAgent{ + LookPath: func(_ string) (string, error) { + return "", exec.ErrNotFound + }, + } + installed, err := ag.IsInstalled() - // installed is environment-dependent, just verify it's a valid bool - _ = installed + if err != nil { + t.Fatalf("IsInstalled() error = %v", err) + } + if installed { + t.Error("IsInstalled() = true, want false") + } +} + +func TestIsInstalled_OSError(t *testing.T) { + t.Parallel() + + ag := &GeminiCLIAgent{ + LookPath: func(_ string) (string, error) { + return "", errors.New("permission denied") + }, + } + installed, err := ag.IsInstalled() + + if err == nil { + t.Fatal("IsInstalled() should return error for OS errors") + } + if installed { + t.Error("IsInstalled() = true, want false on error") + } } func TestNewGeminiCLIAgent(t *testing.T) { From ddf55b62a61b5b14c4a36a2fb20ce0eec63d82ab Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 11 Feb 2026 13:06:14 +0100 Subject: [PATCH 09/12] make error message lower case Entire-Checkpoint: 91aeda2ef5a2 --- cmd/entire/cli/setup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 31b926b4f..cfd23171c 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -508,7 +508,7 @@ func isInstalledWithHookSupport(ag agent.Agent) (bool, error) { } installed, err := ag.IsInstalled() if err != nil { - return false, fmt.Errorf("IsInstalled failed: %w", err) + return false, fmt.Errorf("checking installation status: %w", err) } return installed, nil } From 73be2dc523b9ed4a044c01822c3d55a9735ba5d8 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 11 Feb 2026 13:28:59 +0100 Subject: [PATCH 10/12] make it work for integration tests too Entire-Checkpoint: 37a33a6a62fd --- cmd/entire/cli/integration_test/testenv.go | 2 ++ cmd/entire/cli/setup.go | 24 ++++++++++++++++++++-- cmd/entire/cli/setup_test.go | 2 ++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index 297e56e58..1fe03b4ac 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -102,10 +102,12 @@ func (env *TestEnv) Cleanup() { // cliEnv returns the environment variables for CLI execution. // Includes both Claude and Gemini project dirs so tests work for any agent. +// Sets ENTIRE_SKIP_AGENT_CHECK=1 to bypass agent installation checks in tests. func (env *TestEnv) cliEnv() []string { return append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, "ENTIRE_TEST_GEMINI_PROJECT_DIR="+env.GeminiProjectDir, + "ENTIRE_SKIP_AGENT_CHECK=1", ) } diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index cfd23171c..d12442872 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -513,14 +513,34 @@ func isInstalledWithHookSupport(ag agent.Agent) (bool, error) { return installed, nil } -// findInstalledAgent returns the first installed agent that supports hooks. +// findInstalledAgentFn is the function used to find an installed agent. +// It can be overridden in tests to return a mock agent. +var findInstalledAgentFn = findInstalledAgentImpl + +// findInstalledAgent calls the findInstalledAgentFn function variable. +// This indirection allows tests to mock the agent detection. +func findInstalledAgent(errW io.Writer) (agent.Agent, error) { + return findInstalledAgentFn(errW) +} + +// findInstalledAgentImpl returns the first installed agent that supports hooks. // It tries the default agent first, then checks all registered agents. // Only agents that are both installed (binary in PATH) and implement HookSupport // are returned, so the enable flow can always install hooks. // Returns the agent and nil error if found, or nil agent and an error with // helpful install instructions written to errW (stderr). OS errors from // IsInstalled() (e.g. permission errors on PATH) are propagated to the caller. -func findInstalledAgent(errW io.Writer) (agent.Agent, error) { +// +// If ENTIRE_SKIP_AGENT_CHECK=1 is set (used by integration tests), the default +// agent is returned without checking if it's installed. +func findInstalledAgentImpl(errW io.Writer) (agent.Agent, error) { + // Allow tests to skip the agent check + if os.Getenv("ENTIRE_SKIP_AGENT_CHECK") == "1" { + ag := agent.Default() + if ag != nil { + return ag, nil + } + } var firstErr error // Try the default agent first diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 53d2dd9a4..23056a9e6 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -344,6 +344,7 @@ func TestDetermineSettingsTarget_SettingsNotExists_NoFlags(t *testing.T) { } func TestRunEnableWithStrategy_PreservesExistingSettings(t *testing.T) { + t.Setenv("ENTIRE_SKIP_AGENT_CHECK", "1") setupTestRepo(t) // Create initial settings with strategy_options (like push enabled) @@ -388,6 +389,7 @@ func TestRunEnableWithStrategy_PreservesExistingSettings(t *testing.T) { } func TestRunEnableWithStrategy_PreservesLocalSettings(t *testing.T) { + t.Setenv("ENTIRE_SKIP_AGENT_CHECK", "1") setupTestRepo(t) // Create project settings From 894864bc9f4096ef719e036bf1df930ecba6a478 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 11 Feb 2026 13:47:56 +0100 Subject: [PATCH 11/12] one more skip agent check Entire-Checkpoint: b87290028c1b --- cmd/entire/cli/setup.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index d12442872..cbf60b010 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -89,16 +89,18 @@ Strategies: manual-commit (default), auto-commit`, return NewSilentError(errors.New("wrong agent name")) } - // Check if the agent binary is installed - installed, err := ag.IsInstalled() - if err != nil { - return fmt.Errorf("error checking if %s is installed: %w", agentName, err) - } - if !installed { - fmt.Fprintf(cmd.ErrOrStderr(), - "%s is not installed or not found in PATH.\nInstall it from: %s\n", - agentName, ag.InstallURL()) - return NewSilentError(errors.New("agent not installed")) + // Check if the agent binary is installed (skip in test mode) + if os.Getenv("ENTIRE_SKIP_AGENT_CHECK") != "1" { + installed, err := ag.IsInstalled() + if err != nil { + return fmt.Errorf("error checking if %s is installed: %w", agentName, err) + } + if !installed { + fmt.Fprintf(cmd.ErrOrStderr(), + "%s is not installed or not found in PATH.\nInstall it from: %s\n", + agentName, ag.InstallURL()) + return NewSilentError(errors.New("agent not installed")) + } } return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, strategyFlag, localDev, forceHooks, skipPushSessions, telemetry) From 7ecc48641233ff08dea11696603f9faca53cc04f Mon Sep 17 00:00:00 2001 From: Zoheb Malik Date: Sat, 14 Feb 2026 16:29:35 -0500 Subject: [PATCH 12/12] Warn when agent not in PATH instead of blocking enable --- cmd/entire/cli/agent/agent_test.go | 7 +++---- cmd/entire/cli/setup.go | 5 ++--- cmd/entire/cli/strategy/manual_commit_rewind.go | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/agent/agent_test.go b/cmd/entire/cli/agent/agent_test.go index fecf08f60..6f641a611 100644 --- a/cmd/entire/cli/agent/agent_test.go +++ b/cmd/entire/cli/agent/agent_test.go @@ -24,10 +24,9 @@ func (m *mockAgent) SupportsHooks() bool { return false } func (m *mockAgent) ParseHookInput(_ HookType, _ io.Reader) (*HookInput, error) { return nil, nil } -func (m *mockAgent) GetSessionID(_ *HookInput) string { return "" } -func (m *mockAgent) TransformSessionID(agentID string) string { return agentID } -func (m *mockAgent) ProtectedDirs() []string { return nil } -func (m *mockAgent) GetSessionDir(_ string) (string, error) { return "", nil } +func (m *mockAgent) GetSessionID(_ *HookInput) string { return "" } +func (m *mockAgent) ProtectedDirs() []string { return nil } +func (m *mockAgent) GetSessionDir(_ string) (string, error) { return "", nil } func (m *mockAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { return sessionDir + "/" + agentSessionID + ".jsonl" } diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index ceaaccc6f..afb25df75 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -97,7 +97,7 @@ Strategies: manual-commit (default), auto-commit`, return NewSilentError(errors.New("wrong agent name")) } - // Check if the agent binary is installed (skip in test mode) + // If agent isn't in PATH, warn but still proceed (hooks are only run when the agent runs). if os.Getenv("ENTIRE_SKIP_AGENT_CHECK") != "1" { installed, err := ag.IsInstalled() if err != nil { @@ -105,9 +105,8 @@ Strategies: manual-commit (default), auto-commit`, } if !installed { fmt.Fprintf(cmd.ErrOrStderr(), - "%s is not installed or not found in PATH.\nInstall it from: %s\n", + "Note: %s is not in PATH. Hooks were installed; install it from %s when you're ready to capture sessions.\n", agentName, ag.InstallURL()) - return NewSilentError(errors.New("agent not installed")) } } diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index ac30eafcd..5c8a06803 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -792,7 +792,7 @@ func ResolveAgentForRewind(agentType agent.AgentType) (agent.Agent, error) { // ResolveSessionFilePath determines the correct file path for an agent's session transcript. // Checks session state for transcript_path first (needed for agents like Gemini that store // transcripts at paths that GetSessionDir can't reconstruct, e.g. SHA-256 hashed directories). -// Falls back to the agent's ExtractAgentSessionID + ResolveSessionFile with fallbackSessionDir. +// Falls back to the agent's ResolveSessionFile with fallbackSessionDir. func ResolveSessionFilePath(sessionID string, ag agent.Agent, fallbackSessionDir string) string { state, err := LoadSessionState(sessionID) if err == nil && state != nil && state.TranscriptPath != "" {