diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 513ae560..ea8cd076 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -13,6 +13,8 @@ pub struct McpApps { pub codex: bool, #[serde(default)] pub gemini: bool, + #[serde(default)] + pub opencode: bool, } impl McpApps { @@ -22,6 +24,7 @@ impl McpApps { AppType::Claude => self.claude, AppType::Codex => self.codex, AppType::Gemini => self.gemini, + AppType::OpenCode => self.opencode, } } @@ -31,6 +34,7 @@ impl McpApps { AppType::Claude => self.claude = enabled, AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, + AppType::OpenCode => self.opencode = enabled, } } @@ -46,12 +50,15 @@ impl McpApps { if self.gemini { apps.push(AppType::Gemini); } + if self.opencode { + apps.push(AppType::OpenCode); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini + !self.claude && !self.codex && !self.gemini && !self.opencode } } @@ -64,6 +71,8 @@ pub struct SkillApps { pub codex: bool, #[serde(default)] pub gemini: bool, + #[serde(default)] + pub opencode: bool, } impl SkillApps { @@ -73,6 +82,7 @@ impl SkillApps { AppType::Claude => self.claude, AppType::Codex => self.codex, AppType::Gemini => self.gemini, + AppType::OpenCode => self.opencode, } } @@ -82,6 +92,7 @@ impl SkillApps { AppType::Claude => self.claude = enabled, AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, + AppType::OpenCode => self.opencode = enabled, } } @@ -97,12 +108,15 @@ impl SkillApps { if self.gemini { apps.push(AppType::Gemini); } + if self.opencode { + apps.push(AppType::OpenCode); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini + !self.claude && !self.codex && !self.gemini && !self.opencode } /// 仅启用指定应用(其他应用设为禁用) @@ -205,6 +219,9 @@ pub struct McpRoot { pub codex: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub gemini: McpConfig, + /// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json) + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub opencode: McpConfig, } impl Default for McpRoot { @@ -216,6 +233,7 @@ impl Default for McpRoot { claude: McpConfig::default(), codex: McpConfig::default(), gemini: McpConfig::default(), + opencode: McpConfig::default(), } } } @@ -236,6 +254,8 @@ pub struct PromptRoot { pub codex: PromptConfig, #[serde(default)] pub gemini: PromptConfig, + #[serde(default)] + pub opencode: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -249,7 +269,8 @@ use crate::provider::ProviderManager; pub enum AppType { Claude, Codex, - Gemini, // 新增 + Gemini, + OpenCode, } impl AppType { @@ -257,7 +278,8 @@ impl AppType { match self { AppType::Claude => "claude", AppType::Codex => "codex", - AppType::Gemini => "gemini", // 新增 + AppType::Gemini => "gemini", + AppType::OpenCode => "opencode", } } } @@ -270,11 +292,12 @@ impl FromStr for AppType { match normalized.as_str() { "claude" => Ok(AppType::Claude), "codex" => Ok(AppType::Codex), - "gemini" => Ok(AppType::Gemini), // 新增 + "gemini" => Ok(AppType::Gemini), + "opencode" => Ok(AppType::OpenCode), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"), - format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."), + format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"), + format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."), )), } } @@ -291,6 +314,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub gemini: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode: Option, } impl CommonConfigSnippets { @@ -300,6 +326,7 @@ impl CommonConfigSnippets { AppType::Claude => self.claude.as_ref(), AppType::Codex => self.codex.as_ref(), AppType::Gemini => self.gemini.as_ref(), + AppType::OpenCode => self.opencode.as_ref(), } } @@ -309,6 +336,7 @@ impl CommonConfigSnippets { AppType::Claude => self.claude = snippet, AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, + AppType::OpenCode => self.opencode = snippet, } } } @@ -347,7 +375,8 @@ impl Default for MultiAppConfig { let mut apps = HashMap::new(); apps.insert("claude".to_string(), ProviderManager::default()); apps.insert("codex".to_string(), ProviderManager::default()); - apps.insert("gemini".to_string(), ProviderManager::default()); // 新增 + apps.insert("gemini".to_string(), ProviderManager::default()); + apps.insert("opencode".to_string(), ProviderManager::default()); Self { version: 2, @@ -506,6 +535,7 @@ impl MultiAppConfig { AppType::Claude => &self.mcp.claude, AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, + AppType::OpenCode => &self.mcp.opencode, } } @@ -515,6 +545,7 @@ impl MultiAppConfig { AppType::Claude => &mut self.mcp.claude, AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, + AppType::OpenCode => &mut self.mcp.opencode, } } @@ -528,6 +559,7 @@ impl MultiAppConfig { Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; Ok(config) } @@ -547,6 +579,7 @@ impl MultiAppConfig { if !self.prompts.claude.prompts.is_empty() || !self.prompts.codex.prompts.is_empty() || !self.prompts.gemini.prompts.is_empty() + || !self.prompts.opencode.prompts.is_empty() { return Ok(false); } @@ -554,7 +587,7 @@ impl MultiAppConfig { log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); let mut imported = false; - for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { imported = true; @@ -623,6 +656,7 @@ impl MultiAppConfig { AppType::Claude => &mut config.prompts.claude.prompts, AppType::Codex => &mut config.prompts.codex.prompts, AppType::Gemini => &mut config.prompts.gemini.prompts, + AppType::OpenCode => &mut config.prompts.opencode.prompts, }; prompts.insert(id, prompt); @@ -656,6 +690,7 @@ impl MultiAppConfig { AppType::Claude => &self.mcp.claude.servers, AppType::Codex => &self.mcp.codex.servers, AppType::Gemini => &self.mcp.gemini.servers, + AppType::OpenCode => &self.mcp.opencode.servers, }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index c4fa8682..f000a4d2 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -51,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result { Ok(ConfigStatus { exists, path }) } + AppType::OpenCode => { + let config_path = crate::opencode_config::get_opencode_config_path(); + let exists = config_path.exists(); + let path = crate::opencode_config::get_opencode_dir() + .to_string_lossy() + .to_string(); + + Ok(ConfigStatus { exists, path }) + } } } @@ -67,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result { AppType::Claude => config::get_claude_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), + AppType::OpenCode => crate::opencode_config::get_opencode_dir(), }; Ok(dir.to_string_lossy().to_string()) @@ -79,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result config::get_claude_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), + AppType::OpenCode => crate::opencode_config::get_opencode_dir(), }; if !config_dir.exists() { diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 004d9339..d5c60163 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -13,7 +13,7 @@ impl Database { pub fn get_all_mcp_servers(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn.prepare( - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode FROM mcp_servers ORDER BY name ASC, id ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -30,6 +30,7 @@ impl Database { let enabled_claude: bool = row.get(7)?; let enabled_codex: bool = row.get(8)?; let enabled_gemini: bool = row.get(9)?; + let enabled_opencode: bool = row.get(10)?; let server = serde_json::from_str(&server_config_str).unwrap_or_default(); let tags = serde_json::from_str(&tags_str).unwrap_or_default(); @@ -44,6 +45,7 @@ impl Database { claude: enabled_claude, codex: enabled_codex, gemini: enabled_gemini, + opencode: enabled_opencode, }, description, homepage, @@ -68,8 +70,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO mcp_servers ( id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + enabled_claude, enabled_codex, enabled_gemini, enabled_opencode + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ server.id, server.name, @@ -84,6 +86,7 @@ impl Database { server.apps.claude, server.apps.codex, server.apps.gemini, + server.apps.opencode, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 269d1175..67df0d2c 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -3,7 +3,7 @@ //! 提供 Skills 和 Skill Repos 的 CRUD 操作。 //! //! v3.10.0+ 统一管理架构: -//! - Skills 使用统一的 id 主键,支持三应用启用标志 +//! - Skills 使用统一的 id 主键,支持四应用启用标志 //! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录 use crate::app_config::{InstalledSkill, SkillApps}; @@ -22,7 +22,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -42,8 +42,9 @@ impl Database { claude: row.get(8)?, codex: row.get(9)?, gemini: row.get(10)?, + opencode: row.get(11)?, }, - installed_at: row.get(11)?, + installed_at: row.get(12)?, }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -62,7 +63,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -81,8 +82,9 @@ impl Database { claude: row.get(8)?, codex: row.get(9)?, gemini: row.get(10)?, + opencode: row.get(11)?, }, - installed_at: row.get(11)?, + installed_at: row.get(12)?, }) }); @@ -99,8 +101,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ skill.id, skill.name, @@ -113,6 +115,7 @@ impl Database { skill.apps.claude, skill.apps.codex, skill.apps.gemini, + skill.apps.opencode, skill.installed_at, ], ) @@ -142,8 +145,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4", - params![apps.claude, apps.codex, apps.gemini, id], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5", + params![apps.claude, apps.codex, apps.gemini, apps.opencode, id], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index bf59dcd6..1c855a5c 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 3; +pub(crate) const SCHEMA_VERSION: i32 = 4; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 46d74df8..4bd334fd 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -58,7 +58,7 @@ impl Database { id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, - enabled_gemini BOOLEAN NOT NULL DEFAULT 0 + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0 )", [], ) @@ -85,6 +85,7 @@ impl Database { enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0 )", [], @@ -346,6 +347,11 @@ impl Database { Self::migrate_v2_to_v3(conn)?; Self::set_user_version(conn, 3)?; } + 3 => { + log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)"); + Self::migrate_v3_to_v4(conn)?; + Self::set_user_version(conn, 4)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -849,6 +855,30 @@ impl Database { Ok(()) } + /// v3 -> v4 迁移:添加 OpenCode 支持 + /// + /// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。 + fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> { + // 为 mcp_servers 表添加 enabled_opencode 列 + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_opencode", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + // 为 skills 表添加 enabled_opencode 列 + Self::add_column_if_missing( + conn, + "skills", + "enabled_opencode", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 75eb2865..04d3f11b 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { claude: false, codex: false, gemini: false, + opencode: false, }; for app in apps_str.split(',') { @@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { "claude" => apps.claude = true, "codex" => apps.codex = true, "gemini" => apps.gemini = true, + "opencode" => apps.opencode = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 88f5709e..ec75d55e 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request( AppType::Claude => build_claude_settings(request), AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), + AppType::OpenCode => build_opencode_settings(request), }; // Build usage script configuration if provided @@ -363,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value { json!({ "env": env }) } +/// Build OpenCode settings configuration +fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + + // Build options object + let mut options = serde_json::Map::new(); + if !endpoint.is_empty() { + options.insert("baseURL".to_string(), json!(endpoint)); + } + if let Some(api_key) = &request.api_key { + options.insert("apiKey".to_string(), json!(api_key)); + } + + // Build models object + let mut models = serde_json::Map::new(); + if let Some(model) = &request.model { + models.insert(model.clone(), json!({ "name": model })); + } + + // Default to openai-compatible npm package + json!({ + "npm": "@ai-sdk/openai-compatible", + "options": options, + "models": models + }) +} + // ============================================================================= // Config Merge Logic // ============================================================================= diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d719f58..d39c83c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod gemini_config; mod gemini_mcp; mod init_status; mod mcp; +mod opencode_config; mod panic_hook; mod prompt; mod prompt_files; diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index c2108b9b..25d2f426 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result claude: false, codex: true, gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index 9a1a3064..c8a7809f 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result PathBuf { + if let Some(override_dir) = get_opencode_override_dir() { + return override_dir; + } + + #[cfg(target_os = "windows")] + { + // Windows: %APPDATA%\opencode + dirs::data_dir() + .map(|d| d.join("opencode")) + .unwrap_or_else(|| PathBuf::from(".config").join("opencode")) + } + + #[cfg(not(target_os = "windows"))] + { + // Unix: ~/.config/opencode + dirs::home_dir() + .map(|h| h.join(".config").join("opencode")) + .unwrap_or_else(|| PathBuf::from(".config").join("opencode")) + } +} + +/// 获取 OpenCode 配置文件路径 +/// +/// 返回 `~/.config/opencode/opencode.json` +pub fn get_opencode_config_path() -> PathBuf { + get_opencode_dir().join("opencode.json") +} + +/// 读取 OpenCode 配置文件 +/// +/// 返回完整的配置 JSON 对象 +pub fn read_opencode_config() -> Result { + let path = get_opencode_config_path(); + + if !path.exists() { + // Return empty config with schema + return Ok(serde_json::json!({ + "$schema": "https://opencode.ai/config.json" + })); + } + + let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + serde_json::from_str(&content).map_err(|e| AppError::json(&path, e)) +} + +/// 写入 OpenCode 配置文件(原子写入) +/// +/// 使用临时文件 + 重命名确保原子性 +pub fn write_opencode_config(config: &serde_json::Value) -> Result<(), AppError> { + let path = get_opencode_config_path(); + + // Ensure directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + // Write to temporary file first + let temp_path = path.with_extension("json.tmp"); + let content = serde_json::to_string_pretty(config) + .map_err(|e| AppError::JsonSerialize { source: e })?; + + std::fs::write(&temp_path, &content).map_err(|e| AppError::io(&temp_path, e))?; + + // Atomic rename + std::fs::rename(&temp_path, &path).map_err(|e| AppError::io(&path, e))?; + + Ok(()) +} + +/// 获取所有供应商配置 +pub fn get_providers() -> Result, AppError> { + let config = read_opencode_config()?; + Ok(config + .get("provider") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default()) +} + +/// 设置供应商配置 +pub fn set_provider(id: &str, config: serde_json::Value) -> Result<(), AppError> { + let mut full_config = read_opencode_config()?; + + if full_config.get("provider").is_none() { + full_config["provider"] = serde_json::json!({}); + } + + if let Some(providers) = full_config.get_mut("provider").and_then(|v| v.as_object_mut()) { + providers.insert(id.to_string(), config); + } + + write_opencode_config(&full_config) +} + +/// 删除供应商配置 +pub fn remove_provider(id: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) { + providers.remove(id); + } + + write_opencode_config(&config) +} + +/// 获取所有 MCP 服务器配置 +pub fn get_mcp_servers() -> Result, AppError> { + let config = read_opencode_config()?; + Ok(config + .get("mcp") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default()) +} + +/// 设置 MCP 服务器配置 +pub fn set_mcp_server(id: &str, config: serde_json::Value) -> Result<(), AppError> { + let mut full_config = read_opencode_config()?; + + if full_config.get("mcp").is_none() { + full_config["mcp"] = serde_json::json!({}); + } + + if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) { + mcp.insert(id.to_string(), config); + } + + write_opencode_config(&full_config) +} + +/// 删除 MCP 服务器配置 +pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) { + mcp.remove(id); + } + + write_opencode_config(&config) +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 5fe320fa..dbcb5896 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path; use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::gemini_config::get_gemini_dir; +use crate::opencode_config::get_opencode_dir; /// 返回指定应用所使用的提示词文件路径。 pub fn prompt_file_path(app: &AppType) -> Result { @@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?, AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, AppType::Gemini => get_gemini_dir(), + AppType::OpenCode => get_opencode_dir(), }; let filename = match app { AppType::Claude => "CLAUDE.md", AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", + AppType::OpenCode => "OPENCODE.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 64ae8ff2..61be1087 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -132,6 +132,10 @@ impl ProviderType { } ProviderType::Gemini } + AppType::OpenCode => { + // OpenCode doesn't support proxy, but return a default type for completeness + ProviderType::Codex // Fallback to Codex-like type + } } } @@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Claude => Box::new(ClaudeAdapter::new()), AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), + AppType::OpenCode => { + // OpenCode doesn't support proxy, fallback to Codex adapter + Box::new(CodexAdapter::new()) + } } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 8a46a2b2..229eebdf 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -122,6 +122,10 @@ impl ConfigService { AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?, AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, + AppType::OpenCode => { + // OpenCode uses additive mode, no live sync needed + // OpenCode providers are managed directly in the config file + } } Ok(()) diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index ea472af3..81decbac 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -113,6 +113,11 @@ impl McpService { AppType::Gemini => { mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?; } + AppType::OpenCode => { + // OpenCode MCP sync will be implemented in Phase 4 + // For now, skip silently + log::debug!("OpenCode MCP sync not yet implemented, skipping"); + } } Ok(()) } @@ -135,6 +140,10 @@ impl McpService { AppType::Claude => mcp::remove_server_from_claude(id)?, AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, + AppType::OpenCode => { + // OpenCode MCP removal will be implemented in Phase 4 + log::debug!("OpenCode MCP removal not yet implemented, skipping"); + } } Ok(()) } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 9854f224..7599b0ab 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -120,6 +120,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re // Delegate to write_gemini_live which handles env file writing correctly write_gemini_live(provider)?; } + AppType::OpenCode => { + // OpenCode uses additive mode - providers are written directly to config + // This will be fully implemented in Phase 3 + log::debug!("OpenCode live snapshot not needed (additive mode)"); + } } Ok(()) } @@ -220,6 +225,21 @@ pub fn read_live_settings(app_type: AppType) -> Result { "config": config_obj })) } + AppType::OpenCode => { + use crate::opencode_config::{get_opencode_config_path, read_opencode_config}; + + let config_path = get_opencode_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "opencode.config.missing", + "OpenCode 配置文件不存在", + "OpenCode configuration file not found", + )); + } + + let config = read_opencode_config()?; + Ok(config) + } } } @@ -295,6 +315,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + // OpenCode uses additive mode - import from live is not the same pattern + // For now, return an empty config structure + use crate::opencode_config::{get_opencode_config_path, read_opencode_config}; + + let config_path = get_opencode_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "opencode.live.missing", + "OpenCode 配置文件不存在", + "OpenCode configuration file is missing", + )); + } + + // For OpenCode, we return the full config - but note that OpenCode + // uses additive mode, so importing defaults works differently + read_opencode_config()? + } }; let mut provider = Provider::with_id( diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7b45b4e6..0c48ed43 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -380,6 +380,7 @@ impl ProviderService { AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), + AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), } } @@ -392,6 +393,7 @@ impl ProviderService { AppType::Claude => Self::extract_claude_common_config(settings_config), AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), + AppType::OpenCode => Self::extract_opencode_common_config(settings_config), } } @@ -525,6 +527,29 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } + /// Extract common config for OpenCode (JSON format) + fn extract_opencode_common_config(settings: &Value) -> Result { + // OpenCode uses a different config structure with npm, options, models + // For common config, we exclude provider-specific fields like apiKey + let mut config = settings.clone(); + + // Remove provider-specific fields + if let Some(obj) = config.as_object_mut() { + if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) { + options.remove("apiKey"); + options.remove("baseURL"); + } + // Keep npm and models as they might be common + } + + if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { + return Ok("{}".to_string()); + } + + serde_json::to_string_pretty(&config) + .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + } + /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. @@ -691,6 +716,17 @@ impl ProviderService { use crate::gemini_config::validate_gemini_settings; validate_gemini_settings(&provider.settings_config)? } + AppType::OpenCode => { + // OpenCode uses a different config structure: { npm, options, models } + // Basic validation - must be an object + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.opencode.settings.not_object", + "OpenCode 配置必须是 JSON 对象", + "OpenCode configuration must be a JSON object", + )); + } + } } // Validate and clean UsageScript configuration (common for all app types) @@ -828,6 +864,40 @@ impl ProviderService { Ok((api_key, base_url)) } + AppType::OpenCode => { + // OpenCode uses options.apiKey and options.baseURL + let options = provider + .settings_config + .get("options") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::localized( + "provider.opencode.options.missing", + "配置格式错误: 缺少 options", + "Invalid configuration: missing options section", + ) + })?; + + let api_key = options + .get("apiKey") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.opencode.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })? + .to_string(); + + let base_url = options + .get("baseURL") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok((api_key, base_url)) + } } } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index eaa5665a..823bbfce 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -368,6 +368,10 @@ impl ProxyService { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, + AppType::OpenCode => { + // OpenCode doesn't support proxy features + return Err("OpenCode 不支持代理功能".to_string()); + } }; self.sync_live_config_to_provider(app_type, &live_config) @@ -581,6 +585,9 @@ impl ProxyService { } } } + AppType::OpenCode => { + // OpenCode doesn't support proxy features, skip silently + } } Ok(()) @@ -759,6 +766,10 @@ impl ProxyService { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), + AppType::OpenCode => { + // OpenCode doesn't support proxy features + return Err("OpenCode 不支持代理功能".to_string()); + } }; let json_str = serde_json::to_string(&config) @@ -967,6 +978,10 @@ impl ProxyService { self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } + AppType::OpenCode => { + // OpenCode doesn't support proxy features + return Err("OpenCode 不支持代理功能".to_string()); + } } Ok(()) @@ -1050,6 +1065,9 @@ impl ProxyService { let _ = self.write_gemini_live(&live_config); } } + AppType::OpenCode => { + // OpenCode doesn't support proxy features, skip silently + } } Ok(()) @@ -1082,6 +1100,9 @@ impl ProxyService { log::info!("Gemini Live 配置已恢复"); } } + AppType::OpenCode => { + // OpenCode doesn't support proxy features, skip silently + } } Ok(()) @@ -1161,6 +1182,10 @@ impl ProxyService { AppType::Claude => self.write_claude_live(config), AppType::Codex => self.write_codex_live(config), AppType::Gemini => self.write_gemini_live(config), + AppType::OpenCode => { + // OpenCode doesn't support proxy features + Err("OpenCode 不支持代理功能".to_string()) + } } } @@ -1178,6 +1203,10 @@ impl ProxyService { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, + AppType::OpenCode => { + // OpenCode doesn't support proxy takeover + false + } } } @@ -1217,6 +1246,10 @@ impl ProxyService { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), + AppType::OpenCode => { + // OpenCode doesn't support proxy features + Ok(()) + } } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index e9aa8962..8ea844db 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -183,6 +183,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::OpenCode => { + if let Some(custom) = crate::settings::get_opencode_override_dir() { + return Ok(custom.join("skills")); + } + } } // 默认路径:回退到用户主目录下的标准位置 @@ -196,6 +201,7 @@ impl SkillService { AppType::Claude => home.join(".claude").join("skills"), AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), + AppType::OpenCode => home.join(".config").join("opencode").join("skills"), }) } @@ -425,6 +431,7 @@ impl SkillService { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", + AppType::OpenCode => "opencode", }; unmanaged @@ -468,6 +475,7 @@ impl SkillService { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", + AppType::OpenCode => "opencode", }; found_in.push(app_str.to_string()); } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 900b4569..33877fc1 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -185,6 +185,14 @@ impl StreamCheckService { ) .await } + AppType::OpenCode => { + // OpenCode doesn't support stream check yet + return Err(AppError::localized( + "opencode_no_stream_check", + "OpenCode 暂不支持健康检查", + "OpenCode does not support health check yet" + )); + } }; let response_time = start.elapsed().as_millis() as u64; @@ -477,9 +485,24 @@ impl StreamCheckService { } AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL") .unwrap_or_else(|| config.gemini_model.clone()), + AppType::OpenCode => { + // OpenCode uses models map in settings_config + // Try to extract first model from the models object + Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) + } } } + fn extract_opencode_model(provider: &Provider) -> Option { + let models = provider + .settings_config + .get("models") + .and_then(|m| m.as_object())?; + + // Return the first model ID from the models map + models.keys().next().map(|s| s.to_string()) + } + fn extract_env_model(provider: &Provider, key: &str) -> Option { provider .settings_config diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 21e15d52..88f08728 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -47,6 +47,8 @@ pub struct AppSettings { pub codex_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub gemini_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -58,6 +60,9 @@ pub struct AppSettings { /// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_gemini: Option, + /// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_opencode: Option, } fn default_show_in_tray() -> bool { @@ -84,9 +89,11 @@ impl Default for AppSettings { claude_config_dir: None, codex_config_dir: None, gemini_config_dir: None, + opencode_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, + current_provider_opencode: None, } } } @@ -119,6 +126,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.opencode_config_dir = self + .opencode_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -251,6 +265,14 @@ pub fn get_gemini_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_opencode_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .opencode_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) @@ -263,6 +285,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Claude => settings.current_provider_claude.clone(), AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), + AppType::OpenCode => settings.current_provider_opencode.clone(), } } @@ -277,6 +300,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()), AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()), + AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()), } update_settings(settings)