package config import ( "context" "errors" "fmt" "net/http" "os" "os/exec" "runtime" "slices" "strings" "time" "github.com/ollama/ollama/api" internalcloud "github.com/ollama/ollama/internal/cloud" "github.com/ollama/ollama/internal/modelref" "github.com/ollama/ollama/progress" "github.com/spf13/cobra" ) // Runners execute the launching of a model with the integration - claude, codex // Editors can edit config files (supports multi-model selection) - opencode, droid // They are composable interfaces where in some cases an editor is also a runner - opencode, droid // Runner can run an integration with a model. type Runner interface { Run(model string, args []string) error // String returns the human-readable name of the integration String() string } // Editor can edit config files (supports multi-model selection) type Editor interface { // Paths returns the paths to the config files for the integration Paths() []string // Edit updates the config files for the integration with the given models Edit(models []string) error // Models returns the models currently configured for the integration // TODO(parthsareen): add error return to Models() Models() []string } // AliasConfigurer can configure model aliases (e.g., for subagent routing). // Integrations like Claude and Codex use this to route model requests to local models. type AliasConfigurer interface { // ConfigureAliases prompts the user to configure aliases and returns the updated map. ConfigureAliases(ctx context.Context, primaryModel string, existing map[string]string, force bool) (map[string]string, bool, error) // SetAliases syncs the configured aliases to the server SetAliases(ctx context.Context, aliases map[string]string) error } // integrations is the registry of available integrations. var integrations = map[string]Runner{ "claude": &Claude{}, "clawdbot": &Openclaw{}, "cline": &Cline{}, "codex": &Codex{}, "moltbot": &Openclaw{}, "droid": &Droid{}, "opencode": &OpenCode{}, "openclaw": &Openclaw{}, "pi": &Pi{}, } // recommendedModels are shown when the user has no models or as suggestions. // Order matters: local models first, then cloud models. var recommendedModels = []ModelItem{ {Name: "minimax-m2.5:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true}, {Name: "glm-5:cloud", Description: "Reasoning and code generation", Recommended: true}, {Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true}, {Name: "glm-4.7-flash", Description: "Reasoning and code generation locally", Recommended: true}, {Name: "qwen3:8b", Description: "Efficient all-purpose assistant", Recommended: true}, } // cloudModelLimits maps cloud model base names to their token limits. // TODO(parthsareen): grab context/output limits from model info instead of hardcoding var cloudModelLimits = map[string]cloudModelLimit{ "minimax-m2.5": {Context: 204_800, Output: 128_000}, "cogito-2.1:671b": {Context: 163_840, Output: 65_536}, "deepseek-v3.1:671b": {Context: 163_840, Output: 163_840}, "deepseek-v3.2": {Context: 163_840, Output: 65_536}, "glm-4.6": {Context: 202_752, Output: 131_072}, "glm-4.7": {Context: 202_752, Output: 131_072}, "glm-5": {Context: 202_752, Output: 131_072}, "gpt-oss:120b": {Context: 131_072, Output: 131_072}, "gpt-oss:20b": {Context: 131_072, Output: 131_072}, "kimi-k2:1t": {Context: 262_144, Output: 262_144}, "kimi-k2.5": {Context: 262_144, Output: 262_144}, "kimi-k2-thinking": {Context: 262_144, Output: 262_144}, "nemotron-3-nano:30b": {Context: 1_048_576, Output: 131_072}, "qwen3-coder:480b": {Context: 262_144, Output: 65_536}, "qwen3-coder-next": {Context: 262_144, Output: 32_768}, "qwen3-next:80b": {Context: 262_144, Output: 32_768}, "qwen3.5": {Context: 262_144, Output: 32_768}, } // recommendedVRAM maps local recommended models to their approximate VRAM requirement. var recommendedVRAM = map[string]string{ "glm-4.7-flash": "~25GB", "qwen3:8b": "~11GB", } // integrationAliases are hidden from the interactive selector but work as CLI arguments. var integrationAliases = map[string]bool{ "clawdbot": true, "moltbot": true, } // integrationInstallHints maps integration names to install URLs. var integrationInstallHints = map[string]string{ "claude": "https://code.claude.com/docs/en/quickstart", "cline": "https://cline.bot/cli", "openclaw": "https://docs.openclaw.ai", "codex": "https://developers.openai.com/codex/cli/", "droid": "https://docs.factory.ai/cli/getting-started/quickstart", "opencode": "https://opencode.ai", "pi": "https://github.com/badlogic/pi-mono", } // hyperlink wraps text in an OSC 8 terminal hyperlink so it is cmd+clickable. func hyperlink(url, text string) string { return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) } // IntegrationInfo contains display information about a registered integration. type IntegrationInfo struct { Name string // registry key, e.g. "claude" DisplayName string // human-readable, e.g. "Claude Code" Description string // short description, e.g. "Anthropic's agentic coding tool" } // integrationDescriptions maps integration names to short descriptions. var integrationDescriptions = map[string]string{ "claude": "Anthropic's coding tool with subagents", "cline": "Autonomous coding agent with parallel execution", "codex": "OpenAI's open-source coding agent", "openclaw": "Personal AI with 100+ skills", "droid": "Factory's coding agent across terminal and IDEs", "opencode": "Anomaly's open-source coding agent", "pi": "Minimal AI agent toolkit with plugin support", } // integrationOrder defines a custom display order for integrations. // Integrations listed here are placed at the end in the given order; // all others appear first, sorted alphabetically. var integrationOrder = []string{"opencode", "droid", "pi", "cline"} // ListIntegrationInfos returns all non-alias registered integrations, sorted by name // with integrationOrder entries placed at the end. func ListIntegrationInfos() []IntegrationInfo { var result []IntegrationInfo for name, r := range integrations { if integrationAliases[name] { continue } result = append(result, IntegrationInfo{ Name: name, DisplayName: r.String(), Description: integrationDescriptions[name], }) } orderRank := make(map[string]int, len(integrationOrder)) for i, name := range integrationOrder { orderRank[name] = i + 1 // 1-indexed so 0 means "not in the list" } slices.SortFunc(result, func(a, b IntegrationInfo) int { aRank, bRank := orderRank[a.Name], orderRank[b.Name] // Both have custom order: sort by their rank if aRank > 0 && bRank > 0 { return aRank - bRank } // Only one has custom order: it goes last if aRank > 0 { return 1 } if bRank > 0 { return -1 } // Neither has custom order: alphabetical return strings.Compare(a.Name, b.Name) }) return result } // IntegrationInstallHint returns a user-friendly install hint for the given integration, // or an empty string if none is available. The URL is wrapped in an OSC 8 hyperlink // so it is cmd+clickable in supported terminals. func IntegrationInstallHint(name string) string { url := integrationInstallHints[name] if url == "" { return "" } return "Install from " + hyperlink(url, url) } // IsIntegrationInstalled checks if an integration binary is installed. func IsIntegrationInstalled(name string) bool { switch name { case "claude": c := &Claude{} _, err := c.findPath() return err == nil case "openclaw": if _, err := exec.LookPath("openclaw"); err == nil { return true } if _, err := exec.LookPath("clawdbot"); err == nil { return true } return false case "codex": _, err := exec.LookPath("codex") return err == nil case "droid": _, err := exec.LookPath("droid") return err == nil case "cline": _, err := exec.LookPath("cline") return err == nil case "opencode": _, err := exec.LookPath("opencode") return err == nil case "pi": _, err := exec.LookPath("pi") return err == nil default: return true // Assume installed for unknown integrations } } // AutoInstallable returns true if the integration can be automatically // installed when not found (e.g. via npm). func AutoInstallable(name string) bool { switch strings.ToLower(name) { case "openclaw", "clawdbot", "moltbot": return true default: return false } } // EnsureInstalled checks if an auto-installable integration is present and // offers to install it if missing. Returns nil for non-auto-installable // integrations or when the binary is already on PATH. func EnsureInstalled(name string) error { if !AutoInstallable(name) { return nil } if IsIntegrationInstalled(name) { return nil } _, err := ensureOpenclawInstalled() return err } // IsEditorIntegration returns true if the named integration uses multi-model // selection (implements the Editor interface). func IsEditorIntegration(name string) bool { r, ok := integrations[strings.ToLower(name)] if !ok { return false } _, isEditor := r.(Editor) return isEditor } // SelectModel lets the user select a model to run. // ModelItem represents a model for selection. type ModelItem struct { Name string Description string Recommended bool } // SingleSelector is a function type for single item selection. // current is the name of the previously selected item to highlight; empty means no pre-selection. type SingleSelector func(title string, items []ModelItem, current string) (string, error) // MultiSelector is a function type for multi item selection. type MultiSelector func(title string, items []ModelItem, preChecked []string) ([]string, error) // SelectModelWithSelector prompts the user to select a model using the provided selector. func SelectModelWithSelector(ctx context.Context, selector SingleSelector) (string, error) { client, err := api.ClientFromEnvironment() if err != nil { return "", err } models, err := client.List(ctx) if err != nil { return "", err } var existing []modelInfo for _, m := range models.Models { existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""}) } cloudDisabled, _ := cloudStatusDisabled(ctx, client) if cloudDisabled { existing = filterCloudModels(existing) } lastModel := LastModel() var preChecked []string if lastModel != "" { preChecked = []string{lastModel} } items, _, existingModels, cloudModels := buildModelList(existing, preChecked, lastModel) if cloudDisabled { items = filterCloudItems(items) } if len(items) == 0 { return "", fmt.Errorf("no models available, run 'ollama pull ' first") } selected, err := selector("Select model to run:", items, "") if err != nil { return "", err } // If the selected model isn't installed, pull it first if !existingModels[selected] { if !isCloudModelName(selected) { msg := fmt.Sprintf("Download %s?", selected) if ok, err := confirmPrompt(msg); err != nil { return "", err } else if !ok { return "", errCancelled } fmt.Fprintf(os.Stderr, "\n") if err := pullModel(ctx, client, selected); err != nil { return "", fmt.Errorf("failed to pull %s: %w", selected, err) } } } // If it's a cloud model, ensure user is signed in if cloudModels[selected] { user, err := client.Whoami(ctx) if err == nil && user != nil && user.Name != "" { return selected, nil } var aErr api.AuthorizationError if !errors.As(err, &aErr) || aErr.SigninURL == "" { return "", err } yes, err := confirmPrompt(fmt.Sprintf("sign in to use %s?", selected)) if err != nil || !yes { return "", fmt.Errorf("%s requires sign in", selected) } fmt.Fprintf(os.Stderr, "\nTo sign in, navigate to:\n %s\n\n", aErr.SigninURL) // Auto-open browser (best effort, fail silently) switch runtime.GOOS { case "darwin": _ = exec.Command("open", aErr.SigninURL).Start() case "linux": _ = exec.Command("xdg-open", aErr.SigninURL).Start() case "windows": _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", aErr.SigninURL).Start() } spinnerFrames := []string{"|", "/", "-", "\\"} frame := 0 fmt.Fprintf(os.Stderr, "\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[0]) ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): fmt.Fprintf(os.Stderr, "\r\033[K") return "", ctx.Err() case <-ticker.C: frame++ fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)]) // poll every 10th frame (~2 seconds) if frame%10 == 0 { u, err := client.Whoami(ctx) if err == nil && u != nil && u.Name != "" { fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", u.Name) return selected, nil } } } } } return selected, nil } func SelectModel(ctx context.Context) (string, error) { return SelectModelWithSelector(ctx, DefaultSingleSelector) } // DefaultSingleSelector is the default single-select implementation. var DefaultSingleSelector SingleSelector // DefaultMultiSelector is the default multi-select implementation. var DefaultMultiSelector MultiSelector // DefaultSignIn provides a TUI-based sign-in flow. // When set, ensureAuth uses it instead of plain text prompts. // Returns the signed-in username or an error. var DefaultSignIn func(modelName, signInURL string) (string, error) func selectIntegration() (string, error) { if DefaultSingleSelector == nil { return "", fmt.Errorf("no selector configured") } if len(integrations) == 0 { return "", fmt.Errorf("no integrations available") } var items []ModelItem for name, r := range integrations { if integrationAliases[name] { continue } description := r.String() if conn, err := loadIntegration(name); err == nil && len(conn.Models) > 0 { description = fmt.Sprintf("%s (%s)", r.String(), conn.Models[0]) } items = append(items, ModelItem{Name: name, Description: description}) } orderRank := make(map[string]int, len(integrationOrder)) for i, name := range integrationOrder { orderRank[name] = i + 1 } slices.SortFunc(items, func(a, b ModelItem) int { aRank, bRank := orderRank[a.Name], orderRank[b.Name] if aRank > 0 && bRank > 0 { return aRank - bRank } if aRank > 0 { return 1 } if bRank > 0 { return -1 } return strings.Compare(a.Name, b.Name) }) return DefaultSingleSelector("Select integration:", items, "") } // selectModelsWithSelectors lets the user select models for an integration using provided selectors. func selectModelsWithSelectors(ctx context.Context, name, current string, single SingleSelector, multi MultiSelector) ([]string, error) { r, ok := integrations[name] if !ok { return nil, fmt.Errorf("unknown integration: %s", name) } client, err := api.ClientFromEnvironment() if err != nil { return nil, err } models, err := client.List(ctx) if err != nil { return nil, err } var existing []modelInfo for _, m := range models.Models { existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""}) } cloudDisabled, _ := cloudStatusDisabled(ctx, client) if cloudDisabled { existing = filterCloudModels(existing) } var preChecked []string if saved, err := loadIntegration(name); err == nil { preChecked = saved.Models } else if editor, ok := r.(Editor); ok { preChecked = editor.Models() } items, preChecked, existingModels, cloudModels := buildModelList(existing, preChecked, current) if cloudDisabled { items = filterCloudItems(items) } if len(items) == 0 { return nil, fmt.Errorf("no models available") } var selected []string if _, ok := r.(Editor); ok { selected, err = multi(fmt.Sprintf("Select models for %s:", r), items, preChecked) if err != nil { return nil, err } } else { prompt := fmt.Sprintf("Select model for %s:", r) if _, ok := r.(AliasConfigurer); ok { prompt = fmt.Sprintf("Select Primary model for %s:", r) } model, err := single(prompt, items, current) if err != nil { return nil, err } selected = []string{model} } var toPull []string for _, m := range selected { if !existingModels[m] && !isCloudModelName(m) { toPull = append(toPull, m) } } if len(toPull) > 0 { msg := fmt.Sprintf("Download %s?", strings.Join(toPull, ", ")) if ok, err := confirmPrompt(msg); err != nil { return nil, err } else if !ok { return nil, errCancelled } for _, m := range toPull { fmt.Fprintf(os.Stderr, "\n") if err := pullModel(ctx, client, m); err != nil { return nil, fmt.Errorf("failed to pull %s: %w", m, err) } } } if err := ensureAuth(ctx, client, cloudModels, selected); err != nil { return nil, err } return selected, nil } // TODO(parthsareen): consolidate pull logic from call sites func pullIfNeeded(ctx context.Context, client *api.Client, existingModels map[string]bool, model string) error { if isCloudModelName(model) || existingModels[model] { return nil } return confirmAndPull(ctx, client, model) } // TODO(parthsareen): pull this out to tui package // ShowOrPull checks if a model exists via client.Show and offers to pull it if not found. func ShowOrPull(ctx context.Context, client *api.Client, model string) error { if _, err := client.Show(ctx, &api.ShowRequest{Model: model}); err == nil { return nil } if isCloudModelName(model) { return nil } return confirmAndPull(ctx, client, model) } func confirmAndPull(ctx context.Context, client *api.Client, model string) error { if ok, err := confirmPrompt(fmt.Sprintf("Download %s?", model)); err != nil { return err } else if !ok { return errCancelled } fmt.Fprintf(os.Stderr, "\n") if err := pullModel(ctx, client, model); err != nil { return fmt.Errorf("failed to pull %s: %w", model, err) } return nil } func listModels(ctx context.Context) ([]ModelItem, map[string]bool, map[string]bool, *api.Client, error) { client, err := api.ClientFromEnvironment() if err != nil { return nil, nil, nil, nil, err } models, err := client.List(ctx) if err != nil { return nil, nil, nil, nil, err } var existing []modelInfo for _, m := range models.Models { existing = append(existing, modelInfo{ Name: m.Name, Remote: m.RemoteModel != "", }) } cloudDisabled, _ := cloudStatusDisabled(ctx, client) if cloudDisabled { existing = filterCloudModels(existing) } items, _, existingModels, cloudModels := buildModelList(existing, nil, "") if cloudDisabled { items = filterCloudItems(items) } if len(items) == 0 { return nil, nil, nil, nil, fmt.Errorf("no models available, run 'ollama pull ' first") } return items, existingModels, cloudModels, client, nil } func OpenBrowser(url string) { switch runtime.GOOS { case "darwin": _ = exec.Command("open", url).Start() case "linux": _ = exec.Command("xdg-open", url).Start() case "windows": _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() } } func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]bool, selected []string) error { var selectedCloudModels []string for _, m := range selected { if cloudModels[m] { selectedCloudModels = append(selectedCloudModels, m) } } if len(selectedCloudModels) == 0 { return nil } if disabled, known := cloudStatusDisabled(ctx, client); known && disabled { return errors.New(internalcloud.DisabledError("remote inference is unavailable")) } user, err := client.Whoami(ctx) if err == nil && user != nil && user.Name != "" { return nil } var aErr api.AuthorizationError if !errors.As(err, &aErr) || aErr.SigninURL == "" { return err } modelList := strings.Join(selectedCloudModels, ", ") if DefaultSignIn != nil { _, err := DefaultSignIn(modelList, aErr.SigninURL) if err != nil { return fmt.Errorf("%s requires sign in", modelList) } return nil } // Fallback: plain text sign-in flow yes, err := confirmPrompt(fmt.Sprintf("sign in to use %s?", modelList)) if err != nil || !yes { return fmt.Errorf("%s requires sign in", modelList) } fmt.Fprintf(os.Stderr, "\nTo sign in, navigate to:\n %s\n\n", aErr.SigninURL) OpenBrowser(aErr.SigninURL) spinnerFrames := []string{"|", "/", "-", "\\"} frame := 0 fmt.Fprintf(os.Stderr, "\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[0]) ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): fmt.Fprintf(os.Stderr, "\r\033[K") return ctx.Err() case <-ticker.C: frame++ fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)]) // poll every 10th frame (~2 seconds) if frame%10 == 0 { u, err := client.Whoami(ctx) if err == nil && u != nil && u.Name != "" { fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", u.Name) return nil } } } } } // selectModels lets the user select models for an integration using default selectors. func selectModels(ctx context.Context, name, current string) ([]string, error) { return selectModelsWithSelectors(ctx, name, current, DefaultSingleSelector, DefaultMultiSelector) } func runIntegration(name, modelName string, args []string) error { r, ok := integrations[name] if !ok { return fmt.Errorf("unknown integration: %s", name) } fmt.Fprintf(os.Stderr, "\nLaunching %s with %s...\n", r, modelName) return r.Run(modelName, args) } // syncAliases syncs aliases to server and saves locally for an AliasConfigurer. func syncAliases(ctx context.Context, client *api.Client, ac AliasConfigurer, name, model string, existing map[string]string) error { aliases := make(map[string]string) for k, v := range existing { aliases[k] = v } aliases["primary"] = model if isCloudModelName(model) { aliases["fast"] = model } else { delete(aliases, "fast") } if err := ac.SetAliases(ctx, aliases); err != nil { return err } return saveAliases(name, aliases) } // LaunchIntegration launches the named integration using saved config or prompts for setup. func LaunchIntegration(name string) error { r, ok := integrations[name] if !ok { return fmt.Errorf("unknown integration: %s", name) } // Try to use saved config if ic, err := loadIntegration(name); err == nil && len(ic.Models) > 0 { client, err := api.ClientFromEnvironment() if err != nil { return err } if err := ShowOrPull(context.Background(), client, ic.Models[0]); err != nil { return err } return runIntegration(name, ic.Models[0], nil) } // No saved config - prompt user to run setup return fmt.Errorf("%s is not configured. Run 'ollama launch %s' to set it up", r, name) } // LaunchIntegrationWithModel launches the named integration with the specified model. func LaunchIntegrationWithModel(name, modelName string) error { client, err := api.ClientFromEnvironment() if err != nil { return err } if err := ShowOrPull(context.Background(), client, modelName); err != nil { return err } return runIntegration(name, modelName, nil) } // SaveAndEditIntegration saves the models for an Editor integration and runs its Edit method // to write the integration's config files. func SaveAndEditIntegration(name string, models []string) error { r, ok := integrations[strings.ToLower(name)] if !ok { return fmt.Errorf("unknown integration: %s", name) } if err := SaveIntegration(name, models); err != nil { return fmt.Errorf("failed to save: %w", err) } if editor, isEditor := r.(Editor); isEditor { if err := editor.Edit(models); err != nil { return fmt.Errorf("setup failed: %w", err) } } return nil } // resolveEditorModels filters out cloud-disabled models before editor launch. // If no models remain, it invokes picker to collect a valid replacement list. func resolveEditorModels(name string, models []string, picker func() ([]string, error)) ([]string, error) { filtered := filterDisabledCloudModels(models) if len(filtered) != len(models) { if err := SaveIntegration(name, filtered); err != nil { return nil, fmt.Errorf("failed to save: %w", err) } } if len(filtered) > 0 { return filtered, nil } selected, err := picker() if err != nil { return nil, err } if err := SaveIntegration(name, selected); err != nil { return nil, fmt.Errorf("failed to save: %w", err) } return selected, nil } // ConfigureIntegrationWithSelectors allows the user to select/change the model for an integration using custom selectors. func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single SingleSelector, multi MultiSelector) error { r, ok := integrations[name] if !ok { return fmt.Errorf("unknown integration: %s", name) } models, err := selectModelsWithSelectors(ctx, name, "", single, multi) if errors.Is(err, errCancelled) { return errCancelled } if err != nil { return err } if editor, isEditor := r.(Editor); isEditor { paths := editor.Paths() if len(paths) > 0 { fmt.Fprintf(os.Stderr, "This will modify your %s configuration:\n", r) for _, p := range paths { fmt.Fprintf(os.Stderr, " %s\n", p) } fmt.Fprintf(os.Stderr, "Backups will be saved to %s/\n\n", backupDir()) if ok, _ := confirmPrompt("Proceed?"); !ok { return nil } } if err := editor.Edit(models); err != nil { return fmt.Errorf("setup failed: %w", err) } } if err := SaveIntegration(name, models); err != nil { return fmt.Errorf("failed to save: %w", err) } if len(models) == 1 { fmt.Fprintf(os.Stderr, "Configured %s with %s\n", r, models[0]) } else { fmt.Fprintf(os.Stderr, "Configured %s with %d models (default: %s)\n", r, len(models), models[0]) } return nil } // ConfigureIntegration allows the user to select/change the model for an integration. func ConfigureIntegration(ctx context.Context, name string) error { return ConfigureIntegrationWithSelectors(ctx, name, DefaultSingleSelector, DefaultMultiSelector) } // LaunchCmd returns the cobra command for launching integrations. // The runTUI callback is called when no arguments are provided (alias for main TUI). func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) error, runTUI func(cmd *cobra.Command)) *cobra.Command { var modelFlag string var configFlag bool cmd := &cobra.Command{ Use: "launch [INTEGRATION] [-- [EXTRA_ARGS...]]", Short: "Launch the Ollama menu or an integration", Long: `Launch the Ollama interactive menu, or directly launch a specific integration. Without arguments, this is equivalent to running 'ollama' directly. Supported integrations: claude Claude Code cline Cline codex Codex droid Droid opencode OpenCode openclaw OpenClaw (aliases: clawdbot, moltbot) pi Pi Examples: ollama launch ollama launch claude ollama launch claude --model 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, RunE: func(cmd *cobra.Command, args []string) error { // No args and no flags - show the full TUI (same as bare 'ollama') if len(args) == 0 && modelFlag == "" && !configFlag { runTUI(cmd) return nil } // Extract integration name and args to pass through using -- separator var name string var passArgs []string dashIdx := cmd.ArgsLenAtDash() if dashIdx == -1 { // No "--" separator: only allow 0 or 1 args (integration name) if len(args) > 1 { return fmt.Errorf("unexpected arguments: %v\nUse '--' to pass extra arguments to the integration", args[1:]) } if len(args) == 1 { name = args[0] } } else { // "--" was used: args before it = integration name, args after = passthrough if dashIdx > 1 { return fmt.Errorf("expected at most 1 integration name before '--', got %d", dashIdx) } if dashIdx == 1 { name = args[0] } passArgs = args[dashIdx:] } if name == "" { var err error name, err = selectIntegration() if errors.Is(err, errCancelled) { return nil } if err != nil { return err } } r, ok := integrations[strings.ToLower(name)] if !ok { return fmt.Errorf("unknown integration: %s", name) } if err := EnsureInstalled(name); err != nil { return err } if modelFlag != "" && IsCloudModelDisabled(cmd.Context(), modelFlag) { modelFlag = "" } // Handle AliasConfigurer integrations (claude, codex) if ac, ok := r.(AliasConfigurer); ok { client, err := api.ClientFromEnvironment() if err != nil { return err } // Validate --model flag if provided if modelFlag != "" { if err := ShowOrPull(cmd.Context(), client, modelFlag); err != nil { if errors.Is(err, errCancelled) { return nil } return err } } var model string var existingAliases map[string]string // Load saved config if cfg, err := loadIntegration(name); err == nil { existingAliases = cfg.Aliases if len(cfg.Models) > 0 { model = cfg.Models[0] // AliasConfigurer integrations use single model; sanitize if multiple if len(cfg.Models) > 1 { _ = SaveIntegration(name, []string{model}) } } } // --model flag overrides saved model if modelFlag != "" { model = modelFlag } // Validate saved model still exists if model != "" && modelFlag == "" { if disabled, _ := cloudStatusDisabled(cmd.Context(), client); disabled && isCloudModelName(model) { model = "" } else if _, err := client.Show(cmd.Context(), &api.ShowRequest{Model: model}); err != nil { fmt.Fprintf(os.Stderr, "%sConfigured model %q not found%s\n\n", ansiGray, model, ansiReset) if err := ShowOrPull(cmd.Context(), client, model); err != nil { model = "" } } } // Show picker so user can change model (skip when --model flag provided) aliases, _, err := ac.ConfigureAliases(cmd.Context(), model, existingAliases, modelFlag == "") if errors.Is(err, errCancelled) { return nil } if err != nil { return err } model = aliases["primary"] existingAliases = aliases // Ensure cloud models are authenticated if isCloudModelName(model) { if err := ensureAuth(cmd.Context(), client, map[string]bool{model: true}, []string{model}); err != nil { return err } } // Sync aliases and save if err := syncAliases(cmd.Context(), client, ac, name, model, existingAliases); err != nil { fmt.Fprintf(os.Stderr, "%sWarning: Could not sync aliases: %v%s\n", ansiGray, err, ansiReset) } if err := SaveIntegration(name, []string{model}); err != nil { return fmt.Errorf("failed to save: %w", err) } // Launch (unless --config without confirmation) if configFlag { if launch, _ := confirmPrompt(fmt.Sprintf("Launch %s now?", r)); launch { return runIntegration(name, model, passArgs) } return nil } return runIntegration(name, model, passArgs) } // Validate --model flag for non-AliasConfigurer integrations if modelFlag != "" { client, err := api.ClientFromEnvironment() if err != nil { return err } if err := ShowOrPull(cmd.Context(), client, modelFlag); err != nil { if errors.Is(err, errCancelled) { return nil } return err } } var models []string if modelFlag != "" { models = []string{modelFlag} if existing, err := loadIntegration(name); err == nil && len(existing.Models) > 0 { for _, m := range existing.Models { if m != modelFlag { models = append(models, m) } } } models = filterDisabledCloudModels(models) if len(models) == 0 { var err error models, err = selectModels(cmd.Context(), name, "") if errors.Is(err, errCancelled) { return nil } if err != nil { return err } } } else { current := "" if saved, err := loadIntegration(name); err == nil && len(saved.Models) > 0 { current = saved.Models[0] } var err error models, err = selectModels(cmd.Context(), name, current) if errors.Is(err, errCancelled) { return nil } if err != nil { return err } } if editor, isEditor := r.(Editor); isEditor { paths := editor.Paths() if len(paths) > 0 { fmt.Fprintf(os.Stderr, "This will modify your %s configuration:\n", r) for _, p := range paths { fmt.Fprintf(os.Stderr, " %s\n", p) } fmt.Fprintf(os.Stderr, "Backups will be saved to %s/\n\n", backupDir()) if ok, _ := confirmPrompt("Proceed?"); !ok { return nil } } } if err := SaveIntegration(name, models); err != nil { return fmt.Errorf("failed to save: %w", err) } if editor, isEditor := r.(Editor); isEditor { if err := editor.Edit(models); err != nil { return fmt.Errorf("setup failed: %w", err) } } if _, isEditor := r.(Editor); isEditor { if len(models) == 1 { fmt.Fprintf(os.Stderr, "Added %s to %s\n", models[0], r) } else { fmt.Fprintf(os.Stderr, "Added %d models to %s (default: %s)\n", len(models), r, models[0]) } } if configFlag { if launch, _ := confirmPrompt(fmt.Sprintf("\nLaunch %s now?", r)); launch { return runIntegration(name, models[0], passArgs) } fmt.Fprintf(os.Stderr, "Run 'ollama launch %s' to start with %s\n", strings.ToLower(name), models[0]) return nil } return runIntegration(name, models[0], passArgs) }, } cmd.Flags().StringVar(&modelFlag, "model", "", "Model to use") cmd.Flags().BoolVar(&configFlag, "config", false, "Configure without launching") return cmd } type modelInfo struct { Name string Remote bool ToolCapable bool } // buildModelList merges existing models with recommendations, sorts them, and returns // the ordered items along with maps of existing and cloud model names. func buildModelList(existing []modelInfo, preChecked []string, current string) (items []ModelItem, orderedChecked []string, existingModels, cloudModels map[string]bool) { existingModels = make(map[string]bool) cloudModels = make(map[string]bool) recommended := make(map[string]bool) var hasLocalModel, hasCloudModel bool recDesc := make(map[string]string) for _, rec := range recommendedModels { recommended[rec.Name] = true recDesc[rec.Name] = rec.Description } for _, m := range existing { existingModels[m.Name] = true if m.Remote { cloudModels[m.Name] = true hasCloudModel = true } else { hasLocalModel = true } displayName := strings.TrimSuffix(m.Name, ":latest") existingModels[displayName] = true item := ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]} items = append(items, item) } for _, rec := range recommendedModels { if existingModels[rec.Name] || existingModels[rec.Name+":latest"] { continue } items = append(items, rec) if isCloudModelName(rec.Name) { cloudModels[rec.Name] = true } } checked := make(map[string]bool, len(preChecked)) for _, n := range preChecked { checked[n] = true } // Resolve current to full name (e.g., "llama3.2" -> "llama3.2:latest") for _, item := range items { if item.Name == current || strings.HasPrefix(item.Name, current+":") { current = item.Name break } } if checked[current] { preChecked = append([]string{current}, slices.DeleteFunc(preChecked, func(m string) bool { return m == current })...) } // Non-existing models get "install?" suffix and are pushed to the bottom. // When user has no models, preserve recommended order. notInstalled := make(map[string]bool) for i := range items { if !existingModels[items[i].Name] && !cloudModels[items[i].Name] { notInstalled[items[i].Name] = true var parts []string if items[i].Description != "" { parts = append(parts, items[i].Description) } if vram := recommendedVRAM[items[i].Name]; vram != "" { parts = append(parts, vram) } parts = append(parts, "(not downloaded)") items[i].Description = strings.Join(parts, ", ") } } // Build a recommended rank map to preserve ordering within tiers. recRank := make(map[string]int) for i, rec := range recommendedModels { recRank[rec.Name] = i + 1 // 1-indexed; 0 means not recommended } onlyLocal := hasLocalModel && !hasCloudModel if hasLocalModel || hasCloudModel { slices.SortStableFunc(items, func(a, b ModelItem) int { ac, bc := checked[a.Name], checked[b.Name] aNew, bNew := notInstalled[a.Name], notInstalled[b.Name] aRec, bRec := recRank[a.Name] > 0, recRank[b.Name] > 0 aCloud, bCloud := cloudModels[a.Name], cloudModels[b.Name] // Checked/pre-selected always first if ac != bc { if ac { return -1 } return 1 } // Recommended above non-recommended if aRec != bRec { if aRec { return -1 } return 1 } // Both recommended if aRec && bRec { if aCloud != bCloud { if onlyLocal { // Local before cloud when only local installed if aCloud { return 1 } return -1 } // Cloud before local in mixed case if aCloud { return -1 } return 1 } return recRank[a.Name] - recRank[b.Name] } // Both non-recommended: installed before not-installed if aNew != bNew { if aNew { return 1 } return -1 } return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) } return items, preChecked, existingModels, cloudModels } // IsCloudModelDisabled reports whether the given model name looks like a cloud // model and cloud features are currently disabled on the server. func IsCloudModelDisabled(ctx context.Context, name string) bool { if !isCloudModelName(name) { return false } client, err := api.ClientFromEnvironment() if err != nil { return false } disabled, _ := cloudStatusDisabled(ctx, client) return disabled } func isCloudModelName(name string) bool { // TODO(drifkin): Replace this wrapper with inlining once things stabilize a bit return modelref.HasExplicitCloudSource(name) } func filterCloudModels(existing []modelInfo) []modelInfo { filtered := existing[:0] for _, m := range existing { if !m.Remote { filtered = append(filtered, m) } } return filtered } // filterDisabledCloudModels removes cloud models from a list when cloud is disabled. func filterDisabledCloudModels(models []string) []string { var filtered []string for _, m := range models { if !IsCloudModelDisabled(context.Background(), m) { filtered = append(filtered, m) } } return filtered } func filterCloudItems(items []ModelItem) []ModelItem { filtered := items[:0] for _, item := range items { if !isCloudModelName(item.Name) { filtered = append(filtered, item) } } return filtered } func isCloudModel(ctx context.Context, client *api.Client, name string) bool { if client == nil { return false } resp, err := client.Show(ctx, &api.ShowRequest{Model: name}) if err != nil { return false } return resp.RemoteModel != "" } // GetModelItems returns a list of model items including recommendations for the TUI. // It includes all locally available models plus recommended models that aren't installed. func GetModelItems(ctx context.Context) ([]ModelItem, map[string]bool) { client, err := api.ClientFromEnvironment() if err != nil { return nil, nil } models, err := client.List(ctx) if err != nil { return nil, nil } var existing []modelInfo for _, m := range models.Models { existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""}) } cloudDisabled, _ := cloudStatusDisabled(ctx, client) if cloudDisabled { existing = filterCloudModels(existing) } lastModel := LastModel() var preChecked []string if lastModel != "" { preChecked = []string{lastModel} } items, _, existingModels, _ := buildModelList(existing, preChecked, lastModel) if cloudDisabled { items = filterCloudItems(items) } return items, existingModels } func cloudStatusDisabled(ctx context.Context, client *api.Client) (disabled bool, known bool) { status, err := client.CloudStatusExperimental(ctx) if err != nil { var statusErr api.StatusError if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound { return false, false } return false, false } return status.Cloud.Disabled, true } func pullModel(ctx context.Context, client *api.Client, model string) error { p := progress.NewProgress(os.Stderr) defer p.Stop() bars := make(map[string]*progress.Bar) var status string var spinner *progress.Spinner fn := func(resp api.ProgressResponse) error { if resp.Digest != "" { if resp.Completed == 0 { return nil } if spinner != nil { spinner.Stop() } bar, ok := bars[resp.Digest] if !ok { name, isDigest := strings.CutPrefix(resp.Digest, "sha256:") name = strings.TrimSpace(name) if isDigest { name = name[:min(12, len(name))] } bar = progress.NewBar(fmt.Sprintf("pulling %s:", name), resp.Total, resp.Completed) bars[resp.Digest] = bar p.Add(resp.Digest, bar) } bar.Set(resp.Completed) } else if status != resp.Status { if spinner != nil { spinner.Stop() } status = resp.Status spinner = progress.NewSpinner(status) p.Add(status, spinner) } return nil } request := api.PullRequest{Name: model} return client.Pull(ctx, &request, fn) }