diff --git a/app/store/database.go b/app/store/database.go index bb81bdad..00d1a948 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -1201,15 +1201,16 @@ func (db *database) getSettings() (Settings, error) { func (db *database) setSettings(s Settings) error { lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView)) validLaunchView := map[string]struct{}{ - "launch": {}, - "openclaw": {}, - "claude": {}, - "hermes": {}, - "codex": {}, - "copilot": {}, - "opencode": {}, - "droid": {}, - "pi": {}, + "launch": {}, + "openclaw": {}, + "claude": {}, + "claude-desktop": {}, + "hermes": {}, + "codex": {}, + "copilot": {}, + "opencode": {}, + "droid": {}, + "pi": {}, } if lastHomeView != "chat" { if _, ok := validLaunchView[lastHomeView]; !ok { diff --git a/app/ui/app/public/launch-icons/claude-code.svg b/app/ui/app/public/launch-icons/claude-code.svg new file mode 100644 index 00000000..ed1293c4 --- /dev/null +++ b/app/ui/app/public/launch-icons/claude-code.svg @@ -0,0 +1 @@ +Claude Code \ No newline at end of file diff --git a/app/ui/app/src/components/LaunchCommands.tsx b/app/ui/app/src/components/LaunchCommands.tsx index bc42a0a8..772b99b9 100644 --- a/app/ui/app/src/components/LaunchCommands.tsx +++ b/app/ui/app/src/components/LaunchCommands.tsx @@ -13,6 +13,22 @@ interface LaunchCommand { } const LAUNCH_COMMANDS: LaunchCommand[] = [ + { + id: "claude-desktop", + name: "Claude Desktop", + command: "ollama launch claude-desktop", + description: "Claude Desktop with Ollama Cloud", + icon: "/launch-icons/claude.svg", + iconClassName: "h-7 w-7", + }, + { + id: "claude", + name: "Claude Code", + command: "ollama launch claude", + description: "Anthropic's coding tool with subagents", + icon: "/launch-icons/claude-code.svg", + iconClassName: "h-7 w-7", + }, { id: "openclaw", name: "OpenClaw", @@ -21,20 +37,11 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [ icon: "/launch-icons/openclaw.svg", }, { - id: "claude", - name: "Claude", - command: "ollama launch claude", - description: "Anthropic's coding tool with subagents", - icon: "/launch-icons/claude.svg", - iconClassName: "h-7 w-7", - }, - { - id: "codex", - name: "Codex", - command: "ollama launch codex", - description: "OpenAI's open-source coding agent", - icon: "/launch-icons/codex.svg", - darkIcon: "/launch-icons/codex-dark.svg", + id: "hermes", + name: "Hermes Agent", + command: "ollama launch hermes", + description: "Self-improving AI agent built by Nous Research", + icon: "/launch-icons/hermes-agent.svg", iconClassName: "h-7 w-7", }, { @@ -46,11 +53,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [ iconClassName: "h-7 w-7 rounded", }, { - id: "hermes", - name: "Hermes Agent", - command: "ollama launch hermes", - description: "Self-improving AI agent built by Nous Research", - icon: "/launch-icons/hermes-agent.svg", + id: "codex", + name: "Codex", + command: "ollama launch codex", + description: "OpenAI's open-source coding agent", + icon: "/launch-icons/codex.svg", + darkIcon: "/launch-icons/codex-dark.svg", iconClassName: "h-7 w-7", }, { diff --git a/cmd/cmd.go b/cmd/cmd.go index a5e8c087..ad3bdfd7 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2119,8 +2119,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe if err != nil { return true, fmt.Errorf("launching %s: %w", action.Integration, err) } - // VS Code is a GUI app — exit the TUI loop after launching - if action.Integration == "vscode" { + if launcherActionExitsLoop(action.Integration) { return false, nil } return true, nil @@ -2129,6 +2128,15 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe } } +func launcherActionExitsLoop(integration string) bool { + switch integration { + case "claude-desktop", "vscode": + return true + default: + return false + } +} + func NewCLI() *cobra.Command { log.SetFlags(log.LstdFlags | log.Lshortfile) cobra.EnableCommandSorting = false diff --git a/cmd/cmd_launcher_test.go b/cmd/cmd_launcher_test.go index 2d76219b..a90c74e9 100644 --- a/cmd/cmd_launcher_test.go +++ b/cmd/cmd_launcher_test.go @@ -209,29 +209,30 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) { } } -func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) { +func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) { setCmdTestHome(t, t.TempDir()) cmd := &cobra.Command{} cmd.SetContext(context.Background()) - // VS Code should exit the TUI loop (return false) after a successful launch. - continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{ - resolveRunModel: unexpectedRunModelResolution(t), - launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { - return nil - }, - runModel: unexpectedModelLaunch(t), - }) - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - if continueLoop { - t.Fatal("expected vscode launch to exit the TUI loop (return false)") + for _, integration := range []string{"claude-desktop", "vscode"} { + continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{ + resolveRunModel: unexpectedRunModelResolution(t), + launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { + return nil + }, + runModel: unexpectedModelLaunch(t), + }) + if err != nil { + t.Fatalf("expected nil error for %s, got %v", integration, err) + } + if continueLoop { + t.Fatalf("expected %s launch to exit the TUI loop (return false)", integration) + } } // Other integrations should continue the TUI loop (return true). - continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{ + continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{ resolveRunModel: unexpectedRunModelResolution(t), launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { return nil diff --git a/cmd/launch/claude_desktop.go b/cmd/launch/claude_desktop.go new file mode 100644 index 00000000..588fbe37 --- /dev/null +++ b/cmd/launch/claude_desktop.go @@ -0,0 +1,889 @@ +package launch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/internal/fileutil" + "golang.org/x/term" +) + +const ( + claudeDesktopIntegrationName = "claude-desktop" + claudeDesktopProfileName = "Ollama" + claudeDesktopProfileID = "00000000-0000-4000-8000-000000000114" + claudeDesktopGatewayBaseURL = "https://ollama.com" + claudeDesktopAPIKeyURL = "https://ollama.com/settings/keys" + claudeDesktopModelLabel = "Ollama Cloud" + claudeDesktopSuccessMessage = "Claude Desktop profile changed to Ollama Cloud." + claudeDesktopRestoreMessage = "To restore the usual Claude profile, run: ollama launch claude-desktop --restore" + claudeDesktopRestoredMessage = "Claude Desktop restored to the usual Claude profile." +) + +var ( + claudeDesktopGOOS = runtime.GOOS + claudeDesktopUserHome = os.UserHomeDir + claudeDesktopStat = os.Stat + claudeDesktopOpenApp = defaultClaudeDesktopOpenApp + claudeDesktopOpenAppPath = defaultClaudeDesktopOpenAppPath + claudeDesktopQuitApp = defaultClaudeDesktopQuitApp + claudeDesktopIsRunning = defaultClaudeDesktopIsRunning + claudeDesktopRunningAppPath = defaultClaudeDesktopRunningAppPath + claudeDesktopGlob = filepath.Glob + claudeDesktopSleep = time.Sleep + claudeDesktopHTTPClient = http.DefaultClient + claudeDesktopPromptAPIKey = promptClaudeDesktopAPIKey + claudeDesktopValidateAPIKey = validateClaudeDesktopAPIKey +) + +// ClaudeDesktop configures and launches Claude Desktop in third-party +// inference mode using Ollama Cloud as the gateway. +type ClaudeDesktop struct{} + +func (c *ClaudeDesktop) String() string { return "Claude Desktop" } + +func (c *ClaudeDesktop) Supported() error { return claudeDesktopSupported() } + +func (c *ClaudeDesktop) Paths() []string { + return nil +} + +func (c *ClaudeDesktop) AutodiscoveredModel() string { + return claudeDesktopModelLabel +} + +func (c *ClaudeDesktop) ConfigureAutodiscovery() error { + if err := claudeDesktopSupported(); err != nil { + return err + } + + targets, err := claudeDesktopTargetPaths() + if err != nil { + return err + } + + key, err := claudeDesktopValidatedAPIKey(context.Background(), claudeDesktopTargetProfilePaths(targets)) + if err != nil { + return err + } + + for _, path := range targets.normalConfigs { + if err := writeClaudeDesktopDeploymentMode(path, "3p"); err != nil { + return err + } + } + for _, target := range targets.thirdPartyProfiles { + if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "3p"); err != nil { + return err + } + if err := writeClaudeDesktopMeta(target.meta, claudeDesktopProfileID, claudeDesktopProfileName); err != nil { + return err + } + if err := writeClaudeDesktopGatewayProfile(target.profile, key, true); err != nil { + return err + } + } + return nil +} + +func (c *ClaudeDesktop) RestoreHint() string { + return claudeDesktopRestoreMessage +} + +func (c *ClaudeDesktop) ConfigurationSuccessMessage() string { + return claudeDesktopSuccessMessage + "\n" + claudeDesktopRestoreMessage +} + +func (c *ClaudeDesktop) RestoreSuccessMessage() string { + return claudeDesktopRestoredMessage +} + +func (c *ClaudeDesktop) AutodiscoveryConfigured() bool { + targets, err := claudeDesktopTargetPaths() + if err != nil { + return false + } + return claudeDesktopTargetsConfigured(targets) +} + +func (c *ClaudeDesktop) Onboard() error { + return config.MarkIntegrationOnboarded(claudeDesktopIntegrationName) +} + +func (c *ClaudeDesktop) RequiresInteractiveOnboarding() bool { + return false +} + +func (c *ClaudeDesktop) SkipModelReadiness() bool { + return true +} + +func (c *ClaudeDesktop) Run(_ string, args []string) error { + if err := claudeDesktopSupported(); err != nil { + return err + } + if len(args) > 0 { + return fmt.Errorf("claude-desktop does not accept extra arguments") + } + return claudeDesktopLaunchOrRestart("Restart Claude Desktop to use Ollama?") +} + +func (c *ClaudeDesktop) Restore() error { + if err := claudeDesktopSupported(); err != nil { + return err + } + targets, err := claudeDesktopTargetPaths() + if err != nil { + return err + } + + for _, path := range targets.normalConfigs { + if err := writeClaudeDesktopDeploymentMode(path, "1p"); err != nil { + return err + } + } + for _, target := range targets.thirdPartyProfiles { + if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "1p"); err != nil { + return err + } + if err := restoreClaudeDesktopMeta(target.meta); err != nil { + return err + } + if err := restoreClaudeDesktopOllamaProfile(target.profile); err != nil { + return err + } + } + return claudeDesktopLaunchOrRestart("Restart Claude Desktop to use the usual Claude profile?") +} + +func claudeDesktopSupported() error { + switch claudeDesktopGOOS { + case "darwin", "windows": + return nil + default: + return fmt.Errorf("Claude Desktop launch is only supported on macOS and Windows") + } +} + +func claudeDesktopInstalled() bool { + if claudeDesktopAppPath() != "" { + return true + } + if claudeDesktopGOOS == "windows" && claudeDesktopIsRunning() { + return true + } + for _, dir := range claudeDesktopProfileDirCandidates(false) { + if _, err := claudeDesktopStat(dir); err == nil { + return true + } + } + return false +} + +func claudeDesktopAppPath() string { + if claudeDesktopGOOS != "darwin" && claudeDesktopGOOS != "windows" { + return "" + } + for _, path := range claudeDesktopAppCandidates() { + if _, err := claudeDesktopStat(path); err == nil { + return path + } + } + return "" +} + +func claudeDesktopAppCandidates() []string { + switch claudeDesktopGOOS { + case "darwin": + return claudeDesktopDarwinAppCandidates() + case "windows": + return claudeDesktopWindowsAppCandidates() + default: + return nil + } +} + +func claudeDesktopDarwinAppCandidates() []string { + candidates := []string{"/Applications/Claude.app"} + if home, err := claudeDesktopUserHome(); err == nil { + candidates = append(candidates, filepath.Join(home, "Applications", "Claude.app")) + } + return candidates +} + +func claudeDesktopWindowsAppCandidates() []string { + local, err := claudeDesktopLocalAppData() + if err != nil { + return nil + } + + candidates := []string{ + filepath.Join(local, "Programs", "Claude", "Claude.exe"), + filepath.Join(local, "Programs", "Claude Desktop", "Claude.exe"), + filepath.Join(local, "Claude", "Claude.exe"), + filepath.Join(local, "Claude Nest", "Claude.exe"), + filepath.Join(local, "Claude Desktop", "Claude.exe"), + filepath.Join(local, "AnthropicClaude", "Claude.exe"), + } + for _, pattern := range []string{ + filepath.Join(local, "AnthropicClaude", "app-*", "Claude.exe"), + filepath.Join(local, "Programs", "Claude", "app-*", "Claude.exe"), + filepath.Join(local, "Programs", "Claude Desktop", "app-*", "Claude.exe"), + } { + matches, _ := claudeDesktopGlob(pattern) + candidates = append(candidates, matches...) + } + return claudeDesktopDedupePaths(candidates) +} + +func claudeDesktopDedupePaths(paths []string) []string { + out := make([]string, 0, len(paths)) + seen := make(map[string]bool, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + key := strings.ToLower(path) + if seen[key] { + continue + } + seen[key] = true + out = append(out, path) + } + return out +} + +type claudeDesktopPaths struct { + normalConfig string + desktopConfig string + meta string + profile string +} + +type claudeDesktopThirdPartyPaths struct { + desktopConfig string + meta string + profile string +} + +type claudeDesktopTargets struct { + normalConfigs []string + thirdPartyProfiles []claudeDesktopThirdPartyPaths +} + +func claudeDesktopConfigPaths() (claudeDesktopPaths, error) { + switch claudeDesktopGOOS { + case "darwin": + return claudeDesktopDarwinConfigPaths() + case "windows": + return claudeDesktopWindowsConfigPaths() + default: + return claudeDesktopPaths{}, claudeDesktopSupported() + } +} + +func claudeDesktopDarwinConfigPaths() (claudeDesktopPaths, error) { + normalRoots, thirdPartyRoots, err := claudeDesktopDarwinProfileRoots() + if err != nil { + return claudeDesktopPaths{}, err + } + normalBase := normalRoots[0] + thirdPartyBase := thirdPartyRoots[0] + return claudeDesktopPaths{ + normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"), + desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"), + meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"), + profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"), + }, nil +} + +func claudeDesktopWindowsConfigPaths() (claudeDesktopPaths, error) { + normalBase, err := claudeDesktopProfileDir(true) + if err != nil { + return claudeDesktopPaths{}, err + } + thirdPartyBase, err := claudeDesktopProfileDir(false) + if err != nil { + return claudeDesktopPaths{}, err + } + return claudeDesktopPaths{ + normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"), + desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"), + meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"), + profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"), + }, nil +} + +func claudeDesktopProfileDir(normal bool) (string, error) { + candidates := claudeDesktopProfileDirCandidates(normal) + if len(candidates) == 0 { + return "", fmt.Errorf("Claude Desktop profile directory could not be resolved") + } + for _, candidate := range candidates { + if _, err := claudeDesktopStat(candidate); err == nil { + return candidate, nil + } + } + return candidates[0], nil +} + +func claudeDesktopProfileDirCandidates(normal bool) []string { + if claudeDesktopGOOS != "windows" { + return nil + } + normalRoots, thirdPartyRoots, err := claudeDesktopWindowsProfileRoots() + if err != nil { + return nil + } + if normal { + return normalRoots + } + return thirdPartyRoots +} + +func claudeDesktopDarwinProfileRoots() ([]string, []string, error) { + home, err := claudeDesktopUserHome() + if err != nil { + return nil, nil, err + } + base := filepath.Join(home, "Library", "Application Support") + return []string{filepath.Join(base, "Claude")}, []string{filepath.Join(base, "Claude-3p")}, nil +} + +func claudeDesktopWindowsProfileRoots() ([]string, []string, error) { + local, err := claudeDesktopLocalAppData() + if err != nil { + return nil, nil, err + } + normalRoots := []string{ + filepath.Join(local, "Claude"), + filepath.Join(local, "Claude Nest"), + } + thirdPartyRoots := []string{ + filepath.Join(local, "Claude-3p"), + filepath.Join(local, "Claude Nest-3p"), + } + return normalRoots, thirdPartyRoots, nil +} + +func claudeDesktopTargetPaths() (claudeDesktopTargets, error) { + var ( + normalRoots []string + thirdPartyRoots []string + err error + ) + + switch claudeDesktopGOOS { + case "darwin": + normalRoots, thirdPartyRoots, err = claudeDesktopDarwinProfileRoots() + case "windows": + normalRoots, thirdPartyRoots, err = claudeDesktopWindowsProfileRoots() + default: + err = claudeDesktopSupported() + } + if err != nil { + return claudeDesktopTargets{}, err + } + + return newClaudeDesktopTargets(normalRoots, thirdPartyRoots), nil +} + +func newClaudeDesktopTargets(normalRoots, thirdPartyRoots []string) claudeDesktopTargets { + targets := claudeDesktopTargets{} + for _, root := range claudeDesktopDedupePaths(normalRoots) { + targets.normalConfigs = append(targets.normalConfigs, filepath.Join(root, "claude_desktop_config.json")) + } + for _, root := range claudeDesktopDedupePaths(thirdPartyRoots) { + targets.thirdPartyProfiles = append(targets.thirdPartyProfiles, claudeDesktopThirdPartyPaths{ + desktopConfig: filepath.Join(root, "claude_desktop_config.json"), + meta: filepath.Join(root, "configLibrary", "_meta.json"), + profile: filepath.Join(root, "configLibrary", claudeDesktopProfileID+".json"), + }) + } + return targets +} + +func claudeDesktopTargetProfilePaths(targets claudeDesktopTargets) []string { + paths := make([]string, 0, len(targets.thirdPartyProfiles)) + for _, target := range targets.thirdPartyProfiles { + paths = append(paths, target.profile) + } + return paths +} + +func claudeDesktopLocalAppData() (string, error) { + if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" { + return local, nil + } + if home := strings.TrimSpace(os.Getenv("USERPROFILE")); home != "" { + return filepath.Join(home, "AppData", "Local"), nil + } + home, err := claudeDesktopUserHome() + if err != nil { + return "", err + } + return filepath.Join(home, "AppData", "Local"), nil +} + +type claudeDesktopAPIKeySource int + +const ( + claudeDesktopAPIKeySourceNone claudeDesktopAPIKeySource = iota + claudeDesktopAPIKeySourceEnv + claudeDesktopAPIKeySourceProfile +) + +func claudeDesktopValidatedAPIKey(ctx context.Context, profilePaths []string) (string, error) { + key, source, err := claudeDesktopAPIKey(profilePaths) + if err != nil { + return "", err + } + if err := claudeDesktopValidateAPIKey(ctx, key); err == nil { + return key, nil + } else if source != claudeDesktopAPIKeySourceProfile || !canPromptClaudeDesktopAPIKey() { + return "", err + } + return promptValidClaudeDesktopAPIKey(ctx) +} + +func claudeDesktopAPIKey(profilePaths []string) (string, claudeDesktopAPIKeySource, error) { + if key := strings.TrimSpace(os.Getenv("OLLAMA_API_KEY")); key != "" { + return key, claudeDesktopAPIKeySourceEnv, nil + } + for _, profilePath := range profilePaths { + if key := readClaudeDesktopGatewayAPIKey(profilePath); key != "" { + return key, claudeDesktopAPIKeySourceProfile, nil + } + } + key, err := promptClaudeDesktopAPIKeyValue() + return key, claudeDesktopAPIKeySourceNone, err +} + +func canPromptClaudeDesktopAPIKey() bool { + return isInteractiveSession() && !currentLaunchConfirmPolicy.requireYesMessage +} + +func promptValidClaudeDesktopAPIKey(ctx context.Context) (string, error) { + key, err := promptClaudeDesktopAPIKeyValue() + if err != nil { + return "", err + } + if err := claudeDesktopValidateAPIKey(ctx, key); err != nil { + return "", err + } + return key, nil +} + +func promptClaudeDesktopAPIKeyValue() (string, error) { + if !canPromptClaudeDesktopAPIKey() { + return "", missingClaudeDesktopAPIKeyError() + } + key, err := claudeDesktopPromptAPIKey() + if err != nil { + return "", err + } + key = strings.TrimSpace(key) + if key == "" { + return "", missingClaudeDesktopAPIKeyError() + } + return key, nil +} + +func missingClaudeDesktopAPIKeyError() error { + return fmt.Errorf("OLLAMA_API_KEY is required for Claude Desktop. Create an API key at %s, then re-run with OLLAMA_API_KEY set", claudeDesktopAPIKeyURL) +} + +func promptClaudeDesktopAPIKey() (string, error) { + fmt.Fprint(os.Stderr, claudeDesktopAPIKeyPrompt()) + key, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + return string(key), nil +} + +func claudeDesktopAPIKeyPrompt() string { + return fmt.Sprintf("Create an Ollama API key at %s\nEnter Ollama API key (input hidden): ", claudeDesktopAPIKeyURL) +} + +func readClaudeDesktopGatewayAPIKey(path string) string { + cfg, err := readClaudeDesktopJSON(path) + if err != nil { + return "" + } + key, _ := cfg["inferenceGatewayApiKey"].(string) + return strings.TrimSpace(key) +} + +func validateClaudeDesktopAPIKey(ctx context.Context, key string) error { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + if claudeDesktopAPIKeyHasInvalidHeaderChars(key) { + return claudeDesktopAPIKeyVerificationError() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeDesktopGatewayBaseURL+"/v1/models", nil) + if err != nil { + return claudeDesktopAPIKeyVerificationError() + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Accept", "application/json") + + resp, err := claudeDesktopHTTPClient.Do(req) + if err != nil { + return claudeDesktopAPIKeyVerificationError() + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) + + switch { + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return fmt.Errorf("Ollama API key was rejected; create a valid key at %s", claudeDesktopAPIKeyURL) + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return nil + default: + return fmt.Errorf("could not verify Ollama API key; ollama.com returned status %d, try again later", resp.StatusCode) + } +} + +func claudeDesktopAPIKeyHasInvalidHeaderChars(key string) bool { + return strings.ContainsFunc(key, func(r rune) bool { + return r < ' ' || r == 0x7f + }) +} + +func claudeDesktopAPIKeyVerificationError() error { + return fmt.Errorf("could not verify Ollama API key; copy a key from %s and try again", claudeDesktopAPIKeyURL) +} + +func writeClaudeDesktopDeploymentMode(path, mode string) error { + cfg, err := readClaudeDesktopJSONAllowMissing(path) + if err != nil { + return fmt.Errorf("parse Claude Desktop config: %w", err) + } + cfg["deploymentMode"] = mode + return writeClaudeDesktopJSON(path, cfg) +} + +func writeClaudeDesktopMeta(path, id, name string) error { + meta, err := readClaudeDesktopJSONAllowMissing(path) + if err != nil { + return fmt.Errorf("parse Claude Desktop config metadata: %w", err) + } + + meta["appliedId"] = id + entries := make([]any, 0) + for _, entry := range claudeDesktopAnySlice(meta["entries"]) { + entryMap, _ := entry.(map[string]any) + if entryMap == nil { + entries = append(entries, entry) + continue + } + if entryID, _ := entryMap["id"].(string); entryID == id { + continue + } + entries = append(entries, entryMap) + } + entries = append(entries, map[string]any{ + "id": id, + "name": name, + }) + meta["entries"] = entries + return writeClaudeDesktopJSON(path, meta) +} + +func writeClaudeDesktopGatewayProfile(path string, apiKey string, forceChooser bool) error { + cfg, err := readClaudeDesktopJSONAllowMissing(path) + if err != nil { + return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err) + } + cfg["inferenceProvider"] = "gateway" + cfg["inferenceGatewayBaseUrl"] = claudeDesktopGatewayBaseURL + cfg["inferenceGatewayApiKey"] = apiKey + cfg["inferenceGatewayAuthScheme"] = "bearer" + delete(cfg, "inferenceModels") + cfg["disableDeploymentModeChooser"] = forceChooser + return writeClaudeDesktopJSON(path, cfg) +} + +func restoreClaudeDesktopMeta(path string) error { + meta, err := readClaudeDesktopJSONAllowMissing(path) + if err != nil { + return fmt.Errorf("parse Claude Desktop config metadata: %w", err) + } + if len(meta) == 0 { + return nil + } + + changed := false + if appliedID, _ := meta["appliedId"].(string); appliedID == claudeDesktopProfileID { + delete(meta, "appliedId") + changed = true + } + + entries := claudeDesktopAnySlice(meta["entries"]) + if entries != nil { + filtered := make([]any, 0, len(entries)) + for _, entry := range entries { + entryMap, _ := entry.(map[string]any) + if entryID, _ := entryMap["id"].(string); entryID == claudeDesktopProfileID { + changed = true + continue + } + filtered = append(filtered, entry) + } + meta["entries"] = filtered + } + + if !changed { + return nil + } + return writeClaudeDesktopJSON(path, meta) +} + +func restoreClaudeDesktopOllamaProfile(path string) error { + cfg, err := readClaudeDesktopJSONAllowMissing(path) + if err != nil { + return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err) + } + if len(cfg) == 0 { + return nil + } + cfg["disableDeploymentModeChooser"] = false + delete(cfg, "inferenceProvider") + delete(cfg, "inferenceGatewayBaseUrl") + delete(cfg, "inferenceGatewayAuthScheme") + delete(cfg, "inferenceModels") + return writeClaudeDesktopJSON(path, cfg) +} + +func readClaudeDesktopAppliedID(path string) string { + meta, err := readClaudeDesktopJSON(path) + if err != nil { + return "" + } + applied, _ := meta["appliedId"].(string) + return applied +} + +func readClaudeDesktopDeploymentMode(path string) string { + cfg, err := readClaudeDesktopJSON(path) + if err != nil { + return "" + } + mode, _ := cfg["deploymentMode"].(string) + return mode +} + +func claudeDesktopTargetsConfigured(targets claudeDesktopTargets) bool { + if len(targets.normalConfigs) == 0 || len(targets.thirdPartyProfiles) == 0 { + return false + } + for _, path := range targets.normalConfigs { + if readClaudeDesktopDeploymentMode(path) != "3p" { + return false + } + } + for _, target := range targets.thirdPartyProfiles { + if readClaudeDesktopDeploymentMode(target.desktopConfig) != "3p" { + return false + } + if !claudeDesktopThirdPartyProfileConfigured(target) { + return false + } + } + return true +} + +func claudeDesktopThirdPartyProfileConfigured(target claudeDesktopThirdPartyPaths) bool { + if readClaudeDesktopAppliedID(target.meta) != claudeDesktopProfileID { + return false + } + + cfg, err := readClaudeDesktopJSON(target.profile) + if err != nil { + return false + } + if s, _ := cfg["inferenceProvider"].(string); s != "gateway" { + return false + } + if s, _ := cfg["inferenceGatewayBaseUrl"].(string); strings.TrimRight(s, "/") != claudeDesktopGatewayBaseURL { + return false + } + if s, _ := cfg["inferenceGatewayApiKey"].(string); strings.TrimSpace(s) == "" { + return false + } + return true +} + +func readClaudeDesktopJSONAllowMissing(path string) (map[string]any, error) { + cfg, err := readClaudeDesktopJSON(path) + if errors.Is(err, os.ErrNotExist) { + return map[string]any{}, nil + } + return cfg, err +} + +func readClaudeDesktopJSON(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg == nil { + cfg = map[string]any{} + } + return cfg, nil +} + +func writeClaudeDesktopJSON(path string, cfg any) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return fileutil.WriteWithBackup(path, data) +} + +func claudeDesktopAnySlice(value any) []any { + switch v := value.(type) { + case []any: + return v + case nil: + return nil + default: + return nil + } +} + +func claudeDesktopLaunchOrRestart(prompt string) error { + if !claudeDesktopIsRunning() { + return claudeDesktopOpenApp() + } + restartAppPath := "" + if claudeDesktopGOOS == "windows" { + restartAppPath = claudeDesktopRunningAppPath() + } + + restart, err := ConfirmPrompt(prompt) + if err != nil { + return err + } + if !restart { + fmt.Fprintln(os.Stderr, "\nQuit and reopen Claude Desktop when you're ready for the profile change to take effect.") + return nil + } + + if err := claudeDesktopQuitApp(); err != nil { + return fmt.Errorf("quit Claude Desktop: %w", err) + } + if err := waitForClaudeDesktopExit(30 * time.Second); err != nil { + return err + } + if restartAppPath != "" { + return claudeDesktopOpenAppPath(restartAppPath) + } + return claudeDesktopOpenApp() +} + +func waitForClaudeDesktopExit(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !claudeDesktopIsRunning() { + return nil + } + claudeDesktopSleep(200 * time.Millisecond) + } + return fmt.Errorf("Claude Desktop did not quit; quit it manually and re-run the command") +} + +func defaultClaudeDesktopIsRunning() bool { + switch claudeDesktopGOOS { + case "darwin": + out, err := exec.Command("pgrep", "-f", "Claude.app/Contents/MacOS/Claude").Output() + return err == nil && strings.TrimSpace(string(out)) != "" + case "windows": + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id`).Output() + return err == nil && strings.TrimSpace(string(out)) != "" + default: + return false + } +} + +func defaultClaudeDesktopOpenApp() error { + switch claudeDesktopGOOS { + case "windows": + if path := claudeDesktopAppPath(); path != "" { + return claudeDesktopOpenAppPath(path) + } + if path := claudeDesktopRunningAppPath(); path != "" { + return claudeDesktopOpenAppPath(path) + } + return fmt.Errorf("Claude Desktop executable was not found; open Claude Desktop manually once and re-run 'ollama launch claude-desktop'") + case "darwin": + return openClaudeDesktopDarwin() + default: + return claudeDesktopSupported() + } +} + +func defaultClaudeDesktopOpenAppPath(path string) error { + switch claudeDesktopGOOS { + case "windows": + return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process -FilePath "+quotePowerShellString(path)).Run() + case "darwin": + return openClaudeDesktopDarwin() + default: + return claudeDesktopSupported() + } +} + +func openClaudeDesktopDarwin() error { + cmd := exec.Command("open", "-a", "Claude") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func defaultClaudeDesktopRunningAppPath() string { + if claudeDesktopGOOS != "windows" { + return "" + } + script := `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)` + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func defaultClaudeDesktopQuitApp() error { + if claudeDesktopGOOS == "windows" { + script := `Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }` + return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run() + } + return exec.Command("osascript", "-e", `tell application "Claude" to quit`).Run() +} + +func quotePowerShellString(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} diff --git a/cmd/launch/claude_desktop_test.go b/cmd/launch/claude_desktop_test.go new file mode 100644 index 00000000..4f2071fe --- /dev/null +++ b/cmd/launch/claude_desktop_test.go @@ -0,0 +1,938 @@ +package launch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func withClaudeDesktopPlatform(t *testing.T, goos string) { + t.Helper() + old := claudeDesktopGOOS + claudeDesktopGOOS = goos + t.Cleanup(func() { + claudeDesktopGOOS = old + }) +} + +func withClaudeDesktopValidation(t *testing.T, fn func(context.Context, string) error) { + t.Helper() + old := claudeDesktopValidateAPIKey + claudeDesktopValidateAPIKey = fn + t.Cleanup(func() { + claudeDesktopValidateAPIKey = old + }) +} + +func withClaudeDesktopPrompt(t *testing.T, fn func() (string, error)) { + t.Helper() + old := claudeDesktopPromptAPIKey + claudeDesktopPromptAPIKey = fn + t.Cleanup(func() { + claudeDesktopPromptAPIKey = old + }) +} + +func withClaudeDesktopProcessHooks(t *testing.T, running func() bool, quit func() error, open func() error) { + t.Helper() + oldRunning := claudeDesktopIsRunning + oldQuit := claudeDesktopQuitApp + oldOpen := claudeDesktopOpenApp + oldOpenPath := claudeDesktopOpenAppPath + oldRunningPath := claudeDesktopRunningAppPath + oldSleep := claudeDesktopSleep + claudeDesktopIsRunning = running + claudeDesktopQuitApp = quit + claudeDesktopOpenApp = open + claudeDesktopOpenAppPath = oldOpenPath + claudeDesktopRunningAppPath = oldRunningPath + claudeDesktopSleep = func(time.Duration) {} + t.Cleanup(func() { + claudeDesktopIsRunning = oldRunning + claudeDesktopQuitApp = oldQuit + claudeDesktopOpenApp = oldOpen + claudeDesktopOpenAppPath = oldOpenPath + claudeDesktopRunningAppPath = oldRunningPath + claudeDesktopSleep = oldSleep + }) +} + +func claudeDesktopReadJSON(t *testing.T, path string) map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return cfg +} + +func TestClaudeDesktopIntegration(t *testing.T) { + c := &ClaudeDesktop{} + + t.Run("implements Runner", func(t *testing.T) { + var _ Runner = c + }) + t.Run("implements managed autodiscovery integration", func(t *testing.T) { + var _ ManagedAutodiscoveryIntegration = c + }) + t.Run("does not use local Ollama Cloud auth gate", func(t *testing.T) { + if _, ok := any(c).(ManagedAutodiscoveryCloudIntegration); ok { + t.Fatal("Claude Desktop should validate OLLAMA_API_KEY directly instead of requiring local Ollama Cloud sign-in") + } + }) + t.Run("implements restore", func(t *testing.T) { + var _ RestorableIntegration = c + }) + t.Run("has restore hint", func(t *testing.T) { + var _ RestoreHintIntegration = c + if !strings.Contains(c.RestoreHint(), "--restore") { + t.Fatalf("expected restore hint to mention --restore, got %q", c.RestoreHint()) + } + if strings.Contains(c.RestoreHint(), "Tip:") { + t.Fatalf("restore hint should not use Tip wording, got %q", c.RestoreHint()) + } + }) + t.Run("has success messages", func(t *testing.T) { + var _ ConfigurationSuccessIntegration = c + var _ RestoreSuccessIntegration = c + if got := c.ConfigurationSuccessMessage(); got != "Claude Desktop profile changed to Ollama Cloud.\nTo restore the usual Claude profile, run: ollama launch claude-desktop --restore" { + t.Fatalf("configuration success message = %q", got) + } + if got := c.RestoreSuccessMessage(); got != "Claude Desktop restored to the usual Claude profile." { + t.Fatalf("restore success message = %q", got) + } + }) + t.Run("skips local model readiness", func(t *testing.T) { + var _ ManagedModelReadinessSkipper = c + if !c.SkipModelReadiness() { + t.Fatal("expected Claude Desktop to skip local model readiness") + } + }) +} + +func TestLaunchIntegration_ClaudeDesktopDoesNotRequireLocalCloudSignIn(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + withInteractiveSession(t, true) + withLauncherHooks(t) + t.Setenv("OLLAMA_API_KEY", "test-api-key") + + if err := os.MkdirAll(filepath.Join(tmpDir, "Applications", "Claude.app"), 0o755); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/status": + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":"not found"}`) + case "/api/me": + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + var validatedKey string + withClaudeDesktopValidation(t, func(_ context.Context, key string) error { + validatedKey = key + return nil + }) + + DefaultSignIn = func(modelName, signInURL string) (string, error) { + t.Fatalf("Claude Desktop launch should not require local Ollama Cloud sign-in, got %s at %s", modelName, signInURL) + return "", nil + } + + var openCalls int + withClaudeDesktopProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { + openCalls++ + return nil + }, + ) + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "claude-desktop"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + if validatedKey != "test-api-key" { + t.Fatalf("validated key = %q, want test API key", validatedKey) + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } +} + +func TestClaudeDesktopConfigureWritesOllamaCloudProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + t.Setenv("OLLAMA_API_KEY", "test-api-key") + + var validatedKey string + withClaudeDesktopValidation(t, func(_ context.Context, key string) error { + validatedKey = key + return nil + }) + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(paths.desktopConfig), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(paths.meta), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.desktopConfig, []byte(`{"existing":true}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.meta, []byte(`{"entries":[{"id":"custom","name":"Custom"}]}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + if validatedKey != "test-api-key" { + t.Fatalf("validated key = %q, want test API key", validatedKey) + } + + desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig) + if desktopConfig["existing"] != true { + t.Fatalf("existing desktop config key was not preserved: %v", desktopConfig) + } + if desktopConfig["deploymentMode"] != "3p" { + t.Fatalf("deploymentMode = %v, want 3p", desktopConfig["deploymentMode"]) + } + normalConfig := claudeDesktopReadJSON(t, paths.normalConfig) + if normalConfig["deploymentMode"] != "3p" { + t.Fatalf("normal deploymentMode = %v, want 3p", normalConfig["deploymentMode"]) + } + + meta := claudeDesktopReadJSON(t, paths.meta) + if meta["appliedId"] != claudeDesktopProfileID { + t.Fatalf("appliedId = %v, want %s", meta["appliedId"], claudeDesktopProfileID) + } + entries, _ := meta["entries"].([]any) + if len(entries) != 2 { + t.Fatalf("entries len = %d, want 2: %v", len(entries), entries) + } + + profile := claudeDesktopReadJSON(t, paths.profile) + if profile["inferenceProvider"] != "gateway" { + t.Fatalf("inferenceProvider = %v, want gateway", profile["inferenceProvider"]) + } + if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL { + t.Fatalf("base URL = %v, want %s", profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL) + } + if profile["inferenceGatewayApiKey"] != "test-api-key" { + t.Fatal("expected configured API key to be written") + } + if profile["inferenceGatewayAuthScheme"] != "bearer" { + t.Fatalf("auth scheme = %v, want bearer", profile["inferenceGatewayAuthScheme"]) + } + if profile["disableDeploymentModeChooser"] != true { + t.Fatalf("disableDeploymentModeChooser = %v, want true", profile["disableDeploymentModeChooser"]) + } + if _, ok := profile["inferenceModels"]; ok { + t.Fatalf("inferenceModels should be omitted so Claude can discover models, got %v", profile["inferenceModels"]) + } +} + +func TestClaudeDesktopConfigureAutodiscoveryRemovesExistingModelCatalog(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + t.Setenv("OLLAMA_API_KEY", "test-api-key") + withClaudeDesktopValidation(t, func(context.Context, string) error { return nil }) + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.profile, []byte(`{"inferenceModels":["qwen3.5"],"inferenceGatewayApiKey":"old"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil { + t.Fatalf("ConfigureAutodiscovery returned error: %v", err) + } + + profile := claudeDesktopReadJSON(t, paths.profile) + if _, ok := profile["inferenceModels"]; ok { + t.Fatalf("inferenceModels should be removed, got %v", profile["inferenceModels"]) + } + if profile["inferenceGatewayApiKey"] != "test-api-key" { + t.Fatal("expected env API key to replace the old key") + } +} + +func TestClaudeDesktopWindowsConfigPathsUseLocalAppData(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData")) + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(tmpDir, "LocalAppData", "Claude-3p", "claude_desktop_config.json"); paths.desktopConfig != want { + t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want) + } + if want := filepath.Join(tmpDir, "LocalAppData", "Claude", "claude_desktop_config.json"); paths.normalConfig != want { + t.Fatalf("normal config = %q, want %q", paths.normalConfig, want) + } +} + +func TestClaudeDesktopWindowsConfigPathsFallbackToNestProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + local := filepath.Join(tmpDir, "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + if err := os.MkdirAll(filepath.Join(local, "Claude Nest-3p"), 0o755); err != nil { + t.Fatal(err) + } + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(local, "Claude Nest-3p", "claude_desktop_config.json"); paths.desktopConfig != want { + t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want) + } +} + +func TestClaudeDesktopAutodiscoveryConfiguredOnWindows(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData")) + t.Setenv("OLLAMA_API_KEY", "test-api-key") + withClaudeDesktopValidation(t, func(context.Context, string) error { return nil }) + + c := &ClaudeDesktop{} + if err := c.ConfigureAutodiscovery(); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + if !c.AutodiscoveryConfigured() { + t.Fatal("expected Claude Desktop autodiscovery config to be detected on Windows") + } +} + +func TestClaudeDesktopConfigureAutodiscoveryTouchesAllWindowsProfileCandidates(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + local := filepath.Join(tmpDir, "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + t.Setenv("OLLAMA_API_KEY", "test-api-key") + withClaudeDesktopValidation(t, func(context.Context, string) error { return nil }) + + targets, err := claudeDesktopTargetPaths() + if err != nil { + t.Fatal(err) + } + if len(targets.normalConfigs) != 2 { + t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs)) + } + if len(targets.thirdPartyProfiles) != 2 { + t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles)) + } + + c := &ClaudeDesktop{} + if err := c.ConfigureAutodiscovery(); err != nil { + t.Fatalf("ConfigureAutodiscovery returned error: %v", err) + } + + for _, path := range targets.normalConfigs { + cfg := claudeDesktopReadJSON(t, path) + if cfg["deploymentMode"] != "3p" { + t.Fatalf("%s deploymentMode = %v, want 3p", path, cfg["deploymentMode"]) + } + } + for _, target := range targets.thirdPartyProfiles { + cfg := claudeDesktopReadJSON(t, target.desktopConfig) + if cfg["deploymentMode"] != "3p" { + t.Fatalf("%s deploymentMode = %v, want 3p", target.desktopConfig, cfg["deploymentMode"]) + } + meta := claudeDesktopReadJSON(t, target.meta) + if meta["appliedId"] != claudeDesktopProfileID { + t.Fatalf("%s appliedId = %v, want %s", target.meta, meta["appliedId"], claudeDesktopProfileID) + } + profile := claudeDesktopReadJSON(t, target.profile) + if profile["inferenceProvider"] != "gateway" { + t.Fatalf("%s inferenceProvider = %v, want gateway", target.profile, profile["inferenceProvider"]) + } + if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL { + t.Fatalf("%s base URL = %v, want %s", target.profile, profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL) + } + if profile["inferenceGatewayApiKey"] != "test-api-key" { + t.Fatalf("%s should contain the configured API key", target.profile) + } + if _, ok := profile["inferenceModels"]; ok { + t.Fatalf("%s inferenceModels should be omitted, got %v", target.profile, profile["inferenceModels"]) + } + } + if !c.AutodiscoveryConfigured() { + t.Fatal("expected all Windows profile candidates to be considered configured") + } + + if err := writeClaudeDesktopDeploymentMode(targets.thirdPartyProfiles[1].desktopConfig, "1p"); err != nil { + t.Fatal(err) + } + if c.AutodiscoveryConfigured() { + t.Fatal("expected a stale Windows candidate to force reconfiguration") + } +} + +func TestClaudeDesktopInstalledOnWindowsRecognizesLocalProfileDir(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + local := filepath.Join(tmpDir, "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }) + if err := os.MkdirAll(filepath.Join(local, "Claude-3p"), 0o755); err != nil { + t.Fatal(err) + } + + if !claudeDesktopInstalled() { + t.Fatal("expected Claude Desktop to be installed when the Windows profile directory exists") + } +} + +func TestClaudeDesktopWindowsAppPathFindsAnthropicClaudeInstall(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + local := filepath.Join(tmpDir, "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + want := filepath.Join(local, "AnthropicClaude", "app-1.2.3", "Claude.exe") + if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(want, []byte(""), 0o755); err != nil { + t.Fatal(err) + } + + if got := claudeDesktopAppPath(); got != want { + t.Fatalf("claudeDesktopAppPath() = %q, want %q", got, want) + } +} + +func TestWaitForClaudeDesktopExitUsesRunningHook(t *testing.T) { + withClaudeDesktopPlatform(t, "windows") + runningChecks := 0 + withClaudeDesktopProcessHooks(t, + func() bool { + runningChecks++ + return runningChecks == 1 + }, + func() error { return nil }, + func() error { return nil }, + ) + + if err := waitForClaudeDesktopExit(time.Second); err != nil { + t.Fatalf("waitForClaudeDesktopExit returned error: %v", err) + } + if runningChecks < 2 { + t.Fatalf("expected running hook to be checked until the visible window exits, got %d checks", runningChecks) + } +} + +func TestClaudeDesktopWindowsRestartUsesCapturedDesktopPath(t *testing.T) { + withClaudeDesktopPlatform(t, "windows") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + desktopPath := `C:\Users\parth\AppData\Local\AnthropicClaude\app-1.2.3\Claude.exe` + running := true + var openedPath string + withClaudeDesktopProcessHooks(t, + func() bool { return running }, + func() error { + running = false + return nil + }, + func() error { + t.Fatal("expected restart to open the captured Desktop executable path, not the generic launcher") + return nil + }, + ) + claudeDesktopRunningAppPath = func() string { return desktopPath } + claudeDesktopOpenAppPath = func(path string) error { + openedPath = path + return nil + } + + if err := (&ClaudeDesktop{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if openedPath != desktopPath { + t.Fatalf("opened path = %q, want %q", openedPath, desktopPath) + } +} + +func TestClaudeDesktopWindowsOpenDoesNotFallBackToClaudeCommand(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData")) + + oldRunningPath := claudeDesktopRunningAppPath + claudeDesktopRunningAppPath = func() string { return "" } + t.Cleanup(func() { claudeDesktopRunningAppPath = oldRunningPath }) + + err := defaultClaudeDesktopOpenApp() + if err == nil || !strings.Contains(err.Error(), "Claude Desktop executable was not found") { + t.Fatalf("defaultClaudeDesktopOpenApp error = %v, want executable-not-found error", err) + } +} + +func TestClaudeDesktopConfigureStopsBeforeWriteWhenKeyValidationFails(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + t.Setenv("OLLAMA_API_KEY", "bad-key") + withClaudeDesktopValidation(t, func(context.Context, string) error { + return errors.New("invalid key") + }) + + err := (&ClaudeDesktop{}).ConfigureAutodiscovery() + if err == nil || !strings.Contains(err.Error(), "invalid key") { + t.Fatalf("Configure error = %v, want invalid key", err) + } + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(paths.desktopConfig); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("desktop config should not be written after validation failure, stat err = %v", err) + } +} + +func TestValidateClaudeDesktopAPIKeyUsesClaudeModelsRoute(t *testing.T) { + oldClient := claudeDesktopHTTPClient + var gotPath, gotAuth string + claudeDesktopHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + gotAuth = req.Header.Get("Authorization") + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), + Header: make(http.Header), + }, nil + })} + t.Cleanup(func() { + claudeDesktopHTTPClient = oldClient + }) + + if err := validateClaudeDesktopAPIKey(context.Background(), "test-key"); err != nil { + t.Fatalf("validateClaudeDesktopAPIKey returned error: %v", err) + } + if gotPath != "/v1/models" { + t.Fatalf("validation path = %q, want /v1/models", gotPath) + } + if gotAuth != "Bearer test-key" { + t.Fatalf("Authorization header = %q, want bearer key", gotAuth) + } +} + +func TestValidateClaudeDesktopAPIKeyHidesInvalidHeaderDetails(t *testing.T) { + err := validateClaudeDesktopAPIKey(context.Background(), "bad\nkey") + if err == nil { + t.Fatal("expected validation error for key with newline") + } + if !strings.Contains(err.Error(), "could not verify Ollama API key") { + t.Fatalf("validation error = %v, want friendly verification message", err) + } + if strings.Contains(err.Error(), "invalid header") || strings.Contains(err.Error(), "net/http") { + t.Fatalf("validation error should not expose transport internals: %v", err) + } + if !strings.Contains(err.Error(), "https://ollama.com/settings/keys") { + t.Fatalf("validation error should include settings link: %v", err) + } +} + +func TestClaudeDesktopConfigureRequiresAPIKey(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + t.Setenv("OLLAMA_API_KEY", "") + withClaudeDesktopValidation(t, func(context.Context, string) error { + t.Fatal("validation should not run without an API key") + return nil + }) + + err := (&ClaudeDesktop{}).ConfigureAutodiscovery() + if err == nil || !strings.Contains(err.Error(), "OLLAMA_API_KEY is required") { + t.Fatalf("Configure error = %v, want missing key guidance", err) + } +} + +func TestClaudeDesktopAPIKeyPromptIncludesSettingsLink(t *testing.T) { + prompt := claudeDesktopAPIKeyPrompt() + if !strings.Contains(prompt, "Enter Ollama API key") { + t.Fatalf("prompt should ask for the API key, got %q", prompt) + } + if !strings.Contains(prompt, "https://ollama.com/settings/keys") { + t.Fatalf("prompt should include API key settings link, got %q", prompt) + } +} + +func TestClaudeDesktopConfigureReusesExistingAPIKey(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + t.Setenv("OLLAMA_API_KEY", "") + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"existing-key"}`), 0o644); err != nil { + t.Fatal(err) + } + + var validatedKey string + withClaudeDesktopValidation(t, func(_ context.Context, key string) error { + validatedKey = key + return nil + }) + + if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil { + t.Fatalf("ConfigureAutodiscovery returned error: %v", err) + } + if validatedKey != "existing-key" { + t.Fatalf("validated key = %q, want existing-key", validatedKey) + } +} + +func TestClaudeDesktopConfigureReplacesInvalidExistingAPIKey(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + withInteractiveSession(t, true) + t.Setenv("OLLAMA_API_KEY", "") + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"stale-key"}`), 0o644); err != nil { + t.Fatal(err) + } + + var validated []string + withClaudeDesktopValidation(t, func(_ context.Context, key string) error { + validated = append(validated, key) + if key == "stale-key" { + return errors.New("invalid key") + } + return nil + }) + withClaudeDesktopPrompt(t, func() (string, error) { + return "replacement-key", nil + }) + + if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil { + t.Fatalf("ConfigureAutodiscovery returned error: %v", err) + } + if diff := compareStrings(validated, []string{"stale-key", "replacement-key"}); diff != "" { + t.Fatalf("validated keys mismatch: %s", diff) + } + profile := claudeDesktopReadJSON(t, paths.profile) + if profile["inferenceGatewayApiKey"] != "replacement-key" { + t.Fatalf("configured key = %v, want replacement-key", profile["inferenceGatewayApiKey"]) + } +} + +func TestClaudeDesktopConfigureReusesExistingAPIKeyFromAnyWindowsProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + local := filepath.Join(tmpDir, "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + t.Setenv("OLLAMA_API_KEY", "") + + targets, err := claudeDesktopTargetPaths() + if err != nil { + t.Fatal(err) + } + fallbackProfile := targets.thirdPartyProfiles[1].profile + if err := os.MkdirAll(filepath.Dir(fallbackProfile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fallbackProfile, []byte(`{"inferenceGatewayApiKey":"fallback-key"}`), 0o644); err != nil { + t.Fatal(err) + } + + var validatedKey string + withClaudeDesktopValidation(t, func(_ context.Context, key string) error { + validatedKey = key + return nil + }) + + if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil { + t.Fatalf("ConfigureAutodiscovery returned error: %v", err) + } + if validatedKey != "fallback-key" { + t.Fatalf("validated key = %q, want fallback-key", validatedKey) + } + for _, target := range targets.thirdPartyProfiles { + profile := claudeDesktopReadJSON(t, target.profile) + if profile["inferenceGatewayApiKey"] != "fallback-key" { + t.Fatalf("%s should reuse fallback key, got %v", target.profile, profile["inferenceGatewayApiKey"]) + } + } +} + +func TestClaudeDesktopAutodiscoveryConfiguredRequiresAppliedOllamaProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + t.Setenv("OLLAMA_API_KEY", "test-api-key") + withClaudeDesktopValidation(t, func(context.Context, string) error { return nil }) + + c := &ClaudeDesktop{} + if err := c.ConfigureAutodiscovery(); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + if !c.AutodiscoveryConfigured() { + t.Fatal("expected Claude Desktop autodiscovery config to be detected") + } + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"custom"}`), 0o644); err != nil { + t.Fatal(err) + } + if c.AutodiscoveryConfigured() { + t.Fatal("expected another applied profile to hide Claude Desktop autodiscovery config") + } +} + +func TestClaudeDesktopAutodiscoveryConfiguredRequiresAPIKey(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + t.Setenv("OLLAMA_API_KEY", "test-api-key") + withClaudeDesktopValidation(t, func(context.Context, string) error { return nil }) + + c := &ClaudeDesktop{} + if err := c.ConfigureAutodiscovery(); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + profile := claudeDesktopReadJSON(t, paths.profile) + delete(profile, "inferenceGatewayApiKey") + data, err := json.Marshal(profile) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.profile, data, 0o644); err != nil { + t.Fatal(err) + } + + if c.AutodiscoveryConfigured() { + t.Fatal("expected missing gateway API key to force Claude Desktop reconfiguration") + } +} + +func TestClaudeDesktopRestoreSwitchesBackToFirstPartyMode(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "darwin") + withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }) + + paths, err := claudeDesktopConfigPaths() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&ClaudeDesktop{}).Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig) + if desktopConfig["deploymentMode"] != "1p" { + t.Fatalf("deploymentMode = %v, want 1p", desktopConfig["deploymentMode"]) + } + normalConfig := claudeDesktopReadJSON(t, paths.normalConfig) + if normalConfig["deploymentMode"] != "1p" { + t.Fatalf("normal deploymentMode = %v, want 1p", normalConfig["deploymentMode"]) + } + profile := claudeDesktopReadJSON(t, paths.profile) + if profile["disableDeploymentModeChooser"] != false { + t.Fatalf("disableDeploymentModeChooser = %v, want false", profile["disableDeploymentModeChooser"]) + } + if profile["inferenceGatewayApiKey"] != "keep" { + t.Fatal("restore should leave existing Ollama profile credentials in place") + } + for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} { + if _, ok := profile[key]; ok { + t.Fatalf("restore should clear stale %s from the Ollama profile: %v", key, profile) + } + } + meta := claudeDesktopReadJSON(t, paths.meta) + if _, ok := meta["appliedId"]; ok { + t.Fatalf("restore should clear the applied Ollama third-party profile: %v", meta) + } + if (&ClaudeDesktop{}).AutodiscoveryConfigured() { + t.Fatal("restore should leave Claude Desktop autodiscovery unconfigured") + } +} + +func TestClaudeDesktopRestoreTouchesAllWindowsProfileCandidates(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withClaudeDesktopPlatform(t, "windows") + local := filepath.Join(tmpDir, "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }) + + targets, err := claudeDesktopTargetPaths() + if err != nil { + t.Fatal(err) + } + if len(targets.normalConfigs) != 2 { + t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs)) + } + if len(targets.thirdPartyProfiles) != 2 { + t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles)) + } + for _, target := range targets.thirdPartyProfiles { + if err := os.MkdirAll(filepath.Dir(target.profile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil { + t.Fatal(err) + } + } + + if err := (&ClaudeDesktop{}).Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + for _, path := range targets.normalConfigs { + cfg := claudeDesktopReadJSON(t, path) + if cfg["deploymentMode"] != "1p" { + t.Fatalf("%s deploymentMode = %v, want 1p", path, cfg["deploymentMode"]) + } + } + for _, target := range targets.thirdPartyProfiles { + cfg := claudeDesktopReadJSON(t, target.desktopConfig) + if cfg["deploymentMode"] != "1p" { + t.Fatalf("%s deploymentMode = %v, want 1p", target.desktopConfig, cfg["deploymentMode"]) + } + meta := claudeDesktopReadJSON(t, target.meta) + if _, ok := meta["appliedId"]; ok { + t.Fatalf("%s should not keep the Ollama applied profile: %v", target.meta, meta) + } + profile := claudeDesktopReadJSON(t, target.profile) + if profile["disableDeploymentModeChooser"] != false { + t.Fatalf("%s disableDeploymentModeChooser = %v, want false", target.profile, profile["disableDeploymentModeChooser"]) + } + if profile["inferenceGatewayApiKey"] != "keep" { + t.Fatalf("%s should preserve gateway API key", target.profile) + } + for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} { + if _, ok := profile[key]; ok { + t.Fatalf("%s should clear stale %s: %v", target.profile, key, profile) + } + } + } +} + +func TestClaudeDesktopRunRestartsRunningAppWhenConfirmed(t *testing.T) { + withClaudeDesktopPlatform(t, "darwin") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + running := true + var quitCalls, openCalls int + withClaudeDesktopProcessHooks(t, + func() bool { return running }, + func() error { + quitCalls++ + running = false + return nil + }, + func() error { + openCalls++ + return nil + }, + ) + + if err := (&ClaudeDesktop{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if quitCalls != 1 || openCalls != 1 { + t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls) + } +} + +func TestClaudeDesktopRunRejectsExtraArgs(t *testing.T) { + withClaudeDesktopPlatform(t, "darwin") + err := (&ClaudeDesktop{}).Run("qwen3.5", []string{"--foo"}) + if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") { + t.Fatalf("Run error = %v, want extra args rejection", err) + } +} diff --git a/cmd/launch/command_test.go b/cmd/launch/command_test.go index c00e8e21..386b55cd 100644 --- a/cmd/launch/command_test.go +++ b/cmd/launch/command_test.go @@ -73,6 +73,9 @@ func TestLaunchCmd(t *testing.T) { if cmd.Flags().Lookup("config") == nil { t.Error("--config flag should exist") } + if cmd.Flags().Lookup("restore") == nil { + t.Error("--restore flag should exist") + } if cmd.Flags().Lookup("yes") == nil { t.Error("--yes flag should exist") } @@ -207,6 +210,27 @@ func TestLaunchCmdTUICallback(t *testing.T) { t.Error("TUI callback should NOT be called when flags or extra args are provided without an integration") } }) + + t.Run("--restore flag without integration returns error", func(t *testing.T) { + tuiCalled := false + mockTUI := func(cmd *cobra.Command) { + tuiCalled = true + } + + cmd := LaunchCmd(mockCheck, mockTUI) + cmd.SetArgs([]string{"--restore"}) + err := cmd.Execute() + + if err == nil { + t.Fatal("expected --restore without an integration to fail") + } + if !strings.Contains(err.Error(), "require an integration name") { + t.Fatalf("expected integration-name guidance, got %v", err) + } + if tuiCalled { + t.Error("TUI callback should NOT be called when --restore is provided without an integration") + } + }) } func TestLaunchCmdNilHeartbeat(t *testing.T) { @@ -331,6 +355,41 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) { } } +func TestLaunchCmdAutodiscoveryDefaultLaunchDoesNotForceConfigure(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{ + autodiscoveryConfigured: true, + } + restore := OverrideIntegration("stubauto", runner) + defer restore() + + if err := config.SaveIntegration("stubauto", []string{"Ollama Cloud"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + if err := config.MarkIntegrationOnboarded("stubauto"); err != nil { + t.Fatalf("failed to mark integration onboarded: %v", err) + } + + cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) { + t.Fatal("TUI callback should not run for direct integration launch") + }) + cmd.SetArgs([]string{"stubauto"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("launch command failed: %v", err) + } + + if runner.autodiscoveryConfigures != 0 { + t.Fatalf("expected default autodiscovery launch to reuse existing config, got %d configures", runner.autodiscoveryConfigures) + } + if runner.ranModel != "Ollama Cloud" { + t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel) + } +} + func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 60c8ba9f..310aaf95 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -53,6 +53,8 @@ func TestIntegrationLookup(t *testing.T) { {"claude lowercase", "claude", true, "Claude Code"}, {"claude uppercase", "CLAUDE", true, "Claude Code"}, {"claude mixed case", "Claude", true, "Claude Code"}, + {"claude desktop", "claude-desktop", true, "Claude Desktop"}, + {"claude desktop alias", "claude-app", true, "Claude Desktop"}, {"codex", "codex", true, "Codex"}, {"kimi", "kimi", true, "Kimi Code CLI"}, {"droid", "droid", true, "Droid"}, @@ -76,7 +78,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "codex", "kimi", "droid", "opencode", "hermes", "pool"} + expectedIntegrations := []string{"claude", "claude-desktop", "codex", "kimi", "droid", "opencode", "hermes", "pool"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { r, ok := integrations[name] @@ -1364,6 +1366,30 @@ func TestEnsureAuth_SkipsWhenNoCloudSelected(t *testing.T) { } } +func TestEnsureAuth_EmptyWhoamiRequiresSignIn(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/status": + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"error":"not found"}`) + case "/api/me": + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + err := ensureAuth(context.Background(), client, map[string]bool{"cloud-model:cloud": true}, []string{"cloud-model:cloud"}) + if err == nil || !strings.Contains(err.Error(), "cloud-model:cloud requires sign in") { + t.Fatalf("ensureAuth error = %v, want sign-in required", err) + } +} + func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) { oldSignIn := DefaultSignIn DefaultSignIn = func(modelName, signInURL string) (string, error) { @@ -1484,6 +1510,11 @@ func TestIntegration_InstallHint(t *testing.T) { input: "claude", wantURL: "https://code.claude.com/docs/en/quickstart", }, + { + name: "claude desktop has hint", + input: "claude-desktop", + wantURL: "https://claude.com/download", + }, { name: "codex has hint", input: "codex", @@ -1565,6 +1596,15 @@ func TestListIntegrationInfos(t *testing.T) { } want = filtered } + if claudeDesktopSupported() != nil { + filtered := make([]string, 0, len(want)) + for _, name := range want { + if name != "claude-desktop" { + filtered = append(filtered, name) + } + } + want = filtered + } if diff := compareStrings(got, want); diff != "" { t.Fatalf("launcher integration order mismatch: %s", diff) @@ -1584,6 +1624,9 @@ func TestListIntegrationInfos(t *testing.T) { t.Run("includes known integrations", func(t *testing.T) { known := map[string]bool{"claude": false, "codex": false, "opencode": false} + if claudeDesktopSupported() == nil { + known["claude-desktop"] = false + } if poolsideGOOS != "windows" { known["pool"] = false } @@ -1621,6 +1664,7 @@ func TestListIntegrationInfos(t *testing.T) { } }) } + func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) { prev := poolsideGOOS poolsideGOOS = "windows" @@ -1633,6 +1677,18 @@ func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) { } } +func TestListIntegrationInfos_HidesClaudeDesktopOnUnsupportedPlatform(t *testing.T) { + prev := claudeDesktopGOOS + claudeDesktopGOOS = "linux" + t.Cleanup(func() { claudeDesktopGOOS = prev }) + + for _, info := range ListIntegrationInfos() { + if info.Name == "claude-desktop" { + t.Fatal("expected claude-desktop to be hidden on unsupported platforms") + } + } +} + func TestBuildModelList_Descriptions(t *testing.T) { t.Run("installed recommended has base description", func(t *testing.T) { existing := []modelInfo{ @@ -1695,6 +1751,7 @@ func TestIntegration_Editor(t *testing.T) { {"opencode", true}, {"openclaw", true}, {"claude", false}, + {"claude-desktop", false}, {"codex", false}, {"nonexistent", false}, } @@ -1721,6 +1778,7 @@ func TestIntegration_AutoInstallable(t *testing.T) { {"pi", true}, {"hermes", true}, {"claude", false}, + {"claude-desktop", false}, {"codex", false}, {"opencode", false}, } diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 524b714d..34948bdf 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -121,6 +121,7 @@ type IntegrationLaunchRequest struct { ModelOverride string ForceConfigure bool ConfigureOnly bool + Restore bool ExtraArgs []string Policy *LaunchPolicy } @@ -154,6 +155,46 @@ type ManagedSingleModel interface { Onboard() error } +// ManagedModelListConfigurer lets managed single-model integrations receive +// the launcher's model list while still preserving one primary selected model. +type ManagedModelListConfigurer interface { + ConfigureWithModels(primary string, models []string) error +} + +// ManagedAutodiscoveryIntegration is for managed integrations that do not need +// a launcher-selected model because the app discovers available models itself. +type ManagedAutodiscoveryIntegration interface { + Paths() []string + AutodiscoveredModel() string + AutodiscoveryConfigured() bool + ConfigureAutodiscovery() error + Onboard() error +} + +// ManagedAutodiscoveryCloudIntegration marks an autodiscovery integration whose +// discovered model catalog depends on the user's local Ollama Cloud auth state. +type ManagedAutodiscoveryCloudIntegration interface { + UsesOllamaCloud() bool +} + +// RestoreHintIntegration can provide a short restore command after launch +// switches an app into a launch-managed mode. +type RestoreHintIntegration interface { + RestoreHint() string +} + +// ConfigurationSuccessIntegration can print a short message after launcher +// successfully switches an app into a launch-managed mode. +type ConfigurationSuccessIntegration interface { + ConfigurationSuccessMessage() string +} + +// RestoreSuccessIntegration can print a short message after launcher restores +// an app back to its default mode. +type RestoreSuccessIntegration interface { + RestoreSuccessMessage() string +} + // ManagedRuntimeRefresher lets managed integrations refresh any long-lived // background runtime after launch rewrites their config. type ManagedRuntimeRefresher interface { @@ -172,6 +213,25 @@ type ManagedInteractiveOnboarding interface { RequiresInteractiveOnboarding() bool } +// ManagedModelReadinessSkipper lets managed integrations opt out of local +// Ollama model readiness checks when the configured runtime is not the local +// daemon. +type ManagedModelReadinessSkipper interface { + SkipModelReadiness() bool +} + +// RestorableIntegration lets integrations switch back from a launch-managed +// mode to the application's normal/default mode. +type RestorableIntegration interface { + Restore() error +} + +// SupportedIntegration lets an integration report platform support separately +// from whether the underlying app binary is installed. +type SupportedIntegration interface { + Supported() error +} + type modelInfo struct { Name string Remote bool @@ -197,6 +257,7 @@ func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) erro var modelFlag string var configFlag bool var yesFlag bool + var restoreFlag bool cmd := &cobra.Command{ Use: "launch [INTEGRATION] [-- [EXTRA_ARGS...]]", @@ -207,29 +268,37 @@ Without arguments, this is equivalent to running 'ollama' directly. Flags and extra arguments require an integration name. Supported integrations: - claude Claude Code - cline Cline - codex Codex - copilot Copilot CLI (aliases: copilot-cli) - droid Droid - hermes Hermes Agent - kimi Kimi Code CLI - opencode OpenCode - openclaw OpenClaw (aliases: clawdbot, moltbot) - pi Pi - pool Pool - vscode    VS Code (aliases: code) + claude Claude Code + claude-desktop Claude Desktop (aliases: claude-app) + cline Cline + codex Codex + copilot Copilot CLI (aliases: copilot-cli) + droid Droid + hermes Hermes Agent + kimi Kimi Code CLI + opencode OpenCode + openclaw OpenClaw (aliases: clawdbot, moltbot) + pi Pi + pool Pool + vscode VS Code (aliases: code) Examples: ollama launch ollama launch claude ollama launch claude --model + ollama launch claude-desktop + ollama launch claude-desktop --restore ollama launch hermes ollama launch droid --config (does not auto-launch) ollama launch codex -- -p myprofile (pass extra args to integration) ollama launch codex -- --sandbox workspace-write`, - Args: cobra.ArbitraryArgs, - PreRunE: checkServerHeartbeat, + Args: cobra.ArbitraryArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if restoreFlag || launchCommandCanSkipHeartbeat(args) { + return nil + } + return checkServerHeartbeat(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { policy := defaultLaunchPolicy(isInteractiveSession(), yesFlag) // reset when done to make sure state doens't leak between launches @@ -258,7 +327,7 @@ Examples: } if name == "" { - if cmd.Flags().Changed("model") || cmd.Flags().Changed("config") || cmd.Flags().Changed("yes") || len(passArgs) > 0 { + if cmd.Flags().Changed("model") || cmd.Flags().Changed("config") || cmd.Flags().Changed("yes") || cmd.Flags().Changed("restore") || len(passArgs) > 0 { return fmt.Errorf("flags and extra args require an integration name, for example: 'ollama launch claude --model qwen3.5'") } runTUI(cmd) @@ -275,11 +344,20 @@ Examples: } headlessYes := yesFlag && !isInteractiveSession() + forceConfigure := configFlag || (modelFlag == "" && !headlessYes) + if forceConfigure && !configFlag && modelFlag == "" { + if _, runner, err := LookupIntegration(name); err == nil { + if _, ok := runner.(ManagedAutodiscoveryIntegration); ok { + forceConfigure = false + } + } + } err := LaunchIntegration(cmd.Context(), IntegrationLaunchRequest{ Name: name, ModelOverride: modelFlag, - ForceConfigure: configFlag || (modelFlag == "" && !headlessYes), + ForceConfigure: forceConfigure, ConfigureOnly: configFlag, + Restore: restoreFlag, ExtraArgs: passArgs, Policy: &policy, }) @@ -292,10 +370,19 @@ Examples: cmd.Flags().StringVar(&modelFlag, "model", "", "Model to use") cmd.Flags().BoolVar(&configFlag, "config", false, "Configure without launching") + cmd.Flags().BoolVar(&restoreFlag, "restore", false, "Restore an integration to its default profile") cmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Automatically answer yes to confirmation prompts") return cmd } +func launchCommandCanSkipHeartbeat(args []string) bool { + if len(args) == 0 { + return false + } + name, _, err := LookupIntegration(args[0]) + return err == nil && name == "claude-desktop" +} + type launcherClient struct { apiClient *api.Client modelInventory []ModelInfo @@ -349,8 +436,13 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error } policy := launchIntegrationPolicy(req) + if req.Restore { + return restoreIntegration(name, runner, req) + } if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" { - return fmt.Errorf("headless --yes launch for %s requires --model ", name) + if _, ok := runner.(ManagedAutodiscoveryIntegration); !ok { + return fmt.Errorf("headless --yes launch for %s requires --model ", name) + } } launchClient, saved, err := prepareIntegrationLaunch(name, policy) @@ -358,6 +450,13 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error return err } + if autodiscovery, ok := runner.(ManagedAutodiscoveryIntegration); ok { + if err := EnsureIntegrationInstalled(name, runner); err != nil { + return err + } + return launchClient.launchManagedAutodiscoveryIntegration(ctx, name, runner, autodiscovery, saved, req) + } + if managed, ok := runner.(ManagedSingleModel); ok { if err := EnsureIntegrationInstalled(name, runner); err != nil { return err @@ -377,6 +476,24 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error return launchClient.launchSingleIntegration(ctx, name, runner, saved, req) } +func restoreIntegration(name string, runner Runner, req IntegrationLaunchRequest) error { + if req.ModelOverride != "" || req.ConfigureOnly || len(req.ExtraArgs) > 0 { + return fmt.Errorf("--restore cannot be combined with --model, --config, or extra args") + } + restorable, ok := runner.(RestorableIntegration) + if !ok { + return fmt.Errorf("%s does not support --restore", name) + } + if err := EnsureIntegrationInstalled(name, runner); err != nil { + return err + } + if err := restorable.Restore(); err != nil { + return err + } + printRestoreSuccess(restorable) + return nil +} + func launchIntegrationPolicy(req IntegrationLaunchRequest) LaunchPolicy { // TUI does not set a policy, whereas ollama launch does as it can // have flags which change the behavior. @@ -427,7 +544,12 @@ func (c *launcherClient) buildLauncherIntegrationState(ctx context.Context, info } var currentModel string var usable bool - if managed, ok := integration.spec.Runner.(ManagedSingleModel); ok { + if autodiscovery, ok := integration.spec.Runner.(ManagedAutodiscoveryIntegration); ok { + currentModel, usable, err = c.launcherManagedAutodiscoveryState(ctx, info.Name, autodiscovery) + if err != nil { + return LauncherIntegrationState{}, err + } + } else if managed, ok := integration.spec.Runner.(ManagedSingleModel); ok { currentModel, usable, err = c.launcherManagedModelState(ctx, info.Name, managed) if err != nil { return LauncherIntegrationState{}, err @@ -489,6 +611,10 @@ func (c *launcherClient) launcherManagedModelState(ctx context.Context, name str return "", false, nil } + if skips, ok := managed.(ManagedModelReadinessSkipper); ok && skips.SkipModelReadiness() { + return current, true, nil + } + usable, err := c.savedModelUsable(ctx, current) if err != nil { return current, false, err @@ -496,6 +622,20 @@ func (c *launcherClient) launcherManagedModelState(ctx context.Context, name str return current, usable, nil } +func (c *launcherClient) launcherManagedAutodiscoveryState(ctx context.Context, name string, autodiscovery ManagedAutodiscoveryIntegration) (string, bool, error) { + if autodiscovery.AutodiscoveryConfigured() { + return autodiscovery.AutodiscoveredModel(), c.managedAutodiscoveryUsable(ctx, autodiscovery), nil + } + + cfg, loadErr := loadStoredIntegrationConfig(name) + if loadErr == nil { + if current := primaryModelFromConfig(cfg); current != "" { + return current, false, nil + } + } + return "", false, nil +} + func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelRequest) (string, error) { current := config.LastModel() if !req.ForcePicker && current != "" && c.policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() { @@ -594,7 +734,11 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam } if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) { - if err := prepareManagedSingleIntegration(name, runner, managed, target); err != nil { + configureModels, err := c.managedSingleConfigureModels(ctx, managed, target) + if err != nil { + return err + } + if err := prepareManagedSingleIntegration(name, runner, managed, target, configureModels); err != nil { return err } if refresher, ok := managed.(ManagedRuntimeRefresher); ok { @@ -620,15 +764,146 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam return runIntegration(runner, target, req.ExtraArgs) } +func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error { + if req.ModelOverride != "" { + return fmt.Errorf("%s discovers models automatically; omit --model", runner) + } + + target := autodiscovery.AutodiscoveredModel() + if err := c.ensureManagedAutodiscoveryUsable(ctx, autodiscovery, target); err != nil { + return err + } + + needsConfigure := req.ForceConfigure || req.ConfigureOnly || !autodiscovery.AutodiscoveryConfigured() || !savedMatchesModels(saved, []string{target}) + + if needsConfigure { + if err := prepareManagedAutodiscoveryIntegration(name, runner, autodiscovery, target); err != nil { + return err + } + if refresher, ok := autodiscovery.(ManagedRuntimeRefresher); ok { + if err := refresher.RefreshRuntimeAfterConfigure(); err != nil { + return err + } + } + } + + if !managedIntegrationOnboarded(saved, autodiscovery) { + if !isInteractiveSession() && managedRequiresInteractiveOnboarding(autodiscovery) { + return fmt.Errorf("%s still needs interactive gateway setup; run 'ollama launch %s' in a terminal to finish onboarding", runner, name) + } + if err := autodiscovery.Onboard(); err != nil { + return err + } + } + + if !printConfigurationSuccess(autodiscovery) { + printRestoreHint(autodiscovery) + } + + if req.ConfigureOnly { + return nil + } + + return runIntegration(runner, target, req.ExtraArgs) +} + +func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool { + if !managedAutodiscoveryUsesOllamaCloud(autodiscovery) { + return true + } + return c.ollamaCloudSignedIn(ctx) +} + +func (c *launcherClient) ensureManagedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration, label string) error { + if !managedAutodiscoveryUsesOllamaCloud(autodiscovery) { + return nil + } + return ensureCloudAuth(ctx, c.apiClient, label) +} + +func managedAutodiscoveryUsesOllamaCloud(autodiscovery ManagedAutodiscoveryIntegration) bool { + cloud, ok := autodiscovery.(ManagedAutodiscoveryCloudIntegration) + return ok && cloud.UsesOllamaCloud() +} + +func printRestoreHint(integration any) { + hint, ok := integration.(RestoreHintIntegration) + if !ok { + return + } + if msg := strings.TrimSpace(hint.RestoreHint()); msg != "" { + fmt.Fprintln(os.Stderr, msg) + } +} + +func printConfigurationSuccess(integration any) bool { + success, ok := integration.(ConfigurationSuccessIntegration) + if !ok { + return false + } + if msg := strings.TrimSpace(success.ConfigurationSuccessMessage()); msg != "" { + fmt.Fprintln(os.Stderr, msg) + return true + } + return false +} + +func printRestoreSuccess(integration any) { + success, ok := integration.(RestoreSuccessIntegration) + if !ok { + return + } + if msg := strings.TrimSpace(success.RestoreSuccessMessage()); msg != "" { + fmt.Fprintln(os.Stderr, msg) + } +} + +func (c *launcherClient) ollamaCloudSignedIn(ctx context.Context) bool { + if disabled, known := cloudStatusDisabled(ctx, c.apiClient); known && disabled { + return false + } + user, err := c.apiClient.Whoami(ctx) + return err == nil && user != nil && user.Name != "" +} + +func (c *launcherClient) managedSingleConfigureModels(ctx context.Context, managed ManagedSingleModel, target string) ([]string, error) { + models := []string{target} + if _, ok := managed.(ManagedModelListConfigurer); !ok { + return models, nil + } + + items, _, err := c.loadSelectableModels(ctx, []string{target}, target, "no models available") + if err != nil { + // Managed integrations that can use a model catalog should still be + // configurable with an explicit target even if the broader inventory + // cannot be loaded in the moment. + //nolint:nilerr + return models, nil + } + + for _, item := range items { + models = append(models, item.Name) + } + return dedupeModelList(models), nil +} + func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) { target := req.ModelOverride needsConfigure := req.ForceConfigure + skipReadiness := false + if skipper, ok := runner.(ManagedModelReadinessSkipper); ok { + skipReadiness = skipper.SkipModelReadiness() + } if target == "" { target = current - usable, err := c.savedModelUsable(ctx, target) - if err != nil { - return "", false, err + usable := skipReadiness && target != "" + if !skipReadiness { + var err error + usable, err = c.savedModelUsable(ctx, target) + if err != nil { + return "", false, err + } } if !usable { needsConfigure = true @@ -636,13 +911,19 @@ func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, run } if needsConfigure { - selected, err := c.selectSingleModelWithSelector(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector) + selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness) if err != nil { return "", false, err } target = selected - } else if err := c.ensureModelsReady(ctx, []string{target}); err != nil { - return "", false, err + } else if !skipReadiness { + if err := c.ensureModelsReady(ctx, []string{target}); err != nil { + return "", false, err + } + } + + if target == "" { + return "", false, nil } return target, needsConfigure, nil @@ -652,7 +933,7 @@ func savedIntegrationOnboarded(saved *config.IntegrationConfig) bool { return saved != nil && saved.Onboarded } -func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed ManagedSingleModel) bool { +func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed any) bool { if !savedIntegrationOnboarded(saved) { return false } @@ -666,7 +947,7 @@ func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed Manage // Most managed integrations treat onboarding as an interactive terminal step. // Hermes opts out because its launch-owned onboarding is just bookkeeping, so // headless launches should not be blocked once config is already prepared. -func managedRequiresInteractiveOnboarding(managed ManagedSingleModel) bool { +func managedRequiresInteractiveOnboarding(managed any) bool { onboarding, ok := managed.(ManagedInteractiveOnboarding) if !ok { return true @@ -675,6 +956,10 @@ func managedRequiresInteractiveOnboarding(managed ManagedSingleModel) bool { } func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, title, current string, selector SingleSelector) (string, error) { + return c.selectSingleModelWithSelectorReady(ctx, title, current, selector, true) +} + +func (c *launcherClient) selectSingleModelWithSelectorReady(ctx context.Context, title, current string, selector SingleSelector, ensureReady bool) (string, error) { if selector == nil { return "", fmt.Errorf("no selector configured") } @@ -688,8 +973,13 @@ func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, titl if err != nil { return "", err } - if err := c.ensureModelsReady(ctx, []string{selected}); err != nil { - return "", err + if selected == "" { + return "", ErrCancelled + } + if ensureReady { + if err := c.ensureModelsReady(ctx, []string{selected}); err != nil { + return "", err + } } return selected, nil } diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index 20e9ae50..14b21331 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -50,6 +50,22 @@ func (r *launcherSingleRunner) Run(model string, args []string) error { func (r *launcherSingleRunner) String() string { return "StubSingle" } +type launcherRestorableRunner struct { + launcherSingleRunner + restored bool + restoreErr error + restoreSuccessMessage string +} + +func (r *launcherRestorableRunner) Restore() error { + r.restored = true + return r.restoreErr +} + +func (r *launcherRestorableRunner) RestoreSuccessMessage() string { + return r.restoreSuccessMessage +} + type launcherManagedRunner struct { paths []string currentModel string @@ -99,6 +115,45 @@ type launcherHeadlessManagedRunner struct { func (r *launcherHeadlessManagedRunner) RequiresInteractiveOnboarding() bool { return false } +type launcherManagedListRunner struct { + launcherManagedRunner + configuredModelLists [][]string +} + +func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []string) error { + r.configuredModelLists = append(r.configuredModelLists, append([]string(nil), models...)) + return r.Configure(primary) +} + +type launcherManagedAutodiscoveryRunner struct { + launcherManagedRunner + autodiscoveryConfigures int + autodiscoveryConfigured bool + usesCloud bool + restoreHint string + configSuccessMessage string +} + +func (r *launcherManagedAutodiscoveryRunner) AutodiscoveredModel() string { return "Ollama Cloud" } + +func (r *launcherManagedAutodiscoveryRunner) UsesOllamaCloud() bool { return r.usesCloud } + +func (r *launcherManagedAutodiscoveryRunner) RestoreHint() string { return r.restoreHint } + +func (r *launcherManagedAutodiscoveryRunner) ConfigurationSuccessMessage() string { + return r.configSuccessMessage +} + +func (r *launcherManagedAutodiscoveryRunner) AutodiscoveryConfigured() bool { + return r.autodiscoveryConfigured +} + +func (r *launcherManagedAutodiscoveryRunner) ConfigureAutodiscovery() error { + r.autodiscoveryConfigures++ + r.autodiscoveryConfigured = true + return nil +} + func setLaunchTestHome(t *testing.T, dir string) { t.Helper() t.Setenv("HOME", dir) @@ -627,6 +682,378 @@ func TestLaunchIntegration_ManagedSingleIntegrationStopsWhenRuntimeRefreshFails( } } +func TestLaunchIntegration_ManagedSingleIntegrationCanConfigureWithModelList(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/experimental/model-recommendations": + fmt.Fprint(w, `{"recommendations":[]}`) + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"}]}`) + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherManagedListRunner{} + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "gemma4", + }); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if diff := compareStringSlices(runner.configuredModelLists, [][]string{{"gemma4", "kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "qwen3.5", "qwen3:8b"}}); diff != "" { + t.Fatalf("configured model list mismatch (-want +got):\n%s", diff) + } + if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" { + t.Fatalf("configured primary mismatch: %s", diff) + } +} + +func TestLaunchIntegration_ManagedAutodiscoverySkipsModelPicker(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{} + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { + t.Fatal("model selector should not run for autodiscovery integrations") + return "", nil + } + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if runner.autodiscoveryConfigures != 1 { + t.Fatalf("expected one autodiscovery configure, got %d", runner.autodiscoveryConfigures) + } + if runner.ranModel != "Ollama Cloud" { + t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel) + } + saved, err := config.LoadIntegration("stubmanaged") + if err != nil { + t.Fatalf("failed to reload managed integration config: %v", err) + } + if diff := compareStrings(saved.Models, []string{"Ollama Cloud"}); diff != "" { + t.Fatalf("saved models mismatch: %s", diff) + } +} + +func TestLaunchIntegration_ManagedAutodiscoveryPrintsConfigurationSuccessAfterConfigure(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{ + restoreHint: "run restore command", + configSuccessMessage: "configured successfully\nrestore via success message", + } + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + stderr := captureStderr(t, func() { + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + }) + + if runner.autodiscoveryConfigures != 1 { + t.Fatalf("expected one autodiscovery configure, got %d", runner.autodiscoveryConfigures) + } + if !strings.Contains(stderr, "configured successfully") { + t.Fatalf("expected configuration success in stderr, got %q", stderr) + } + if !strings.Contains(stderr, "restore via success message") { + t.Fatalf("expected restore guidance in configuration success, got %q", stderr) + } + if strings.Contains(stderr, "run restore command") { + t.Fatalf("restore hint should not print separately after configure, got %q", stderr) + } +} + +func TestLaunchIntegration_ManagedAutodiscoveryPrintsRestoreHintWhenAlreadyConfigured(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{ + autodiscoveryConfigured: true, + restoreHint: "run restore command", + } + withIntegrationOverride(t, "stubmanaged", runner) + + if err := config.SaveIntegration("stubmanaged", []string{"Ollama Cloud"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil { + t.Fatalf("failed to mark integration onboarded: %v", err) + } + + stderr := captureStderr(t, func() { + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + }) + + if runner.autodiscoveryConfigures != 0 { + t.Fatalf("expected configured autodiscovery integration not to reconfigure, got %d configures", runner.autodiscoveryConfigures) + } + if runner.ranModel != "Ollama Cloud" { + t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel) + } + if !strings.Contains(stderr, "run restore command") { + t.Fatalf("expected restore hint in stderr, got %q", stderr) + } +} + +func TestLaunchIntegration_ManagedAutodiscoveryPrintsConfigurationSuccessWhenAlreadyConfigured(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{ + autodiscoveryConfigured: true, + restoreHint: "run restore command", + configSuccessMessage: "configured successfully\nrestore via success message", + } + withIntegrationOverride(t, "stubmanaged", runner) + + if err := config.SaveIntegration("stubmanaged", []string{"Ollama Cloud"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil { + t.Fatalf("failed to mark integration onboarded: %v", err) + } + + stderr := captureStderr(t, func() { + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + }) + + if runner.autodiscoveryConfigures != 0 { + t.Fatalf("expected configured autodiscovery integration not to reconfigure, got %d configures", runner.autodiscoveryConfigures) + } + if !strings.Contains(stderr, "configured successfully") { + t.Fatalf("expected configuration success in stderr, got %q", stderr) + } + if !strings.Contains(stderr, "restore via success message") { + t.Fatalf("expected restore guidance in configuration success, got %q", stderr) + } + if strings.Contains(stderr, "run restore command") { + t.Fatalf("restore hint should not print separately when success message exists, got %q", stderr) + } +} + +func TestLaunchIntegration_RestorePrintsSuccessMessage(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withLauncherHooks(t) + + runner := &launcherRestorableRunner{ + restoreSuccessMessage: "restored successfully", + } + withIntegrationOverride(t, "stubrestore", runner) + + stderr := captureStderr(t, func() { + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubrestore", + Restore: true, + }); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + }) + + if !runner.restored { + t.Fatal("expected restore to run") + } + if !strings.Contains(stderr, "restored successfully") { + t.Fatalf("expected restore success in stderr, got %q", stderr) + } +} + +func TestLaunchIntegration_ManagedAutodiscoveryForceConfigureRerunsSetup(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{ + autodiscoveryConfigured: true, + } + withIntegrationOverride(t, "stubmanaged", runner) + + if err := config.SaveIntegration("stubmanaged", []string{"Ollama Cloud"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil { + t.Fatalf("failed to mark integration onboarded: %v", err) + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ForceConfigure: true, + }); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if runner.autodiscoveryConfigures != 1 { + t.Fatalf("expected forced autodiscovery configure to rerun setup, got %d configures", runner.autodiscoveryConfigures) + } + if runner.ranModel != "Ollama Cloud" { + t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel) + } +} + +func TestLaunchIntegration_CloudAutodiscoveryUsesSignInHook(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{usesCloud: true} + withIntegrationOverride(t, "stubmanaged", runner) + + signInCalled := false + DefaultSignIn = func(modelName, signInURL string) (string, error) { + signInCalled = true + if modelName != "Ollama Cloud" { + t.Fatalf("sign-in model = %q, want Ollama Cloud", modelName) + } + if signInURL != "https://example.com/signin" { + t.Fatalf("sign-in URL = %q, want test URL", signInURL) + } + return "test-user", nil + } + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/status": + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":"not found"}`) + case "/api/me": + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if !signInCalled { + t.Fatal("expected cloud autodiscovery launch to use the sign-in hook") + } + if runner.autodiscoveryConfigures != 1 { + t.Fatalf("expected one autodiscovery configure, got %d", runner.autodiscoveryConfigures) + } + if runner.ranModel != "Ollama Cloud" { + t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel) + } +} + +func TestBuildLauncherIntegrationState_CloudAutodiscoveryRequiresSignedIn(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{ + autodiscoveryConfigured: true, + usesCloud: true, + } + withIntegrationOverride(t, "stubmanaged", runner) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/status": + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":"not found"}`) + case "/api/me": + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + launchClient, err := newLauncherClient(defaultLaunchPolicy(true, false)) + if err != nil { + t.Fatal(err) + } + state, err := launchClient.buildLauncherIntegrationState(context.Background(), IntegrationInfo{ + Name: "stubmanaged", + DisplayName: "Stub Managed", + }) + if err != nil { + t.Fatalf("buildLauncherIntegrationState returned error: %v", err) + } + + if state.CurrentModel != "Ollama Cloud" { + t.Fatalf("current model = %q, want Ollama Cloud", state.CurrentModel) + } + if state.ModelUsable { + t.Fatal("expected cloud autodiscovery config to be unusable while signed out") + } +} + +func TestLaunchIntegration_ManagedAutodiscoveryRejectsModelOverride(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedAutodiscoveryRunner{} + withIntegrationOverride(t, "stubmanaged", runner) + + err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "qwen3.5:cloud", + }) + if err == nil || !strings.Contains(err.Error(), "discovers models automatically") { + t.Fatalf("LaunchIntegration error = %v, want automatic discovery guidance", err) + } + if runner.autodiscoveryConfigures != 0 { + t.Fatalf("expected no configure after model override rejection, got %d", runner.autodiscoveryConfigures) + } +} + func TestLaunchIntegration_ManagedSingleIntegrationHeadlessNeedsInteractiveOnboarding(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) diff --git a/cmd/launch/models.go b/cmd/launch/models.go index f985950d..a3911bf5 100644 --- a/cmd/launch/models.go +++ b/cmd/launch/models.go @@ -181,6 +181,10 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string] if len(selectedCloudModels) == 0 { return nil } + return ensureCloudAuth(ctx, client, strings.Join(selectedCloudModels, ", ")) +} + +func ensureCloudAuth(ctx context.Context, client *api.Client, modelList string) error { if disabled, known := cloudStatusDisabled(ctx, client); known && disabled { return errors.New(internalcloud.DisabledError("remote inference is unavailable")) } @@ -192,11 +196,12 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string] var aErr api.AuthorizationError if !errors.As(err, &aErr) || aErr.SigninURL == "" { - return err + if err != nil { + return err + } + return fmt.Errorf("%s requires sign in", modelList) } - modelList := strings.Join(selectedCloudModels, ", ") - if DefaultSignIn != nil { _, err := DefaultSignIn(modelList, aErr.SigninURL) if errors.Is(err, ErrCancelled) { @@ -310,13 +315,35 @@ func prepareEditorIntegration(name string, runner Runner, editor Editor, models return nil } -func prepareManagedSingleIntegration(name string, runner Runner, managed ManagedSingleModel, model string) error { +func prepareManagedSingleIntegration(name string, runner Runner, managed ManagedSingleModel, model string, models []string) error { if ok, err := confirmConfigEdit(runner, managed.Paths()); err != nil { return err } else if !ok { return errCancelled } - if err := managed.Configure(model); err != nil { + models = dedupeModelList(append([]string{model}, models...)) + var err error + if withModels, ok := managed.(ManagedModelListConfigurer); ok { + err = withModels.ConfigureWithModels(model, models) + } else { + err = managed.Configure(model) + } + if err != nil { + return fmt.Errorf("setup failed: %w", err) + } + if err := config.SaveIntegration(name, []string{model}); err != nil { + return fmt.Errorf("failed to save: %w", err) + } + return nil +} + +func prepareManagedAutodiscoveryIntegration(name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, model string) error { + if ok, err := confirmConfigEdit(runner, autodiscovery.Paths()); err != nil { + return err + } else if !ok { + return errCancelled + } + if err := autodiscovery.ConfigureAutodiscovery(); err != nil { return fmt.Errorf("setup failed: %w", err) } if err := config.SaveIntegration(name, []string{model}); err != nil { diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 10d54c42..8c422171 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "copilot", "droid", "pi", "pool"} +var launcherIntegrationOrder = []string{"claude-desktop", "claude", "openclaw", "hermes", "opencode", "codex", "copilot", "droid", "pi", "pool"} var integrationSpecs = []*IntegrationSpec{ { @@ -48,6 +48,18 @@ var integrationSpecs = []*IntegrationSpec{ URL: "https://code.claude.com/docs/en/quickstart", }, }, + { + Name: "claude-desktop", + Runner: &ClaudeDesktop{}, + Aliases: []string{"claude-app"}, + Description: "Claude Desktop with Ollama Cloud", + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + return claudeDesktopInstalled() + }, + URL: "https://claude.com/download", + }, + }, { Name: "cline", Runner: &Cline{}, @@ -297,6 +309,9 @@ func ListVisibleIntegrationSpecs() []IntegrationSpec { if spec.Hidden { continue } + if supported, ok := spec.Runner.(SupportedIntegration); ok && supported.Supported() != nil { + continue + } if spec.Name == "pool" && poolsideGOOS == "windows" { continue } @@ -414,6 +429,12 @@ func EnsureIntegrationInstalled(name string, runner Runner) error { return fmt.Errorf("%s is not installed", runner) } + if supported, ok := runner.(SupportedIntegration); ok { + if err := supported.Supported(); err != nil { + return err + } + } + if integration.spec.Name == "pool" && poolsideGOOS == "windows" { return poolsideUnsupportedError() } diff --git a/cmd/tui/signin_test.go b/cmd/tui/signin_test.go index b2577183..430b4f7b 100644 --- a/cmd/tui/signin_test.go +++ b/cmd/tui/signin_test.go @@ -25,7 +25,6 @@ func TestRenderSignIn_ContainsURL(t *testing.T) { } } - func TestRenderSignIn_ContainsSpinner(t *testing.T) { got := renderSignIn("test:cloud", "https://example.com", 0, 80) if !strings.Contains(got, "Waiting for sign in to complete") { diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index a683c1c5..6f051e4c 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -45,7 +45,7 @@ type menuItem struct { isOthers bool } -const pinnedIntegrationCount = 3 +const pinnedIntegrationCount = 4 var runModelMenuItem = menuItem{ title: "Chat with a model", diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 6f5811d5..4587dbbf 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -44,6 +44,13 @@ func launcherTestState() *launch.LauncherState { Selectable: true, Changeable: true, }, + "claude-desktop": { + Name: "claude-desktop", + DisplayName: "Claude Desktop", + Description: "Claude Desktop with Ollama Cloud", + Selectable: true, + Changeable: true, + }, "hermes": { Name: "hermes", DisplayName: "Hermes Agent", @@ -97,18 +104,45 @@ func compareStrings(got, want []string) string { return cmp.Diff(want, got) } +func expectedCollapsedSequence(state *launch.LauncherState) []string { + sequence := []string{"run"} + for _, item := range pinnedIntegrationItems(state) { + sequence = append(sequence, item.integration) + } + if len(otherIntegrationItems(state)) > 0 { + sequence = append(sequence, "more") + } + return sequence +} + +func expectedExpandedSequence(state *launch.LauncherState) []string { + sequence := []string{"run"} + for _, item := range pinnedIntegrationItems(state) { + sequence = append(sequence, item.integration) + } + for _, item := range otherIntegrationItems(state) { + sequence = append(sequence, item.integration) + } + return sequence +} + func TestMenuRendersPinnedItemsAndMore(t *testing.T) { - menu := newModel(launcherTestState()) + state := launcherTestState() + menu := newModel(state) view := menu.View() - for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} { + for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch OpenClaw", "Launch Hermes Agent", "More..."} { if !strings.Contains(view, want) { t.Fatalf("expected menu view to contain %q\n%s", want, view) } } - if strings.Contains(view, "Launch Codex") { - t.Fatalf("expected Codex to be under More, not pinned\n%s", view) + if findMenuCursorByIntegration(menu.items, "claude-desktop") == -1 { + if strings.Contains(view, "Launch Claude Desktop") { + t.Fatalf("expected Claude Desktop to be hidden on unsupported platforms\n%s", view) + } + } else if !strings.Contains(view, "Launch Claude Desktop") { + t.Fatalf("expected menu view to contain Claude Desktop\n%s", view) } - wantOrder := []string{"run", "openclaw", "claude", "opencode", "more"} + wantOrder := expectedCollapsedSequence(state) if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" { t.Fatalf("unexpected pinned order: %s", diff) } @@ -116,20 +150,24 @@ func TestMenuRendersPinnedItemsAndMore(t *testing.T) { func TestMenuExpandsOthersFromLastSelection(t *testing.T) { state := launcherTestState() - state.LastSelection = "codex" + overflow := otherIntegrationItems(state) + if len(overflow) == 0 { + t.Fatal("expected at least one overflow integration") + } + state.LastSelection = overflow[0].integration menu := newModel(state) if !menu.showOthers { t.Fatal("expected others section to expand when last selection is in the overflow list") } view := menu.View() - if !strings.Contains(view, "Launch Codex") { + if !strings.Contains(view, overflow[0].title) { t.Fatalf("expected expanded view to contain overflow integration\n%s", view) } if strings.Contains(view, "More...") { t.Fatalf("expected expanded view to replace More... item\n%s", view) } - wantOrder := []string{"run", "openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"} + wantOrder := expectedExpandedSequence(state) if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" { t.Fatalf("unexpected expanded order: %s", diff) } diff --git a/docs/docs.json b/docs/docs.json index 79db465d..ef227193 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -115,7 +115,8 @@ "expanded": true, "pages": [ "/integrations/openclaw", - "/integrations/hermes" + "/integrations/hermes", + "/integrations/claude-desktop" ] }, { diff --git a/docs/images/claude-code-kimi-k2-6.png b/docs/images/claude-code-kimi-k2-6.png new file mode 100644 index 00000000..d1b0e4d6 Binary files /dev/null and b/docs/images/claude-code-kimi-k2-6.png differ diff --git a/docs/images/claude-cowork-kimi-k2-6.png b/docs/images/claude-cowork-kimi-k2-6.png new file mode 100644 index 00000000..3d9e95fa Binary files /dev/null and b/docs/images/claude-cowork-kimi-k2-6.png differ diff --git a/docs/integrations/claude-desktop.mdx b/docs/integrations/claude-desktop.mdx new file mode 100644 index 00000000..e991962f --- /dev/null +++ b/docs/integrations/claude-desktop.mdx @@ -0,0 +1,78 @@ +--- +title: Claude Desktop +--- + +Claude Desktop can use Ollama Cloud, including Claude Cowork and Claude Code inside the app. + +Claude Cowork using kimi-k2.6 through Ollama Cloud + +## Requirements + +- Claude Desktop for macOS or Windows +- An [Ollama API key](https://ollama.com/settings/keys) + +Set the key in your shell before launching: + +```shell +export OLLAMA_API_KEY=your_api_key +``` + +## Quick setup + +```shell +ollama launch claude-desktop +``` + +To bring back the usual Anthropic Claude profile later, run: + +```shell +ollama launch claude-desktop --restore +``` + +## Using Ollama Cloud models + +After setup, Claude Desktop discovers your available Ollama Cloud models automatically. For example, `kimi-k2.6` appears inside Claude Cowork. + +The same models are also available to Claude Code inside Claude Desktop: + +![Claude Code using kimi-k2.6 inside Claude Desktop](/images/claude-code-kimi-k2-6.png) + +## Configure without launching + +```shell +ollama launch claude-desktop --config +``` + +## Restore normal Claude + +Switch Claude Desktop back to its usual profile: + +```shell +ollama launch claude-desktop --restore +``` + + +If Claude Desktop is running, use `--yes` to approve the restart automatically: + +```shell +ollama launch claude-desktop --restore --yes +``` + +## Supported with Ollama + +Claude Desktop with Ollama currently supports: + +- Ollama Cloud as the third-party inference gateway +- Automatic model discovery from Ollama Cloud +- Claude Cowork with Ollama Cloud models +- Claude Code inside Claude Desktop with the same cloud models +- subagents (tell Claude to have subagents inherit the current model) + +Claude Desktop with Ollama does not support yet: + +- Web search +- Extensions \ No newline at end of file diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx index 83f2417d..68ccbd17 100644 --- a/docs/integrations/index.mdx +++ b/docs/integrations/index.mdx @@ -23,6 +23,7 @@ AI assistants that help with everyday tasks. - [OpenClaw](/integrations/openclaw) - [Hermes Agent](/integrations/hermes) +- [Claude Desktop](/integrations/claude-desktop) ## IDEs & Editors