@@ -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 (
2933func 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 {
3944func 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+
59166func 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+
9381129func 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