Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 175 additions & 4 deletions crates/reliary-agent/src/init.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::io::{self, Write};
use std::path::PathBuf;
use std::fs;
use std::collections::HashMap;
use std::process::Command;
use serde_json::Value;

Expand Down Expand Up @@ -123,7 +124,7 @@ pub fn run() {
Some(home.join(".config/opencode/opencode.json"))
};

if let Some(cfg_path) = opencode_cfg {
if let Some(cfg_path) = opencode_cfg {
if cfg_path.exists() {
if ask_yes_no("Found OpenCode config. Add Reliary MCP server?", true) {
if inject_mcp_server(&cfg_path, "reliary") {
Expand All @@ -135,6 +136,21 @@ pub fn run() {
} else {
println!(" {} Skipped\n", "\x1b[33m-\x1b[0m");
}

// Proxy routing — after MCP injection, ask to mutate provider baseURLs
if ask_yes_no("\nRoute all OpenCode providers through Reliary proxy?\n(Your provider baseURLs will be updated to point at localhost:9090\nand proxy-routes.json will be generated automatically)", true) {
let (count, routes) = inject_opencode_proxy_routes(&cfg_path);
if count > 0 {
ok(&format!("Updated {} OpenCode provider baseURLs", count));
if write_proxy_routes(&routes) {
ok("Generated ~/.reliary/proxy-routes.json");
}
} else {
println!(" {} No providers found to update\n", "\x1b[33m-\x1b[0m");
}
} else {
println!(" {} Skipped\n", "\x1b[33m-\x1b[0m");
}
}
}
}
Expand Down Expand Up @@ -360,9 +376,15 @@ pub fn uninstall() {
};

if let Some(cfg_path) = opencode_cfg {
if cfg_path.exists() && remove_mcp_server(&cfg_path, "reliary") {
ok("Removed Reliary from OpenCode");
removed_agents += 1;
if cfg_path.exists() {
if remove_mcp_server(&cfg_path, "reliary") {
ok("Removed Reliary from OpenCode");
removed_agents += 1;
}
// Restore original provider baseURLs
if restore_opencode_proxy_routes() {
ok("Restored OpenCode provider baseURLs");
}
}
}
}
Expand Down Expand Up @@ -485,3 +507,152 @@ fn uninstall_daemon() -> bool {

removed
}

/// Inject proxy baseURLs for all OpenCode providers.
/// Returns (count_of_updated_providers, routes_map).
fn inject_opencode_proxy_routes(cfg_path: &PathBuf) -> (usize, HashMap<String, String>) {
let content = match std::fs::read_to_string(cfg_path) {
Ok(c) => c,
Err(_) => return (0, HashMap::new()),
};
let mut v: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return (0, HashMap::new()),
};

let mut routes: HashMap<String, String> = HashMap::new();
let mut backups: HashMap<String, String> = HashMap::new();
let mut count = 0;

if let Some(obj) = v.as_object_mut() {
if let Some(providers) = obj.get_mut("provider").and_then(|p| p.as_object_mut()) {
for (name, provider) in providers.iter_mut() {
if let Some(options) = provider.get_mut("options").and_then(|o| o.as_object_mut()) {
let original_url = match options.get("baseURL").and_then(|v| v.as_str()) {
Some(u) => u.to_string(),
None => continue,
};
// Skip if already pointing at proxy
if original_url.contains("127.0.0.1:9090") || original_url.contains("localhost:9090") {
continue;
}
let api_key = match options.get("apiKey").and_then(|v| v.as_str()) {
Some(k) => k.to_string(),
None => continue,
};
// Update baseURL to proxy
options.insert("baseURL".to_string(), Value::String("http://127.0.0.1:9090/v1".to_string()));
routes.insert(api_key, original_url.clone());
backups.insert(name.clone(), original_url);
count += 1;
}
}
}
}

// Write updated opencode.json
if count > 0 {
// Add backup metadata to the routes map
let mut routes_with_backups = routes.clone();
if !backups.is_empty() {
routes_with_backups.insert("__backups".to_string(), serde_json::to_string(&backups).unwrap_or_default());
}

if let Ok(new_content) = serde_json::to_string_pretty(&v) {
atomic_write(&cfg_path.to_string_lossy(), &new_content);
}
(count, routes)
} else {
(0, routes)
}
}

/// Write proxy-routes.json to ~/.reliary/
fn write_proxy_routes(routes: &HashMap<String, String>) -> bool {
let home = match dirs::home_dir() {
Some(h) => h,
None => return false,
};
let config_dir = home.join(".reliary");
let _ = std::fs::create_dir_all(&config_dir);

// Build routes JSON from api_key -> upstream_url
let mut routes_json = serde_json::Map::new();
for (key, value) in routes {
routes_json.insert(key.clone(), Value::String(value.clone()));
}

let routes_path = config_dir.join("proxy-routes.json");
let content = serde_json::to_string_pretty(&serde_json::Value::Object(routes_json))
.unwrap_or_default();
atomic_write(&routes_path.to_string_lossy(), &content)
}

/// Restore original OpenCode provider baseURLs from proxy-routes.json backup.
pub fn restore_opencode_proxy_routes() -> bool {
let home = match dirs::home_dir() {
Some(h) => h,
None => return false,
};
let cfg_path = if cfg!(target_os = "windows") {
std::env::var("APPDATA").ok()
.map(|d| PathBuf::from(d).join("opencode").join("opencode.json"))
} else if cfg!(target_os = "macos") {
Some(home.join("Library/Application Support/opencode/opencode.json"))
} else {
Some(home.join(".config/opencode/opencode.json"))
};
let cfg_path = match cfg_path {
Some(p) => p,
None => return false,
};
if !cfg_path.exists() { return false; }

let routes_path = home.join(".reliary/proxy-routes.json");
let routes_content = match std::fs::read_to_string(&routes_path) {
Ok(c) => c,
Err(_) => return false,
};
let routes: Value = match serde_json::from_str(&routes_content) {
Ok(v) => v,
Err(_) => return false,
};
let backups = match routes.get("__backups").and_then(|v| v.as_str()) {
Some(s) => s,
None => return false,
};
let backups: HashMap<String, String> = match serde_json::from_str(backups) {
Ok(m) => m,
Err(_) => return false,
};

let content = match std::fs::read_to_string(&cfg_path) {
Ok(c) => c,
Err(_) => return false,
};
let mut v: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return false,
};

let mut restored = 0;
if let Some(obj) = v.as_object_mut() {
if let Some(providers) = obj.get_mut("provider").and_then(|p| p.as_object_mut()) {
for (name, provider) in providers.iter_mut() {
if let Some(original_url) = backups.get(name) {
if let Some(options) = provider.get_mut("options").and_then(|o| o.as_object_mut()) {
options.insert("baseURL".to_string(), Value::String(original_url.clone()));
restored += 1;
}
}
}
}
}

if restored > 0 {
if let Ok(new_content) = serde_json::to_string_pretty(&v) {
atomic_write(&cfg_path.to_string_lossy(), &new_content);
}
}
restored > 0
}
133 changes: 124 additions & 9 deletions crates/reliary-agent/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ pub fn discover_upstream(auth_key: &str) -> Option<String> {
if let Some(url) = scan_proxy_routes(auth_key) {
return Some(url);
}
// 2. Pi provider configs
// 2. OpenCode provider configs
if let Some(url) = scan_opencode_configs(auth_key) {
return Some(url);
}
// 3. Claude Code config
if let Some(url) = scan_claude_config(auth_key) {
return Some(url);
}
// 4. Cline config
if let Some(url) = scan_cline_config(auth_key) {
return Some(url);
}
// 5. Pi provider configs
if let Some(url) = scan_pi_configs(auth_key) {
return Some(url);
}
// 3. Environment variables (generic fallback)
// 6. Environment variables (generic fallback)
if let Some(url) = scan_env_vars(auth_key) {
return Some(url);
}
Expand All @@ -26,6 +38,88 @@ fn scan_proxy_routes(auth_key: &str) -> Option<String> {
routes.get(auth_key).cloned()
}

/// Scan OpenCode's opencode.json for provider API keys matching the auth key.
fn scan_opencode_configs(auth_key: &str) -> Option<String> {
let cfg_path = opencode_config_path()?;
let content = std::fs::read_to_string(cfg_path).ok()?;
let config: serde_json::Value = serde_json::from_str(&content).ok()?;
let providers = config.get("provider")?.as_object()?;
for (_name, provider) in providers {
let options = provider.get("options")?.as_object()?;
let api_key = options.get("apiKey")?.as_str()?;
if api_key == auth_key {
let base_url = options.get("baseURL")?.as_str()?;
// Normalize: append /chat/completions if missing
let url = normalize_url(base_url);
return Some(url);
}
}
None
}

/// Scan Claude Code's ~/.claude.json for provider API keys.
fn scan_claude_config(auth_key: &str) -> Option<String> {
let home = home_dir();
let claude_path = home.join(".claude.json");
let content = std::fs::read_to_string(claude_path).ok()?;
// Claude Code can have api keys under providers or directly
// Also check top-level project configs
let config: serde_json::Value = serde_json::from_str(&content).ok()?;
// Check for Anthropic API key
if let Some(api_key) = config.get("apiKey").and_then(|v| v.as_str()) {
if api_key == auth_key {
return Some("https://api.anthropic.com/v1/messages".to_string());
}
}
// Check for custom keys under providers
if let Some(providers) = config.get("providers").and_then(|v| v.as_object()) {
for (_name, provider) in providers {
if let Some(api_key) = provider.get("apiKey").and_then(|v| v.as_str()) {
if api_key == auth_key {
if let Some(base_url) = provider.get("baseUrl").and_then(|v| v.as_str()) {
return Some(normalize_url(base_url));
}
}
}
}
}
None
}

/// Scan Cline's cline_mcp_settings.json for provider API keys.
fn scan_cline_config(auth_key: &str) -> Option<String> {
let home = home_dir();
let cline_paths = vec![
if cfg!(target_os = "macos") {
home.join("Library/Application Support/Code/User/globalStorage/rooveterinery.cline/cline_mcp_settings.json")
} else {
home.join(".config/Code/User/globalStorage/rooveterinery.cline/cline_mcp_settings.json")
},
home.join(".config/Code/User/globalStorage/rooveterinary.cline/cline_mcp_settings.json"),
home.join("AppData/Roaming/Code/User/globalStorage/rooveterinery.cline/cline_mcp_settings.json"),
];
for path in &cline_paths {
if !path.exists() { continue; }
if let Some(url) = scan_single_cline_config(path, auth_key) {
return Some(url);
}
}
None
}

fn scan_single_cline_config(path: &PathBuf, auth_key: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let config: serde_json::Value = serde_json::from_str(&content).ok()?;
// Cline stores API keys inline in MCP server args, not in a providers structure.
// Check for anthropic key
if let Some(api_key) = config.get("apiKey").and_then(|v| v.as_str()) {
if api_key == auth_key {
return Some("https://api.anthropic.com/v1/messages".to_string());
}
}
None
}

/// Scan Pi's ~/.pi/agent/models.json for provider API keys matching the auth key.
fn scan_pi_configs(auth_key: &str) -> Option<String> {
let pi_config = home_dir().join(".pi/agent/models.json");
Expand All @@ -39,12 +133,7 @@ fn scan_pi_configs(auth_key: &str) -> Option<String> {
let resolved = resolve_env_var(api_field);
if resolved == auth_key {
if let Some(base_url) = provider.get("baseUrl").and_then(|v| v.as_str()) {
// Append chat completions path if needed
let url = if base_url.ends_with("/chat/completions") || base_url.ends_with("/v1/messages") {
base_url.to_string()
} else {
format!("{}/chat/completions", base_url.trim_end_matches('/'))
};
let url = normalize_url(base_url);
return Some(url);
}
}
Expand All @@ -65,7 +154,6 @@ fn scan_env_vars(auth_key: &str) -> Option<String> {
if let Ok(val) = std::env::var(env_var) {
if val == auth_key {
if *env_var == "RELIARY_UPSTREAM_URL" {
// The env var IS the URL, not the key
return Some(auth_key.to_string());
}
return Some(default_url.to_string());
Expand All @@ -75,6 +163,22 @@ fn scan_env_vars(auth_key: &str) -> Option<String> {
None
}

/// Normalize a base URL: append /chat/completions if missing and not Anthropic-style.
fn normalize_url(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
if trimmed.ends_with("/chat/completions") || trimmed.ends_with("/v1/messages") || trimmed.contains("/v1/messages") {
trimmed.to_string()
} else if trimmed.starts_with("https://api.anthropic.com") || trimmed.contains("anthropic") {
if trimmed.ends_with("/v1") {
format!("{}/messages", trimmed)
} else {
format!("{}/v1/messages", trimmed)
}
} else {
format!("{}/chat/completions", trimmed)
}
}

/// Resolve an env var reference like "$DEEPSEEK_API_KEY" to its value.
fn resolve_env_var(val: &str) -> String {
if val.starts_with('$') {
Expand All @@ -84,6 +188,17 @@ fn resolve_env_var(val: &str) -> String {
}
}

fn opencode_config_path() -> Option<PathBuf> {
let home = home_dir();
if cfg!(target_os = "windows") {
std::env::var("APPDATA").ok().map(|d| PathBuf::from(d).join("opencode").join("opencode.json"))
} else if cfg!(target_os = "macos") {
Some(home.join("Library/Application Support/opencode/opencode.json"))
} else {
Some(home.join(".config/opencode/opencode.json"))
}
}

fn home_dir() -> PathBuf {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
Expand Down
Loading