Skip to main content

lean_ctx/core/editor_registry/
writers.rs

1use serde_json::Value;
2
3use super::types::{ConfigType, EditorTarget};
4
5fn toml_quote(value: &str) -> String {
6    if value.contains('\\') {
7        format!("'{value}'")
8    } else {
9        format!("\"{value}\"")
10    }
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WriteAction {
15    Created,
16    Updated,
17    Already,
18}
19
20#[derive(Debug, Clone, Copy, Default)]
21pub struct WriteOptions {
22    pub overwrite_invalid: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WriteResult {
27    pub action: WriteAction,
28    pub note: Option<String>,
29}
30
31pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
32    write_config_with_options(target, binary, WriteOptions::default())
33}
34
35pub fn write_config_with_options(
36    target: &EditorTarget,
37    binary: &str,
38    opts: WriteOptions,
39) -> Result<WriteResult, String> {
40    if let Some(parent) = target.config_path.parent() {
41        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
42    }
43
44    match target.config_type {
45        ConfigType::McpJson => write_mcp_json(target, binary, opts),
46        ConfigType::Zed => write_zed_config(target, binary, opts),
47        ConfigType::Codex => write_codex_config(target, binary),
48        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
49        ConfigType::OpenCode => write_opencode_config(target, binary, opts),
50        ConfigType::Crush => write_crush_config(target, binary, opts),
51        ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
52        ConfigType::Amp => write_amp_config(target, binary, opts),
53        ConfigType::HermesYaml => write_hermes_yaml(target, binary, opts),
54        ConfigType::GeminiSettings => write_gemini_settings(target, binary, opts),
55        ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
56    }
57}
58
59pub fn remove_lean_ctx_mcp_server(
60    path: &std::path::Path,
61    opts: WriteOptions,
62) -> Result<WriteResult, String> {
63    if !path.exists() {
64        return Ok(WriteResult {
65            action: WriteAction::Already,
66            note: Some("mcp.json not found".to_string()),
67        });
68    }
69
70    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
71    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
72        Ok(v) => v,
73        Err(e) => {
74            if !opts.overwrite_invalid {
75                return Err(e.to_string());
76            }
77            backup_invalid_file(path)?;
78            return Ok(WriteResult {
79                action: WriteAction::Updated,
80                note: Some("backed up invalid JSON; did not modify".to_string()),
81            });
82        }
83    };
84
85    let obj = json
86        .as_object_mut()
87        .ok_or_else(|| "root JSON must be an object".to_string())?;
88
89    let Some(servers) = obj.get_mut("mcpServers") else {
90        return Ok(WriteResult {
91            action: WriteAction::Already,
92            note: Some("no mcpServers key".to_string()),
93        });
94    };
95    let servers_obj = servers
96        .as_object_mut()
97        .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
98
99    if servers_obj.remove("lean-ctx").is_none() {
100        return Ok(WriteResult {
101            action: WriteAction::Already,
102            note: Some("lean-ctx not configured".to_string()),
103        });
104    }
105
106    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
107    crate::config_io::write_atomic_with_backup(path, &formatted)?;
108    Ok(WriteResult {
109        action: WriteAction::Updated,
110        note: Some("removed lean-ctx from mcpServers".to_string()),
111    })
112}
113
114pub fn remove_lean_ctx_server(
115    target: &EditorTarget,
116    opts: WriteOptions,
117) -> Result<WriteResult, String> {
118    match target.config_type {
119        ConfigType::McpJson
120        | ConfigType::JetBrains
121        | ConfigType::GeminiSettings
122        | ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
123        ConfigType::VsCodeMcp => remove_lean_ctx_vscode_server(&target.config_path, opts),
124        ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
125        ConfigType::OpenCode | ConfigType::Crush => {
126            remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
127        }
128        ConfigType::Zed => {
129            remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
130        }
131        ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
132        ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
133    }
134}
135
136fn remove_lean_ctx_vscode_server(
137    path: &std::path::Path,
138    opts: WriteOptions,
139) -> Result<WriteResult, String> {
140    if !path.exists() {
141        return Ok(WriteResult {
142            action: WriteAction::Already,
143            note: Some("vscode mcp.json not found".to_string()),
144        });
145    }
146
147    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
148    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
149        Ok(v) => v,
150        Err(e) => {
151            if !opts.overwrite_invalid {
152                return Err(e.to_string());
153            }
154            backup_invalid_file(path)?;
155            return Ok(WriteResult {
156                action: WriteAction::Updated,
157                note: Some("backed up invalid JSON; did not modify".to_string()),
158            });
159        }
160    };
161
162    let obj = json
163        .as_object_mut()
164        .ok_or_else(|| "root JSON must be an object".to_string())?;
165
166    let Some(servers) = obj.get_mut("servers") else {
167        return Ok(WriteResult {
168            action: WriteAction::Already,
169            note: Some("no servers key".to_string()),
170        });
171    };
172    let servers_obj = servers
173        .as_object_mut()
174        .ok_or_else(|| "\"servers\" must be an object".to_string())?;
175
176    if servers_obj.remove("lean-ctx").is_none() {
177        return Ok(WriteResult {
178            action: WriteAction::Already,
179            note: Some("lean-ctx not configured".to_string()),
180        });
181    }
182
183    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
184    crate::config_io::write_atomic_with_backup(path, &formatted)?;
185    Ok(WriteResult {
186        action: WriteAction::Updated,
187        note: Some("removed lean-ctx from servers".to_string()),
188    })
189}
190
191fn remove_lean_ctx_amp_server(
192    path: &std::path::Path,
193    opts: WriteOptions,
194) -> Result<WriteResult, String> {
195    if !path.exists() {
196        return Ok(WriteResult {
197            action: WriteAction::Already,
198            note: Some("amp settings not found".to_string()),
199        });
200    }
201
202    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
203    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
204        Ok(v) => v,
205        Err(e) => {
206            if !opts.overwrite_invalid {
207                return Err(e.to_string());
208            }
209            backup_invalid_file(path)?;
210            return Ok(WriteResult {
211                action: WriteAction::Updated,
212                note: Some("backed up invalid JSON; did not modify".to_string()),
213            });
214        }
215    };
216
217    let obj = json
218        .as_object_mut()
219        .ok_or_else(|| "root JSON must be an object".to_string())?;
220    let Some(servers) = obj.get_mut("amp.mcpServers") else {
221        return Ok(WriteResult {
222            action: WriteAction::Already,
223            note: Some("no amp.mcpServers key".to_string()),
224        });
225    };
226    let servers_obj = servers
227        .as_object_mut()
228        .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
229
230    if servers_obj.remove("lean-ctx").is_none() {
231        return Ok(WriteResult {
232            action: WriteAction::Already,
233            note: Some("lean-ctx not configured".to_string()),
234        });
235    }
236
237    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
238    crate::config_io::write_atomic_with_backup(path, &formatted)?;
239    Ok(WriteResult {
240        action: WriteAction::Updated,
241        note: Some("removed lean-ctx from amp.mcpServers".to_string()),
242    })
243}
244
245fn remove_lean_ctx_named_json_server(
246    path: &std::path::Path,
247    container_key: &str,
248    opts: WriteOptions,
249) -> Result<WriteResult, String> {
250    if !path.exists() {
251        return Ok(WriteResult {
252            action: WriteAction::Already,
253            note: Some("config not found".to_string()),
254        });
255    }
256
257    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
258    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
259        Ok(v) => v,
260        Err(e) => {
261            if !opts.overwrite_invalid {
262                return Err(e.to_string());
263            }
264            backup_invalid_file(path)?;
265            return Ok(WriteResult {
266                action: WriteAction::Updated,
267                note: Some("backed up invalid JSON; did not modify".to_string()),
268            });
269        }
270    };
271
272    let obj = json
273        .as_object_mut()
274        .ok_or_else(|| "root JSON must be an object".to_string())?;
275    let Some(container) = obj.get_mut(container_key) else {
276        return Ok(WriteResult {
277            action: WriteAction::Already,
278            note: Some(format!("no {container_key} key")),
279        });
280    };
281    let container_obj = container
282        .as_object_mut()
283        .ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
284
285    if container_obj.remove("lean-ctx").is_none() {
286        return Ok(WriteResult {
287            action: WriteAction::Already,
288            note: Some("lean-ctx not configured".to_string()),
289        });
290    }
291
292    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
293    crate::config_io::write_atomic_with_backup(path, &formatted)?;
294    Ok(WriteResult {
295        action: WriteAction::Updated,
296        note: Some(format!("removed lean-ctx from {container_key}")),
297    })
298}
299
300fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
301    if !path.exists() {
302        return Ok(WriteResult {
303            action: WriteAction::Already,
304            note: Some("codex config not found".to_string()),
305        });
306    }
307    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
308    let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
309    if updated == content {
310        return Ok(WriteResult {
311            action: WriteAction::Already,
312            note: Some("lean-ctx not configured".to_string()),
313        });
314    }
315    crate::config_io::write_atomic_with_backup(path, &updated)?;
316    Ok(WriteResult {
317        action: WriteAction::Updated,
318        note: Some("removed [mcp_servers.lean-ctx]".to_string()),
319    })
320}
321
322fn remove_codex_toml_section(existing: &str, header: &str) -> String {
323    let prefix = header.trim_end_matches(']');
324    let mut out = String::with_capacity(existing.len());
325    let mut skipping = false;
326    for line in existing.lines() {
327        let trimmed = line.trim();
328        if trimmed.starts_with('[') && trimmed.ends_with(']') {
329            if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
330                skipping = true;
331                continue;
332            }
333            skipping = false;
334        }
335        if skipping {
336            continue;
337        }
338        out.push_str(line);
339        out.push('\n');
340    }
341    out
342}
343
344fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
345    if !path.exists() {
346        return Ok(WriteResult {
347            action: WriteAction::Already,
348            note: Some("hermes config not found".to_string()),
349        });
350    }
351    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
352    let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
353    if updated == content {
354        return Ok(WriteResult {
355            action: WriteAction::Already,
356            note: Some("lean-ctx not configured".to_string()),
357        });
358    }
359    crate::config_io::write_atomic_with_backup(path, &updated)?;
360    Ok(WriteResult {
361        action: WriteAction::Updated,
362        note: Some("removed lean-ctx from mcp_servers".to_string()),
363    })
364}
365
366fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
367    let mut out = String::with_capacity(existing.len());
368    let mut in_mcp = false;
369    let mut skipping = false;
370    for line in existing.lines() {
371        let trimmed = line.trim_end();
372        if trimmed == "mcp_servers:" {
373            in_mcp = true;
374            out.push_str(line);
375            out.push('\n');
376            continue;
377        }
378
379        if in_mcp {
380            let is_child = line.starts_with("  ") && !line.starts_with("    ");
381            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
382
383            if is_toplevel {
384                in_mcp = false;
385                skipping = false;
386            }
387
388            if skipping {
389                if is_child || is_toplevel {
390                    skipping = false;
391                    out.push_str(line);
392                    out.push('\n');
393                }
394                continue;
395            }
396
397            if is_child && line.trim() == format!("{name}:") {
398                skipping = true;
399                continue;
400            }
401        }
402
403        out.push_str(line);
404        out.push('\n');
405    }
406    out
407}
408
409pub fn auto_approve_tools() -> Vec<&'static str> {
410    vec![
411        "ctx_read",
412        "ctx_shell",
413        "ctx_search",
414        "ctx_tree",
415        "ctx_overview",
416        "ctx_preload",
417        "ctx_compress",
418        "ctx_metrics",
419        "ctx_session",
420        "ctx_knowledge",
421        "ctx_agent",
422        "ctx_share",
423        "ctx_analyze",
424        "ctx_benchmark",
425        "ctx_cache",
426        "ctx_discover",
427        "ctx_smart_read",
428        "ctx_delta",
429        "ctx_edit",
430        "ctx_dedup",
431        "ctx_fill",
432        "ctx_intent",
433        "ctx_response",
434        "ctx_context",
435        "ctx_graph",
436        "ctx_wrapped",
437        "ctx_multi_read",
438        "ctx_semantic_search",
439        "ctx_symbol",
440        "ctx_outline",
441        "ctx_callers",
442        "ctx_callees",
443        "ctx_callgraph",
444        "ctx_routes",
445        "ctx_graph_diagram",
446        "ctx_cost",
447        "ctx_heatmap",
448        "ctx_task",
449        "ctx_impact",
450        "ctx_architecture",
451        "ctx_workflow",
452        "ctx",
453    ]
454}
455
456fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
457    let mut entry = serde_json::json!({
458        "command": binary,
459        "env": {
460            "LEAN_CTX_DATA_DIR": data_dir
461        }
462    });
463    if include_auto_approve {
464        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
465    }
466    entry
467}
468
469fn supports_auto_approve(target: &EditorTarget) -> bool {
470    crate::core::client_constraints::by_editor_name(target.name)
471        .is_some_and(|c| c.supports_auto_approve)
472}
473
474fn default_data_dir() -> Result<String, String> {
475    Ok(crate::core::data_dir::lean_ctx_data_dir()?
476        .to_string_lossy()
477        .to_string())
478}
479
480fn write_mcp_json(
481    target: &EditorTarget,
482    binary: &str,
483    opts: WriteOptions,
484) -> Result<WriteResult, String> {
485    let data_dir = default_data_dir()?;
486    let include_aa = supports_auto_approve(target);
487    let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
488
489    // Claude Code manages ~/.claude.json and may overwrite it on first start.
490    // Prefer the official CLI integration when available.
491    // Skip when LEAN_CTX_QUIET=1 (bootstrap --json / setup --json) to avoid
492    // spawning `claude mcp add-json` which can stall in non-interactive CI.
493    if (target.agent_key == "claude" || target.name == "Claude Code")
494        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
495    {
496        if let Ok(result) = try_claude_mcp_add(&desired) {
497            return Ok(result);
498        }
499    }
500
501    if target.config_path.exists() {
502        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
503        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
504            Ok(v) => v,
505            Err(e) => {
506                if !opts.overwrite_invalid {
507                    return Err(e.to_string());
508                }
509                backup_invalid_file(&target.config_path)?;
510                return write_mcp_json_fresh(
511                    &target.config_path,
512                    &desired,
513                    Some("overwrote invalid JSON".to_string()),
514                );
515            }
516        };
517        let obj = json
518            .as_object_mut()
519            .ok_or_else(|| "root JSON must be an object".to_string())?;
520
521        let servers = obj
522            .entry("mcpServers")
523            .or_insert_with(|| serde_json::json!({}));
524        let servers_obj = servers
525            .as_object_mut()
526            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
527
528        let existing = servers_obj.get("lean-ctx").cloned();
529        if existing.as_ref() == Some(&desired) {
530            return Ok(WriteResult {
531                action: WriteAction::Already,
532                note: None,
533            });
534        }
535        servers_obj.insert("lean-ctx".to_string(), desired);
536
537        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
538        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
539        return Ok(WriteResult {
540            action: WriteAction::Updated,
541            note: None,
542        });
543    }
544
545    write_mcp_json_fresh(&target.config_path, &desired, None)
546}
547
548fn find_in_path(binary: &str) -> Option<std::path::PathBuf> {
549    let path_var = std::env::var("PATH").ok()?;
550    for dir in std::env::split_paths(&path_var) {
551        let candidate = dir.join(binary);
552        if candidate.is_file() {
553            return Some(candidate);
554        }
555    }
556    None
557}
558
559fn validate_claude_binary() -> Result<std::path::PathBuf, String> {
560    let path = find_in_path("claude").ok_or("claude binary not found in PATH")?;
561
562    let canonical =
563        std::fs::canonicalize(&path).map_err(|e| format!("cannot resolve claude path: {e}"))?;
564
565    let canonical_str = canonical.to_string_lossy();
566    let is_trusted = canonical_str.contains("/.claude/")
567        || canonical_str.contains("\\AppData\\")
568        || canonical_str.contains("/usr/local/bin/")
569        || canonical_str.contains("/opt/homebrew/")
570        || canonical_str.contains("/nix/store/")
571        || canonical_str.contains("/.npm/")
572        || canonical_str.contains("/.nvm/")
573        || canonical_str.contains("/node_modules/.bin/")
574        || std::env::var("LEAN_CTX_TRUST_CLAUDE_PATH").is_ok();
575
576    if !is_trusted {
577        return Err(format!(
578            "claude binary resolved to untrusted path: {canonical_str} — set LEAN_CTX_TRUST_CLAUDE_PATH=1 to override"
579        ));
580    }
581    Ok(canonical)
582}
583
584fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
585    use std::io::Write;
586    use std::process::{Command, Stdio};
587    use std::time::{Duration, Instant};
588
589    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
590
591    let mut cmd = if cfg!(windows) {
592        let mut c = Command::new("cmd");
593        c.args([
594            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
595        ]);
596        c
597    } else {
598        let claude_path = validate_claude_binary()?;
599        let mut c = Command::new(claude_path);
600        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
601        c
602    };
603
604    let mut child = cmd
605        .stdin(Stdio::piped())
606        .stdout(Stdio::null())
607        .stderr(Stdio::null())
608        .spawn()
609        .map_err(|e| e.to_string())?;
610
611    if let Some(mut stdin) = child.stdin.take() {
612        let _ = stdin.write_all(server_json.as_bytes());
613    }
614
615    let deadline = Duration::from_secs(3);
616    let start = Instant::now();
617    loop {
618        match child.try_wait() {
619            Ok(Some(status)) => {
620                return if status.success() {
621                    Ok(WriteResult {
622                        action: WriteAction::Updated,
623                        note: Some("via claude mcp add-json".to_string()),
624                    })
625                } else {
626                    Err("claude mcp add-json failed".to_string())
627                };
628            }
629            Ok(None) => {
630                if start.elapsed() > deadline {
631                    let _ = child.kill();
632                    let _ = child.wait();
633                    return Err("claude mcp add-json timed out".to_string());
634                }
635                std::thread::sleep(Duration::from_millis(20));
636            }
637            Err(e) => return Err(e.to_string()),
638        }
639    }
640}
641
642fn write_mcp_json_fresh(
643    path: &std::path::Path,
644    desired: &Value,
645    note: Option<String>,
646) -> Result<WriteResult, String> {
647    let content = serde_json::to_string_pretty(&serde_json::json!({
648        "mcpServers": { "lean-ctx": desired }
649    }))
650    .map_err(|e| e.to_string())?;
651    crate::config_io::write_atomic_with_backup(path, &content)?;
652    Ok(WriteResult {
653        action: if note.is_some() {
654            WriteAction::Updated
655        } else {
656            WriteAction::Created
657        },
658        note,
659    })
660}
661
662fn write_zed_config(
663    target: &EditorTarget,
664    binary: &str,
665    opts: WriteOptions,
666) -> Result<WriteResult, String> {
667    let desired = serde_json::json!({
668        "source": "custom",
669        "command": binary,
670        "args": [],
671        "env": {}
672    });
673
674    if target.config_path.exists() {
675        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
676        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
677            Ok(v) => v,
678            Err(e) => {
679                if !opts.overwrite_invalid {
680                    return Err(e.to_string());
681                }
682                backup_invalid_file(&target.config_path)?;
683                return write_zed_config_fresh(
684                    &target.config_path,
685                    &desired,
686                    Some("overwrote invalid JSON".to_string()),
687                );
688            }
689        };
690        let obj = json
691            .as_object_mut()
692            .ok_or_else(|| "root JSON must be an object".to_string())?;
693
694        let servers = obj
695            .entry("context_servers")
696            .or_insert_with(|| serde_json::json!({}));
697        let servers_obj = servers
698            .as_object_mut()
699            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
700
701        let existing = servers_obj.get("lean-ctx").cloned();
702        if existing.as_ref() == Some(&desired) {
703            return Ok(WriteResult {
704                action: WriteAction::Already,
705                note: None,
706            });
707        }
708        servers_obj.insert("lean-ctx".to_string(), desired);
709
710        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
711        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
712        return Ok(WriteResult {
713            action: WriteAction::Updated,
714            note: None,
715        });
716    }
717
718    write_zed_config_fresh(&target.config_path, &desired, None)
719}
720
721fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
722    if target.config_path.exists() {
723        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
724        let updated = upsert_codex_toml(&content, binary);
725        if updated == content {
726            return Ok(WriteResult {
727                action: WriteAction::Already,
728                note: None,
729            });
730        }
731        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
732        return Ok(WriteResult {
733            action: WriteAction::Updated,
734            note: None,
735        });
736    }
737
738    let content = format!(
739        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
740        toml_quote(binary)
741    );
742    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
743    Ok(WriteResult {
744        action: WriteAction::Created,
745        note: None,
746    })
747}
748
749fn write_zed_config_fresh(
750    path: &std::path::Path,
751    desired: &Value,
752    note: Option<String>,
753) -> Result<WriteResult, String> {
754    let content = serde_json::to_string_pretty(&serde_json::json!({
755        "context_servers": { "lean-ctx": desired }
756    }))
757    .map_err(|e| e.to_string())?;
758    crate::config_io::write_atomic_with_backup(path, &content)?;
759    Ok(WriteResult {
760        action: if note.is_some() {
761            WriteAction::Updated
762        } else {
763            WriteAction::Created
764        },
765        note,
766    })
767}
768
769fn write_vscode_mcp(
770    target: &EditorTarget,
771    binary: &str,
772    opts: WriteOptions,
773) -> Result<WriteResult, String> {
774    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
775        .map(|d| d.to_string_lossy().to_string())
776        .unwrap_or_default();
777    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
778
779    if target.config_path.exists() {
780        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
781        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
782            Ok(v) => v,
783            Err(e) => {
784                if !opts.overwrite_invalid {
785                    return Err(e.to_string());
786                }
787                backup_invalid_file(&target.config_path)?;
788                return write_vscode_mcp_fresh(
789                    &target.config_path,
790                    binary,
791                    Some("overwrote invalid JSON".to_string()),
792                );
793            }
794        };
795        let obj = json
796            .as_object_mut()
797            .ok_or_else(|| "root JSON must be an object".to_string())?;
798
799        let servers = obj
800            .entry("servers")
801            .or_insert_with(|| serde_json::json!({}));
802        let servers_obj = servers
803            .as_object_mut()
804            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
805
806        let existing = servers_obj.get("lean-ctx").cloned();
807        if existing.as_ref() == Some(&desired) {
808            return Ok(WriteResult {
809                action: WriteAction::Already,
810                note: None,
811            });
812        }
813        servers_obj.insert("lean-ctx".to_string(), desired);
814
815        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
816        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
817        return Ok(WriteResult {
818            action: WriteAction::Updated,
819            note: None,
820        });
821    }
822
823    write_vscode_mcp_fresh(&target.config_path, binary, None)
824}
825
826fn write_vscode_mcp_fresh(
827    path: &std::path::Path,
828    binary: &str,
829    note: Option<String>,
830) -> Result<WriteResult, String> {
831    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
832        .map(|d| d.to_string_lossy().to_string())
833        .unwrap_or_default();
834    let content = serde_json::to_string_pretty(&serde_json::json!({
835        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
836    }))
837    .map_err(|e| e.to_string())?;
838    crate::config_io::write_atomic_with_backup(path, &content)?;
839    Ok(WriteResult {
840        action: if note.is_some() {
841            WriteAction::Updated
842        } else {
843            WriteAction::Created
844        },
845        note,
846    })
847}
848
849fn write_opencode_config(
850    target: &EditorTarget,
851    binary: &str,
852    opts: WriteOptions,
853) -> Result<WriteResult, String> {
854    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
855        .map(|d| d.to_string_lossy().to_string())
856        .unwrap_or_default();
857    let desired = serde_json::json!({
858        "type": "local",
859        "command": [binary],
860        "enabled": true,
861        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
862    });
863
864    if target.config_path.exists() {
865        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
866        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
867            Ok(v) => v,
868            Err(e) => {
869                if !opts.overwrite_invalid {
870                    return Err(e.to_string());
871                }
872                backup_invalid_file(&target.config_path)?;
873                return write_opencode_fresh(
874                    &target.config_path,
875                    binary,
876                    Some("overwrote invalid JSON".to_string()),
877                );
878            }
879        };
880        let obj = json
881            .as_object_mut()
882            .ok_or_else(|| "root JSON must be an object".to_string())?;
883        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
884        let mcp_obj = mcp
885            .as_object_mut()
886            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
887
888        let existing = mcp_obj.get("lean-ctx").cloned();
889        if existing.as_ref() == Some(&desired) {
890            return Ok(WriteResult {
891                action: WriteAction::Already,
892                note: None,
893            });
894        }
895        mcp_obj.insert("lean-ctx".to_string(), desired);
896
897        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
898        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
899        return Ok(WriteResult {
900            action: WriteAction::Updated,
901            note: None,
902        });
903    }
904
905    write_opencode_fresh(&target.config_path, binary, None)
906}
907
908fn write_opencode_fresh(
909    path: &std::path::Path,
910    binary: &str,
911    note: Option<String>,
912) -> Result<WriteResult, String> {
913    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
914        .map(|d| d.to_string_lossy().to_string())
915        .unwrap_or_default();
916    let content = serde_json::to_string_pretty(&serde_json::json!({
917        "$schema": "https://opencode.ai/config.json",
918        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
919    }))
920    .map_err(|e| e.to_string())?;
921    crate::config_io::write_atomic_with_backup(path, &content)?;
922    Ok(WriteResult {
923        action: if note.is_some() {
924            WriteAction::Updated
925        } else {
926            WriteAction::Created
927        },
928        note,
929    })
930}
931
932fn write_jetbrains_config(
933    target: &EditorTarget,
934    binary: &str,
935    opts: WriteOptions,
936) -> Result<WriteResult, String> {
937    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
938        .map(|d| d.to_string_lossy().to_string())
939        .unwrap_or_default();
940    // JetBrains AI Assistant expects an "mcpServers" mapping in the JSON snippet
941    // you paste into Settings | Tools | AI Assistant | Model Context Protocol (MCP).
942    // We write that snippet to a file for easy copy/paste.
943    let desired = serde_json::json!({
944        "command": binary,
945        "args": [],
946        "env": { "LEAN_CTX_DATA_DIR": data_dir }
947    });
948
949    if target.config_path.exists() {
950        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
951        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
952            Ok(v) => v,
953            Err(e) => {
954                if !opts.overwrite_invalid {
955                    return Err(e.to_string());
956                }
957                backup_invalid_file(&target.config_path)?;
958                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
959                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
960                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
961                return Ok(WriteResult {
962                    action: WriteAction::Updated,
963                    note: Some(
964                        "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
965                            .to_string(),
966                    ),
967                });
968            }
969        };
970        let obj = json
971            .as_object_mut()
972            .ok_or_else(|| "root JSON must be an object".to_string())?;
973
974        let servers = obj
975            .entry("mcpServers")
976            .or_insert_with(|| serde_json::json!({}));
977        let servers_obj = servers
978            .as_object_mut()
979            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
980
981        let existing = servers_obj.get("lean-ctx").cloned();
982        if existing.as_ref() == Some(&desired) {
983            return Ok(WriteResult {
984                action: WriteAction::Already,
985                note: Some("paste this snippet into JetBrains MCP settings".to_string()),
986            });
987        }
988        servers_obj.insert("lean-ctx".to_string(), desired);
989
990        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
991        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
992        return Ok(WriteResult {
993            action: WriteAction::Updated,
994            note: Some("paste this snippet into JetBrains MCP settings".to_string()),
995        });
996    }
997
998    let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
999    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1000    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1001    Ok(WriteResult {
1002        action: WriteAction::Created,
1003        note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1004    })
1005}
1006
1007fn write_amp_config(
1008    target: &EditorTarget,
1009    binary: &str,
1010    opts: WriteOptions,
1011) -> Result<WriteResult, String> {
1012    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1013        .map(|d| d.to_string_lossy().to_string())
1014        .unwrap_or_default();
1015    let entry = serde_json::json!({
1016        "command": binary,
1017        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1018    });
1019
1020    if target.config_path.exists() {
1021        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1022        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1023            Ok(v) => v,
1024            Err(e) => {
1025                if !opts.overwrite_invalid {
1026                    return Err(e.to_string());
1027                }
1028                backup_invalid_file(&target.config_path)?;
1029                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1030                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1031                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1032                return Ok(WriteResult {
1033                    action: WriteAction::Updated,
1034                    note: Some("overwrote invalid JSON".to_string()),
1035                });
1036            }
1037        };
1038        let obj = json
1039            .as_object_mut()
1040            .ok_or_else(|| "root JSON must be an object".to_string())?;
1041        let servers = obj
1042            .entry("amp.mcpServers")
1043            .or_insert_with(|| serde_json::json!({}));
1044        let servers_obj = servers
1045            .as_object_mut()
1046            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1047
1048        let existing = servers_obj.get("lean-ctx").cloned();
1049        if existing.as_ref() == Some(&entry) {
1050            return Ok(WriteResult {
1051                action: WriteAction::Already,
1052                note: None,
1053            });
1054        }
1055        servers_obj.insert("lean-ctx".to_string(), entry);
1056
1057        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1058        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1059        return Ok(WriteResult {
1060            action: WriteAction::Updated,
1061            note: None,
1062        });
1063    }
1064
1065    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1066    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1067    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1068    Ok(WriteResult {
1069        action: WriteAction::Created,
1070        note: None,
1071    })
1072}
1073
1074fn write_crush_config(
1075    target: &EditorTarget,
1076    binary: &str,
1077    opts: WriteOptions,
1078) -> Result<WriteResult, String> {
1079    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1080        .map(|d| d.to_string_lossy().to_string())
1081        .unwrap_or_default();
1082    let desired = serde_json::json!({
1083        "type": "stdio",
1084        "command": binary,
1085        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1086    });
1087
1088    if target.config_path.exists() {
1089        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1090        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1091            Ok(v) => v,
1092            Err(e) => {
1093                if !opts.overwrite_invalid {
1094                    return Err(e.to_string());
1095                }
1096                backup_invalid_file(&target.config_path)?;
1097                return write_crush_fresh(
1098                    &target.config_path,
1099                    &desired,
1100                    Some("overwrote invalid JSON".to_string()),
1101                );
1102            }
1103        };
1104        let obj = json
1105            .as_object_mut()
1106            .ok_or_else(|| "root JSON must be an object".to_string())?;
1107        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1108        let mcp_obj = mcp
1109            .as_object_mut()
1110            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1111
1112        let existing = mcp_obj.get("lean-ctx").cloned();
1113        if existing.as_ref() == Some(&desired) {
1114            return Ok(WriteResult {
1115                action: WriteAction::Already,
1116                note: None,
1117            });
1118        }
1119        mcp_obj.insert("lean-ctx".to_string(), desired);
1120
1121        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1122        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1123        return Ok(WriteResult {
1124            action: WriteAction::Updated,
1125            note: None,
1126        });
1127    }
1128
1129    write_crush_fresh(&target.config_path, &desired, None)
1130}
1131
1132fn write_crush_fresh(
1133    path: &std::path::Path,
1134    desired: &Value,
1135    note: Option<String>,
1136) -> Result<WriteResult, String> {
1137    let content = serde_json::to_string_pretty(&serde_json::json!({
1138        "mcp": { "lean-ctx": desired }
1139    }))
1140    .map_err(|e| e.to_string())?;
1141    crate::config_io::write_atomic_with_backup(path, &content)?;
1142    Ok(WriteResult {
1143        action: if note.is_some() {
1144            WriteAction::Updated
1145        } else {
1146            WriteAction::Created
1147        },
1148        note,
1149    })
1150}
1151
1152fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1153    let mut out = String::with_capacity(existing.len() + 128);
1154    let mut in_section = false;
1155    let mut saw_section = false;
1156    let mut wrote_command = false;
1157    let mut wrote_args = false;
1158    let mut inserted_parent_before_subtable = false;
1159
1160    let parent_block = format!(
1161        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
1162        toml_quote(binary)
1163    );
1164
1165    for line in existing.lines() {
1166        let trimmed = line.trim();
1167        if trimmed == "[]" {
1168            continue;
1169        }
1170        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1171            if in_section && !wrote_command {
1172                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1173                wrote_command = true;
1174            }
1175            if in_section && !wrote_args {
1176                out.push_str("args = []\n");
1177                wrote_args = true;
1178            }
1179            in_section = trimmed == "[mcp_servers.lean-ctx]";
1180            if in_section {
1181                saw_section = true;
1182            } else if !saw_section
1183                && !inserted_parent_before_subtable
1184                && trimmed.starts_with("[mcp_servers.lean-ctx.")
1185            {
1186                out.push_str(&parent_block);
1187                inserted_parent_before_subtable = true;
1188            }
1189            out.push_str(line);
1190            out.push('\n');
1191            continue;
1192        }
1193
1194        if in_section {
1195            if trimmed.starts_with("command") && trimmed.contains('=') {
1196                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1197                wrote_command = true;
1198                continue;
1199            }
1200            if trimmed.starts_with("args") && trimmed.contains('=') {
1201                out.push_str("args = []\n");
1202                wrote_args = true;
1203                continue;
1204            }
1205        }
1206
1207        out.push_str(line);
1208        out.push('\n');
1209    }
1210
1211    if saw_section {
1212        if in_section && !wrote_command {
1213            out.push_str(&format!("command = {}\n", toml_quote(binary)));
1214        }
1215        if in_section && !wrote_args {
1216            out.push_str("args = []\n");
1217        }
1218        return out;
1219    }
1220
1221    if inserted_parent_before_subtable {
1222        return out;
1223    }
1224
1225    if !out.ends_with('\n') {
1226        out.push('\n');
1227    }
1228    out.push_str("\n[mcp_servers.lean-ctx]\n");
1229    out.push_str(&format!("command = {}\n", toml_quote(binary)));
1230    out.push_str("args = []\n");
1231    out
1232}
1233
1234fn write_gemini_settings(
1235    target: &EditorTarget,
1236    binary: &str,
1237    opts: WriteOptions,
1238) -> Result<WriteResult, String> {
1239    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1240        .map(|d| d.to_string_lossy().to_string())
1241        .unwrap_or_default();
1242    let entry = serde_json::json!({
1243        "command": binary,
1244        "env": { "LEAN_CTX_DATA_DIR": data_dir },
1245        "trust": true,
1246    });
1247
1248    if target.config_path.exists() {
1249        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1250        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1251            Ok(v) => v,
1252            Err(e) => {
1253                if !opts.overwrite_invalid {
1254                    return Err(e.to_string());
1255                }
1256                backup_invalid_file(&target.config_path)?;
1257                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1258                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1259                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1260                return Ok(WriteResult {
1261                    action: WriteAction::Updated,
1262                    note: Some("overwrote invalid JSON".to_string()),
1263                });
1264            }
1265        };
1266        let obj = json
1267            .as_object_mut()
1268            .ok_or_else(|| "root JSON must be an object".to_string())?;
1269        let servers = obj
1270            .entry("mcpServers")
1271            .or_insert_with(|| serde_json::json!({}));
1272        let servers_obj = servers
1273            .as_object_mut()
1274            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1275
1276        let existing = servers_obj.get("lean-ctx").cloned();
1277        if existing.as_ref() == Some(&entry) {
1278            return Ok(WriteResult {
1279                action: WriteAction::Already,
1280                note: None,
1281            });
1282        }
1283        servers_obj.insert("lean-ctx".to_string(), entry);
1284
1285        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1286        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1287        return Ok(WriteResult {
1288            action: WriteAction::Updated,
1289            note: None,
1290        });
1291    }
1292
1293    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1294    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1295    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1296    Ok(WriteResult {
1297        action: WriteAction::Created,
1298        note: None,
1299    })
1300}
1301
1302fn write_hermes_yaml(
1303    target: &EditorTarget,
1304    binary: &str,
1305    _opts: WriteOptions,
1306) -> Result<WriteResult, String> {
1307    let data_dir = default_data_dir()?;
1308
1309    let lean_ctx_block = format!(
1310        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
1311    );
1312
1313    if target.config_path.exists() {
1314        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1315
1316        if content.contains("lean-ctx") {
1317            return Ok(WriteResult {
1318                action: WriteAction::Already,
1319                note: None,
1320            });
1321        }
1322
1323        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1324        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1325        return Ok(WriteResult {
1326            action: WriteAction::Updated,
1327            note: None,
1328        });
1329    }
1330
1331    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1332    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1333    Ok(WriteResult {
1334        action: WriteAction::Created,
1335        note: None,
1336    })
1337}
1338
1339fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1340    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1341    let mut in_mcp_section = false;
1342    let mut saw_mcp_child = false;
1343    let mut inserted = false;
1344    let lines: Vec<&str> = existing.lines().collect();
1345
1346    for line in &lines {
1347        if !inserted && line.trim_end() == "mcp_servers:" {
1348            in_mcp_section = true;
1349            out.push_str(line);
1350            out.push('\n');
1351            continue;
1352        }
1353
1354        if in_mcp_section && !inserted {
1355            let is_child = line.starts_with("  ") && !line.trim().is_empty();
1356            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1357
1358            if is_child {
1359                saw_mcp_child = true;
1360                out.push_str(line);
1361                out.push('\n');
1362                continue;
1363            }
1364
1365            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1366                out.push_str(lean_ctx_block);
1367                out.push('\n');
1368                inserted = true;
1369                in_mcp_section = false;
1370            }
1371        }
1372
1373        out.push_str(line);
1374        out.push('\n');
1375    }
1376
1377    if in_mcp_section && !inserted {
1378        out.push_str(lean_ctx_block);
1379        out.push('\n');
1380        inserted = true;
1381    }
1382
1383    if !inserted {
1384        if !out.ends_with('\n') {
1385            out.push('\n');
1386        }
1387        out.push_str("\nmcp_servers:\n");
1388        out.push_str(lean_ctx_block);
1389        out.push('\n');
1390    }
1391
1392    out
1393}
1394
1395fn write_qoder_settings(
1396    target: &EditorTarget,
1397    binary: &str,
1398    opts: WriteOptions,
1399) -> Result<WriteResult, String> {
1400    let data_dir = default_data_dir()?;
1401    let desired = serde_json::json!({
1402        "command": binary,
1403        "args": [],
1404        "env": {
1405            "LEAN_CTX_DATA_DIR": data_dir,
1406            "LEAN_CTX_FULL_TOOLS": "1"
1407        }
1408    });
1409
1410    if target.config_path.exists() {
1411        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1412        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1413            Ok(v) => v,
1414            Err(e) => {
1415                if !opts.overwrite_invalid {
1416                    return Err(e.to_string());
1417                }
1418                backup_invalid_file(&target.config_path)?;
1419                return write_mcp_json_fresh(
1420                    &target.config_path,
1421                    &desired,
1422                    Some("overwrote invalid JSON".to_string()),
1423                );
1424            }
1425        };
1426        let obj = json
1427            .as_object_mut()
1428            .ok_or_else(|| "root JSON must be an object".to_string())?;
1429        let servers = obj
1430            .entry("mcpServers")
1431            .or_insert_with(|| serde_json::json!({}));
1432        let servers_obj = servers
1433            .as_object_mut()
1434            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1435
1436        let existing = servers_obj.get("lean-ctx").cloned();
1437        if existing.as_ref() == Some(&desired) {
1438            return Ok(WriteResult {
1439                action: WriteAction::Already,
1440                note: None,
1441            });
1442        }
1443        servers_obj.insert("lean-ctx".to_string(), desired);
1444
1445        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1446        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1447        return Ok(WriteResult {
1448            action: WriteAction::Updated,
1449            note: None,
1450        });
1451    }
1452
1453    write_mcp_json_fresh(&target.config_path, &desired, None)
1454}
1455
1456fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
1457    if !path.exists() {
1458        return Ok(());
1459    }
1460    let parent = path
1461        .parent()
1462        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1463    let filename = path
1464        .file_name()
1465        .ok_or_else(|| "invalid path (no filename)".to_string())?
1466        .to_string_lossy();
1467    let pid = std::process::id();
1468    let nanos = std::time::SystemTime::now()
1469        .duration_since(std::time::UNIX_EPOCH)
1470        .map_or(0, |d| d.as_nanos());
1471    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1472    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
1473    Ok(())
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::*;
1479    use std::path::PathBuf;
1480
1481    fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1482        EditorTarget {
1483            name,
1484            agent_key: "test".to_string(),
1485            config_path: path,
1486            detect_path: PathBuf::from("/nonexistent"),
1487            config_type: ty,
1488        }
1489    }
1490
1491    #[test]
1492    fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1493        let dir = tempfile::tempdir().unwrap();
1494        let path = dir.path().join("mcp.json");
1495        std::fs::write(
1496            &path,
1497            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1498        )
1499        .unwrap();
1500
1501        let t = target("test", path.clone(), ConfigType::McpJson);
1502        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1503        assert_eq!(res.action, WriteAction::Updated);
1504
1505        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1506        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1507        assert_eq!(
1508            json["mcpServers"]["lean-ctx"]["command"],
1509            "/new/path/lean-ctx"
1510        );
1511        assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1512    }
1513
1514    #[test]
1515    fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1516        let dir = tempfile::tempdir().unwrap();
1517        let path = dir.path().join("mcp.json");
1518        std::fs::write(
1519            &path,
1520            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1521        )
1522        .unwrap();
1523
1524        let t = target("Cursor", path.clone(), ConfigType::McpJson);
1525        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1526        assert_eq!(res.action, WriteAction::Updated);
1527
1528        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1529        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1530        assert_eq!(
1531            json["mcpServers"]["lean-ctx"]["command"],
1532            "/new/path/lean-ctx"
1533        );
1534        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1535        assert!(
1536            json["mcpServers"]["lean-ctx"]["autoApprove"]
1537                .as_array()
1538                .unwrap()
1539                .len()
1540                > 5
1541        );
1542    }
1543
1544    #[test]
1545    fn crush_config_writes_mcp_root() {
1546        let dir = tempfile::tempdir().unwrap();
1547        let path = dir.path().join("crush.json");
1548        std::fs::write(
1549            &path,
1550            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1551        )
1552        .unwrap();
1553
1554        let t = target("test", path.clone(), ConfigType::Crush);
1555        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1556        assert_eq!(res.action, WriteAction::Updated);
1557
1558        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1559        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1560        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1561    }
1562
1563    #[test]
1564    fn codex_toml_upserts_existing_section() {
1565        let dir = tempfile::tempdir().unwrap();
1566        let path = dir.path().join("config.toml");
1567        std::fs::write(
1568            &path,
1569            r#"[mcp_servers.lean-ctx]
1570command = "old"
1571args = ["x"]
1572"#,
1573        )
1574        .unwrap();
1575
1576        let t = target("test", path.clone(), ConfigType::Codex);
1577        let res = write_codex_config(&t, "new").unwrap();
1578        assert_eq!(res.action, WriteAction::Updated);
1579
1580        let content = std::fs::read_to_string(&path).unwrap();
1581        assert!(content.contains(r#"command = "new""#));
1582        assert!(content.contains("args = []"));
1583    }
1584
1585    #[test]
1586    fn upsert_codex_toml_inserts_new_section_when_missing() {
1587        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1588        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1589        assert!(updated.contains("command = \"lean-ctx\""));
1590        assert!(updated.contains("args = []"));
1591    }
1592
1593    #[test]
1594    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1595        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1596        let updated = upsert_codex_toml("", win_path);
1597        assert!(
1598            updated.contains(&format!("command = '{win_path}'")),
1599            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1600        );
1601    }
1602
1603    #[test]
1604    fn codex_toml_uses_double_quotes_for_unix_paths() {
1605        let unix_path = "/usr/local/bin/lean-ctx";
1606        let updated = upsert_codex_toml("", unix_path);
1607        assert!(
1608            updated.contains(&format!("command = \"{unix_path}\"")),
1609            "Unix paths should use double quotes: {updated}"
1610        );
1611    }
1612
1613    #[test]
1614    fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1615        let input = "\
1616[mcp_servers.lean-ctx.tools.ctx_multi_read]
1617approval_mode = \"approve\"
1618
1619[mcp_servers.lean-ctx.tools.ctx_read]
1620approval_mode = \"approve\"
1621";
1622        let updated = upsert_codex_toml(input, "lean-ctx");
1623        let parent_pos = updated
1624            .find("[mcp_servers.lean-ctx]\n")
1625            .expect("parent section must be inserted");
1626        let tools_pos = updated
1627            .find("[mcp_servers.lean-ctx.tools.")
1628            .expect("tool sub-tables must be preserved");
1629        assert!(
1630            parent_pos < tools_pos,
1631            "parent must come before tool sub-tables:\n{updated}"
1632        );
1633        assert!(updated.contains("command = \"lean-ctx\""));
1634        assert!(updated.contains("args = []"));
1635        assert!(updated.contains("approval_mode = \"approve\""));
1636    }
1637
1638    #[test]
1639    fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1640        let input = "\
1641[mcp_servers.lean-ctx.tools.ctx_multi_read]
1642approval_mode = \"approve\"
1643
1644[mcp_servers.lean-ctx.tools.ctx_read]
1645approval_mode = \"approve\"
1646
1647[mcp_servers.lean-ctx.tools.ctx_search]
1648approval_mode = \"approve\"
1649
1650[mcp_servers.lean-ctx.tools.ctx_tree]
1651approval_mode = \"approve\"
1652";
1653        let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1654        let updated = upsert_codex_toml(input, win_path);
1655        assert!(
1656            updated.contains(&format!("command = '{win_path}'")),
1657            "Windows path must use single quotes: {updated}"
1658        );
1659        let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1660        let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1661        assert!(parent_pos < first_tool);
1662        assert_eq!(
1663            updated.matches("[mcp_servers.lean-ctx]\n").count(),
1664            1,
1665            "parent section must appear exactly once"
1666        );
1667    }
1668
1669    #[test]
1670    fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1671        let input = "\
1672[mcp_servers.lean-ctx]
1673command = \"old\"
1674args = [\"x\"]
1675
1676[mcp_servers.lean-ctx.tools.ctx_read]
1677approval_mode = \"approve\"
1678";
1679        let updated = upsert_codex_toml(input, "new");
1680        assert_eq!(
1681            updated.matches("[mcp_servers.lean-ctx]").count(),
1682            1,
1683            "must not duplicate parent section"
1684        );
1685        assert!(updated.contains("command = \"new\""));
1686        assert!(updated.contains("args = []"));
1687        assert!(updated.contains("approval_mode = \"approve\""));
1688    }
1689
1690    #[test]
1691    fn auto_approve_contains_core_tools() {
1692        let tools = auto_approve_tools();
1693        assert!(tools.contains(&"ctx_read"));
1694        assert!(tools.contains(&"ctx_shell"));
1695        assert!(tools.contains(&"ctx_search"));
1696        assert!(tools.contains(&"ctx_workflow"));
1697        assert!(tools.contains(&"ctx_cost"));
1698    }
1699
1700    #[test]
1701    fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1702        let dir = tempfile::tempdir().unwrap();
1703        let path = dir.path().join("mcp.json");
1704        std::fs::write(
1705            &path,
1706            r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1707        )
1708        .unwrap();
1709
1710        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1711        let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1712        assert_eq!(res.action, WriteAction::Updated);
1713
1714        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1715        assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1716        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1717        assert_eq!(
1718            json["mcpServers"]["lean-ctx"]["args"],
1719            serde_json::json!([])
1720        );
1721        assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1722            .as_str()
1723            .is_some_and(|s| !s.trim().is_empty()));
1724        assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1725        assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1726        assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1727    }
1728
1729    #[test]
1730    fn qoder_mcp_config_is_idempotent() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let path = dir.path().join("mcp.json");
1733        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1734
1735        let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1736        let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1737
1738        assert_eq!(first.action, WriteAction::Created);
1739        assert_eq!(second.action, WriteAction::Already);
1740    }
1741
1742    #[test]
1743    fn qoder_mcp_config_creates_missing_parent_directories() {
1744        let dir = tempfile::tempdir().unwrap();
1745        let path = dir
1746            .path()
1747            .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
1748        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1749
1750        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
1751
1752        assert_eq!(res.action, WriteAction::Created);
1753        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1754        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1755    }
1756
1757    #[test]
1758    fn antigravity_config_omits_auto_approve() {
1759        let dir = tempfile::tempdir().unwrap();
1760        let path = dir.path().join("mcp_config.json");
1761
1762        let t = EditorTarget {
1763            name: "Antigravity",
1764            agent_key: "gemini".to_string(),
1765            config_path: path.clone(),
1766            detect_path: PathBuf::from("/nonexistent"),
1767            config_type: ConfigType::McpJson,
1768        };
1769        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1770        assert_eq!(res.action, WriteAction::Created);
1771
1772        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1773        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1774        assert_eq!(
1775            json["mcpServers"]["lean-ctx"]["command"],
1776            "/usr/local/bin/lean-ctx"
1777        );
1778    }
1779
1780    #[test]
1781    fn hermes_yaml_inserts_into_existing_mcp_servers() {
1782        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";
1783        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1784        let result = upsert_hermes_yaml_mcp(existing, block);
1785        assert!(result.contains("lean-ctx"));
1786        assert!(result.contains("model: anthropic/claude-sonnet-4"));
1787        assert!(result.contains("tool_allowlist:"));
1788        assert!(result.contains("github:"));
1789    }
1790
1791    #[test]
1792    fn hermes_yaml_creates_mcp_servers_section() {
1793        let existing = "model: openai/gpt-4o\n";
1794        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
1795        let result = upsert_hermes_yaml_mcp(existing, block);
1796        assert!(result.contains("mcp_servers:"));
1797        assert!(result.contains("lean-ctx"));
1798        assert!(result.contains("model: openai/gpt-4o"));
1799    }
1800
1801    #[test]
1802    fn hermes_yaml_skips_if_already_present() {
1803        let dir = tempfile::tempdir().unwrap();
1804        let path = dir.path().join("config.yaml");
1805        std::fs::write(
1806            &path,
1807            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
1808        )
1809        .unwrap();
1810        let t = target("test", path.clone(), ConfigType::HermesYaml);
1811        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1812        assert_eq!(res.action, WriteAction::Already);
1813    }
1814
1815    #[test]
1816    fn remove_codex_section_also_removes_env_subtable() {
1817        let input = "\
1818[other]
1819x = 1
1820
1821[mcp_servers.lean-ctx]
1822args = []
1823command = \"/usr/local/bin/lean-ctx\"
1824
1825[mcp_servers.lean-ctx.env]
1826LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
1827
1828[features]
1829codex_hooks = true
1830";
1831        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1832        assert!(
1833            !result.contains("[mcp_servers.lean-ctx]"),
1834            "parent section must be removed"
1835        );
1836        assert!(
1837            !result.contains("LEAN_CTX_DATA_DIR"),
1838            "env sub-table must be removed too"
1839        );
1840        assert!(result.contains("[other]"), "unrelated sections preserved");
1841        assert!(
1842            result.contains("[features]"),
1843            "sections after must be preserved"
1844        );
1845    }
1846
1847    #[test]
1848    fn remove_codex_section_preserves_other_mcp_servers() {
1849        let input = "\
1850[mcp_servers.lean-ctx]
1851command = \"lean-ctx\"
1852
1853[mcp_servers.lean-ctx.env]
1854X = \"1\"
1855
1856[mcp_servers.other]
1857command = \"other\"
1858";
1859        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1860        assert!(!result.contains("[mcp_servers.lean-ctx]"));
1861        assert!(
1862            result.contains("[mcp_servers.other]"),
1863            "other MCP servers must be preserved"
1864        );
1865        assert!(result.contains("command = \"other\""));
1866    }
1867
1868    #[test]
1869    fn remove_codex_section_does_not_remove_similarly_named_server() {
1870        let input = "\
1871[mcp_servers.lean-ctx]
1872command = \"lean-ctx\"
1873
1874[mcp_servers.lean-ctx-probe]
1875command = \"probe\"
1876";
1877        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1878        assert!(
1879            !result.contains("[mcp_servers.lean-ctx]\n"),
1880            "target section must be removed"
1881        );
1882        assert!(
1883            result.contains("[mcp_servers.lean-ctx-probe]"),
1884            "similarly-named server must NOT be removed"
1885        );
1886        assert!(result.contains("command = \"probe\""));
1887    }
1888
1889    #[test]
1890    fn remove_codex_section_handles_no_match() {
1891        let input = "[other]\nx = 1\n";
1892        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1893        assert_eq!(result, "[other]\nx = 1\n");
1894    }
1895}