支持Cline Agent
This commit is contained in:
@@ -298,6 +298,297 @@ fn load_opencode_auth_json_raw() -> Option<String> {
|
||||
fs::read_to_string(opencode_auth_json_path()).ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cline config helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cline_data_dir() -> PathBuf {
|
||||
if let Ok(custom) = std::env::var("CLINE_DIR") {
|
||||
let trimmed = custom.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
home_dir_or_default().join(".cline").join("data")
|
||||
}
|
||||
|
||||
fn cline_global_state_path() -> PathBuf {
|
||||
cline_data_dir().join("globalState.json")
|
||||
}
|
||||
|
||||
fn cline_secrets_path() -> PathBuf {
|
||||
cline_data_dir().join("secrets.json")
|
||||
}
|
||||
|
||||
fn load_cline_secrets_json_raw() -> Option<String> {
|
||||
fs::read_to_string(cline_secrets_path()).ok()
|
||||
}
|
||||
|
||||
/// Cline provider → secrets.json field name for the API key.
|
||||
fn cline_api_key_field_for_provider(provider: &str) -> &'static str {
|
||||
match provider {
|
||||
"anthropic" => "apiKey",
|
||||
"openrouter" => "openRouterApiKey",
|
||||
"openai-native" => "openAiNativeApiKey",
|
||||
"openai" => "openAiApiKey",
|
||||
"gemini" => "geminiApiKey",
|
||||
"deepseek" => "deepSeekApiKey",
|
||||
"mistral" => "mistralApiKey",
|
||||
"xai" => "xaiApiKey",
|
||||
_ => "openAiApiKey",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cline provider → globalState model ID key suffix.
|
||||
/// Providers in ProviderKeyMap use `actMode{Suffix}` / `planMode{Suffix}`,
|
||||
/// others use `actModeApiModelId` / `planModeApiModelId`.
|
||||
fn cline_model_id_keys_for_provider(provider: &str) -> (&'static str, &'static str) {
|
||||
match provider {
|
||||
"openrouter" | "cline" => ("actModeOpenRouterModelId", "planModeOpenRouterModelId"),
|
||||
"openai" => ("actModeOpenAiModelId", "planModeOpenAiModelId"),
|
||||
"ollama" => ("actModeOllamaModelId", "planModeOllamaModelId"),
|
||||
"lmstudio" => ("actModeLmStudioModelId", "planModeLmStudioModelId"),
|
||||
"litellm" => ("actModeLiteLlmModelId", "planModeLiteLlmModelId"),
|
||||
"requesty" => ("actModeRequestyModelId", "planModeRequestyModelId"),
|
||||
"groq" => ("actModeGroqModelId", "planModeGroqModelId"),
|
||||
_ => ("actModeApiModelId", "planModeApiModelId"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read globalState.json + secrets.json and merge into a unified config JSON
|
||||
/// with keys: apiProvider, model, apiKey, apiBaseUrl.
|
||||
fn load_cline_local_config_json() -> Option<String> {
|
||||
let mut merged = serde_json::Map::new();
|
||||
|
||||
if let Ok(raw) = fs::read_to_string(cline_global_state_path()) {
|
||||
if let Ok(state) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
// Cline uses actModeApiProvider / planModeApiProvider (prefer actMode)
|
||||
let provider = state
|
||||
.get("actModeApiProvider")
|
||||
.or_else(|| state.get("planModeApiProvider"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or("anthropic")
|
||||
.to_string();
|
||||
|
||||
merged.insert(
|
||||
"apiProvider".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
|
||||
// Read model from provider-specific key
|
||||
let (act_key, _plan_key) = cline_model_id_keys_for_provider(&provider);
|
||||
if let Some(model_id) = state
|
||||
.get(act_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
merged.insert(
|
||||
"model".to_string(),
|
||||
serde_json::Value::String(model_id.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// Read provider-specific baseUrl key
|
||||
let base_url_key = match provider.as_str() {
|
||||
"anthropic" => "anthropicBaseUrl",
|
||||
"gemini" => "geminiBaseUrl",
|
||||
"ollama" => "ollamaBaseUrl",
|
||||
"lmstudio" => "lmStudioBaseUrl",
|
||||
"litellm" => "liteLlmBaseUrl",
|
||||
"requesty" => "requestyBaseUrl",
|
||||
_ => "openAiBaseUrl",
|
||||
};
|
||||
if let Some(base_url) = state
|
||||
.get(base_url_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
merged.insert(
|
||||
"apiBaseUrl".to_string(),
|
||||
serde_json::Value::String(base_url.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read API key from secrets.json based on provider
|
||||
if let Ok(raw) = fs::read_to_string(cline_secrets_path()) {
|
||||
if let Ok(secrets) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
let provider = merged
|
||||
.get("apiProvider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("anthropic");
|
||||
let key_field = cline_api_key_field_for_provider(provider);
|
||||
if let Some(api_key) = secrets
|
||||
.get(key_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
merged.insert(
|
||||
"apiKey".to_string(),
|
||||
serde_json::Value::String(api_key.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if merged.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::to_string_pretty(&serde_json::Value::Object(merged)).ok()
|
||||
}
|
||||
|
||||
/// Split merged config back into globalState.json + secrets.json.
|
||||
/// Writes `actModeApiProvider`, `planModeApiProvider`, provider-specific model keys,
|
||||
/// `openAiBaseUrl`, and `welcomeViewCompleted` to globalState.json,
|
||||
/// and the provider-specific API key to secrets.json.
|
||||
fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), AcpError> {
|
||||
let Some(raw_patch) = config_patch_json else {
|
||||
return Ok(());
|
||||
};
|
||||
let runtime = serde_json::from_str::<AgentRuntimeConfig>(raw_patch)
|
||||
.map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?;
|
||||
let patch = serde_json::from_str::<serde_json::Value>(raw_patch)
|
||||
.map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?;
|
||||
|
||||
let provider = patch
|
||||
.get("apiProvider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("anthropic")
|
||||
.to_string();
|
||||
|
||||
// --- Update globalState.json (merge) ---
|
||||
let gs_path = cline_global_state_path();
|
||||
let mut gs = if gs_path.exists() {
|
||||
match fs::read_to_string(&gs_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||
{
|
||||
Some(existing) if existing.is_object() => existing,
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let gs_obj = gs
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| AcpError::protocol("globalState root must be object"))?;
|
||||
|
||||
// Cline checks welcomeViewCompleted first in isAuthConfigured()
|
||||
gs_obj.insert(
|
||||
"welcomeViewCompleted".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
|
||||
// Set both act/plan mode providers
|
||||
gs_obj.insert(
|
||||
"actModeApiProvider".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
gs_obj.insert(
|
||||
"planModeApiProvider".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
|
||||
// Set provider-specific model ID keys
|
||||
let (act_model_key, plan_model_key) = cline_model_id_keys_for_provider(&provider);
|
||||
match trim_non_empty(runtime.model) {
|
||||
Some(model) => {
|
||||
gs_obj.insert(
|
||||
act_model_key.to_string(),
|
||||
serde_json::Value::String(model.clone()),
|
||||
);
|
||||
gs_obj.insert(
|
||||
plan_model_key.to_string(),
|
||||
serde_json::Value::String(model),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
gs_obj.remove(act_model_key);
|
||||
gs_obj.remove(plan_model_key);
|
||||
}
|
||||
}
|
||||
|
||||
// Each provider uses its own baseUrl key in globalState
|
||||
let base_url_key = match provider.as_str() {
|
||||
"anthropic" => "anthropicBaseUrl",
|
||||
"gemini" => "geminiBaseUrl",
|
||||
"ollama" => "ollamaBaseUrl",
|
||||
"lmstudio" => "lmStudioBaseUrl",
|
||||
"litellm" => "liteLlmBaseUrl",
|
||||
"requesty" => "requestyBaseUrl",
|
||||
_ => "openAiBaseUrl",
|
||||
};
|
||||
match trim_non_empty(runtime.api_base_url) {
|
||||
Some(base_url) => {
|
||||
gs_obj.insert(
|
||||
base_url_key.to_string(),
|
||||
serde_json::Value::String(base_url),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
gs_obj.remove(base_url_key);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = gs_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
AcpError::protocol(format!("create cline data directory failed: {e}"))
|
||||
})?;
|
||||
}
|
||||
let serialized_gs = serde_json::to_string_pretty(&gs)
|
||||
.map_err(|e| AcpError::protocol(format!("serialize cline globalState failed: {e}")))?;
|
||||
fs::write(&gs_path, format!("{serialized_gs}\n"))
|
||||
.map_err(|e| AcpError::protocol(format!("write cline globalState failed: {e}")))?;
|
||||
|
||||
// --- Update secrets.json ---
|
||||
let secrets_path = cline_secrets_path();
|
||||
let mut secrets = if secrets_path.exists() {
|
||||
match fs::read_to_string(&secrets_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||
{
|
||||
Some(existing) if existing.is_object() => existing,
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let secrets_obj = secrets
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| AcpError::protocol("secrets root must be object"))?;
|
||||
|
||||
let key_field = cline_api_key_field_for_provider(&provider);
|
||||
match trim_non_empty(runtime.api_key) {
|
||||
Some(api_key) => {
|
||||
secrets_obj.insert(
|
||||
key_field.to_string(),
|
||||
serde_json::Value::String(api_key),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
secrets_obj.remove(key_field);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = secrets_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
AcpError::protocol(format!("create cline data directory failed: {e}"))
|
||||
})?;
|
||||
}
|
||||
let serialized_secrets = serde_json::to_string_pretty(&secrets)
|
||||
.map_err(|e| AcpError::protocol(format!("serialize cline secrets failed: {e}")))?;
|
||||
fs::write(&secrets_path, format!("{serialized_secrets}\n"))
|
||||
.map_err(|e| AcpError::protocol(format!("write cline secrets failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_codex_auth_json_raw() -> Option<String> {
|
||||
fs::read_to_string(codex_auth_json_path()).ok()
|
||||
}
|
||||
@@ -620,6 +911,7 @@ fn agent_local_config_path(agent_type: AgentType) -> Option<PathBuf> {
|
||||
AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")),
|
||||
AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")),
|
||||
AgentType::OpenCode => Some(resolve_opencode_config_path()),
|
||||
AgentType::Cline => Some(cline_global_state_path()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -628,6 +920,9 @@ pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option<Stri
|
||||
if agent_type == AgentType::Codex {
|
||||
return load_codex_local_config_json();
|
||||
}
|
||||
if agent_type == AgentType::Cline {
|
||||
return load_cline_local_config_json();
|
||||
}
|
||||
|
||||
let path = agent_local_config_path(agent_type)?;
|
||||
if !path.exists() {
|
||||
@@ -665,6 +960,9 @@ fn persist_agent_local_config_json(
|
||||
if agent_type == AgentType::Codex {
|
||||
return persist_codex_local_config(config_patch_json);
|
||||
}
|
||||
if agent_type == AgentType::Cline {
|
||||
return persist_cline_local_config(config_patch_json);
|
||||
}
|
||||
|
||||
let Some(path) = agent_local_config_path(agent_type) else {
|
||||
return Ok(());
|
||||
@@ -755,6 +1053,7 @@ fn skill_storage_spec(agent_type: AgentType) -> Option<SkillStorageSpec> {
|
||||
global_dirs: vec![home_dir_or_default().join(".openclaw").join("skills")],
|
||||
project_rel_dirs: vec!["skills"],
|
||||
}),
|
||||
AgentType::Cline => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1325,6 +1624,11 @@ pub async fn acp_list_agents(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cline_secrets_json = if agent_type == AgentType::Cline {
|
||||
load_cline_secrets_json_raw()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
agents.push(AcpAgentInfo {
|
||||
agent_type,
|
||||
@@ -1344,6 +1648,7 @@ pub async fn acp_list_agents(
|
||||
opencode_auth_json,
|
||||
codex_auth_json,
|
||||
codex_config_toml,
|
||||
cline_secrets_json,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1447,6 +1752,14 @@ pub(crate) async fn acp_update_agent_preferences_core(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if agent_type == AgentType::Cline {
|
||||
if let Some(raw) = config_json.as_deref() {
|
||||
persist_cline_local_config(Some(raw))?;
|
||||
}
|
||||
emit_acp_agents_updated(app, "preferences_updated", Some(agent_type));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut local_patch_value = config_json
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
|
||||
|
||||
Reference in New Issue
Block a user