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 @@
+
\ 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.
+
+
+
+## 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:
+
+
+
+## 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