From c72ab96b244ea6e379921bec54abfd04e0e9f845 Mon Sep 17 00:00:00 2001 From: kylezhao Date: Wed, 18 Mar 2026 16:54:36 +0800 Subject: [PATCH 1/3] feat: Add Windows-compatible agent name derivation --- cmd/entire/cli/agent/external/discovery.go | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/agent/external/discovery.go b/cmd/entire/cli/agent/external/discovery.go index e233a2d3b..4ff7d087e 100644 --- a/cmd/entire/cli/agent/external/discovery.go +++ b/cmd/entire/cli/agent/external/discovery.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "strings" "github.com/entireio/cli/cmd/entire/cli/agent" @@ -49,7 +50,10 @@ func DiscoverAndRegister(ctx context.Context) { } seen[name] = true - agentName := types.AgentName(strings.TrimPrefix(name, binaryPrefix)) + // Strip Windows executable extensions (.exe, .bat) before deriving agent name. + // On Unix, binaries have no extension, so this is a no-op. + cleanName := stripExeExt(name) + agentName := types.AgentName(strings.TrimPrefix(cleanName, binaryPrefix)) if registered[agentName] { logging.Debug(ctx, "skipping external agent (name conflict with built-in)", slog.String("binary", name), @@ -61,8 +65,8 @@ func DiscoverAndRegister(ctx context.Context) { if err != nil || finfo.IsDir() { continue } - // Check executable bit (on Unix) - if finfo.Mode()&0o111 == 0 { + // Check executable bit (on Unix; Windows doesn't set execute bits) + if runtime.GOOS != "windows" && finfo.Mode()&0o111 == 0 { continue } @@ -94,3 +98,16 @@ func DiscoverAndRegister(ctx context.Context) { } } } + +// stripExeExt removes Windows executable extensions (.exe, .bat, .cmd) from a +// file name so that the agent name derived from the binary matches on all platforms. +// On Unix this is effectively a no-op because binaries have no extension. +func stripExeExt(name string) string { + lower := strings.ToLower(name) + for _, ext := range []string{".exe", ".bat", ".cmd"} { + if strings.HasSuffix(lower, ext) { + return name[:len(name)-len(ext)] + } + } + return name +} From f6414f011f684944de39c1ea7f6132937e74eef1 Mon Sep 17 00:00:00 2001 From: kylezhao Date: Tue, 24 Mar 2026 10:08:13 +0800 Subject: [PATCH 2/3] fix: simplify stripExeExt and add tests for Windows binary discovery --- cmd/entire/cli/agent/external/discovery.go | 8 ++--- .../cli/agent/external/discovery_test.go | 33 +++++++++++++++++++ .../cli/agent/external/external_test.go | 32 ++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/agent/external/discovery.go b/cmd/entire/cli/agent/external/discovery.go index 4ff7d087e..e80be5562 100644 --- a/cmd/entire/cli/agent/external/discovery.go +++ b/cmd/entire/cli/agent/external/discovery.go @@ -103,11 +103,9 @@ func DiscoverAndRegister(ctx context.Context) { // file name so that the agent name derived from the binary matches on all platforms. // On Unix this is effectively a no-op because binaries have no extension. func stripExeExt(name string) string { - lower := strings.ToLower(name) - for _, ext := range []string{".exe", ".bat", ".cmd"} { - if strings.HasSuffix(lower, ext) { - return name[:len(name)-len(ext)] - } + switch strings.ToLower(filepath.Ext(name)) { + case ".exe", ".bat", ".cmd": + return strings.TrimSuffix(name, filepath.Ext(name)) } return name } diff --git a/cmd/entire/cli/agent/external/discovery_test.go b/cmd/entire/cli/agent/external/discovery_test.go index 6700d1994..be01656b7 100644 --- a/cmd/entire/cli/agent/external/discovery_test.go +++ b/cmd/entire/cli/agent/external/discovery_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" "github.com/entireio/cli/cmd/entire/cli/agent" @@ -279,3 +280,35 @@ func TestDiscoverAndRegister_SkipsInfoFailure(t *testing.T) { t.Error("expected agent with bad info to be skipped, but it was registered") } } + +// TestDiscoverAndRegister_RegistersBatOnWindows verifies that a .bat agent +// binary is discovered and registered on Windows, with the file extension +// stripped from the agent name. .cmd and .exe follow the same code path. +func TestDiscoverAndRegister_RegistersBatOnWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("this test only applies on Windows") + } + + enableExternalAgents(t) + + name := "disc-bat" + infoJSON := `{"protocol_version":1,"name":"` + name + `","type":"` + name + ` Agent","description":"Agent ` + name + `","is_preview":false,"protected_dirs":[],"hook_names":[],"capabilities":{}}` + script := "@echo off\r\nif not \"%1\"==\"info\" goto :notinfo\r\necho " + infoJSON + "\r\ngoto :eof\r\n:notinfo\r\necho unknown subcommand: %1 1>&2\r\nexit /b 1\r\n" + + dir := t.TempDir() + binPath := filepath.Join(dir, binaryPrefix+name+".bat") + if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil { + t.Fatalf("write mock binary: %v", err) + } + t.Setenv("PATH", dir) + + DiscoverAndRegister(context.Background()) + + ag, err := agent.Get(types.AgentName(name)) + if err != nil { + t.Fatalf("expected agent %q to be registered after stripping .bat, got error: %v", name, err) + } + if string(ag.Name()) != name { + t.Errorf("agent Name() = %q, want %q", ag.Name(), name) + } +} diff --git a/cmd/entire/cli/agent/external/external_test.go b/cmd/entire/cli/agent/external/external_test.go index 5143fbe9a..c99d9b669 100644 --- a/cmd/entire/cli/agent/external/external_test.go +++ b/cmd/entire/cli/agent/external/external_test.go @@ -664,3 +664,35 @@ func TestWrap_HooksAnalyzerPreparer(t *testing.T) { t.Error("Wrap() should not return TokenCalculator when token_calculator=false") } } + +func TestStripExeExt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "exe lowercase", in: "entire-agent-test.exe", want: "entire-agent-test"}, + {name: "bat lowercase", in: "entire-agent-test.bat", want: "entire-agent-test"}, + {name: "cmd lowercase", in: "entire-agent-test.cmd", want: "entire-agent-test"}, + {name: "exe uppercase", in: "entire-agent-test.EXE", want: "entire-agent-test"}, + {name: "bat mixed case", in: "entire-agent-test.Bat", want: "entire-agent-test"}, + {name: "cmd mixed case", in: "entire-agent-test.CmD", want: "entire-agent-test"}, + {name: "no extension", in: "entire-agent-test", want: "entire-agent-test"}, + {name: "unrelated extension", in: "entire-agent-test.sh", want: "entire-agent-test.sh"}, + {name: "dot only", in: "entire-agent-test.", want: "entire-agent-test."}, + {name: "empty string", in: "", want: ""}, + {name: "exe in middle", in: "entire-agent-exe-test", want: "entire-agent-exe-test"}, + {name: "double extension", in: "entire-agent-test.tar.exe", want: "entire-agent-test.tar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := stripExeExt(tt.in); got != tt.want { + t.Errorf("stripExeExt(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} From c5c78b851ec687625e82e4c3030259ddb40ec5b4 Mon Sep 17 00:00:00 2001 From: kylezhao Date: Wed, 25 Mar 2026 10:40:36 +0800 Subject: [PATCH 3/3] fix: add nolint directive for executable bit check on Windows --- cmd/entire/cli/agent/external/external_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/external/external_test.go b/cmd/entire/cli/agent/external/external_test.go index c99d9b669..7c867d7ae 100644 --- a/cmd/entire/cli/agent/external/external_test.go +++ b/cmd/entire/cli/agent/external/external_test.go @@ -22,7 +22,7 @@ func testBinaryDir(t *testing.T, script string) string { dir := t.TempDir() name := "entire-agent-test" - if runtime.GOOS == "windows" { + if runtime.GOOS == "windows" { //nolint:goconst // idiomatic OS comparison name += ".bat" }