use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
const OPENCODE_DIR: &str = ".config/opencode";
const OPENCODE_CONFIG_FILE: &str = "opencode.json";
const ANTIGRAVITY_CONFIG_FILE: &str = "antigravity.json";
const ANTIGRAVITY_ACCOUNTS_FILE: &str = "antigravity-accounts.json";
const BACKUP_SUFFIX: &str = ".antigravity.bak";
const ANTHROPIC_MODELS: &[&str] = crate::proxy::common::model_mapping::OPENCODE_ANTHROPIC_MODELS;
const GOOGLE_MODELS: &[&str] = crate::proxy::common::model_mapping::OPENCODE_GOOGLE_MODELS;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OpencodeStatus {
pub installed: bool,
pub version: Option<String>,
pub is_synced: bool,
pub has_backup: bool,
pub current_base_url: Option<String>,
pub files: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct OpencodeAccount {
email: String,
#[serde(rename = "refreshToken")]
refresh_token: String,
#[serde(rename = "projectId", skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
#[serde(
rename = "rateLimitResetTimes",
skip_serializing_if = "Option::is_none"
)]
rate_limit_reset_times: Option<HashMap<String, i64>>,
}
fn get_opencode_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(OPENCODE_DIR))
}
fn get_config_paths() -> Option<(PathBuf, PathBuf, PathBuf)> {
get_opencode_dir().map(|dir| {
(
dir.join(OPENCODE_CONFIG_FILE),
dir.join(ANTIGRAVITY_CONFIG_FILE),
dir.join(ANTIGRAVITY_ACCOUNTS_FILE),
)
})
}
fn extract_version(raw: &str) -> String {
let trimmed = raw.trim();
let parts: Vec<&str> = trimmed.split_whitespace().collect();
for part in parts {
if let Some(slash_idx) = part.find('/') {
let after_slash = &part[slash_idx + 1..];
if is_valid_version(after_slash) {
return after_slash.to_string();
}
}
if is_valid_version(part) {
return part.to_string();
}
}
let version_chars: String = trimmed
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if !version_chars.is_empty() && version_chars.contains('.') {
return version_chars;
}
"unknown".to_string()
}
fn is_valid_version(s: &str) -> bool {
s.chars().next().is_some_and(|c| c.is_ascii_digit())
&& s.contains('.')
&& s.chars().all(|c| c.is_ascii_digit() || c == '.')
}
fn resolve_opencode_path() -> Option<PathBuf> {
if let Some(path) = find_in_path("opencode") {
tracing::debug!("Found opencode in PATH: {:?}", path);
return Some(path);
}
#[cfg(target_os = "windows")]
{
resolve_opencode_path_windows()
}
#[cfg(not(target_os = "windows"))]
{
resolve_opencode_path_unix()
}
}
#[cfg(target_os = "windows")]
fn resolve_opencode_path_windows() -> Option<PathBuf> {
if let Ok(app_data) = env::var("APPDATA") {
let npm_opencode_cmd = PathBuf::from(&app_data).join("npm").join("opencode.cmd");
if npm_opencode_cmd.exists() {
tracing::debug!("Found opencode.cmd in APPDATA\\npm: {:?}", npm_opencode_cmd);
return Some(npm_opencode_cmd);
}
let npm_opencode_exe = PathBuf::from(&app_data).join("npm").join("opencode.exe");
if npm_opencode_exe.exists() {
tracing::debug!("Found opencode.exe in APPDATA\\npm: {:?}", npm_opencode_exe);
return Some(npm_opencode_exe);
}
}
if let Ok(local_app_data) = env::var("LOCALAPPDATA") {
let pnpm_opencode_cmd = PathBuf::from(&local_app_data)
.join("pnpm")
.join("opencode.cmd");
if pnpm_opencode_cmd.exists() {
tracing::debug!(
"Found opencode.cmd in LOCALAPPDATA\\pnpm: {:?}",
pnpm_opencode_cmd
);
return Some(pnpm_opencode_cmd);
}
let pnpm_opencode_exe = PathBuf::from(&local_app_data)
.join("pnpm")
.join("opencode.exe");
if pnpm_opencode_exe.exists() {
tracing::debug!(
"Found opencode.exe in LOCALAPPDATA\\pnpm: {:?}",
pnpm_opencode_exe
);
return Some(pnpm_opencode_exe);
}
}
if let Ok(local_app_data) = env::var("LOCALAPPDATA") {
let yarn_opencode = PathBuf::from(&local_app_data)
.join("Yarn")
.join("bin")
.join("opencode.cmd");
if yarn_opencode.exists() {
tracing::debug!("Found opencode.cmd in Yarn bin: {:?}", yarn_opencode);
return Some(yarn_opencode);
}
}
if let Ok(nvm_home) = env::var("NVM_HOME") {
if let Some(path) = scan_nvm_directory(&nvm_home) {
return Some(path);
}
}
if let Some(home) = dirs::home_dir() {
let nvm_default = home.join(".nvm");
if let Some(path) = scan_nvm_directory(&nvm_default) {
return Some(path);
}
}
None
}
#[cfg(not(target_os = "windows"))]
fn resolve_opencode_path_unix() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let user_bins = [
home.join(".local").join("bin").join("opencode"),
home.join(".npm-global").join("bin").join("opencode"),
home.join("bin").join("opencode"),
];
for path in &user_bins {
if path.exists() {
tracing::debug!("Found opencode in user bin: {:?}", path);
return Some(path.clone());
}
}
let system_bins = [
PathBuf::from("/opt/homebrew/bin/opencode"),
PathBuf::from("/usr/local/bin/opencode"),
PathBuf::from("/usr/bin/opencode"),
];
for path in &system_bins {
if path.exists() {
tracing::debug!("Found opencode in system bin: {:?}", path);
return Some(path.clone());
}
}
let nvm_dirs = [home.join(".nvm").join("versions").join("node")];
for nvm_dir in &nvm_dirs {
if let Some(path) = scan_node_versions(nvm_dir) {
return Some(path);
}
}
let fnm_dirs = [
home.join(".fnm").join("node-versions"),
home.join("Library")
.join("Application Support")
.join("fnm")
.join("node-versions"),
];
for fnm_dir in &fnm_dirs {
if let Some(path) = scan_fnm_versions(fnm_dir) {
return Some(path);
}
}
None
}
#[cfg(target_os = "windows")]
fn scan_nvm_directory(nvm_path: impl AsRef<std::path::Path>) -> Option<PathBuf> {
let nvm_path = nvm_path.as_ref();
if !nvm_path.exists() {
return None;
}
let entries = fs::read_dir(nvm_path).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let opencode_cmd = path.join("opencode.cmd");
if opencode_cmd.exists() {
tracing::debug!("Found opencode.cmd in NVM: {:?}", opencode_cmd);
return Some(opencode_cmd);
}
let opencode_exe = path.join("opencode.exe");
if opencode_exe.exists() {
tracing::debug!("Found opencode.exe in NVM: {:?}", opencode_exe);
return Some(opencode_exe);
}
}
}
None
}
#[cfg(not(target_os = "windows"))]
fn scan_node_versions(versions_dir: impl AsRef<std::path::Path>) -> Option<PathBuf> {
let versions_dir = versions_dir.as_ref();
if !versions_dir.exists() {
return None;
}
let entries = fs::read_dir(versions_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let opencode = path.join("bin").join("opencode");
if opencode.exists() {
tracing::debug!("Found opencode in nvm: {:?}", opencode);
return Some(opencode);
}
}
}
None
}
#[cfg(not(target_os = "windows"))]
fn scan_fnm_versions(versions_dir: impl AsRef<std::path::Path>) -> Option<PathBuf> {
let versions_dir = versions_dir.as_ref();
if !versions_dir.exists() {
return None;
}
let entries = fs::read_dir(versions_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let opencode = path.join("installation").join("bin").join("opencode");
if opencode.exists() {
tracing::debug!("Found opencode in fnm: {:?}", opencode);
return Some(opencode);
}
}
}
None
}
fn find_in_path(executable: &str) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
let extensions = ["exe", "cmd", "bat"];
if let Ok(path_var) = env::var("PATH") {
for dir in path_var.split(';') {
for ext in &extensions {
let full_path = PathBuf::from(dir).join(format!("{}.{}", executable, ext));
if full_path.exists() {
return Some(full_path);
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(path_var) = env::var("PATH") {
for dir in path_var.split(':') {
let full_path = PathBuf::from(dir).join(executable);
if full_path.exists() {
return Some(full_path);
}
}
}
}
None
}
#[cfg(target_os = "windows")]
fn run_opencode_version(opencode_path: &PathBuf) -> Option<String> {
let path_str = opencode_path.to_string_lossy();
let is_cmd = path_str.ends_with(".cmd") || path_str.ends_with(".bat");
let output = if is_cmd {
let mut cmd = Command::new("cmd.exe");
cmd.arg("/C")
.arg(opencode_path)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW);
cmd.output()
} else {
let mut cmd = Command::new(opencode_path);
cmd.arg("--version").creation_flags(CREATE_NO_WINDOW);
cmd.output()
};
match output {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let raw = if stdout.trim().is_empty() {
stderr.to_string()
} else {
stdout.to_string()
};
tracing::debug!("opencode --version output: {}", raw.trim());
Some(extract_version(&raw))
}
Ok(output) => {
tracing::debug!("opencode --version failed with status: {:?}", output.status);
None
}
Err(e) => {
tracing::debug!("Failed to run opencode --version: {}", e);
None
}
}
}
#[cfg(not(target_os = "windows"))]
fn run_opencode_version(opencode_path: &PathBuf) -> Option<String> {
let output = Command::new(opencode_path).arg("--version").output();
match output {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let raw = if stdout.trim().is_empty() {
stderr.to_string()
} else {
stdout.to_string()
};
tracing::debug!("opencode --version output: {}", raw.trim());
Some(extract_version(&raw))
}
Ok(output) => {
tracing::debug!("opencode --version failed with status: {:?}", output.status);
None
}
Err(e) => {
tracing::debug!("Failed to run opencode --version: {}", e);
None
}
}
}
pub fn check_opencode_installed() -> (bool, Option<String>) {
tracing::debug!("Checking opencode installation...");
let opencode_path = match resolve_opencode_path() {
Some(path) => {
tracing::debug!("Resolved opencode path: {:?}", path);
path
}
None => {
tracing::debug!("Could not resolve opencode path");
return (false, None);
}
};
match run_opencode_version(&opencode_path) {
Some(version) => {
tracing::debug!("opencode version detected: {}", version);
(true, Some(version))
}
None => {
tracing::debug!("Failed to get opencode version");
(false, None)
}
}
}
fn get_provider_options<'a>(value: &'a Value, provider_name: &str) -> Option<&'a Value> {
value
.get("provider")
.and_then(|p| p.get(provider_name))
.and_then(|prov| prov.get("options"))
}
pub fn get_sync_status(proxy_url: &str) -> (bool, bool, Option<String>) {
let Some((config_path, _, _)) = get_config_paths() else {
return (false, false, None);
};
let mut is_synced = true;
let mut has_backup = false;
let mut current_base_url = None;
let backup_path =
config_path.with_file_name(format!("{}{}", OPENCODE_CONFIG_FILE, BACKUP_SUFFIX));
if backup_path.exists() {
has_backup = true;
}
if !config_path.exists() {
return (false, has_backup, None);
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return (false, has_backup, None),
};
let json: Value = serde_json::from_str(&content).unwrap_or_default();
let normalized_proxy = proxy_url.trim_end_matches('/');
let anthropic_opts = get_provider_options(&json, "anthropic");
let anthropic_url = anthropic_opts
.and_then(|o| o.get("baseURL"))
.and_then(|v| v.as_str());
let anthropic_key = anthropic_opts
.and_then(|o| o.get("apiKey"))
.and_then(|v| v.as_str());
let google_opts = get_provider_options(&json, "google");
let google_url = google_opts
.and_then(|o| o.get("baseURL"))
.and_then(|v| v.as_str());
let google_key = google_opts
.and_then(|o| o.get("apiKey"))
.and_then(|v| v.as_str());
if let (Some(url), Some(_key)) = (anthropic_url, anthropic_key) {
current_base_url = Some(url.to_string());
if url.trim_end_matches('/') != normalized_proxy {
is_synced = false;
}
} else {
is_synced = false;
}
if let (Some(url), Some(_key)) = (google_url, google_key) {
if url.trim_end_matches('/') != normalized_proxy {
is_synced = false;
}
} else {
is_synced = false;
}
(is_synced, has_backup, current_base_url)
}
fn create_backup(path: &PathBuf) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let backup_path = path.with_file_name(format!(
"{}{}",
path.file_name().unwrap_or_default().to_string_lossy(),
BACKUP_SUFFIX
));
if backup_path.exists() {
return Ok(());
}
fs::copy(path, &backup_path).map_err(|e| format!("Failed to create backup: {}", e))?;
Ok(())
}
fn ensure_object(value: &mut Value, key: &str) {
let needs_reset = match value.get(key) {
None => true,
Some(v) if !v.is_object() => true,
_ => false,
};
if needs_reset {
value[key] = serde_json::json!({});
}
}
fn ensure_provider_object(provider: &mut serde_json::Map<String, Value>, name: &str) {
let needs_reset = match provider.get(name) {
None => true,
Some(v) if !v.is_object() => true,
_ => false,
};
if needs_reset {
provider.insert(name.to_string(), serde_json::json!({}));
}
}
fn merge_provider_options(provider: &mut Value, base_url: &str, api_key: &str) {
if provider.get("options").is_none() {
provider["options"] = serde_json::json!({});
}
if let Some(options) = provider.get_mut("options").and_then(|o| o.as_object_mut()) {
options.insert("baseURL".to_string(), Value::String(base_url.to_string()));
options.insert("apiKey".to_string(), Value::String(api_key.to_string()));
}
}
fn add_missing_models(provider: &mut Value, model_ids: &[&str]) {
if provider.get("models").is_none() {
provider["models"] = serde_json::json!({});
}
if let Some(models) = provider.get_mut("models").and_then(|m| m.as_object_mut()) {
for &model_id in model_ids {
if !models.contains_key(model_id) {
models.insert(
model_id.to_string(),
serde_json::json!({ "name": model_id }),
);
}
}
}
}
pub fn sync_opencode_config(
proxy_url: &str,
api_key: &str,
sync_accounts: bool,
) -> Result<(), String> {
let Some((config_path, _ag_config_path, ag_accounts_path)) = get_config_paths() else {
return Err("Failed to get OpenCode config directory".to_string());
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
create_backup(&config_path)?;
let mut config: Value = if config_path.exists() {
fs::read_to_string(&config_path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if !config.is_object() {
config = serde_json::json!({});
}
if config.get("$schema").is_none() {
config["$schema"] = Value::String("https://opencode.ai/config.json".to_string());
}
let normalized_url = proxy_url.trim_end_matches('/').to_string();
ensure_object(&mut config, "provider");
if let Some(provider) = config.get_mut("provider").and_then(|p| p.as_object_mut()) {
ensure_provider_object(provider, "anthropic");
if let Some(anthropic) = provider.get_mut("anthropic") {
merge_provider_options(anthropic, &normalized_url, api_key);
add_missing_models(anthropic, ANTHROPIC_MODELS);
}
ensure_provider_object(provider, "google");
if let Some(google) = provider.get_mut("google") {
merge_provider_options(google, &normalized_url, api_key);
add_missing_models(google, GOOGLE_MODELS);
}
}
let tmp_path = config_path.with_extension("tmp");
fs::write(&tmp_path, serde_json::to_string_pretty(&config).unwrap())
.map_err(|e| format!("Failed to write temp file: {}", e))?;
fs::rename(&tmp_path, &config_path)
.map_err(|e| format!("Failed to rename config file: {}", e))?;
if sync_accounts {
sync_accounts_file(&ag_accounts_path)?;
}
Ok(())
}
fn sync_accounts_file(accounts_path: &PathBuf) -> Result<(), String> {
create_backup(accounts_path)?;
let existing_content = if accounts_path.exists() {
fs::read_to_string(accounts_path).ok()
} else {
None
};
let mut existing_rate_limits_by_email: HashMap<String, HashMap<String, i64>> = HashMap::new();
if let Some(ref content) = existing_content {
if let Ok(existing_json) = serde_json::from_str::<Value>(content) {
if let Some(existing_accounts) =
existing_json.get("accounts").and_then(|a| a.as_array())
{
for acc in existing_accounts {
if let (Some(email), Some(rlt)) = (
acc.get("email").and_then(|e| e.as_str()),
acc.get("rateLimitResetTimes").and_then(|r| r.as_object()),
) {
let mut limits = HashMap::new();
for (key, val) in rlt.iter() {
if let Some(ts) = val.as_i64() {
limits.insert(key.clone(), ts);
}
}
if !limits.is_empty() {
existing_rate_limits_by_email.insert(email.to_string(), limits);
}
}
}
}
}
}
let app_accounts = crate::modules::auth::account::list_accounts()
.map_err(|e| format!("Failed to list accounts: {}", e))?;
let mut new_accounts: Vec<OpencodeAccount> = Vec::new();
for acc in app_accounts {
if acc.disabled || acc.proxy_disabled {
continue;
}
let refresh_token = acc.token.refresh_token.clone();
let project_id = acc.token.project_id.clone();
let rate_limit_reset_times = existing_rate_limits_by_email
.get(&acc.email)
.cloned()
.filter(|m| !m.is_empty());
new_accounts.push(OpencodeAccount {
email: acc.email,
refresh_token,
project_id,
rate_limit_reset_times,
});
}
let new_data = serde_json::json!({
"accounts": new_accounts
});
let tmp_path = accounts_path.with_extension("tmp");
fs::write(&tmp_path, serde_json::to_string_pretty(&new_data).unwrap())
.map_err(|e| format!("Failed to write accounts temp file: {}", e))?;
fs::rename(&tmp_path, accounts_path)
.map_err(|e| format!("Failed to rename accounts file: {}", e))?;
Ok(())
}
pub fn restore_opencode_config() -> Result<(), String> {
let Some((config_path, _, accounts_path)) = get_config_paths() else {
return Err("Failed to get OpenCode config directory".to_string());
};
let mut restored = false;
let config_backup =
config_path.with_file_name(format!("{}{}", OPENCODE_CONFIG_FILE, BACKUP_SUFFIX));
if config_backup.exists() {
fs::rename(&config_backup, &config_path)
.map_err(|e| format!("Failed to restore config: {}", e))?;
restored = true;
}
let accounts_backup =
accounts_path.with_file_name(format!("{}{}", ANTIGRAVITY_ACCOUNTS_FILE, BACKUP_SUFFIX));
if accounts_backup.exists() {
fs::rename(&accounts_backup, &accounts_path)
.map_err(|e| format!("Failed to restore accounts: {}", e))?;
restored = true;
}
if restored {
Ok(())
} else {
Err("No backup files found".to_string())
}
}
pub fn read_opencode_config_content(file_name: Option<String>) -> Result<String, String> {
let Some((opencode_path, ag_config_path, ag_accounts_path)) = get_config_paths() else {
return Err("Failed to get OpenCode config directory".to_string());
};
let allowed_files = [
OPENCODE_CONFIG_FILE,
ANTIGRAVITY_CONFIG_FILE,
ANTIGRAVITY_ACCOUNTS_FILE,
];
let target_path = match file_name.as_deref() {
Some(name) if name == ANTIGRAVITY_CONFIG_FILE => ag_config_path,
Some(name) if name == ANTIGRAVITY_ACCOUNTS_FILE => ag_accounts_path,
Some(name) if name == OPENCODE_CONFIG_FILE => opencode_path,
Some(name) => {
return Err(format!(
"Invalid file name: {}. Allowed: {:?}",
name, allowed_files
))
}
None => opencode_path,
};
if !target_path.exists() {
return Err(format!("Config file does not exist: {:?}", target_path));
}
fs::read_to_string(&target_path).map_err(|e| format!("Failed to read config: {}", e))
}
pub async fn get_opencode_sync_status(proxy_url: String) -> Result<OpencodeStatus, String> {
let (installed, version) = check_opencode_installed();
let (is_synced, has_backup, current_base_url) = if installed {
get_sync_status(&proxy_url)
} else {
(false, false, None)
};
Ok(OpencodeStatus {
installed,
version,
is_synced,
has_backup,
current_base_url,
files: vec![
OPENCODE_CONFIG_FILE.to_string(),
ANTIGRAVITY_CONFIG_FILE.to_string(),
ANTIGRAVITY_ACCOUNTS_FILE.to_string(),
],
})
}
pub async fn execute_opencode_sync(
proxy_url: String,
api_key: String,
sync_accounts: Option<bool>,
) -> Result<(), String> {
sync_opencode_config(&proxy_url, &api_key, sync_accounts.unwrap_or(false))
}
pub async fn execute_opencode_restore() -> Result<(), String> {
restore_opencode_config()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_version_opencode_format() {
let input = "opencode/1.2.3";
assert_eq!(extract_version(input), "1.2.3");
}
#[test]
fn test_extract_version_codex_cli_format() {
let input = "codex-cli 0.86.0\n";
assert_eq!(extract_version(input), "0.86.0");
}
#[test]
fn test_extract_version_simple() {
let input = "v2.0.1";
assert_eq!(extract_version(input), "2.0.1");
}
#[test]
fn test_extract_version_unknown() {
let input = "some random text without version";
assert_eq!(extract_version(input), "unknown");
}
}