From e434a93884cffda10add8effa5abc05dc667df33 Mon Sep 17 00:00:00 2001 From: Eva H <63033505+hoyyeva@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:12:11 -0700 Subject: [PATCH] launch: auto-install opencode when missing (#16806) --- cmd/launch/integrations_test.go | 2 +- cmd/launch/opencode.go | 84 ++++++++++- cmd/launch/opencode_test.go | 258 ++++++++++++++++++++++++++++++++ cmd/launch/registry.go | 4 + 4 files changed, 343 insertions(+), 5 deletions(-) diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 31d60ea7..a6e1a238 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -2038,7 +2038,7 @@ func TestIntegration_AutoInstallable(t *testing.T) { {"claude", true}, {"claude-desktop", false}, {"codex", false}, - {"opencode", false}, + {"opencode", true}, {"omp", false}, } for _, tt := range tests { diff --git a/cmd/launch/opencode.go b/cmd/launch/opencode.go index 80d333ad..1f38666a 100644 --- a/cmd/launch/opencode.go +++ b/cmd/launch/opencode.go @@ -14,6 +14,10 @@ import ( "github.com/ollama/ollama/envconfig" ) +const openCodeInstallScript = "curl -fsSL https://opencode.ai/install | bash" + +var openCodeGOOS = runtime.GOOS + // OpenCode implements Runner and Editor for OpenCode integration. // Config is passed via OPENCODE_CONFIG_CONTENT env var at launch time // instead of writing to opencode's config files. @@ -34,7 +38,7 @@ func findOpenCode() (string, bool) { return "", false } name := "opencode" - if runtime.GOOS == "windows" { + if openCodeGOOS == "windows" { name = "opencode.exe" } fallback := filepath.Join(home, ".opencode", "bin", name) @@ -45,9 +49,9 @@ func findOpenCode() (string, bool) { } func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error { - opencodePath, ok := findOpenCode() - if !ok { - return fmt.Errorf("opencode is not installed, install from https://opencode.ai") + opencodePath, err := ensureOpenCodeInstalled() + if err != nil { + return err } cmd := exec.Command(opencodePath, args...) @@ -61,6 +65,78 @@ func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error return cmd.Run() } +func ensureOpenCodeInstalled() (string, error) { + if opencodePath, ok := findOpenCode(); ok { + return opencodePath, nil + } + + if err := checkOpenCodeInstallerDependencies(); err != nil { + return "", err + } + + ok, err := ConfirmPrompt("OpenCode is not installed. Install now?") + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("opencode installation cancelled") + } + + bin, args, err := openCodeInstallerCommand(openCodeGOOS) + if err != nil { + return "", err + } + + fmt.Fprintf(os.Stderr, "\nInstalling OpenCode...\n") + cmd := exec.Command(bin, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to install opencode: %w", err) + } + + opencodePath, ok := findOpenCode() + if !ok { + return "", fmt.Errorf("opencode was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + } + + fmt.Fprintf(os.Stderr, "%sOpenCode installed successfully%s\n\n", ansiGreen, ansiReset) + return opencodePath, nil +} + +func checkOpenCodeInstallerDependencies() error { + switch openCodeGOOS { + case "windows": + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("opencode is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch opencode") + } + default: + var missing []string + if _, err := exec.LookPath("curl"); err != nil { + missing = append(missing, "curl: https://curl.se/") + } + if _, err := exec.LookPath("bash"); err != nil { + missing = append(missing, "bash: https://www.gnu.org/software/bash/") + } + if len(missing) > 0 { + return fmt.Errorf("opencode is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch opencode", strings.Join(missing, "\n ")) + } + } + return nil +} + +func openCodeInstallerCommand(goos string) (string, []string, error) { + switch goos { + case "windows": + return "npm", []string{"install", "-g", "opencode-ai@latest"}, nil + case "darwin", "linux": + return "bash", []string{"-c", "set -o pipefail; " + openCodeInstallScript}, nil + default: + return "", nil, fmt.Errorf("unsupported platform for opencode install: %s", goos) + } +} + // resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT. // Returns content built by Edit if available, otherwise builds from model.json // with the requested model as primary (e.g. re-launch with saved config). diff --git a/cmd/launch/opencode_test.go b/cmd/launch/opencode_test.go index fd23b52b..14c9af17 100644 --- a/cmd/launch/opencode_test.go +++ b/cmd/launch/opencode_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/ollama/ollama/api" @@ -338,12 +339,16 @@ func TestLookupCloudModelLimit(t *testing.T) { } func TestFindOpenCode(t *testing.T) { + oldGOOS := openCodeGOOS + t.Cleanup(func() { openCodeGOOS = oldGOOS }) + t.Run("fallback to ~/.opencode/bin", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) // Ensure opencode is not on PATH t.Setenv("PATH", tmpDir) + openCodeGOOS = runtime.GOOS // Without the fallback binary, findOpenCode should fail if _, ok := findOpenCode(); ok { @@ -371,6 +376,259 @@ func TestFindOpenCode(t *testing.T) { }) } +func TestEnsureOpenCodeInstalled(t *testing.T) { + oldGOOS := openCodeGOOS + t.Cleanup(func() { openCodeGOOS = oldGOOS }) + + withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) { + t.Helper() + oldConfirm := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return fn(prompt) + } + t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm }) + } + + t.Run("already installed", func(t *testing.T) { + setTestHome(t, t.TempDir()) + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + openCodeGOOS = runtime.GOOS + writeFakeBinary(t, tmpDir, "opencode") + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect prompt, got %q", prompt) + return false, nil + }) + + bin, err := ensureOpenCodeInstalled() + if err != nil { + t.Fatalf("ensureOpenCodeInstalled() error = %v", err) + } + if filepath.Base(bin) == "" { + t.Fatalf("expected opencode binary path, got %q", bin) + } + }) + + t.Run("missing dependencies", func(t *testing.T) { + setTestHome(t, t.TempDir()) + t.Setenv("PATH", t.TempDir()) + openCodeGOOS = "linux" + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect prompt, got %q", prompt) + return false, nil + }) + + _, err := ensureOpenCodeInstalled() + if err == nil || !strings.Contains(err.Error(), "required dependencies are missing") { + t.Fatalf("expected missing dependency error, got %v", err) + } + }) + + t.Run("missing and user declines install", func(t *testing.T) { + setTestHome(t, t.TempDir()) + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + openCodeGOOS = "linux" + writeFakeBinary(t, tmpDir, "curl") + writeFakeBinary(t, tmpDir, "bash") + + withConfirm(t, func(prompt string) (bool, error) { + if !strings.Contains(prompt, "OpenCode is not installed.") { + t.Fatalf("unexpected prompt: %q", prompt) + } + return false, nil + }) + + _, err := ensureOpenCodeInstalled() + if err == nil || !strings.Contains(err.Error(), "installation cancelled") { + t.Fatalf("expected cancellation error, got %v", err) + } + }) + + t.Run("missing and user confirms unix install succeeds", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell fake binaries") + } + + homeDir := t.TempDir() + setTestHome(t, homeDir) + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + openCodeGOOS = "linux" + writeFakeBinary(t, tmpDir, "curl") + + installLog := filepath.Join(tmpDir, "bash.log") + opencodePath := filepath.Join(homeDir, ".opencode", "bin", "opencode") + bashScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "-c" ]; then + /bin/mkdir -p %q + /bin/cat > %q <<'EOS' +#!/bin/sh +exit 0 +EOS + /bin/chmod +x %q +fi +exit 0 +`, installLog, filepath.Dir(opencodePath), opencodePath, opencodePath) + if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte(bashScript), 0o755); err != nil { + t.Fatalf("failed to write fake bash: %v", err) + } + + withConfirm(t, func(prompt string) (bool, error) { + return true, nil + }) + + bin, err := ensureOpenCodeInstalled() + if err != nil { + t.Fatalf("ensureOpenCodeInstalled() error = %v", err) + } + if bin != opencodePath { + t.Fatalf("bin = %q, want %q", bin, opencodePath) + } + + logData, err := os.ReadFile(installLog) + if err != nil { + t.Fatalf("failed to read install log: %v", err) + } + if !strings.Contains(string(logData), openCodeInstallScript) { + t.Fatalf("expected opencode install script in log, got:\n%s", string(logData)) + } + }) + + t.Run("missing and user confirms windows install succeeds", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell fake binaries") + } + + homeDir := t.TempDir() + setTestHome(t, homeDir) + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + openCodeGOOS = "windows" + + installLog := filepath.Join(tmpDir, "npm.log") + opencodePath := filepath.Join(homeDir, ".opencode", "bin", "opencode.exe") + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +/bin/mkdir -p %q +/bin/cat > %q <<'EOS' +@echo off +exit /b 0 +EOS +/bin/chmod +x %q +exit 0 +`, installLog, filepath.Dir(opencodePath), opencodePath, opencodePath) + if err := os.WriteFile(filepath.Join(tmpDir, "npm"), []byte(npmScript), 0o755); err != nil { + t.Fatalf("failed to write fake npm: %v", err) + } + + withConfirm(t, func(prompt string) (bool, error) { + return true, nil + }) + + bin, err := ensureOpenCodeInstalled() + if err != nil { + t.Fatalf("ensureOpenCodeInstalled() error = %v", err) + } + if bin != opencodePath { + t.Fatalf("bin = %q, want %q", bin, opencodePath) + } + + logData, err := os.ReadFile(installLog) + if err != nil { + t.Fatalf("failed to read install log: %v", err) + } + if !strings.Contains(string(logData), "install -g opencode-ai@latest") { + t.Fatalf("expected npm install command in log, got:\n%s", string(logData)) + } + }) + + t.Run("install command fails", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell fake binaries") + } + + setTestHome(t, t.TempDir()) + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + openCodeGOOS = "linux" + writeFakeBinary(t, tmpDir, "curl") + if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatalf("failed to write fake bash: %v", err) + } + + withConfirm(t, func(prompt string) (bool, error) { + return true, nil + }) + + _, err := ensureOpenCodeInstalled() + if err == nil || !strings.Contains(err.Error(), "failed to install opencode") { + t.Fatalf("expected install failure error, got %v", err) + } + }) +} + +func TestOpenCodeInstallerCommand(t *testing.T) { + tests := []struct { + name string + goos string + wantBin string + wantParts []string + wantErr bool + }{ + { + name: "linux", + goos: "linux", + wantBin: "bash", + wantParts: []string{"-c", "set -o pipefail", "https://opencode.ai/install"}, + }, + { + name: "darwin", + goos: "darwin", + wantBin: "bash", + wantParts: []string{"-c", "set -o pipefail", "https://opencode.ai/install"}, + }, + { + name: "windows", + goos: "windows", + wantBin: "npm", + wantParts: []string{"install", "-g", "opencode-ai@latest"}, + }, + { + name: "unsupported", + goos: "plan9", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bin, args, err := openCodeInstallerCommand(tt.goos) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("openCodeInstallerCommand() error = %v", err) + } + if bin != tt.wantBin { + t.Fatalf("bin = %q, want %q", bin, tt.wantBin) + } + joined := strings.Join(args, " ") + for _, want := range tt.wantParts { + if !strings.Contains(joined, want) { + t.Fatalf("args %q missing %q", joined, want) + } + } + }) + } +} + // Verify that the BackfillsCloudModelLimitOnExistingEntry test from the old // file-based approach is covered by the new inline config approach. func TestOpenCodeEdit_CloudModelLimitStructure(t *testing.T) { diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 8c4e7312..46f2879f 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -157,6 +157,10 @@ var integrationSpecs = []*IntegrationSpec{ _, ok := findOpenCode() return ok }, + EnsureInstalled: func() error { + _, err := ensureOpenCodeInstalled() + return err + }, URL: "https://opencode.ai", }, },