mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
launch: auto-install opencode when missing (#16806)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -157,6 +157,10 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
_, ok := findOpenCode()
|
||||
return ok
|
||||
},
|
||||
EnsureInstalled: func() error {
|
||||
_, err := ensureOpenCodeInstalled()
|
||||
return err
|
||||
},
|
||||
URL: "https://opencode.ai",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user