cmd/launch: fix configure cline ollama provider via providers.json (#16352)

This commit is contained in:
Eva H
2026-06-01 16:41:40 -04:00
committed by GitHub
parent 5e9636fa05
commit 00381496a3
2 changed files with 352 additions and 23 deletions

View File

@@ -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
}

View File

@@ -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)
}
}
}