From 9c02d8e69dc49f80edfd910d39478e8622c578e0 Mon Sep 17 00:00:00 2001 From: Eva H <63033505+hoyyeva@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:11:50 -0700 Subject: [PATCH] launch: auto-install Claude Code (#16802) --- cmd/launch/claude.go | 98 +++++++++++++- cmd/launch/claude_test.go | 227 ++++++++++++++++++++++++++++++++ cmd/launch/integrations_test.go | 2 +- cmd/launch/registry.go | 4 + 4 files changed, 324 insertions(+), 7 deletions(-) diff --git a/cmd/launch/claude.go b/cmd/launch/claude.go index cab90bb1..476bf942 100644 --- a/cmd/launch/claude.go +++ b/cmd/launch/claude.go @@ -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 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{ diff --git a/cmd/launch/claude_test.go b/cmd/launch/claude_test.go index bdfa8ecb..d02e01be 100644 --- a/cmd/launch/claude_test.go +++ b/cmd/launch/claude_test.go @@ -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{} diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 812bc4d0..31d60ea7 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -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}, diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 613b916a..8c4e7312 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -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", }, },