Skip to content

Commit 53d5e16

Browse files
zhichligsnaiper
authored andcommitted
Native Windows Support (dagger#242)
* 1. signal handling * 2. logger log path * 3. get notify signal * 4. repository.go * 5. git * 6. config.go * 7. watch * 8. config agent * 9. docker daemon * 10. completion * 11. skip non compatible tests on Windows * 12. smooth watch in windows * 13. release: add windows support
1 parent 2654b6d commit 53d5e16

28 files changed

+542
-71
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/cu
22
/container-use
3+
/container-use.exe
34
/completions/
45

56
# Go targets

.goreleaser.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ builds:
1818
goos:
1919
- linux
2020
- darwin
21+
- windows
2122
goarch:
2223
- amd64
2324
- arm64

cmd/container-use/agent/configure.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"runtime"
78
"strings"
89

910
"github.com/dagger/container-use/mcpserver"
@@ -71,6 +72,14 @@ type ConfigurableAgent interface {
7172

7273
// Add agents here
7374
func selectAgent(agentKey string) (ConfigurableAgent, error) {
75+
// Check if agent is supported on current platform
76+
if runtime.GOOS == "windows" {
77+
switch agentKey {
78+
case "codex", "amazonq":
79+
return nil, fmt.Errorf("agent '%s' is not supported on native Windows.\nTo use this agent, please install and run container-use in Windows Subsystem for Linux (WSL)", agentKey)
80+
}
81+
}
82+
7483
switch agentKey {
7584
case "claude":
7685
return &ConfigureClaude{}, nil

cmd/container-use/agent/configure_goose.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"os/exec"
77
"path/filepath"
8+
"runtime"
89

910
"github.com/dagger/container-use/rules"
1011
"github.com/mitchellh/go-homedir"
@@ -35,9 +36,23 @@ func (a *ConfigureGoose) description() string {
3536

3637
// Save the MCP config with container-use enabled
3738
func (a *ConfigureGoose) editMcpConfig() error {
38-
configPath, err := homedir.Expand(filepath.Join("~", ".config", "goose", "config.yaml"))
39-
if err != nil {
40-
return err
39+
var configPath string
40+
var err error
41+
42+
if runtime.GOOS == "windows" {
43+
// Windows: %APPDATA%\Block\goose\config\config.yaml
44+
// Reference: https://block.github.io/goose/docs/guides/config-file
45+
appData := os.Getenv("APPDATA")
46+
if appData == "" {
47+
return fmt.Errorf("APPDATA environment variable not set")
48+
}
49+
configPath = filepath.Join(appData, "Block", "goose", "config", "config.yaml")
50+
} else {
51+
// macOS/Linux: ~/.config/goose/config.yaml
52+
configPath, err = homedir.Expand(filepath.Join("~", ".config", "goose", "config.yaml"))
53+
if err != nil {
54+
return err
55+
}
4156
}
4257

4358
// Create directory if it doesn't exist

cmd/container-use/agent/configure_ui.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package agent
22

33
import (
44
"fmt"
5+
"runtime"
56
"strings"
67

78
tea "github.com/charmbracelet/bubbletea"
@@ -35,15 +36,30 @@ var agents = []Agent{
3536
{
3637
Key: "codex",
3738
Name: "OpenAI Codex",
38-
Description: "OpenAI's lightweight coding agent that runs in your terminal",
39+
Description: "OpenAI's lightweight coding agent that runs in your terminal (Linux/macOS/WSL)",
3940
},
4041
{
4142
Key: "amazonq",
4243
Name: "Amazon Q Developer",
43-
Description: "Amazon's agentic chat experience in your terminal",
44+
Description: "Amazon's agentic chat experience in your terminal (Linux/macOS/WSL)",
4445
},
4546
}
4647

48+
// getSupportedAgents returns agents that are supported on the current platform
49+
func getSupportedAgents() []Agent {
50+
if runtime.GOOS == "windows" {
51+
// Filter out Windows-incompatible agents
52+
var supportedAgents []Agent
53+
for _, agent := range agents {
54+
if agent.Key != "codex" && agent.Key != "amazonq" {
55+
supportedAgents = append(supportedAgents, agent)
56+
}
57+
}
58+
return supportedAgents
59+
}
60+
return agents
61+
}
62+
4763
// AgentSelectorModel represents the bubbletea model for agent selection
4864
type AgentSelectorModel struct {
4965
cursor int
@@ -63,6 +79,7 @@ func (m AgentSelectorModel) Init() tea.Cmd {
6379

6480
// Update handles incoming messages and updates the model
6581
func (m AgentSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82+
supportedAgents := getSupportedAgents()
6683
switch msg := msg.(type) {
6784
case tea.KeyMsg:
6885
switch msg.String() {
@@ -74,11 +91,11 @@ func (m AgentSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7491
m.cursor--
7592
}
7693
case "down", "j":
77-
if m.cursor < len(agents)-1 {
94+
if m.cursor < len(supportedAgents)-1 {
7895
m.cursor++
7996
}
8097
case "enter", " ":
81-
m.selected = agents[m.cursor].Key
98+
m.selected = supportedAgents[m.cursor].Key
8299
m.quit = true
83100
return m, tea.Quit
84101
}
@@ -137,8 +154,19 @@ func (m AgentSelectorModel) View() string {
137154
s.WriteString(headerStyle.Render("Select an agent to configure:"))
138155
s.WriteString("\n\n")
139156

157+
// Show WSL note for Windows users
158+
if runtime.GOOS == "windows" {
159+
wslNoteStyle := lipgloss.NewStyle().
160+
Foreground(lipgloss.Color("#FFA500")).
161+
Padding(0, 1).
162+
Italic(true)
163+
s.WriteString(wslNoteStyle.Render("Note: OpenAI Codex and Amazon Q Developer are available in WSL"))
164+
s.WriteString("\n\n")
165+
}
166+
140167
// Agent list TODO: filter or sort agents based on if they are installed (ConfigurableAgent.isInstalled())
141-
for i, agent := range agents {
168+
supportedAgents := getSupportedAgents()
169+
for i, agent := range supportedAgents {
142170
cursor := " " // not selected
143171
if m.cursor == i {
144172
cursor = "▶ " // selected

cmd/container-use/completion_override.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ var commandName string
1212
func init() {
1313
// Override cobra's default completion command
1414
completionCmd := &cobra.Command{
15-
Use: "completion [bash|zsh|fish]",
15+
Use: "completion [bash|zsh|fish|powershell]",
1616
Short: "Generate the autocompletion script for the specified shell",
1717
Long: `Generate the autocompletion script for container-use for the specified shell.
1818
See each sub-command's help for details on how to use the generated script.
1919
2020
Use --command-name to generate completions for a different command name (e.g., 'cu').`,
2121
DisableFlagsInUseLine: true,
22-
ValidArgs: []string{"bash", "zsh", "fish"},
22+
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
2323
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
2424
RunE: func(cmd *cobra.Command, args []string) error {
2525
return generateCompletionForBinary(args[0])
@@ -29,7 +29,7 @@ Use --command-name to generate completions for a different command name (e.g., '
2929
completionCmd.PersistentFlags().StringVar(&commandName, "command-name", "container-use", "Command name to use in completions")
3030

3131
// Add help subcommands that show usage instructions
32-
for _, shell := range []string{"bash", "zsh", "fish"} {
32+
for _, shell := range []string{"bash", "zsh", "fish", "powershell"} {
3333
shell := shell // capture loop variable
3434
helpCmd := &cobra.Command{
3535
Use: shell,
@@ -65,6 +65,8 @@ func generateCompletionForBinary(shell string) error {
6565
return tempRootCmd.GenZshCompletion(os.Stdout)
6666
case "fish":
6767
return tempRootCmd.GenFishCompletion(os.Stdout, true)
68+
case "powershell":
69+
return tempRootCmd.GenPowerShellCompletion(os.Stdout)
6870
}
6971
return nil
7072
}
@@ -81,8 +83,14 @@ If it is not installed already, you can install it via your OS's package manager
8183
To load completions in your current shell session:
8284
source <({{.command}} completion bash)
8385
84-
To load completions for every new session, execute once:
85-
{{.command}} completion bash > /usr/local/etc/bash_completion.d/{{.command}}`,
86+
To load completions for every new session, save the output to your bash completion directory.
87+
Common locations include:
88+
- Linux: /usr/local/etc/bash_completion.d/{{.command}}
89+
- macOS: $(brew --prefix)/etc/bash_completion.d/{{.command}}
90+
- Windows (Git Bash): /usr/share/bash-completion/completions/{{.command}}
91+
92+
Example:
93+
{{.command}} completion bash > /path/to/bash_completion.d/{{.command}}`,
8694

8795
"zsh": `Generate the autocompletion script for the zsh shell.
8896
@@ -93,16 +101,42 @@ you will need to enable it. You can execute the following once:
93101
To load completions in your current shell session:
94102
source <({{.command}} completion zsh)
95103
96-
To load completions for every new session, execute once:
97-
{{.command}} completion zsh > /usr/local/share/zsh/site-functions/_{{.command}}`,
104+
To load completions for every new session, save the output to your zsh completion directory.
105+
Common locations include:
106+
- Linux: /usr/local/share/zsh/site-functions/_{{.command}}
107+
- macOS: $(brew --prefix)/share/zsh/site-functions/_{{.command}}
108+
- Custom: Any directory in your $fpath
109+
110+
Example:
111+
{{.command}} completion zsh > /path/to/zsh/site-functions/_{{.command}}`,
98112

99113
"fish": `Generate the autocompletion script for the fish shell.
100114
101115
To load completions in your current shell session:
102116
{{.command}} completion fish | source
103117
104-
To load completions for every new session, execute once:
118+
To load completions for every new session, save the output to your fish completion directory.
119+
Common locations include:
120+
- Linux/macOS: ~/.config/fish/completions/{{.command}}.fish
121+
- Windows: %APPDATA%\fish\completions\{{.command}}.fish
122+
123+
Example:
105124
{{.command}} completion fish > ~/.config/fish/completions/{{.command}}.fish`,
125+
126+
"powershell": `Generate the autocompletion script for PowerShell.
127+
128+
To load completions in your current shell session:
129+
{{.command}} completion powershell | Out-String | Invoke-Expression
130+
131+
To load completions for every new session, add the output to your PowerShell profile.
132+
Common profile locations include:
133+
- Windows: $PROFILE (usually %USERPROFILE%\Documents\PowerShell\Microsoft.PowerShell_profile.ps1)
134+
- Linux/macOS: ~/.config/powershell/Microsoft.PowerShell_profile.ps1
135+
136+
Example:
137+
{{.command}} completion powershell >> $PROFILE
138+
139+
Note: You may need to create the profile file if it doesn't exist.`,
106140
}
107141

108142
template := templates[shell]

cmd/container-use/docker_errors.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,24 @@ func isDockerDaemonError(err error) bool {
1313
}
1414

1515
errStr := strings.ToLower(err.Error())
16-
return strings.Contains(errStr, "cannot connect to the docker daemon") ||
17-
strings.Contains(errStr, "docker daemon") ||
16+
17+
// Linux: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
18+
if strings.Contains(errStr, "cannot connect to the docker daemon") {
19+
return true
20+
}
21+
22+
// Windows: error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.51/containers/json": open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.
23+
if strings.Contains(errStr, "error during connect") && strings.Contains(errStr, "pipe/dockerdesktoplinuxengine") && strings.Contains(errStr, "the system cannot find the file specified") {
24+
return true
25+
}
26+
27+
// macOS: request returned 500 Internal Server Error for API route and version http://%2FUsers%2Fb1tank%2F.docker%2Frun%2Fdocker.sock/v1.50/containers/json, check if the server supports the requested API version
28+
if strings.Contains(errStr, "request returned 500 internal server error") && strings.Contains(errStr, "docker.sock") && strings.Contains(errStr, "check if the server supports the requested api version") {
29+
return true
30+
}
31+
32+
// Generic fallbacks
33+
return strings.Contains(errStr, "docker daemon") ||
1834
strings.Contains(errStr, "docker.sock")
1935
}
2036

cmd/container-use/docker_errors_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,30 @@ func TestIsDockerDaemonError(t *testing.T) {
1717
expected: false,
1818
},
1919
{
20-
name: "docker daemon error",
20+
name: "docker daemon error - linux",
2121
err: errors.New("Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"),
2222
expected: true,
2323
},
24+
{
25+
name: "docker daemon error - windows",
26+
err: errors.New("error during connect: Get \"http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.51/containers/json\": open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified."),
27+
expected: true,
28+
},
29+
{
30+
name: "docker daemon error - macos",
31+
err: errors.New("request returned 500 Internal Server Error for API route and version http://%2FUsers%2Fb1tank%2F.docker%2Frun%2Fdocker.sock/v1.50/containers/json, check if the server supports the requested API version"),
32+
expected: true,
33+
},
34+
{
35+
name: "docker daemon error - generic",
36+
err: errors.New("docker daemon is not running"),
37+
expected: true,
38+
},
39+
{
40+
name: "docker socket error - generic",
41+
err: errors.New("connection to docker.sock failed"),
42+
expected: true,
43+
},
2444
{
2545
name: "other error",
2646
err: errors.New("some other error"),

cmd/container-use/logger.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"log/slog"
77
"os"
8+
"path/filepath"
89
"time"
910
)
1011

@@ -30,7 +31,7 @@ func parseLogLevel(levelStr string) slog.Level {
3031
func setupLogger() error {
3132
var writers []io.Writer
3233

33-
logFile := "/tmp/container-use.debug.stderr.log"
34+
logFile := filepath.Join(os.TempDir(), "container-use.debug.stderr.log")
3435
if v, ok := os.LookupEnv("CONTAINER_USE_STDERR_FILE"); ok {
3536
logFile = v
3637
}

cmd/container-use/main.go

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@ import (
44
"context"
55
_ "embed"
66
"fmt"
7-
"io"
87
"os"
9-
"os/signal"
10-
"runtime"
11-
"syscall"
128

139
"github.com/charmbracelet/fang"
1410
"github.com/dagger/container-use/repository"
@@ -26,9 +22,7 @@ Each environment runs in its own container with dedicated git branches.`,
2622

2723
func main() {
2824
ctx := context.Background()
29-
sigusrCh := make(chan os.Signal, 1)
30-
signal.Notify(sigusrCh, syscall.SIGUSR1)
31-
go handleSIGUSR(sigusrCh)
25+
setupSignalHandling()
3226

3327
if err := setupLogger(); err != nil {
3428
fmt.Fprintf(os.Stderr, "Failed to setup logger: %v\n", err)
@@ -50,26 +44,12 @@ func main() {
5044
rootCmd,
5145
fang.WithVersion(version),
5246
fang.WithCommit(commit),
53-
fang.WithNotifySignal(os.Interrupt, os.Kill, syscall.SIGTERM),
47+
fang.WithNotifySignal(getNotifySignals()...),
5448
); err != nil {
5549
os.Exit(1)
5650
}
5751
}
5852

59-
func handleSIGUSR(sigusrCh <-chan os.Signal) {
60-
for sig := range sigusrCh {
61-
if sig == syscall.SIGUSR1 {
62-
dumpStacks()
63-
}
64-
}
65-
}
66-
67-
func dumpStacks() {
68-
buf := make([]byte, 1<<20) // 1MB buffer
69-
n := runtime.Stack(buf, true)
70-
io.MultiWriter(logWriter, os.Stderr).Write(buf[:n])
71-
}
72-
7353
func suggestEnvironments(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
7454
ctx := cmd.Context()
7555

0 commit comments

Comments
 (0)