use serde_json::Value;
use super::types::{ConfigType, EditorTarget};
fn toml_quote(value: &str) -> String {
if value.contains('\\') {
format!("'{value}'")
} else {
format!("\"{value}\"")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteAction {
Created,
Updated,
Already,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WriteOptions {
pub overwrite_invalid: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteResult {
pub action: WriteAction,
pub note: Option<String>,
}
pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
write_config_with_options(target, binary, WriteOptions::default())
}
pub fn write_config_with_options(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
if let Some(parent) = target.config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
match target.config_type {
ConfigType::McpJson => write_mcp_json(target, binary, opts),
ConfigType::Zed => write_zed_config(target, binary, opts),
ConfigType::Codex => write_codex_config(target, binary),
ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
ConfigType::OpenCode => write_opencode_config(target, binary, opts),
ConfigType::Crush => write_crush_config(target, binary, opts),
ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
ConfigType::Amp => write_amp_config(target, binary, opts),
ConfigType::HermesYaml => write_hermes_yaml(target, binary, opts),
ConfigType::GeminiSettings => write_gemini_settings(target, binary, opts),
ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
}
}
pub fn remove_lean_ctx_mcp_server(
path: &std::path::Path,
opts: WriteOptions,
) -> Result<WriteResult, String> {
if !path.exists() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("mcp.json not found".to_string()),
});
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(path)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some("backed up invalid JSON; did not modify".to_string()),
});
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let Some(servers) = obj.get_mut("mcpServers") else {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("no mcpServers key".to_string()),
});
};
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
if servers_obj.remove("lean-ctx").is_none() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("lean-ctx not configured".to_string()),
});
}
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &formatted)?;
Ok(WriteResult {
action: WriteAction::Updated,
note: Some("removed lean-ctx from mcpServers".to_string()),
})
}
pub fn remove_lean_ctx_server(
target: &EditorTarget,
opts: WriteOptions,
) -> Result<WriteResult, String> {
match target.config_type {
ConfigType::McpJson
| ConfigType::JetBrains
| ConfigType::GeminiSettings
| ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
ConfigType::VsCodeMcp => remove_lean_ctx_vscode_server(&target.config_path, opts),
ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
ConfigType::OpenCode | ConfigType::Crush => {
remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
}
ConfigType::Zed => {
remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
}
ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
}
}
fn remove_lean_ctx_vscode_server(
path: &std::path::Path,
opts: WriteOptions,
) -> Result<WriteResult, String> {
if !path.exists() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("vscode mcp.json not found".to_string()),
});
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(path)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some("backed up invalid JSON; did not modify".to_string()),
});
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let Some(servers) = obj.get_mut("servers") else {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("no servers key".to_string()),
});
};
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"servers\" must be an object".to_string())?;
if servers_obj.remove("lean-ctx").is_none() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("lean-ctx not configured".to_string()),
});
}
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &formatted)?;
Ok(WriteResult {
action: WriteAction::Updated,
note: Some("removed lean-ctx from servers".to_string()),
})
}
fn remove_lean_ctx_amp_server(
path: &std::path::Path,
opts: WriteOptions,
) -> Result<WriteResult, String> {
if !path.exists() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("amp settings not found".to_string()),
});
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(path)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some("backed up invalid JSON; did not modify".to_string()),
});
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let Some(servers) = obj.get_mut("amp.mcpServers") else {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("no amp.mcpServers key".to_string()),
});
};
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
if servers_obj.remove("lean-ctx").is_none() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("lean-ctx not configured".to_string()),
});
}
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &formatted)?;
Ok(WriteResult {
action: WriteAction::Updated,
note: Some("removed lean-ctx from amp.mcpServers".to_string()),
})
}
fn remove_lean_ctx_named_json_server(
path: &std::path::Path,
container_key: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
if !path.exists() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("config not found".to_string()),
});
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(path)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some("backed up invalid JSON; did not modify".to_string()),
});
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let Some(container) = obj.get_mut(container_key) else {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some(format!("no {container_key} key")),
});
};
let container_obj = container
.as_object_mut()
.ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
if container_obj.remove("lean-ctx").is_none() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("lean-ctx not configured".to_string()),
});
}
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &formatted)?;
Ok(WriteResult {
action: WriteAction::Updated,
note: Some(format!("removed lean-ctx from {container_key}")),
})
}
fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
if !path.exists() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("codex config not found".to_string()),
});
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
if updated == content {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("lean-ctx not configured".to_string()),
});
}
crate::config_io::write_atomic_with_backup(path, &updated)?;
Ok(WriteResult {
action: WriteAction::Updated,
note: Some("removed [mcp_servers.lean-ctx]".to_string()),
})
}
fn remove_codex_toml_section(existing: &str, header: &str) -> String {
let prefix = header.trim_end_matches(']');
let mut out = String::with_capacity(existing.len());
let mut skipping = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
skipping = true;
continue;
}
skipping = false;
}
if skipping {
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
if !path.exists() {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("hermes config not found".to_string()),
});
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
if updated == content {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("lean-ctx not configured".to_string()),
});
}
crate::config_io::write_atomic_with_backup(path, &updated)?;
Ok(WriteResult {
action: WriteAction::Updated,
note: Some("removed lean-ctx from mcp_servers".to_string()),
})
}
fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
let mut out = String::with_capacity(existing.len());
let mut in_mcp = false;
let mut skipping = false;
for line in existing.lines() {
let trimmed = line.trim_end();
if trimmed == "mcp_servers:" {
in_mcp = true;
out.push_str(line);
out.push('\n');
continue;
}
if in_mcp {
let is_child = line.starts_with(" ") && !line.starts_with(" ");
let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
if is_toplevel {
in_mcp = false;
skipping = false;
}
if skipping {
if is_child || is_toplevel {
skipping = false;
out.push_str(line);
out.push('\n');
}
continue;
}
if is_child && line.trim() == format!("{name}:") {
skipping = true;
continue;
}
}
out.push_str(line);
out.push('\n');
}
out
}
pub fn auto_approve_tools() -> Vec<&'static str> {
vec![
"ctx_read",
"ctx_shell",
"ctx_search",
"ctx_tree",
"ctx_overview",
"ctx_preload",
"ctx_compress",
"ctx_metrics",
"ctx_session",
"ctx_knowledge",
"ctx_agent",
"ctx_share",
"ctx_analyze",
"ctx_benchmark",
"ctx_cache",
"ctx_discover",
"ctx_smart_read",
"ctx_delta",
"ctx_edit",
"ctx_dedup",
"ctx_fill",
"ctx_intent",
"ctx_response",
"ctx_context",
"ctx_graph",
"ctx_wrapped",
"ctx_multi_read",
"ctx_semantic_search",
"ctx_symbol",
"ctx_outline",
"ctx_callers",
"ctx_callees",
"ctx_callgraph",
"ctx_routes",
"ctx_graph_diagram",
"ctx_cost",
"ctx_heatmap",
"ctx_task",
"ctx_impact",
"ctx_architecture",
"ctx_workflow",
"ctx",
]
}
fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
let mut entry = serde_json::json!({
"command": binary,
"env": {
"LEAN_CTX_DATA_DIR": data_dir
}
});
if include_auto_approve {
entry["autoApprove"] = serde_json::json!(auto_approve_tools());
}
entry
}
fn supports_auto_approve(target: &EditorTarget) -> bool {
crate::core::client_constraints::by_editor_name(target.name)
.is_some_and(|c| c.supports_auto_approve)
}
fn default_data_dir() -> Result<String, String> {
Ok(crate::core::data_dir::lean_ctx_data_dir()?
.to_string_lossy()
.to_string())
}
fn write_mcp_json(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = default_data_dir()?;
let include_aa = supports_auto_approve(target);
let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
if (target.agent_key == "claude" || target.name == "Claude Code")
&& !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
{
if let Ok(result) = try_claude_mcp_add(&desired) {
return Ok(result);
}
}
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
return write_mcp_json_fresh(
&target.config_path,
&desired,
Some("overwrote invalid JSON".to_string()),
);
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let servers = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
let existing = servers_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&desired) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
servers_obj.insert("lean-ctx".to_string(), desired);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
write_mcp_json_fresh(&target.config_path, &desired, None)
}
fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd");
c.args([
"/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
]);
c
} else {
let mut c = Command::new("claude");
c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
c
};
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| e.to_string())?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(server_json.as_bytes());
}
let deadline = Duration::from_secs(3);
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
return if status.success() {
Ok(WriteResult {
action: WriteAction::Updated,
note: Some("via claude mcp add-json".to_string()),
})
} else {
Err("claude mcp add-json failed".to_string())
};
}
Ok(None) => {
if start.elapsed() > deadline {
let _ = child.kill();
let _ = child.wait();
return Err("claude mcp add-json timed out".to_string());
}
std::thread::sleep(Duration::from_millis(20));
}
Err(e) => return Err(e.to_string()),
}
}
}
fn write_mcp_json_fresh(
path: &std::path::Path,
desired: &Value,
note: Option<String>,
) -> Result<WriteResult, String> {
let content = serde_json::to_string_pretty(&serde_json::json!({
"mcpServers": { "lean-ctx": desired }
}))
.map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &content)?;
Ok(WriteResult {
action: if note.is_some() {
WriteAction::Updated
} else {
WriteAction::Created
},
note,
})
}
fn write_zed_config(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let desired = serde_json::json!({
"source": "custom",
"command": binary,
"args": [],
"env": {}
});
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
return write_zed_config_fresh(
&target.config_path,
&desired,
Some("overwrote invalid JSON".to_string()),
);
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let servers = obj
.entry("context_servers")
.or_insert_with(|| serde_json::json!({}));
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
let existing = servers_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&desired) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
servers_obj.insert("lean-ctx".to_string(), desired);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
write_zed_config_fresh(&target.config_path, &desired, None)
}
fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let updated = upsert_codex_toml(&content, binary);
if updated == content {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
let content = format!(
"[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
toml_quote(binary)
);
crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
Ok(WriteResult {
action: WriteAction::Created,
note: None,
})
}
fn write_zed_config_fresh(
path: &std::path::Path,
desired: &Value,
note: Option<String>,
) -> Result<WriteResult, String> {
let content = serde_json::to_string_pretty(&serde_json::json!({
"context_servers": { "lean-ctx": desired }
}))
.map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &content)?;
Ok(WriteResult {
action: if note.is_some() {
WriteAction::Updated
} else {
WriteAction::Created
},
note,
})
}
fn write_vscode_mcp(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
return write_vscode_mcp_fresh(
&target.config_path,
binary,
Some("overwrote invalid JSON".to_string()),
);
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let servers = obj
.entry("servers")
.or_insert_with(|| serde_json::json!({}));
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"servers\" must be an object".to_string())?;
let existing = servers_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&desired) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
servers_obj.insert("lean-ctx".to_string(), desired);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
write_vscode_mcp_fresh(&target.config_path, binary, None)
}
fn write_vscode_mcp_fresh(
path: &std::path::Path,
binary: &str,
note: Option<String>,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let content = serde_json::to_string_pretty(&serde_json::json!({
"servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
}))
.map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &content)?;
Ok(WriteResult {
action: if note.is_some() {
WriteAction::Updated
} else {
WriteAction::Created
},
note,
})
}
fn write_opencode_config(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let desired = serde_json::json!({
"type": "local",
"command": [binary],
"enabled": true,
"environment": { "LEAN_CTX_DATA_DIR": data_dir }
});
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
return write_opencode_fresh(
&target.config_path,
binary,
Some("overwrote invalid JSON".to_string()),
);
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
let mcp_obj = mcp
.as_object_mut()
.ok_or_else(|| "\"mcp\" must be an object".to_string())?;
let existing = mcp_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&desired) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
mcp_obj.insert("lean-ctx".to_string(), desired);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
write_opencode_fresh(&target.config_path, binary, None)
}
fn write_opencode_fresh(
path: &std::path::Path,
binary: &str,
note: Option<String>,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let content = serde_json::to_string_pretty(&serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
}))
.map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &content)?;
Ok(WriteResult {
action: if note.is_some() {
WriteAction::Updated
} else {
WriteAction::Created
},
note,
})
}
fn write_jetbrains_config(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let desired = serde_json::json!({
"command": binary,
"args": [],
"env": { "LEAN_CTX_DATA_DIR": data_dir }
});
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some(
"overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
.to_string(),
),
});
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let servers = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
let existing = servers_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&desired) {
return Ok(WriteResult {
action: WriteAction::Already,
note: Some("paste this snippet into JetBrains MCP settings".to_string()),
});
}
servers_obj.insert("lean-ctx".to_string(), desired);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some("paste this snippet into JetBrains MCP settings".to_string()),
});
}
let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
Ok(WriteResult {
action: WriteAction::Created,
note: Some("paste this snippet into JetBrains MCP settings".to_string()),
})
}
fn write_amp_config(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let entry = serde_json::json!({
"command": binary,
"env": { "LEAN_CTX_DATA_DIR": data_dir }
});
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some("overwrote invalid JSON".to_string()),
});
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let servers = obj
.entry("amp.mcpServers")
.or_insert_with(|| serde_json::json!({}));
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
let existing = servers_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&entry) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
servers_obj.insert("lean-ctx".to_string(), entry);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
Ok(WriteResult {
action: WriteAction::Created,
note: None,
})
}
fn write_crush_config(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let desired = serde_json::json!({
"type": "stdio",
"command": binary,
"env": { "LEAN_CTX_DATA_DIR": data_dir }
});
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
return write_crush_fresh(
&target.config_path,
&desired,
Some("overwrote invalid JSON".to_string()),
);
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
let mcp_obj = mcp
.as_object_mut()
.ok_or_else(|| "\"mcp\" must be an object".to_string())?;
let existing = mcp_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&desired) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
mcp_obj.insert("lean-ctx".to_string(), desired);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
write_crush_fresh(&target.config_path, &desired, None)
}
fn write_crush_fresh(
path: &std::path::Path,
desired: &Value,
note: Option<String>,
) -> Result<WriteResult, String> {
let content = serde_json::to_string_pretty(&serde_json::json!({
"mcp": { "lean-ctx": desired }
}))
.map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &content)?;
Ok(WriteResult {
action: if note.is_some() {
WriteAction::Updated
} else {
WriteAction::Created
},
note,
})
}
fn upsert_codex_toml(existing: &str, binary: &str) -> String {
let mut out = String::with_capacity(existing.len() + 128);
let mut in_section = false;
let mut saw_section = false;
let mut wrote_command = false;
let mut wrote_args = false;
let mut inserted_parent_before_subtable = false;
let parent_block = format!(
"[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
toml_quote(binary)
);
for line in existing.lines() {
let trimmed = line.trim();
if trimmed == "[]" {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if in_section && !wrote_command {
out.push_str(&format!("command = {}\n", toml_quote(binary)));
wrote_command = true;
}
if in_section && !wrote_args {
out.push_str("args = []\n");
wrote_args = true;
}
in_section = trimmed == "[mcp_servers.lean-ctx]";
if in_section {
saw_section = true;
} else if !saw_section
&& !inserted_parent_before_subtable
&& trimmed.starts_with("[mcp_servers.lean-ctx.")
{
out.push_str(&parent_block);
inserted_parent_before_subtable = true;
}
out.push_str(line);
out.push('\n');
continue;
}
if in_section {
if trimmed.starts_with("command") && trimmed.contains('=') {
out.push_str(&format!("command = {}\n", toml_quote(binary)));
wrote_command = true;
continue;
}
if trimmed.starts_with("args") && trimmed.contains('=') {
out.push_str("args = []\n");
wrote_args = true;
continue;
}
}
out.push_str(line);
out.push('\n');
}
if saw_section {
if in_section && !wrote_command {
out.push_str(&format!("command = {}\n", toml_quote(binary)));
}
if in_section && !wrote_args {
out.push_str("args = []\n");
}
return out;
}
if inserted_parent_before_subtable {
return out;
}
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str("\n[mcp_servers.lean-ctx]\n");
out.push_str(&format!("command = {}\n", toml_quote(binary)));
out.push_str("args = []\n");
out
}
fn write_gemini_settings(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let entry = serde_json::json!({
"command": binary,
"env": { "LEAN_CTX_DATA_DIR": data_dir },
"trust": true,
});
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some("overwrote invalid JSON".to_string()),
});
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let servers = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
let existing = servers_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&entry) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
servers_obj.insert("lean-ctx".to_string(), entry);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
Ok(WriteResult {
action: WriteAction::Created,
note: None,
})
}
fn write_hermes_yaml(
target: &EditorTarget,
binary: &str,
_opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = default_data_dir()?;
let lean_ctx_block = format!(
" lean-ctx:\n command: \"{binary}\"\n env:\n LEAN_CTX_DATA_DIR: \"{data_dir}\""
);
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
if content.contains("lean-ctx") {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
let content = format!("mcp_servers:\n{lean_ctx_block}\n");
crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
Ok(WriteResult {
action: WriteAction::Created,
note: None,
})
}
fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
let mut in_mcp_section = false;
let mut saw_mcp_child = false;
let mut inserted = false;
let lines: Vec<&str> = existing.lines().collect();
for line in &lines {
if !inserted && line.trim_end() == "mcp_servers:" {
in_mcp_section = true;
out.push_str(line);
out.push('\n');
continue;
}
if in_mcp_section && !inserted {
let is_child = line.starts_with(" ") && !line.trim().is_empty();
let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
if is_child {
saw_mcp_child = true;
out.push_str(line);
out.push('\n');
continue;
}
if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
out.push_str(lean_ctx_block);
out.push('\n');
inserted = true;
in_mcp_section = false;
}
}
out.push_str(line);
out.push('\n');
}
if in_mcp_section && !inserted {
out.push_str(lean_ctx_block);
out.push('\n');
inserted = true;
}
if !inserted {
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str("\nmcp_servers:\n");
out.push_str(lean_ctx_block);
out.push('\n');
}
out
}
fn write_qoder_settings(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let data_dir = default_data_dir()?;
let desired = serde_json::json!({
"command": binary,
"args": [],
"env": {
"LEAN_CTX_DATA_DIR": data_dir,
"LEAN_CTX_FULL_TOOLS": "1"
}
});
if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
let mut json = match crate::core::jsonc::parse_jsonc(&content) {
Ok(v) => v,
Err(e) => {
if !opts.overwrite_invalid {
return Err(e.to_string());
}
backup_invalid_file(&target.config_path)?;
return write_mcp_json_fresh(
&target.config_path,
&desired,
Some("overwrote invalid JSON".to_string()),
);
}
};
let obj = json
.as_object_mut()
.ok_or_else(|| "root JSON must be an object".to_string())?;
let servers = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let servers_obj = servers
.as_object_mut()
.ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
let existing = servers_obj.get("lean-ctx").cloned();
if existing.as_ref() == Some(&desired) {
return Ok(WriteResult {
action: WriteAction::Already,
note: None,
});
}
servers_obj.insert("lean-ctx".to_string(), desired);
let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
return Ok(WriteResult {
action: WriteAction::Updated,
note: None,
});
}
write_mcp_json_fresh(&target.config_path, &desired, None)
}
fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let parent = path
.parent()
.ok_or_else(|| "invalid path (no parent directory)".to_string())?;
let filename = path
.file_name()
.ok_or_else(|| "invalid path (no filename)".to_string())?
.to_string_lossy();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
std::fs::rename(path, bak).map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
EditorTarget {
name,
agent_key: "test".to_string(),
config_path: path,
detect_path: PathBuf::from("/nonexistent"),
config_type: ty,
}
}
#[test]
fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
std::fs::write(
&path,
r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
)
.unwrap();
let t = target("test", path.clone(), ConfigType::McpJson);
let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
assert_eq!(res.action, WriteAction::Updated);
let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
assert_eq!(
json["mcpServers"]["lean-ctx"]["command"],
"/new/path/lean-ctx"
);
assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
}
#[test]
fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
std::fs::write(
&path,
r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
)
.unwrap();
let t = target("Cursor", path.clone(), ConfigType::McpJson);
let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
assert_eq!(res.action, WriteAction::Updated);
let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
assert_eq!(
json["mcpServers"]["lean-ctx"]["command"],
"/new/path/lean-ctx"
);
assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
assert!(
json["mcpServers"]["lean-ctx"]["autoApprove"]
.as_array()
.unwrap()
.len()
> 5
);
}
#[test]
fn crush_config_writes_mcp_root() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("crush.json");
std::fs::write(
&path,
r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
)
.unwrap();
let t = target("test", path.clone(), ConfigType::Crush);
let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
assert_eq!(res.action, WriteAction::Updated);
let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
}
#[test]
fn codex_toml_upserts_existing_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"[mcp_servers.lean-ctx]
command = "old"
args = ["x"]
"#,
)
.unwrap();
let t = target("test", path.clone(), ConfigType::Codex);
let res = write_codex_config(&t, "new").unwrap();
assert_eq!(res.action, WriteAction::Updated);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains(r#"command = "new""#));
assert!(content.contains("args = []"));
}
#[test]
fn upsert_codex_toml_inserts_new_section_when_missing() {
let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
assert!(updated.contains("[mcp_servers.lean-ctx]"));
assert!(updated.contains("command = \"lean-ctx\""));
assert!(updated.contains("args = []"));
}
#[test]
fn codex_toml_uses_single_quotes_for_backslash_paths() {
let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
let updated = upsert_codex_toml("", win_path);
assert!(
updated.contains(&format!("command = '{win_path}'")),
"Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
);
}
#[test]
fn codex_toml_uses_double_quotes_for_unix_paths() {
let unix_path = "/usr/local/bin/lean-ctx";
let updated = upsert_codex_toml("", unix_path);
assert!(
updated.contains(&format!("command = \"{unix_path}\"")),
"Unix paths should use double quotes: {updated}"
);
}
#[test]
fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
let input = "\
[mcp_servers.lean-ctx.tools.ctx_multi_read]
approval_mode = \"approve\"
[mcp_servers.lean-ctx.tools.ctx_read]
approval_mode = \"approve\"
";
let updated = upsert_codex_toml(input, "lean-ctx");
let parent_pos = updated
.find("[mcp_servers.lean-ctx]\n")
.expect("parent section must be inserted");
let tools_pos = updated
.find("[mcp_servers.lean-ctx.tools.")
.expect("tool sub-tables must be preserved");
assert!(
parent_pos < tools_pos,
"parent must come before tool sub-tables:\n{updated}"
);
assert!(updated.contains("command = \"lean-ctx\""));
assert!(updated.contains("args = []"));
assert!(updated.contains("approval_mode = \"approve\""));
}
#[test]
fn upsert_codex_toml_handles_issue_191_windows_scenario() {
let input = "\
[mcp_servers.lean-ctx.tools.ctx_multi_read]
approval_mode = \"approve\"
[mcp_servers.lean-ctx.tools.ctx_read]
approval_mode = \"approve\"
[mcp_servers.lean-ctx.tools.ctx_search]
approval_mode = \"approve\"
[mcp_servers.lean-ctx.tools.ctx_tree]
approval_mode = \"approve\"
";
let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
let updated = upsert_codex_toml(input, win_path);
assert!(
updated.contains(&format!("command = '{win_path}'")),
"Windows path must use single quotes: {updated}"
);
let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
assert!(parent_pos < first_tool);
assert_eq!(
updated.matches("[mcp_servers.lean-ctx]\n").count(),
1,
"parent section must appear exactly once"
);
}
#[test]
fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
let input = "\
[mcp_servers.lean-ctx]
command = \"old\"
args = [\"x\"]
[mcp_servers.lean-ctx.tools.ctx_read]
approval_mode = \"approve\"
";
let updated = upsert_codex_toml(input, "new");
assert_eq!(
updated.matches("[mcp_servers.lean-ctx]").count(),
1,
"must not duplicate parent section"
);
assert!(updated.contains("command = \"new\""));
assert!(updated.contains("args = []"));
assert!(updated.contains("approval_mode = \"approve\""));
}
#[test]
fn auto_approve_contains_core_tools() {
let tools = auto_approve_tools();
assert!(tools.contains(&"ctx_read"));
assert!(tools.contains(&"ctx_shell"));
assert!(tools.contains(&"ctx_search"));
assert!(tools.contains(&"ctx_workflow"));
assert!(tools.contains(&"ctx_cost"));
}
#[test]
fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
std::fs::write(
&path,
r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
)
.unwrap();
let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
assert_eq!(res.action, WriteAction::Updated);
let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
assert_eq!(
json["mcpServers"]["lean-ctx"]["args"],
serde_json::json!([])
);
assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
.as_str()
.is_some_and(|s| !s.trim().is_empty()));
assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
}
#[test]
fn qoder_mcp_config_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
assert_eq!(first.action, WriteAction::Created);
assert_eq!(second.action, WriteAction::Already);
}
#[test]
fn qoder_mcp_config_creates_missing_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir
.path()
.join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
assert_eq!(res.action, WriteAction::Created);
let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
}
#[test]
fn antigravity_config_omits_auto_approve() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_config.json");
let t = EditorTarget {
name: "Antigravity",
agent_key: "gemini".to_string(),
config_path: path.clone(),
detect_path: PathBuf::from("/nonexistent"),
config_type: ConfigType::McpJson,
};
let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
assert_eq!(res.action, WriteAction::Created);
let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
assert_eq!(
json["mcpServers"]["lean-ctx"]["command"],
"/usr/local/bin/lean-ctx"
);
}
#[test]
fn hermes_yaml_inserts_into_existing_mcp_servers() {
let existing = "model: anthropic/claude-sonnet-4\n\nmcp_servers:\n github:\n command: \"npx\"\n args: [\"-y\", \"@modelcontextprotocol/server-github\"]\n\ntool_allowlist:\n - terminal\n";
let block = " lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
let result = upsert_hermes_yaml_mcp(existing, block);
assert!(result.contains("lean-ctx"));
assert!(result.contains("model: anthropic/claude-sonnet-4"));
assert!(result.contains("tool_allowlist:"));
assert!(result.contains("github:"));
}
#[test]
fn hermes_yaml_creates_mcp_servers_section() {
let existing = "model: openai/gpt-4o\n";
let block = " lean-ctx:\n command: \"lean-ctx\"";
let result = upsert_hermes_yaml_mcp(existing, block);
assert!(result.contains("mcp_servers:"));
assert!(result.contains("lean-ctx"));
assert!(result.contains("model: openai/gpt-4o"));
}
#[test]
fn hermes_yaml_skips_if_already_present() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
std::fs::write(
&path,
"mcp_servers:\n lean-ctx:\n command: \"lean-ctx\"\n",
)
.unwrap();
let t = target("test", path.clone(), ConfigType::HermesYaml);
let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
assert_eq!(res.action, WriteAction::Already);
}
#[test]
fn remove_codex_section_also_removes_env_subtable() {
let input = "\
[other]
x = 1
[mcp_servers.lean-ctx]
args = []
command = \"/usr/local/bin/lean-ctx\"
[mcp_servers.lean-ctx.env]
LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
[features]
codex_hooks = true
";
let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
assert!(
!result.contains("[mcp_servers.lean-ctx]"),
"parent section must be removed"
);
assert!(
!result.contains("LEAN_CTX_DATA_DIR"),
"env sub-table must be removed too"
);
assert!(result.contains("[other]"), "unrelated sections preserved");
assert!(
result.contains("[features]"),
"sections after must be preserved"
);
}
#[test]
fn remove_codex_section_preserves_other_mcp_servers() {
let input = "\
[mcp_servers.lean-ctx]
command = \"lean-ctx\"
[mcp_servers.lean-ctx.env]
X = \"1\"
[mcp_servers.other]
command = \"other\"
";
let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
assert!(!result.contains("[mcp_servers.lean-ctx]"));
assert!(
result.contains("[mcp_servers.other]"),
"other MCP servers must be preserved"
);
assert!(result.contains("command = \"other\""));
}
#[test]
fn remove_codex_section_does_not_remove_similarly_named_server() {
let input = "\
[mcp_servers.lean-ctx]
command = \"lean-ctx\"
[mcp_servers.lean-ctx-probe]
command = \"probe\"
";
let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
assert!(
!result.contains("[mcp_servers.lean-ctx]\n"),
"target section must be removed"
);
assert!(
result.contains("[mcp_servers.lean-ctx-probe]"),
"similarly-named server must NOT be removed"
);
assert!(result.contains("command = \"probe\""));
}
#[test]
fn remove_codex_section_handles_no_match() {
let input = "[other]\nx = 1\n";
let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
assert_eq!(result, "[other]\nx = 1\n");
}
}