launch: auto-install Claude Code (#16802)

This commit is contained in:
Eva H
2026-06-19 10:11:50 -07:00
committed by GitHub
parent 07ed752353
commit 9c02d8e69d
4 changed files with 324 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/ollama/ollama/envconfig"
)
@@ -37,17 +38,21 @@ func (c *Claude) findPath() (string, error) {
if runtime.GOOS == "windows" {
name = "claude.exe"
}
fallback := filepath.Join(home, ".claude", "local", name)
if _, err := os.Stat(fallback); err != nil {
return "", err
}
for _, fallback := range []string{
filepath.Join(home, ".local", "bin", name),
filepath.Join(home, ".claude", "local", name),
} {
if _, err := os.Stat(fallback); err == nil {
return fallback, nil
}
}
return "", fmt.Errorf("claude binary not found")
}
func (c *Claude) Run(model string, _ []LaunchModel, args []string) error {
claudePath, err := c.findPath()
claudePath, err := ensureClaudeInstalled()
if err != nil {
return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart")
return err
}
cmd := exec.Command(claudePath, c.args(model, args)...)
@@ -68,6 +73,87 @@ func (c *Claude) Run(model string, _ []LaunchModel, args []string) error {
return cmd.Run()
}
func ensureClaudeInstalled() (string, error) {
if path, err := (&Claude{}).findPath(); err == nil {
return path, nil
}
if err := checkClaudeInstallerDependencies(); err != nil {
return "", err
}
ok, err := ConfirmPrompt("Claude Code is not installed. Install now?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("claude installation cancelled")
}
bin, args, err := claudeInstallerCommand(runtime.GOOS)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "\nInstalling Claude Code...\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 claude: %w", err)
}
path, err := (&Claude{}).findPath()
if err != nil {
return "", fmt.Errorf("claude was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
}
fmt.Fprintf(os.Stderr, "%sClaude Code installed successfully%s\n\n", ansiGreen, ansiReset)
return path, nil
}
func checkClaudeInstallerDependencies() error {
switch runtime.GOOS {
case "windows":
if _, err := exec.LookPath("powershell"); err != nil {
return fmt.Errorf("claude is not installed and required dependencies are missing\n\nInstall the following first:\n PowerShell: https://learn.microsoft.com/powershell/\n\nThen re-run:\n ollama launch claude")
}
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("claude is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch claude", strings.Join(missing, "\n "))
}
}
return nil
}
func claudeInstallerCommand(goos string) (string, []string, error) {
switch goos {
case "windows":
return "powershell", []string{
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
"irm https://claude.ai/install.ps1 | iex",
}, nil
case "darwin", "linux":
return "bash", []string{
"-c",
"curl -fsSL https://claude.ai/install.sh | bash",
}, nil
default:
return "", nil, fmt.Errorf("unsupported platform for claude install: %s", goos)
}
}
// modelEnvVars returns Claude Code env vars that route all model tiers through Ollama.
func (c *Claude) modelEnvVars(model string) []string {
env := []string{

View File

@@ -1,6 +1,7 @@
package launch
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -67,6 +68,28 @@ func TestClaudeFindPath(t *testing.T) {
}
})
t.Run("falls back to ~/.local/bin/claude", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", t.TempDir()) // empty dir, no claude binary
name := "claude"
if runtime.GOOS == "windows" {
name = "claude.exe"
}
fallback := filepath.Join(tmpDir, ".local", "bin", name)
os.MkdirAll(filepath.Dir(fallback), 0o755)
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
got, err := c.findPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != fallback {
t.Errorf("findPath() = %q, want %q", got, fallback)
}
})
t.Run("returns error when neither PATH nor fallback exists", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -79,6 +102,210 @@ func TestClaudeFindPath(t *testing.T) {
})
}
func TestEnsureClaudeInstalled(t *testing.T) {
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)
writeFakeBinary(t, tmpDir, "claude")
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
bin, err := ensureClaudeInstalled()
if err != nil {
t.Fatalf("ensureClaudeInstalled() error = %v", err)
}
if filepath.Base(bin) != "claude" && filepath.Base(bin) != "claude.cmd" {
t.Fatalf("bin = %q, want claude binary", bin)
}
})
t.Run("missing dependencies", func(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
_, err := ensureClaudeInstalled()
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)
writeClaudeInstallerDeps(t, tmpDir)
withConfirm(t, func(prompt string) (bool, error) {
if prompt != "Claude Code is not installed. Install now?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return false, nil
})
_, err := ensureClaudeInstalled()
if err == nil || !strings.Contains(err.Error(), "installation cancelled") {
t.Fatalf("expected cancellation error, got %v", err)
}
})
t.Run("missing and user confirms 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)
writeFakeBinary(t, tmpDir, "curl")
installLog := filepath.Join(tmpDir, "bash.log")
installedClaude := filepath.Join(homeDir, ".local", "bin", "claude")
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(installedClaude), installedClaude, installedClaude)
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 := ensureClaudeInstalled()
if err != nil {
t.Fatalf("ensureClaudeInstalled() error = %v", err)
}
if bin != installedClaude {
t.Fatalf("bin = %q, want %q", bin, installedClaude)
}
logData, err := os.ReadFile(installLog)
if err != nil {
t.Fatalf("failed to read install log: %v", err)
}
if !strings.Contains(string(logData), "https://claude.ai/install.sh") {
t.Fatalf("expected install.sh 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)
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 := ensureClaudeInstalled()
if err == nil || !strings.Contains(err.Error(), "failed to install claude") {
t.Fatalf("expected install failure error, got %v", err)
}
})
}
func writeClaudeInstallerDeps(t *testing.T, dir string) {
t.Helper()
if runtime.GOOS == "windows" {
writeFakeBinary(t, dir, "powershell")
return
}
writeFakeBinary(t, dir, "curl")
writeFakeBinary(t, dir, "bash")
}
func TestClaudeInstallerCommand(t *testing.T) {
tests := []struct {
name string
goos string
wantBin string
want string
wantErr string
}{
{
name: "unix",
goos: "linux",
wantBin: "bash",
want: "curl -fsSL https://claude.ai/install.sh | bash",
},
{
name: "macos",
goos: "darwin",
wantBin: "bash",
want: "curl -fsSL https://claude.ai/install.sh | bash",
},
{
name: "windows",
goos: "windows",
wantBin: "powershell",
want: "irm https://claude.ai/install.ps1 | iex",
},
{
name: "unsupported",
goos: "plan9",
wantErr: "unsupported platform",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bin, args, err := claudeInstallerCommand(tt.goos)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("claudeInstallerCommand() error = %v", err)
}
if bin != tt.wantBin {
t.Fatalf("bin = %q, want %q", bin, tt.wantBin)
}
if !slices.Contains(args, tt.want) {
t.Fatalf("args = %v, want command containing %q", args, tt.want)
}
})
}
}
func TestClaudeArgs(t *testing.T) {
c := &Claude{}

View File

@@ -2035,7 +2035,7 @@ func TestIntegration_AutoInstallable(t *testing.T) {
{"hermes-desktop", true},
{"cline", true},
{"qwen", true},
{"claude", false},
{"claude", true},
{"claude-desktop", false},
{"codex", false},
{"opencode", false},

View File

@@ -45,6 +45,10 @@ var integrationSpecs = []*IntegrationSpec{
_, err := (&Claude{}).findPath()
return err == nil
},
EnsureInstalled: func() error {
_, err := ensureClaudeInstalled()
return err
},
URL: "https://code.claude.com/docs/en/quickstart",
},
},