Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b33e6aa
feat: validate agent installation before enabling
Zoheb-Malik Feb 10, 2026
0db095f
Merge branch 'main' into feature/agent-installation-validation
Zoheb-Malik Feb 10, 2026
fa1c912
fix: require HookSupport in findInstalledAgent for consistent enable …
Zoheb-Malik Feb 10, 2026
d6cf657
fix: assert correct error message in TestFindInstalledAgent_ReturnsAgent
Zoheb-Malik Feb 10, 2026
77ddb66
Address PR feedback: stderr for errors, InstallURL on Agent, rename h…
Zoheb-Malik Feb 10, 2026
efffb0b
fix: remove unused stdout from findInstalledAgent, propagate IsInstal…
Zoheb-Malik Feb 10, 2026
83d369e
Merge branch 'main' into feature/agent-installation-validation
Zoheb-Malik Feb 10, 2026
c5f1794
ci: add stub claude and gemini to PATH so enable tests pass
Zoheb-Malik Feb 10, 2026
34a1122
fix: wrap IsInstalled error in isInstalledWithHookSupport
Zoheb-Malik Feb 10, 2026
ca7f13f
Merge main into feature/agent-installation-validation
Zoheb-Malik Feb 11, 2026
acd9aca
use struct approach to mock binary checks
Soph Feb 11, 2026
ddf55b6
make error message lower case
Soph Feb 11, 2026
73be2dc
make it work for integration tests too
Soph Feb 11, 2026
894864b
one more skip agent check
Soph Feb 11, 2026
73640b0
Merge branch 'main' into feature/agent-installation-validation
Zoheb-Malik Feb 11, 2026
36c63f9
Merge branch 'entireio:main' into feature/agent-installation-validation
Zoheb-Malik Feb 14, 2026
7ecc486
Warn when agent not in PATH instead of blocking enable
Zoheb-Malik Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ 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)

// InstallURL returns the installation documentation URL for this agent.
InstallURL() string
}

// HookSupport is implemented by agents with lifecycle hooks.
Expand Down
9 changes: 5 additions & 4 deletions cmd/entire/cli/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -36,6 +35,8 @@ func (m *mockAgent) ResolveSessionFile(sessionDir, agentSessionID string) string
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 }
func (m *mockAgent) InstallURL() string { return "" }

// mockHookSupport implements both Agent and HookSupport interfaces.
type mockHookSupport struct {
Expand Down
28 changes: 27 additions & 1 deletion cmd/entire/cli/agent/claudecode/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"time"
Expand All @@ -24,7 +25,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 {
Expand Down Expand Up @@ -69,6 +74,27 @@ 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) {
lookPath := c.LookPath
if lookPath == nil {
lookPath = exec.LookPath
}
_, err := 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
}

// 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"
Expand Down
59 changes: 59 additions & 0 deletions cmd/entire/cli/agent/claudecode/claude_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,71 @@
package claudecode

import (
"errors"
"os/exec"
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

func TestIsInstalled_Found(t *testing.T) {
t.Parallel()

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()

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()

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) {
t.Parallel()
ag := &ClaudeCodeAgent{}
Expand Down
28 changes: 27 additions & 1 deletion cmd/entire/cli/agent/geminicli/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"time"
Expand All @@ -26,7 +27,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{}
Expand Down Expand Up @@ -70,6 +75,27 @@ 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) {
lookPath := g.LookPath
if lookPath == nil {
lookPath = exec.LookPath
}
_, err := 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
}

// 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"
Expand Down
59 changes: 59 additions & 0 deletions cmd/entire/cli/agent/geminicli/gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package geminicli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand All @@ -15,6 +17,63 @@ import (
// Test constants
const testSessionID = "abc123"

func TestIsInstalled_Found(t *testing.T) {
t.Parallel()

ag := &GeminiCLIAgent{
LookPath: func(file string) (string, error) {
if file == "gemini" {
return "/usr/bin/gemini", nil
}
return "", exec.ErrNotFound
},
}
installed, err := ag.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()

ag := &GeminiCLIAgent{
LookPath: func(_ string) (string, error) {
return "", exec.ErrNotFound
},
}
installed, err := ag.IsInstalled()

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) {
ag := NewGeminiCLIAgent()
if ag == nil {
Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/integration_test/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
}

Expand Down
Loading