use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientDialect {
StandardMcpServers,
ZedContextServers,
OpenCodeMcp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientAppDef {
pub id: String,
pub name: String,
pub category: String,
pub dialect: ClientDialect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientAppStatus {
pub id: String,
pub name: String,
pub category: String,
pub config_path: String,
pub config_exists: bool,
pub app_installed: bool,
pub is_attached: bool,
pub attached_profile: Option<String>,
pub other_servers_count: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AttachOptions {
pub profile: Option<String>,
pub config_path: Option<String>,
pub binary_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachResult {
pub client_id: String,
pub config_path: String,
pub backup_path: Option<String>,
pub ok: bool,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetachResult {
pub client_id: String,
pub config_path: String,
pub was_attached: bool,
pub ok: bool,
pub message: String,
}
pub fn get_supported_clients() -> Vec<ClientAppDef> {
vec![
ClientAppDef {
id: "claude-desktop".to_string(),
name: "Claude Desktop".to_string(),
category: "Desktop App".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "opencode".to_string(),
name: "OpenCode".to_string(),
category: "AI Terminal / Agent".to_string(),
dialect: ClientDialect::OpenCodeMcp,
},
ClientAppDef {
id: "claude-code".to_string(),
name: "Claude Code CLI".to_string(),
category: "CLI Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "cursor".to_string(),
name: "Cursor (Global)".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "zed".to_string(),
name: "Zed Editor".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::ZedContextServers,
},
ClientAppDef {
id: "windsurf".to_string(),
name: "Windsurf".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "cline".to_string(),
name: "Roo Code / Cline".to_string(),
category: "VS Code Extension".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "antigravity".to_string(),
name: "Google Antigravity (AGY)".to_string(),
category: "IDE / Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "codex".to_string(),
name: "OpenAI Codex CLI".to_string(),
category: "CLI Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "gemini-cli".to_string(),
name: "Gemini CLI".to_string(),
category: "CLI Tool".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "continue".to_string(),
name: "Continue.dev".to_string(),
category: "IDE Extension".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "vscode".to_string(),
name: "VS Code Copilot MCP".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "goose".to_string(),
name: "Goose (Block)".to_string(),
category: "Desktop / CLI Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "librechat".to_string(),
name: "LibreChat".to_string(),
category: "Agent Platform".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "deepseek".to_string(),
name: "DeepSeek Harness".to_string(),
category: "Agent Framework".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "cody".to_string(),
name: "Sourcegraph Cody".to_string(),
category: "IDE Extension".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "devin".to_string(),
name: "Devin CLI".to_string(),
category: "Autonomous Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
]
}
pub fn resolve_client_config_path(client_id: &str) -> Option<PathBuf> {
let home = std::env::var("HOME").ok().map(PathBuf::from);
let _appdata = std::env::var("APPDATA").ok().map(PathBuf::from);
let _userprofile = std::env::var("USERPROFILE").ok().map(PathBuf::from);
match client_id {
"claude-desktop" => {
#[cfg(target_os = "macos")]
if let Some(ref h) = home {
return Some(
h.join("Library/Application Support/Claude/claude_desktop_config.json"),
);
}
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("Claude/claude_desktop_config.json"));
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
if let Some(ref h) = home {
return Some(h.join(".config/Claude/claude_desktop_config.json"));
}
None
}
"opencode" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("opencode/opencode.json"));
}
#[cfg(not(target_os = "windows"))]
if let Some(ref h) = home {
return Some(h.join(".config/opencode/opencode.json"));
}
None
}
"claude-code" => {
if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let dir_path = PathBuf::from(config_dir.trim());
let cand_json = dir_path.join("claude.json");
let cand_dot = dir_path.join(".claude.json");
if cand_json.exists() {
return Some(cand_json);
}
if cand_dot.exists() {
return Some(cand_dot);
}
return Some(cand_dot);
}
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".claude.json"));
}
if let Some(ref h) = home {
return Some(h.join(".claude.json"));
}
None
}
"cursor" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".cursor/mcp.json"));
}
if let Some(ref h) = home {
return Some(h.join(".cursor/mcp.json"));
}
None
}
"zed" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("Zed/settings.json"));
}
if let Some(ref h) = home {
return Some(h.join(".config/zed/settings.json"));
}
None
}
"windsurf" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".codeium/windsurf/mcp_config.json"));
}
if let Some(ref h) = home {
return Some(h.join(".codeium/windsurf/mcp_config.json"));
}
None
}
"cline" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".cline/data/settings/cline_mcp_settings.json"));
}
if let Some(ref h) = home {
return Some(h.join(".cline/data/settings/cline_mcp_settings.json"));
}
None
}
"antigravity" => {
if let Some(ref h) = home {
let p = h.join(".gemini/config/mcp_config.json");
if p.exists() {
return Some(p);
}
return Some(h.join(".gemini/config/mcp_config.json"));
}
None
}
"codex" => {
if let Some(ref h) = home {
return Some(h.join(".codex/mcp_servers.json"));
}
None
}
"gemini-cli" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".gemini/settings.json"));
}
if let Some(ref h) = home {
return Some(h.join(".gemini/settings.json"));
}
None
}
"continue" => {
if let Some(ref h) = home {
return Some(h.join(".continue/config.json"));
}
None
}
"vscode" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(
ad.join("Code/User/globalStorage/github.copilot-chat/mcp-config.json"),
);
}
#[cfg(target_os = "macos")]
if let Some(ref h) = home {
return Some(h.join("Library/Application Support/Code/User/globalStorage/github.copilot-chat/mcp-config.json"));
}
if let Some(ref h) = home {
return Some(
h.join(".config/Code/User/globalStorage/github.copilot-chat/mcp-config.json"),
);
}
None
}
"goose" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("Block/goose/config/config.json"));
}
if let Some(ref h) = home {
return Some(h.join(".config/goose/config.json"));
}
None
}
"librechat" => {
if let Some(ref h) = home {
return Some(h.join(".config/librechat/librechat.json"));
}
None
}
"deepseek" => {
if let Some(ref h) = home {
return Some(h.join(".deepseek/mcp.json"));
}
None
}
"cody" => {
if let Some(ref h) = home {
return Some(h.join(".sourcegraph/cody.json"));
}
None
}
"devin" => {
if let Some(ref h) = home {
return Some(h.join(".devin/mcp.json"));
}
None
}
_ => None,
}
}
pub fn detect_clients() -> Vec<ClientAppStatus> {
let clients = get_supported_clients();
let mut statuses = Vec::new();
for client in clients {
let config_path_opt = resolve_client_config_path(&client.id);
let (
config_path_str,
config_exists,
app_installed,
is_attached,
attached_profile,
other_servers_count,
) = if let Some(ref path) = config_path_opt {
let config_exists = path.exists();
let parent_exists = path.parent().map(|p| p.exists()).unwrap_or(false);
let app_installed = config_exists || parent_exists;
let (is_attached, attached_profile, other_servers_count) = if config_exists {
inspect_client_config(path, client.dialect)
} else {
(false, None, 0)
};
(
path.to_string_lossy().to_string(),
config_exists,
app_installed,
is_attached,
attached_profile,
other_servers_count,
)
} else {
("Unknown".to_string(), false, false, false, None, 0)
};
statuses.push(ClientAppStatus {
id: client.id,
name: client.name,
category: client.category,
config_path: config_path_str,
config_exists,
app_installed,
is_attached,
attached_profile,
other_servers_count,
});
}
statuses
}
fn inspect_client_config(path: &Path, dialect: ClientDialect) -> (bool, Option<String>, usize) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return (false, None, 0),
};
let val: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return (false, None, 0),
};
match dialect {
ClientDialect::StandardMcpServers => {
if let Some(servers) = val.get("mcpServers").and_then(Value::as_object) {
let is_attached = servers.contains_key("warmplane");
let profile = if is_attached {
extract_profile_from_server_val(servers.get("warmplane"))
} else {
None
};
let other_count = if is_attached {
servers.len().saturating_sub(1)
} else {
servers.len()
};
(is_attached, profile, other_count)
} else {
(false, None, 0)
}
}
ClientDialect::ZedContextServers => {
if let Some(servers) = val.get("context_servers").and_then(Value::as_object) {
let is_attached = servers.contains_key("warmplane");
let profile = if is_attached {
extract_profile_from_server_val(servers.get("warmplane"))
} else {
None
};
let other_count = if is_attached {
servers.len().saturating_sub(1)
} else {
servers.len()
};
(is_attached, profile, other_count)
} else {
(false, None, 0)
}
}
ClientDialect::OpenCodeMcp => {
if let Some(servers) = val.get("mcp").and_then(Value::as_object) {
let is_attached = servers.contains_key("warmplane");
let profile = if is_attached {
extract_profile_from_server_val(servers.get("warmplane"))
} else {
None
};
let other_count = if is_attached {
servers.len().saturating_sub(1)
} else {
servers.len()
};
(is_attached, profile, other_count)
} else {
(false, None, 0)
}
}
}
}
fn extract_profile_from_server_val(server_val: Option<&Value>) -> Option<String> {
let s = server_val?;
let args_val = if let Some(args) = s.get("args").and_then(Value::as_array) {
Some(args)
} else if let Some(cmd_obj) = s.get("command").and_then(Value::as_object) {
cmd_obj.get("args").and_then(Value::as_array)
} else {
None
};
if let Some(args) = args_val {
let mut iter = args.iter().filter_map(Value::as_str);
while let Some(arg) = iter.next() {
if arg == "--profile" {
return iter.next().map(ToString::to_string);
}
}
}
None
}
pub fn attach_client(client_id: &str, options: &AttachOptions) -> Result<AttachResult> {
let clients = get_supported_clients();
let client_def = clients
.into_iter()
.find(|c| c.id == client_id)
.with_context(|| format!("Unsupported client ID: {}", client_id))?;
let config_path = resolve_client_config_path(client_id)
.with_context(|| format!("Could not resolve configuration path for {}", client_id))?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let mut backup_path = None;
let mut root_val: Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let bak = config_path.with_extension(format!("bak.{}", now));
let _ = std::fs::copy(&config_path, &bak);
backup_path = Some(bak.to_string_lossy().to_string());
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
if !root_val.is_object() {
root_val = json!({});
}
let binary = options
.binary_path
.clone()
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(ToString::to_string))
})
.unwrap_or_else(|| "warmplane".to_string());
let warmplane_config = options
.config_path
.clone()
.unwrap_or_else(|| "mcp_servers.json".to_string());
let warmplane_config_abs = std::fs::canonicalize(&warmplane_config)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(warmplane_config);
let mut args = vec![
"mcp-server".to_string(),
"--config".to_string(),
warmplane_config_abs,
];
if let Some(ref prof) = options.profile {
args.push("--profile".to_string());
args.push(prof.clone());
}
match client_def.dialect {
ClientDialect::StandardMcpServers => {
let mcp_servers = root_val
.as_object_mut()
.unwrap()
.entry("mcpServers")
.or_insert_with(|| json!({}));
if !mcp_servers.is_object() {
*mcp_servers = json!({});
}
mcp_servers.as_object_mut().unwrap().insert(
"warmplane".to_string(),
json!({
"command": binary,
"args": args,
}),
);
}
ClientDialect::ZedContextServers => {
let context_servers = root_val
.as_object_mut()
.unwrap()
.entry("context_servers")
.or_insert_with(|| json!({}));
if !context_servers.is_object() {
*context_servers = json!({});
}
context_servers.as_object_mut().unwrap().insert(
"warmplane".to_string(),
json!({
"command": {
"path": binary,
"args": args,
}
}),
);
}
ClientDialect::OpenCodeMcp => {
let mcp = root_val
.as_object_mut()
.unwrap()
.entry("mcp")
.or_insert_with(|| json!({}));
if !mcp.is_object() {
*mcp = json!({});
}
mcp.as_object_mut().unwrap().insert(
"warmplane".to_string(),
json!({
"type": "local",
"command": binary,
"args": args,
"enabled": true,
}),
);
}
}
let formatted_json = serde_json::to_string_pretty(&root_val)?;
let temp_path = config_path.with_extension("tmp");
std::fs::write(&temp_path, &formatted_json)
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
std::fs::rename(&temp_path, &config_path)
.with_context(|| format!("Failed to atomically rename to {}", config_path.display()))?;
Ok(AttachResult {
client_id: client_id.to_string(),
config_path: config_path.to_string_lossy().to_string(),
backup_path,
ok: true,
message: format!(
"Successfully attached Warmplane to {} ({})",
client_def.name,
config_path.display()
),
})
}
pub fn detach_client(client_id: &str) -> Result<DetachResult> {
let clients = get_supported_clients();
let client_def = clients
.into_iter()
.find(|c| c.id == client_id)
.with_context(|| format!("Unsupported client ID: {}", client_id))?;
let config_path = resolve_client_config_path(client_id)
.with_context(|| format!("Could not resolve configuration path for {}", client_id))?;
if !config_path.exists() {
return Ok(DetachResult {
client_id: client_id.to_string(),
config_path: config_path.to_string_lossy().to_string(),
was_attached: false,
ok: true,
message: format!(
"Configuration file {} does not exist",
config_path.display()
),
});
}
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let mut root_val: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON in {}", config_path.display()))?;
let mut was_attached = false;
match client_def.dialect {
ClientDialect::StandardMcpServers => {
if let Some(servers) = root_val
.get_mut("mcpServers")
.and_then(Value::as_object_mut)
{
was_attached = servers.remove("warmplane").is_some();
}
}
ClientDialect::ZedContextServers => {
if let Some(servers) = root_val
.get_mut("context_servers")
.and_then(Value::as_object_mut)
{
was_attached = servers.remove("warmplane").is_some();
}
}
ClientDialect::OpenCodeMcp => {
if let Some(servers) = root_val.get_mut("mcp").and_then(Value::as_object_mut) {
was_attached = servers.remove("warmplane").is_some();
}
}
}
if was_attached {
let formatted_json = serde_json::to_string_pretty(&root_val)?;
let temp_path = config_path.with_extension("tmp");
std::fs::write(&temp_path, &formatted_json)
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
std::fs::rename(&temp_path, &config_path)
.with_context(|| format!("Failed to atomically rename to {}", config_path.display()))?;
}
Ok(DetachResult {
client_id: client_id.to_string(),
config_path: config_path.to_string_lossy().to_string(),
was_attached,
ok: true,
message: if was_attached {
format!("Successfully detached Warmplane from {}", client_def.name)
} else {
format!("Warmplane was not attached to {}", client_def.name)
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_client_attach_and_detach_standard() {
let temp_dir =
std::env::temp_dir().join(format!("wp_client_test_std_{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
let cfg_file = temp_dir.join("claude_desktop_config.json");
fs::write(
&cfg_file,
r#"{"mcpServers": {"github": {"command": "npx", "args": ["@modelcontextprotocol/server-github"]}}}"#,
)
.unwrap();
let dialect = ClientDialect::StandardMcpServers;
let (attached, prof, count) = inspect_client_config(&cfg_file, dialect);
assert!(!attached);
assert_eq!(prof, None);
assert_eq!(count, 1);
let mut val: Value = serde_json::from_str(&fs::read_to_string(&cfg_file).unwrap()).unwrap();
val["mcpServers"]["warmplane"] = json!({
"command": "warmplane",
"args": ["mcp-server", "--config", "/path/mcp_servers.json", "--profile", "coding"]
});
fs::write(&cfg_file, serde_json::to_string_pretty(&val).unwrap()).unwrap();
let (attached2, prof2, count2) = inspect_client_config(&cfg_file, dialect);
assert!(attached2);
assert_eq!(prof2.as_deref(), Some("coding"));
assert_eq!(count2, 1);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn test_client_attach_opencode_dialect() {
let temp_dir =
std::env::temp_dir().join(format!("wp_client_test_oc_{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
let cfg_file = temp_dir.join("opencode.json");
fs::write(&cfg_file, r#"{"theme": "dark"}"#).unwrap();
let dialect = ClientDialect::OpenCodeMcp;
let (attached, _, _) = inspect_client_config(&cfg_file, dialect);
assert!(!attached);
let mut val: Value = serde_json::from_str(&fs::read_to_string(&cfg_file).unwrap()).unwrap();
val["mcp"] = json!({
"warmplane": {
"type": "local",
"command": "warmplane",
"args": ["mcp-server", "--config", "/path/mcp_servers.json"],
"enabled": true
}
});
fs::write(&cfg_file, serde_json::to_string_pretty(&val).unwrap()).unwrap();
let (attached2, prof2, count2) = inspect_client_config(&cfg_file, dialect);
assert!(attached2);
assert_eq!(prof2, None);
assert_eq!(count2, 0);
let _ = fs::remove_dir_all(temp_dir);
}
}