diff --git a/cmd/launch/cline.go b/cmd/launch/cline.go index 4a6c7423..656a0ad3 100644 --- a/cmd/launch/cline.go +++ b/cmd/launch/cline.go @@ -6,11 +6,15 @@ import ( "os" "os/exec" "path/filepath" + "strings" + "time" "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" ) +const clineLaunchProvider = "ollama" + // Cline implements Runner and Editor for the Cline CLI integration type Cline struct{} @@ -21,23 +25,34 @@ func (c *Cline) Run(model string, _ []LaunchModel, args []string) error { return fmt.Errorf("cline is not installed, install with: npm install -g cline") } - cmd := exec.Command("cline", args...) + launchArgs := clineLaunchArgs(model, args) + cmd := exec.Command("cline", launchArgs...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } +func clineLaunchArgs(model string, extra []string) []string { + return extra +} + func (c *Cline) Paths() []string { home, err := os.UserHomeDir() if err != nil { return nil } - p := filepath.Join(home, ".cline", "data", "globalState.json") - if _, err := os.Stat(p); err == nil { - return []string{p} + + var paths []string + for _, p := range []string{ + clineProvidersPath(home), + clineLegacyGlobalStatePath(home), + } { + if _, err := os.Stat(p); err == nil { + paths = append(paths, p) + } } - return nil + return paths } func (c *Cline) Edit(models []LaunchModel) error { @@ -50,26 +65,113 @@ func (c *Cline) Edit(models []LaunchModel) error { return err } - configPath := filepath.Join(home, ".cline", "data", "globalState.json") + providersPath := clineProvidersPath(home) + legacyPath := clineLegacyGlobalStatePath(home) + + providersConfig, err := readClineConfig(providersPath) + if err != nil { + return err + } + legacyConfig, err := readClineConfig(legacyPath) + if err != nil { + return err + } + + if err := writeClineProvidersConfig(providersPath, providersConfig, models[0].Name); err != nil { + return err + } + return writeClineLegacyGlobalState(legacyPath, legacyConfig, models[0].Name) +} + +func clineProvidersPath(home string) string { + return filepath.Join(home, ".cline", "data", "settings", "providers.json") +} + +func clineLegacyGlobalStatePath(home string) string { + return filepath.Join(home, ".cline", "data", "globalState.json") +} + +func clineOllamaRootURL() string { + return strings.TrimRight(envconfig.ConnectableHost().String(), "/") +} + +func clineProviderBaseURL() string { + return clineOllamaRootURL() + "/v1" +} + +func readClineConfig(configPath string) (map[string]any, error) { + config := make(map[string]any) + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config: %w, at: %s", err, configPath) + } + } else if !os.IsNotExist(err) { + return nil, err + } + return config, nil +} + +func writeClineProvidersConfig(configPath string, config map[string]any, model string) error { if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return err } - config := make(map[string]any) - if data, err := os.ReadFile(configPath); err == nil { - if err := json.Unmarshal(data, &config); err != nil { - return fmt.Errorf("failed to parse config: %w, at: %s", err, configPath) - } + providers, _ := config["providers"].(map[string]any) + if providers == nil { + providers = make(map[string]any) } - // Set Ollama as the provider for both act and plan modes - baseURL := envconfig.Host().String() + provider, _ := providers[clineLaunchProvider].(map[string]any) + if provider == nil { + provider = make(map[string]any) + } + settings, _ := provider["settings"].(map[string]any) + if settings == nil { + settings = make(map[string]any) + } + + baseURL := clineProviderBaseURL() + previousModel, _ := settings["model"].(string) + previousBaseURL, _ := settings["baseUrl"].(string) + previousTokenSource, _ := provider["tokenSource"].(string) + + settings["provider"] = clineLaunchProvider + settings["model"] = model + settings["baseUrl"] = baseURL + delete(settings, "apiKey") + provider["settings"] = settings + + if previousModel != model || previousBaseURL != baseURL || previousTokenSource != "manual" { + provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) + } else if _, ok := provider["updatedAt"].(string); !ok { + provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) + } + provider["tokenSource"] = "manual" + providers[clineLaunchProvider] = provider + + config["version"] = float64(1) + config["lastUsedProvider"] = clineLaunchProvider + config["providers"] = providers + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return fileutil.WriteWithBackup(configPath, data, "cline") +} + +func writeClineLegacyGlobalState(configPath string, config map[string]any, model string) error { + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return err + } + + baseURL := clineOllamaRootURL() config["ollamaBaseUrl"] = baseURL - config["actModeApiProvider"] = "ollama" - config["actModeOllamaModelId"] = models[0].Name + config["actModeApiProvider"] = clineLaunchProvider + config["actModeOllamaModelId"] = model config["actModeOllamaBaseUrl"] = baseURL - config["planModeApiProvider"] = "ollama" - config["planModeOllamaModelId"] = models[0].Name + config["planModeApiProvider"] = clineLaunchProvider + config["planModeOllamaModelId"] = model config["planModeOllamaBaseUrl"] = baseURL config["welcomeViewCompleted"] = true @@ -87,12 +189,18 @@ func (c *Cline) Models() []string { return nil } - config, err := fileutil.ReadJSON(filepath.Join(home, ".cline", "data", "globalState.json")) + if model := clineProviderModel(home); model != "" { + return []string{model} + } + + config, err := fileutil.ReadJSON(clineLegacyGlobalStatePath(home)) if err != nil { return nil } - if config["actModeApiProvider"] != "ollama" { + switch config["actModeApiProvider"] { + case "ollama": + default: return nil } @@ -102,3 +210,18 @@ func (c *Cline) Models() []string { } return []string{modelID} } + +func clineProviderModel(home string) string { + config, err := fileutil.ReadJSON(clineProvidersPath(home)) + if err != nil { + return "" + } + if config["lastUsedProvider"] != clineLaunchProvider { + return "" + } + providers, _ := config["providers"].(map[string]any) + provider, _ := providers[clineLaunchProvider].(map[string]any) + settings, _ := provider["settings"].(map[string]any) + model, _ := settings["model"].(string) + return model +} diff --git a/cmd/launch/cline_test.go b/cmd/launch/cline_test.go index 9dabca3a..57fc0dc2 100644 --- a/cmd/launch/cline_test.go +++ b/cmd/launch/cline_test.go @@ -32,6 +32,7 @@ func TestClineEdit(t *testing.T) { configDir := filepath.Join(tmpDir, ".cline", "data") configPath := filepath.Join(configDir, "globalState.json") + providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json") readConfig := func() map[string]any { data, _ := os.ReadFile(configPath) @@ -40,6 +41,13 @@ func TestClineEdit(t *testing.T) { return config } + readProvidersConfig := func() map[string]any { + data, _ := os.ReadFile(providersPath) + var config map[string]any + json.Unmarshal(data, &config) + return config + } + t.Run("creates config from scratch", func(t *testing.T) { os.RemoveAll(filepath.Join(tmpDir, ".cline")) @@ -48,26 +56,56 @@ func TestClineEdit(t *testing.T) { } config := readConfig() - if config["actModeApiProvider"] != "ollama" { - t.Errorf("actModeApiProvider = %v, want ollama", config["actModeApiProvider"]) + if config["actModeApiProvider"] != clineLaunchProvider { + t.Errorf("actModeApiProvider = %v, want %s", config["actModeApiProvider"], clineLaunchProvider) } if config["actModeOllamaModelId"] != "kimi-k2.5:cloud" { t.Errorf("actModeOllamaModelId = %v, want kimi-k2.5:cloud", config["actModeOllamaModelId"]) } - if config["planModeApiProvider"] != "ollama" { - t.Errorf("planModeApiProvider = %v, want ollama", config["planModeApiProvider"]) + if config["actModeOllamaBaseUrl"] != "http://127.0.0.1:11434" { + t.Errorf("actModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["actModeOllamaBaseUrl"]) + } + if config["planModeApiProvider"] != clineLaunchProvider { + t.Errorf("planModeApiProvider = %v, want %s", config["planModeApiProvider"], clineLaunchProvider) } if config["planModeOllamaModelId"] != "kimi-k2.5:cloud" { t.Errorf("planModeOllamaModelId = %v, want kimi-k2.5:cloud", config["planModeOllamaModelId"]) } + if config["planModeOllamaBaseUrl"] != "http://127.0.0.1:11434" { + t.Errorf("planModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["planModeOllamaBaseUrl"]) + } + if config["ollamaBaseUrl"] != "http://127.0.0.1:11434" { + t.Errorf("ollamaBaseUrl = %v, want http://127.0.0.1:11434", config["ollamaBaseUrl"]) + } if config["welcomeViewCompleted"] != true { t.Errorf("welcomeViewCompleted = %v, want true", config["welcomeViewCompleted"]) } + + providersConfig := readProvidersConfig() + if providersConfig["lastUsedProvider"] != clineLaunchProvider { + t.Errorf("lastUsedProvider = %v, want %s", providersConfig["lastUsedProvider"], clineLaunchProvider) + } + providers, _ := providersConfig["providers"].(map[string]any) + provider, _ := providers[clineLaunchProvider].(map[string]any) + if provider["updatedAt"] == "" { + t.Errorf("updatedAt = %v, want timestamp", provider["updatedAt"]) + } + settings, _ := provider["settings"].(map[string]any) + if settings["model"] != "kimi-k2.5:cloud" { + t.Errorf("settings.model = %v, want kimi-k2.5:cloud", settings["model"]) + } + if _, ok := settings["apiKey"]; ok { + t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"]) + } + if settings["baseUrl"] != "http://127.0.0.1:11434/v1" { + t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"]) + } }) t.Run("preserves existing fields", func(t *testing.T) { os.RemoveAll(filepath.Join(tmpDir, ".cline")) os.MkdirAll(configDir, 0o755) + os.MkdirAll(filepath.Dir(providersPath), 0o755) existing := map[string]any{ "remoteRulesToggles": map[string]any{}, @@ -77,6 +115,21 @@ func TestClineEdit(t *testing.T) { data, _ := json.Marshal(existing) os.WriteFile(configPath, data, 0o644) + existingProviders := map[string]any{ + "customRoot": "keep-me-too", + "providers": map[string]any{ + clineLaunchProvider: map[string]any{ + "updatedAt": "2026-05-29T16:56:46.111Z", + "settings": map[string]any{ + "apiKey": "bad-migrated-key", + "timeout": float64(30000), + }, + }, + }, + } + data, _ = json.Marshal(existingProviders) + os.WriteFile(providersPath, data, 0o644) + if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil { t.Fatal(err) } @@ -88,6 +141,75 @@ func TestClineEdit(t *testing.T) { if config["actModeOllamaModelId"] != "glm-5:cloud" { t.Errorf("actModeOllamaModelId = %v, want glm-5:cloud", config["actModeOllamaModelId"]) } + + providersConfig := readProvidersConfig() + if providersConfig["customRoot"] != "keep-me-too" { + t.Errorf("customRoot was not preserved") + } + providers, _ := providersConfig["providers"].(map[string]any) + provider, _ := providers[clineLaunchProvider].(map[string]any) + if provider["updatedAt"] == "2026-05-29T16:56:46.111Z" { + t.Errorf("updatedAt = %v, want refreshed timestamp after provider change", provider["updatedAt"]) + } + settings, _ := provider["settings"].(map[string]any) + if settings["timeout"] != float64(30000) { + t.Errorf("settings.timeout = %v, want 30000", settings["timeout"]) + } + if _, ok := settings["apiKey"]; ok { + t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"]) + } + if settings["model"] != "glm-5:cloud" { + t.Errorf("settings.model = %v, want glm-5:cloud", settings["model"]) + } + if settings["baseUrl"] != "http://127.0.0.1:11434/v1" { + t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"]) + } + }) + + t.Run("validates both configs before writing providers config", func(t *testing.T) { + os.RemoveAll(filepath.Join(tmpDir, ".cline")) + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte("{not json"), 0o644) + + err := c.Edit(testLaunchModels("kimi-k2.5:cloud")) + if err == nil { + t.Fatal("expected invalid legacy config error") + } + if _, statErr := os.Stat(providersPath); !os.IsNotExist(statErr) { + t.Fatalf("providers config should not be written when legacy config is invalid, stat err = %v", statErr) + } + }) + + t.Run("preserves updatedAt when provider settings are unchanged", func(t *testing.T) { + os.RemoveAll(filepath.Join(tmpDir, ".cline")) + os.MkdirAll(filepath.Dir(providersPath), 0o755) + + existingProviders := map[string]any{ + "providers": map[string]any{ + clineLaunchProvider: map[string]any{ + "updatedAt": "2026-05-29T16:56:46.111Z", + "tokenSource": "manual", + "settings": map[string]any{ + "provider": clineLaunchProvider, + "model": "kimi-k2.5:cloud", + "baseUrl": "http://127.0.0.1:11434/v1", + }, + }, + }, + } + data, _ := json.Marshal(existingProviders) + os.WriteFile(providersPath, data, 0o644) + + if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil { + t.Fatal(err) + } + + providersConfig := readProvidersConfig() + providers, _ := providersConfig["providers"].(map[string]any) + provider, _ := providers[clineLaunchProvider].(map[string]any) + if provider["updatedAt"] != "2026-05-29T16:56:46.111Z" { + t.Errorf("updatedAt = %v, want preserved timestamp", provider["updatedAt"]) + } }) t.Run("updates model on re-edit", func(t *testing.T) { @@ -142,6 +264,7 @@ func TestClineModels(t *testing.T) { configDir := filepath.Join(tmpDir, ".cline", "data") configPath := filepath.Join(configDir, "globalState.json") + providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json") t.Run("returns nil when no config", func(t *testing.T) { if models := c.Models(); models != nil { @@ -177,6 +300,55 @@ func TestClineModels(t *testing.T) { t.Errorf("Models() = %v, want [kimi-k2.5:cloud]", models) } }) + + t.Run("prefers CLI provider config", func(t *testing.T) { + os.MkdirAll(filepath.Dir(providersPath), 0o755) + config := map[string]any{ + "lastUsedProvider": clineLaunchProvider, + "providers": map[string]any{ + clineLaunchProvider: map[string]any{ + "settings": map[string]any{ + "model": "glm-5:cloud", + }, + }, + }, + } + data, _ := json.Marshal(config) + os.WriteFile(providersPath, data, 0o644) + + models := c.Models() + if len(models) != 1 || models[0] != "glm-5:cloud" { + t.Errorf("Models() = %v, want [glm-5:cloud]", models) + } + }) + + t.Run("ignores stale CLI provider config when ollama is not active", func(t *testing.T) { + os.RemoveAll(filepath.Join(tmpDir, ".cline")) + os.MkdirAll(configDir, 0o755) + os.MkdirAll(filepath.Dir(providersPath), 0o755) + legacyConfig := map[string]any{ + "actModeApiProvider": "anthropic", + "actModeOllamaModelId": "legacy-ollama-model", + } + data, _ := json.Marshal(legacyConfig) + os.WriteFile(configPath, data, 0o644) + providerConfig := map[string]any{ + "lastUsedProvider": "openai", + "providers": map[string]any{ + clineLaunchProvider: map[string]any{ + "settings": map[string]any{ + "model": "stale-ollama-model", + }, + }, + }, + } + data, _ = json.Marshal(providerConfig) + os.WriteFile(providersPath, data, 0o644) + + if models := c.Models(); models != nil { + t.Errorf("Models() = %v, want nil", models) + } + }) } func TestClinePaths(t *testing.T) { @@ -201,4 +373,38 @@ func TestClinePaths(t *testing.T) { t.Errorf("Paths() = %v, want [%s]", paths, configPath) } }) + + t.Run("returns both paths when both configs exist", func(t *testing.T) { + os.RemoveAll(filepath.Join(tmpDir, ".cline")) + legacyPath := clineLegacyGlobalStatePath(tmpDir) + providersPath := clineProvidersPath(tmpDir) + os.MkdirAll(filepath.Dir(legacyPath), 0o755) + os.MkdirAll(filepath.Dir(providersPath), 0o755) + os.WriteFile(legacyPath, []byte("{}"), 0o644) + os.WriteFile(providersPath, []byte("{}"), 0o644) + + paths := c.Paths() + want := []string{providersPath, legacyPath} + if len(paths) != len(want) { + t.Fatalf("Paths() = %v, want %v", paths, want) + } + for i := range want { + if paths[i] != want[i] { + t.Fatalf("Paths() = %v, want %v", paths, want) + } + } + }) +} + +func TestClineLaunchArgs(t *testing.T) { + got := clineLaunchArgs("kimi-k2.5:cloud", []string{"--json", "hello"}) + want := []string{"--json", "hello"} + if len(got) != len(want) { + t.Fatalf("args length = %d, want %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("args[%d] = %q, want %q; got %v", i, got[i], want[i], got) + } + } }