Skip to content

Commit 5b06490

Browse files
authored
Merge pull request #893 from entireio/nodo/ext-agents-detection
Fix external agents detection
2 parents 165fdf1 + 918e4d4 commit 5b06490

File tree

2 files changed

+209
-9
lines changed

2 files changed

+209
-9
lines changed

cmd/entire/cli/setup.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,8 @@ func setupAgentHooks(ctx context.Context, ag agent.Agent, localDev, forceHooks b
916916
// Returns the detected/selected agents and any error.
917917
//
918918
// On first run (no hooks installed):
919-
// - Single detected agent: used automatically
919+
// - Single detected built-in agent: used automatically
920+
// - Single detected external agent: interactive multi-select prompt
920921
// - Multiple/no detected agents: interactive multi-select prompt
921922
//
922923
// On re-run (hooks already installed):
@@ -937,8 +938,10 @@ func detectOrSelectAgent(ctx context.Context, w io.Writer, selectFn func(availab
937938
if !hasInstalledHooks {
938939
switch {
939940
case len(detected) == 1:
940-
fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type())
941-
return detected, nil
941+
if isBuiltInAgent(detected[0]) {
942+
fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type())
943+
return detected, nil
944+
}
942945

943946
case len(detected) > 1:
944947
agentTypes := make([]string, 0, len(detected))
@@ -977,15 +980,17 @@ func detectOrSelectAgent(ctx context.Context, w io.Writer, selectFn func(availab
977980

978981
// Build pre-selection set.
979982
// On re-run: only pre-select agents with hooks installed (respect prior deselection).
980-
// On first run: pre-select all detected agents.
983+
// On first run: pre-select detected built-in agents only.
981984
preSelectedSet := make(map[types.AgentName]struct{})
982985
if hasInstalledHooks {
983986
for _, name := range installedAgentNames {
984987
preSelectedSet[name] = struct{}{}
985988
}
986989
} else {
987990
for _, ag := range detected {
988-
preSelectedSet[ag.Name()] = struct{}{}
991+
if isBuiltInAgent(ag) {
992+
preSelectedSet[ag.Name()] = struct{}{}
993+
}
989994
}
990995
}
991996

@@ -1070,6 +1075,10 @@ func detectOrSelectAgent(ctx context.Context, w io.Writer, selectFn func(availab
10701075
return selectedAgents, nil
10711076
}
10721077

1078+
func isBuiltInAgent(ag agent.Agent) bool {
1079+
return !external.IsExternal(ag)
1080+
}
1081+
10731082
// canPromptInteractively checks if we can show interactive prompts.
10741083
// Returns false when running in CI, tests, or other non-interactive environments.
10751084
func canPromptInteractively() bool {

cmd/entire/cli/setup_test.go

Lines changed: 195 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@ import (
66
"encoding/json"
77
"errors"
88
"os"
9+
"os/exec"
910
"path/filepath"
11+
"slices"
1012
"strings"
1113
"testing"
1214

1315
"github.com/entireio/cli/cmd/entire/cli/agent"
1416
_ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
17+
"github.com/entireio/cli/cmd/entire/cli/agent/external"
1518
_ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli"
19+
"github.com/entireio/cli/cmd/entire/cli/agent/types"
1620
"github.com/entireio/cli/cmd/entire/cli/paths"
1721
"github.com/entireio/cli/cmd/entire/cli/session"
1822
"github.com/entireio/cli/cmd/entire/cli/settings"
1923
"github.com/entireio/cli/cmd/entire/cli/strategy"
20-
"github.com/go-git/go-git/v6"
24+
"github.com/entireio/cli/cmd/entire/cli/testutil"
2125
)
2226

2327
// Note: Tests for hook manipulation functions (addHookToMatcher, hookCommandExists, etc.)
@@ -29,6 +33,7 @@ import (
2933
func setupTestDir(t *testing.T) string {
3034
t.Helper()
3135
tmpDir := t.TempDir()
36+
hideExternalAgentsFromPath(t)
3237
t.Chdir(tmpDir)
3338
paths.ClearWorktreeRootCache()
3439
session.ClearGitCommonDirCache()
@@ -39,9 +44,7 @@ func setupTestDir(t *testing.T) string {
3944
func setupTestRepo(t *testing.T) {
4045
t.Helper()
4146
tmpDir := setupTestDir(t)
42-
if _, err := git.PlainInit(tmpDir, false); err != nil {
43-
t.Fatalf("Failed to init repo: %v", err)
44-
}
47+
testutil.InitRepo(t, tmpDir)
4548
}
4649

4750
// writeSettings writes settings content to the settings file.
@@ -56,6 +59,110 @@ func writeSettings(t *testing.T, content string) {
5659
}
5760
}
5861

62+
func hideExternalAgentsFromPath(t *testing.T) {
63+
t.Helper()
64+
65+
pathDir := t.TempDir()
66+
for _, name := range []string{"git", "sh"} {
67+
if err := preserveToolOnPath(name, pathDir); err != nil {
68+
t.Fatalf("preserve %s on PATH: %v", name, err)
69+
}
70+
}
71+
72+
t.Setenv("PATH", pathDir)
73+
}
74+
75+
func TestSetupTestDir_HidesExternalAgentsButKeepsGitAvailable(t *testing.T) {
76+
gitPath, err := exec.LookPath("git")
77+
if err != nil {
78+
t.Skip("git not available")
79+
}
80+
81+
sharedDir := t.TempDir()
82+
if err := copyExecutable(gitPath, filepath.Join(sharedDir, "git")); err != nil {
83+
t.Fatalf("copy git executable: %v", err)
84+
}
85+
writeExternalAgentBinary(t, sharedDir, "ext-shared-dir")
86+
t.Setenv("PATH", sharedDir)
87+
88+
setupTestDir(t)
89+
90+
if _, err := exec.LookPath("git"); err != nil {
91+
t.Fatalf("expected git to remain available after test PATH isolation: %v", err)
92+
}
93+
if _, err := exec.LookPath("entire-agent-ext-shared-dir"); err == nil {
94+
t.Fatal("expected external agent to be hidden from PATH")
95+
}
96+
}
97+
98+
func preserveToolOnPath(name, dstDir string) error {
99+
src, err := exec.LookPath(name)
100+
if err != nil {
101+
if errors.Is(err, exec.ErrNotFound) {
102+
return nil
103+
}
104+
return err
105+
}
106+
107+
return copyExecutable(src, filepath.Join(dstDir, filepath.Base(src)))
108+
}
109+
110+
func copyExecutable(src, dst string) error {
111+
info, err := os.Stat(src)
112+
if err != nil {
113+
return err
114+
}
115+
116+
if err := os.Symlink(src, dst); err == nil {
117+
return nil
118+
}
119+
if err := os.Link(src, dst); err == nil {
120+
return nil
121+
}
122+
123+
data, err := os.ReadFile(src)
124+
if err != nil {
125+
return err
126+
}
127+
128+
return os.WriteFile(dst, data, info.Mode())
129+
}
130+
131+
func writeExternalAgentBinary(t *testing.T, dir, name string) {
132+
t.Helper()
133+
134+
script := `#!/bin/sh
135+
case "$1" in
136+
info)
137+
echo '{"protocol_version":1,"name":"` + name + `","type":"` + name + ` Agent","description":"External test agent","is_preview":false,"protected_dirs":[],"hook_names":["stop"],"capabilities":{"hooks":true}}'
138+
;;
139+
detect)
140+
if [ "$ENTIRE_TEST_EXTERNAL_PRESENT" = "1" ]; then
141+
echo '{"present": true}'
142+
else
143+
echo '{"present": false}'
144+
fi
145+
;;
146+
install-hooks)
147+
echo '{"hooks_installed": 1}'
148+
;;
149+
uninstall-hooks)
150+
exit 0
151+
;;
152+
are-hooks-installed)
153+
echo '{"installed": false}'
154+
;;
155+
*)
156+
echo '{}'
157+
;;
158+
esac
159+
`
160+
161+
if err := os.WriteFile(filepath.Join(dir, "entire-agent-"+name), []byte(script), 0o755); err != nil {
162+
t.Fatalf("Failed to write external agent binary: %v", err)
163+
}
164+
}
165+
59166
func TestRunEnable(t *testing.T) {
60167
setupTestDir(t)
61168
writeSettings(t, testSettingsDisabled)
@@ -935,6 +1042,90 @@ func TestDetectOrSelectAgent_GeminiDetected(t *testing.T) {
9351042
}
9361043
}
9371044

1045+
func TestDetectOrSelectAgent_OnlyExternalDetected_WithTTY_PromptsUser(t *testing.T) {
1046+
// Cannot use t.Parallel() because we use t.Chdir, t.Setenv, and global agent registration
1047+
if _, err := exec.LookPath("sh"); err != nil {
1048+
t.Skip("sh not available")
1049+
}
1050+
1051+
setupTestRepo(t)
1052+
t.Setenv("ENTIRE_TEST_TTY", "1")
1053+
1054+
externalAgentName := "ext-prompt-pi"
1055+
externalDir := t.TempDir()
1056+
writeExternalAgentBinary(t, externalDir, externalAgentName)
1057+
t.Setenv("ENTIRE_TEST_EXTERNAL_PRESENT", "1")
1058+
t.Setenv("PATH", externalDir)
1059+
1060+
external.DiscoverAndRegisterAlways(context.Background())
1061+
1062+
var receivedAvailable []string
1063+
selectFn := func(available []string) ([]string, error) {
1064+
receivedAvailable = available
1065+
return []string{string(agent.AgentNameClaudeCode)}, nil
1066+
}
1067+
1068+
var buf bytes.Buffer
1069+
agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
1070+
if err != nil {
1071+
t.Fatalf("detectOrSelectAgent() error = %v", err)
1072+
}
1073+
1074+
if len(receivedAvailable) == 0 {
1075+
t.Fatal("Expected interactive prompt when only an external agent is detected")
1076+
}
1077+
if !slices.Contains(receivedAvailable, externalAgentName) {
1078+
t.Fatalf("Expected external agent %q in options, got %v", externalAgentName, receivedAvailable)
1079+
}
1080+
if !slices.Contains(receivedAvailable, string(agent.AgentNameClaudeCode)) {
1081+
t.Fatalf("Expected built-in agent options alongside external agent, got %v", receivedAvailable)
1082+
}
1083+
if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
1084+
t.Fatalf("Expected selected Claude Code agent, got %v", agents)
1085+
}
1086+
if strings.Contains(buf.String(), "Detected agent:") {
1087+
t.Errorf("Expected external-only detection to prompt instead of auto-selecting, got output: %s", buf.String())
1088+
}
1089+
}
1090+
1091+
func TestIsBuiltInAgent_ExternalAgent_False(t *testing.T) {
1092+
if _, err := exec.LookPath("sh"); err != nil {
1093+
t.Skip("sh not available")
1094+
}
1095+
1096+
setupTestRepo(t)
1097+
1098+
externalAgentName := "ext-preselect-pi"
1099+
externalDir := t.TempDir()
1100+
writeExternalAgentBinary(t, externalDir, externalAgentName)
1101+
t.Setenv("ENTIRE_TEST_EXTERNAL_PRESENT", "1")
1102+
t.Setenv("PATH", externalDir)
1103+
1104+
external.DiscoverAndRegisterAlways(context.Background())
1105+
1106+
externalAgent, err := agent.Get(types.AgentName(externalAgentName))
1107+
if err != nil {
1108+
t.Fatalf("failed to get external agent %q: %v", externalAgentName, err)
1109+
}
1110+
1111+
if isBuiltInAgent(externalAgent) {
1112+
t.Fatalf("expected external agent %q to not be treated as built-in", externalAgentName)
1113+
}
1114+
}
1115+
1116+
func TestIsBuiltInAgent_BuiltInAgent_True(t *testing.T) {
1117+
t.Parallel()
1118+
1119+
claudeAgent, err := agent.Get(agent.AgentNameClaudeCode)
1120+
if err != nil {
1121+
t.Fatalf("failed to get claude agent: %v", err)
1122+
}
1123+
1124+
if !isBuiltInAgent(claudeAgent) {
1125+
t.Fatal("expected built-in agent to be treated as built-in")
1126+
}
1127+
}
1128+
9381129
func TestDetectOrSelectAgent_NoDetection_NoTTY_FallsBackToDefault(t *testing.T) {
9391130
// Cannot use t.Parallel() because we use t.Chdir and t.Setenv
9401131
setupTestRepo(t)

0 commit comments

Comments
 (0)