src-tauri/src/commands.rs
Rama master · réplica de lectura del repositorio original.
use nyro_core::Gateway;
use nyro_core::admin::{CopyProviderOptions, ProviderOAuthStatusData};
use nyro_core::auth::{AuthExchangeInput, AuthSessionInitData, AuthSessionStatusData};
use nyro_core::db::models::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::{fs, time::SystemTime};
use tauri::{Manager, State};
// ── Providers ──
#[tauri::command]
pub async fn get_providers(gw: State<'_, Gateway>) -> Result<Vec<Provider>, String> {
gw.admin().list_providers().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_provider(gw: State<'_, Gateway>, id: String) -> Result<Provider, String> {
gw.admin()
.get_provider(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_provider_presets(
gw: State<'_, Gateway>,
) -> Result<Vec<serde_json::Value>, String> {
gw.admin()
.list_provider_presets()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_loaded_extensions(
gw: State<'_, Gateway>,
) -> Result<Vec<serde_json::Value>, String> {
gw.admin()
.list_loaded_extensions()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_provider(
gw: State<'_, Gateway>,
input: CreateProvider,
) -> Result<Provider, String> {
gw.admin()
.create_provider(input)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn copy_provider(
gw: State<'_, Gateway>,
id: String,
options: Option<CopyProviderOptions>,
) -> Result<Provider, String> {
gw.admin()
.copy_provider_with_options(&id, options.unwrap_or_default())
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_provider(
gw: State<'_, Gateway>,
id: String,
input: UpdateProvider,
) -> Result<Provider, String> {
gw.admin()
.update_provider(&id, input)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_provider(gw: State<'_, Gateway>, id: String) -> Result<(), String> {
gw.admin()
.delete_provider(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn test_provider(gw: State<'_, Gateway>, id: String) -> Result<TestResult, String> {
gw.admin()
.test_provider(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn test_provider_models(
gw: State<'_, Gateway>,
id: String,
) -> Result<Vec<String>, String> {
gw.admin()
.test_provider_models(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_provider_models(
gw: State<'_, Gateway>,
id: String,
) -> Result<Vec<String>, String> {
gw.admin()
.get_provider_models(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_model_capabilities(
gw: State<'_, Gateway>,
provider_id: String,
model: String,
) -> Result<ModelCapabilities, String> {
gw.admin()
.get_model_capabilities(&provider_id, &model)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn init_oauth_session(
gw: State<'_, Gateway>,
vendor: String,
use_proxy: Option<bool>,
) -> Result<AuthSessionInitData, String> {
gw.admin()
.init_oauth_session(&vendor, use_proxy.unwrap_or(false))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_oauth_session_status(
gw: State<'_, Gateway>,
session_id: String,
) -> Result<AuthSessionStatusData, String> {
gw.admin()
.get_oauth_session_status(&session_id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn cancel_oauth_session(
gw: State<'_, Gateway>,
session_id: String,
) -> Result<(), String> {
gw.admin()
.cancel_oauth_session(&session_id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn complete_oauth_session(
gw: State<'_, Gateway>,
session_id: String,
code: Option<String>,
callback_url: Option<String>,
metadata: Option<serde_json::Value>,
) -> Result<AuthSessionStatusData, String> {
gw.admin()
.complete_oauth_session(
&session_id,
AuthExchangeInput {
code,
callback_url,
metadata: metadata.unwrap_or(serde_json::Value::Null),
},
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_oauth_provider(
gw: State<'_, Gateway>,
session_id: String,
input: CreateProvider,
) -> Result<Provider, String> {
gw.admin()
.create_provider_with_oauth_session(&session_id, input)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_provider_oauth_status(
gw: State<'_, Gateway>,
id: String,
) -> Result<ProviderOAuthStatusData, String> {
gw.admin()
.get_provider_oauth_status(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn reconnect_provider_oauth(
gw: State<'_, Gateway>,
id: String,
) -> Result<ProviderOAuthStatusData, String> {
gw.admin()
.reconnect_provider_oauth(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn logout_provider_oauth(
gw: State<'_, Gateway>,
id: String,
) -> Result<ProviderOAuthStatusData, String> {
gw.admin()
.logout_provider_oauth(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn bind_provider_oauth(
gw: State<'_, Gateway>,
id: String,
session_id: String,
) -> Result<nyro_core::db::models::Provider, String> {
gw.admin()
.bind_provider_with_oauth_session(&id, &session_id)
.await
.map_err(|e| e.to_string())
}
// ── Routes ──
#[tauri::command]
pub async fn list_models(gw: State<'_, Gateway>) -> Result<Vec<Model>, String> {
gw.admin().list_models().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_model(gw: State<'_, Gateway>, input: CreateModel) -> Result<Model, String> {
gw.admin()
.create_model(input)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_model(
gw: State<'_, Gateway>,
id: String,
input: UpdateModel,
) -> Result<Model, String> {
gw.admin()
.update_model(&id, input)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_model(gw: State<'_, Gateway>, id: String) -> Result<(), String> {
gw.admin()
.delete_model(&id)
.await
.map_err(|e| e.to_string())
}
// ── API Keys ──
#[tauri::command]
pub async fn list_api_keys(gw: State<'_, Gateway>) -> Result<Vec<ApiKeyWithBindings>, String> {
gw.admin().list_api_keys().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_api_key(gw: State<'_, Gateway>, id: String) -> Result<ApiKeyWithBindings, String> {
gw.admin().get_api_key(&id).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_api_key(
gw: State<'_, Gateway>,
input: CreateApiKey,
) -> Result<ApiKeyWithBindings, String> {
gw.admin()
.create_api_key(input)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_api_key(
gw: State<'_, Gateway>,
id: String,
input: UpdateApiKey,
) -> Result<ApiKeyWithBindings, String> {
gw.admin()
.update_api_key(&id, input)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_api_key(gw: State<'_, Gateway>, id: String) -> Result<(), String> {
gw.admin()
.delete_api_key(&id)
.await
.map_err(|e| e.to_string())
}
// ── Logs ──
#[tauri::command]
pub async fn query_logs(gw: State<'_, Gateway>, query: LogQuery) -> Result<LogPage, String> {
gw.admin()
.query_logs(query)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_log(gw: State<'_, Gateway>, id: String) -> Result<Option<RequestLog>, String> {
gw.admin().get_log(&id).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn clear_logs(gw: State<'_, Gateway>) -> Result<u64, String> {
gw.admin().clear_logs().await.map_err(|e| e.to_string())
}
// ── Stats ──
#[tauri::command]
pub async fn get_stats_overview(
gw: State<'_, Gateway>,
hours: Option<i32>,
) -> Result<StatsOverview, String> {
gw.admin()
.get_stats_overview(hours)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_stats_hourly(
gw: State<'_, Gateway>,
hours: Option<i32>,
) -> Result<Vec<StatsHourly>, String> {
gw.admin()
.get_stats_hourly(hours.unwrap_or(24))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_stats_by_model(
gw: State<'_, Gateway>,
hours: Option<i32>,
) -> Result<Vec<ModelStats>, String> {
gw.admin()
.get_stats_by_model(hours)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_stats_by_provider(
gw: State<'_, Gateway>,
hours: Option<i32>,
) -> Result<Vec<ProviderStats>, String> {
gw.admin()
.get_stats_by_provider(hours)
.await
.map_err(|e| e.to_string())
}
// ── Settings ──
#[tauri::command]
pub async fn get_setting(gw: State<'_, Gateway>, key: String) -> Result<Option<String>, String> {
gw.admin()
.get_setting(&key)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_setting(gw: State<'_, Gateway>, key: String, value: String) -> Result<(), String> {
gw.admin()
.set_setting(&key, &value)
.await
.map_err(|e| e.to_string())
}
// ── Status ──
#[tauri::command]
pub async fn get_gateway_status(gw: State<'_, Gateway>) -> Result<serde_json::Value, String> {
Ok(serde_json::json!({
"status": "running",
"proxy_port": gw.config.proxy_port,
}))
}
// ── Config Import/Export ──
#[tauri::command]
pub async fn export_config(gw: State<'_, Gateway>) -> Result<ExportData, String> {
gw.admin().export_config().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn import_config(
gw: State<'_, Gateway>,
data: ExportData,
) -> Result<ImportResult, String> {
gw.admin()
.import_config(data)
.await
.map_err(|e| e.to_string())
}
fn resolve_home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
}
fn get_claude_settings_path(home_dir: &Path) -> PathBuf {
let dir = home_dir.join(".claude");
let settings_path = dir.join("settings.json");
if settings_path.exists() {
return settings_path;
}
let legacy_path = dir.join("claude.json");
if legacy_path.exists() {
return legacy_path;
}
settings_path
}
fn get_codex_auth_path(home_dir: &Path) -> PathBuf {
home_dir.join(".codex").join("auth.json")
}
fn get_codex_config_path(home_dir: &Path) -> PathBuf {
home_dir.join(".codex").join("config.toml")
}
fn get_codex_models_path(home_dir: &Path) -> PathBuf {
home_dir.join(".codex").join("nyro-models.json")
}
fn get_gemini_env_path(home_dir: &Path) -> PathBuf {
home_dir.join(".gemini").join(".env")
}
fn get_gemini_settings_path(home_dir: &Path) -> PathBuf {
home_dir.join(".gemini").join("settings.json")
}
fn get_opencode_config_path(home_dir: &Path) -> PathBuf {
home_dir
.join(".config")
.join("opencode")
.join("opencode.json")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CliBackupFile {
existed: bool,
content: Option<Vec<u8>>,
}
type CliBackupStore = HashMap<String, HashMap<String, CliBackupFile>>;
fn cli_backup_store_path(app: &tauri::AppHandle) -> PathBuf {
let base = app
.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from(".nyro"));
base.join("cli-sync-backups.json")
}
fn load_cli_backup_store(app: &tauri::AppHandle) -> Result<CliBackupStore, String> {
let path = cli_backup_store_path(app);
if !path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("failed reading backup store {}: {e}", path.display()))?;
serde_json::from_str::<CliBackupStore>(&content)
.map_err(|e| format!("failed parsing backup store {}: {e}", path.display()))
}
fn save_cli_backup_store(app: &tauri::AppHandle, store: &CliBackupStore) -> Result<(), String> {
let path = cli_backup_store_path(app);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("failed creating backup dir {}: {e}", parent.display()))?;
}
let content = serde_json::to_vec_pretty(store)
.map_err(|e| format!("failed serializing backup store: {e}"))?;
atomic_write_bytes(&path, &content)
}
fn capture_backups_if_missing(
app: &tauri::AppHandle,
tool_id: &str,
paths: &[PathBuf],
) -> Result<(), String> {
let mut store = load_cli_backup_store(app)?;
let entry = store.entry(tool_id.to_string()).or_default();
for path in paths {
let key = path.to_string_lossy().to_string();
if entry.contains_key(&key) {
continue;
}
if path.exists() {
let bytes = fs::read(path)
.map_err(|e| format!("failed backing up file {}: {e}", path.display()))?;
entry.insert(
key,
CliBackupFile {
existed: true,
content: Some(bytes),
},
);
} else {
entry.insert(
key,
CliBackupFile {
existed: false,
content: None,
},
);
}
}
save_cli_backup_store(app, &store)
}
fn atomic_write_bytes(path: &Path, data: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("failed creating directory {}: {e}", parent.display()))?;
}
let parent = path
.parent()
.ok_or_else(|| format!("invalid target path: {}", path.display()))?;
let file_name = path
.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| format!("invalid file name: {}", path.display()))?;
let ts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| format!("system time error: {e}"))?
.as_nanos();
let tmp_path = parent.join(format!("{file_name}.tmp.{ts}"));
{
let mut file = fs::File::create(&tmp_path)
.map_err(|e| format!("failed creating temp file {}: {e}", tmp_path.display()))?;
file.write_all(data)
.map_err(|e| format!("failed writing temp file {}: {e}", tmp_path.display()))?;
file.flush()
.map_err(|e| format!("failed flushing temp file {}: {e}", tmp_path.display()))?;
}
#[cfg(windows)]
{
if path.exists() {
let _ = fs::remove_file(path);
}
}
fs::rename(&tmp_path, path).map_err(|e| {
format!(
"failed replacing {} with {}: {e}",
path.display(),
tmp_path.display()
)
})?;
Ok(())
}
fn write_json_file(path: &Path, value: &serde_json::Value) -> Result<(), String> {
let bytes = serde_json::to_vec_pretty(value)
.map_err(|e| format!("failed serializing json {}: {e}", path.display()))?;
atomic_write_bytes(path, &bytes)
}
fn write_text_file(path: &Path, text: &str) -> Result<(), String> {
atomic_write_bytes(path, text.as_bytes())
}
fn sanitize_claude_settings_for_live(settings: &serde_json::Value) -> serde_json::Value {
let mut value = settings.clone();
if let Some(obj) = value.as_object_mut() {
obj.remove("api_format");
obj.remove("apiFormat");
obj.remove("openrouter_compat_mode");
obj.remove("openrouterCompatMode");
}
value
}
fn infer_claude_profile(model: &str) -> &'static str {
let m = model.to_lowercase();
if m.contains("haiku") {
"haiku"
} else if m.contains("sonnet") {
"sonnet"
} else {
"opus"
}
}
fn write_gemini_env_atomic(path: &Path, env_map: &HashMap<String, String>) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("failed creating gemini dir {}: {e}", parent.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)
.map_err(|e| format!("failed reading dir metadata {}: {e}", parent.display()))?
.permissions();
perms.set_mode(0o700);
fs::set_permissions(parent, perms)
.map_err(|e| format!("failed setting dir permissions {}: {e}", parent.display()))?;
}
}
let mut keys = env_map.keys().cloned().collect::<Vec<_>>();
keys.sort();
let content = keys
.into_iter()
.map(|key| format!("{key}={}", env_map.get(&key).cloned().unwrap_or_default()))
.collect::<Vec<_>>()
.join("\n");
write_text_file(path, &content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.map_err(|e| format!("failed reading env file metadata {}: {e}", path.display()))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms).map_err(|e| {
format!(
"failed setting env file permissions {}: {e}",
path.display()
)
})?;
}
Ok(())
}
fn update_gemini_selected_type(settings_path: &Path, selected_type: &str) -> Result<(), String> {
let mut settings = if settings_path.exists() {
fs::read_to_string(settings_path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if !settings.is_object() {
settings = serde_json::json!({});
}
let root = settings
.as_object_mut()
.ok_or_else(|| "failed to update gemini settings root".to_string())?;
let security = root
.entry("security".to_string())
.or_insert_with(|| serde_json::json!({}));
if !security.is_object() {
*security = serde_json::json!({});
}
let security_obj = security
.as_object_mut()
.ok_or_else(|| "failed to update gemini security object".to_string())?;
let auth = security_obj
.entry("auth".to_string())
.or_insert_with(|| serde_json::json!({}));
if !auth.is_object() {
*auth = serde_json::json!({});
}
let auth_obj = auth
.as_object_mut()
.ok_or_else(|| "failed to update gemini auth object".to_string())?;
auth_obj.insert(
"selectedType".to_string(),
serde_json::Value::String(selected_type.to_string()),
);
write_json_file(settings_path, &settings)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CliModelCapabilities {
pub context_window: Option<u64>,
pub reasoning: Option<bool>,
}
#[tauri::command]
pub async fn sync_cli_config(
app: tauri::AppHandle,
tool_id: String,
host: String,
api_key: String,
model: String,
_capabilities: Option<CliModelCapabilities>,
) -> Result<Vec<String>, String> {
let home_dir =
resolve_home_dir().ok_or_else(|| "failed to resolve home directory".to_string())?;
let normalized_host = host.trim().trim_end_matches('/').to_string();
let normalized_model = model.trim().to_string();
if normalized_host.is_empty() {
return Err("empty host is not allowed".to_string());
}
if api_key.trim().is_empty() {
return Err("empty api key is not allowed".to_string());
}
if normalized_model.is_empty() {
return Err("empty model is not allowed".to_string());
}
let tool = tool_id.trim().to_lowercase();
match tool.as_str() {
"claude-code" => {
let settings_path = get_claude_settings_path(&home_dir);
capture_backups_if_missing(&app, &tool, &[settings_path.clone()])?;
let mut settings = if settings_path.exists() {
fs::read_to_string(&settings_path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if !settings.is_object() {
settings = serde_json::json!({});
}
let root = settings
.as_object_mut()
.ok_or_else(|| "failed to parse claude settings object".to_string())?;
let env = root
.entry("env".to_string())
.or_insert_with(|| serde_json::json!({}));
if !env.is_object() {
*env = serde_json::json!({});
}
let env_obj = env
.as_object_mut()
.ok_or_else(|| "failed to parse claude env object".to_string())?;
let token_key = if env_obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
"ANTHROPIC_AUTH_TOKEN"
} else if env_obj.contains_key("ANTHROPIC_API_KEY") {
"ANTHROPIC_API_KEY"
} else {
"ANTHROPIC_AUTH_TOKEN"
};
env_obj.insert(
token_key.to_string(),
serde_json::Value::String(api_key.trim().to_string()),
);
env_obj.insert(
"ANTHROPIC_BASE_URL".to_string(),
serde_json::Value::String(normalized_host.clone()),
);
env_obj.insert(
"ANTHROPIC_MODEL".to_string(),
serde_json::Value::String(normalized_model.clone()),
);
env_obj.insert(
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
serde_json::Value::String(normalized_model.clone()),
);
env_obj.insert(
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
serde_json::Value::String(normalized_model.clone()),
);
env_obj.insert(
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
serde_json::Value::String(normalized_model.clone()),
);
env_obj.insert(
"CLAUDE_CODE_NO_FLICKER".to_string(),
serde_json::Value::String("1".to_string()),
);
root.insert(
"model".to_string(),
serde_json::Value::String(infer_claude_profile(&normalized_model).to_string()),
);
let settings = sanitize_claude_settings_for_live(&settings);
write_json_file(&settings_path, &settings)?;
Ok(vec![settings_path.to_string_lossy().to_string()])
}
"codex-cli" => {
let auth_path = get_codex_auth_path(&home_dir);
let config_path = get_codex_config_path(&home_dir);
let models_path = get_codex_models_path(&home_dir);
capture_backups_if_missing(
&app,
&tool,
&[auth_path.clone(), config_path.clone(), models_path.clone()],
)?;
let mut auth_json = if auth_path.exists() {
fs::read_to_string(&auth_path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if !auth_json.is_object() {
auth_json = serde_json::json!({});
}
let auth_obj = auth_json
.as_object_mut()
.ok_or_else(|| "failed to parse codex auth object".to_string())?;
auth_obj.insert(
"OPENAI_API_KEY".to_string(),
serde_json::Value::String(api_key.trim().to_string()),
);
write_json_file(&auth_path, &auth_json)?;
let config_lines = vec![
r#"model_provider = "nyro""#.to_string(),
format!(r#"model = "{normalized_model}""#),
r#"model_reasoning_effort = "high""#.to_string(),
r#"disable_response_storage = true"#.to_string(),
String::new(),
r#"[model_providers]"#.to_string(),
r#"[model_providers.nyro]"#.to_string(),
r#"name = "Nyro Gateway""#.to_string(),
format!(r#"base_url = "{}/v1""#, normalized_host),
r#"wire_api = "responses""#.to_string(),
r#"requires_openai_auth = true"#.to_string(),
];
let config_toml = config_lines.join("\n");
write_text_file(&config_path, &config_toml)?;
let mut changed_paths = vec![
auth_path.to_string_lossy().to_string(),
config_path.to_string_lossy().to_string(),
];
if models_path.exists() {
fs::remove_file(&models_path).map_err(|e| {
format!(
"failed removing codex model catalog {}: {e}",
models_path.display()
)
})?;
changed_paths.push(models_path.to_string_lossy().to_string());
}
Ok(changed_paths)
}
"gemini-cli" => {
let env_path = get_gemini_env_path(&home_dir);
let settings_path = get_gemini_settings_path(&home_dir);
capture_backups_if_missing(&app, &tool, &[env_path.clone(), settings_path.clone()])?;
let env_map = HashMap::from([
("GEMINI_API_KEY".to_string(), api_key.trim().to_string()),
(
"GOOGLE_GEMINI_BASE_URL".to_string(),
normalized_host.to_string(),
),
("GEMINI_MODEL".to_string(), normalized_model),
]);
write_gemini_env_atomic(&env_path, &env_map)?;
update_gemini_selected_type(&settings_path, "gemini-api-key")?;
Ok(vec![
env_path.to_string_lossy().to_string(),
settings_path.to_string_lossy().to_string(),
])
}
"opencode" => {
let config_path = get_opencode_config_path(&home_dir);
capture_backups_if_missing(&app, &tool, &[config_path.clone()])?;
let mut config = if config_path.exists() {
fs::read_to_string(&config_path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if !config.is_object() {
config = serde_json::json!({});
}
let root = config
.as_object_mut()
.ok_or_else(|| "failed to parse opencode config object".to_string())?;
root.entry("$schema".to_string())
.or_insert_with(|| serde_json::json!("https://opencode.ai/config.json"));
root.insert(
"model".to_string(),
serde_json::json!(format!("nyro/{}", normalized_model)),
);
let provider = root
.entry("provider".to_string())
.or_insert_with(|| serde_json::json!({}));
if !provider.is_object() {
*provider = serde_json::json!({});
}
let provider_obj = provider
.as_object_mut()
.ok_or_else(|| "failed to update opencode provider object".to_string())?;
let model_name = normalized_model.clone();
provider_obj.insert(
"nyro".to_string(),
serde_json::json!({
"name": "Nyro Gateway",
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": format!("{}/v1", normalized_host),
"apiKey": api_key.trim(),
"model": model_name.clone(),
},
"models": {
(model_name.clone()): {
"name": model_name,
}
}
}),
);
write_json_file(&config_path, &config)?;
Ok(vec![config_path.to_string_lossy().to_string()])
}
_ => Err(format!("unsupported cli tool: {tool_id}")),
}
}
#[tauri::command]
pub async fn restore_cli_config(
app: tauri::AppHandle,
tool_id: String,
) -> Result<Vec<String>, String> {
let normalized_tool = tool_id.trim().to_lowercase();
let mut store = load_cli_backup_store(&app)?;
let Some(saved_files) = store.remove(&normalized_tool) else {
return Ok(Vec::new());
};
let mut restored_paths = Vec::new();
for (path_str, backup) in saved_files {
let path = PathBuf::from(&path_str);
if backup.existed {
let bytes = backup.content.unwrap_or_default();
atomic_write_bytes(&path, &bytes)?;
} else if path.exists() {
fs::remove_file(&path)
.map_err(|e| format!("failed removing file {}: {e}", path.display()))?;
}
restored_paths.push(path_str);
}
save_cli_backup_store(&app, &store)?;
Ok(restored_paths)
}
#[tauri::command]
pub async fn detect_cli_tools() -> Result<HashMap<String, bool>, String> {
let mut status = HashMap::from([
("claude-code".to_string(), false),
("codex-cli".to_string(), false),
("gemini-cli".to_string(), false),
("opencode".to_string(), false),
]);
let Some(home_dir) = resolve_home_dir() else {
return Ok(status);
};
let checks = [
("claude-code", home_dir.join(".claude")),
("codex-cli", home_dir.join(".codex")),
("gemini-cli", home_dir.join(".gemini")),
("opencode", home_dir.join(".config").join("opencode")),
];
for (tool, path) in checks {
status.insert(tool.to_string(), path.is_dir());
}
Ok(status)
}