launch: auto-install opencode when missing (#16806)

This commit is contained in:
Eva H
2026-06-19 10:12:11 -07:00
committed by GitHub
parent 9c02d8e69d
commit e434a93884
4 changed files with 343 additions and 5 deletions

View File

@@ -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 {

View File

@@ -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).

View File

@@ -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) {

View File

@@ -157,6 +157,10 @@ var integrationSpecs = []*IntegrationSpec{
_, ok := findOpenCode()
return ok
},
EnsureInstalled: func() error {
_, err := ensureOpenCodeInstalled()
return err
},
URL: "https://opencode.ai",
},
},