feat(opencode): Phase 1 - Backend data structure expansion for OpenCode support

Add OpenCode as the 4th supported application with additive provider management:

- Add OpenCode variant to AppType enum with all related match statements
- Add enabled_opencode field to McpApps and SkillApps structures
- Add opencode field to McpRoot and PromptRoot
- Add database schema migration v3→v4 with enabled_opencode columns
- Add settings.rs support for opencode_config_dir and current_provider_opencode
- Create opencode_config.rs module for config file I/O operations
- Update all services (proxy, mcp, skill, provider, stream_check) for OpenCode
- Add OpenCode support to deeplink provider and MCP parsing
- Update commands/config.rs for OpenCode config status and paths

Key design decisions:
- OpenCode uses additive mode (no is_current needed, no proxy support)
- Config path: ~/.config/opencode/opencode.json
- MCP format: stdio→local, sse/http→remote conversion planned
- Stream check returns error (not yet implemented for OpenCode)
This commit is contained in:
Jason
2026-01-15 15:54:29 +08:00
parent e4d24f2df9
commit 5658d93924
23 changed files with 513 additions and 23 deletions
+4
View File
@@ -122,6 +122,10 @@ impl ConfigService {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
}
}
Ok(())
+9
View File
@@ -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(())
}
+38
View File
@@ -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<Value, AppError> {
"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<bool
"config": config_obj
})
}
AppType::OpenCode => {
// 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(
+70
View File
@@ -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<String, AppError> {
// 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))
}
}
}
}
+33
View File
@@ -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(())
}
}
}
+8
View File
@@ -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());
}
+23
View File
@@ -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<String> {
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<String> {
provider
.settings_config