Skip to main content

lean_ctx/core/editor_registry/
writers.rs

1use serde_json::Value;
2
3use super::types::{ConfigType, EditorTarget};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WriteAction {
7    Created,
8    Updated,
9    Already,
10}
11
12#[derive(Debug, Clone, Copy, Default)]
13pub struct WriteOptions {
14    pub overwrite_invalid: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WriteResult {
19    pub action: WriteAction,
20    pub note: Option<String>,
21}
22
23pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
24    write_config_with_options(target, binary, WriteOptions::default())
25}
26
27pub fn write_config_with_options(
28    target: &EditorTarget,
29    binary: &str,
30    opts: WriteOptions,
31) -> Result<WriteResult, String> {
32    if let Some(parent) = target.config_path.parent() {
33        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
34    }
35
36    match target.config_type {
37        ConfigType::McpJson => write_mcp_json(target, binary, opts),
38        ConfigType::Zed => write_zed_config(target, binary, opts),
39        ConfigType::Codex => write_codex_config(target, binary),
40        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
41        ConfigType::OpenCode => write_opencode_config(target, binary, opts),
42        ConfigType::Crush => write_crush_config(target, binary, opts),
43    }
44}
45
46fn auto_approve_tools() -> Vec<&'static str> {
47    vec![
48        "ctx_read",
49        "ctx_shell",
50        "ctx_search",
51        "ctx_tree",
52        "ctx_overview",
53        "ctx_preload",
54        "ctx_compress",
55        "ctx_metrics",
56        "ctx_session",
57        "ctx_knowledge",
58        "ctx_agent",
59        "ctx_share",
60        "ctx_analyze",
61        "ctx_benchmark",
62        "ctx_cache",
63        "ctx_discover",
64        "ctx_smart_read",
65        "ctx_delta",
66        "ctx_edit",
67        "ctx_dedup",
68        "ctx_fill",
69        "ctx_intent",
70        "ctx_response",
71        "ctx_context",
72        "ctx_graph",
73        "ctx_wrapped",
74        "ctx_multi_read",
75        "ctx_semantic_search",
76        "ctx_symbol",
77        "ctx_outline",
78        "ctx_callers",
79        "ctx_callees",
80        "ctx_routes",
81        "ctx_graph_diagram",
82        "ctx_cost",
83        "ctx_heatmap",
84        "ctx_task",
85        "ctx_impact",
86        "ctx_architecture",
87        "ctx_workflow",
88        "ctx",
89    ]
90}
91
92fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
93    let mut entry = serde_json::json!({
94        "command": binary,
95        "env": {
96            "LEAN_CTX_DATA_DIR": data_dir
97        }
98    });
99    if include_auto_approve {
100        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
101    }
102    entry
103}
104
105const NO_AUTO_APPROVE_EDITORS: &[&str] = &["Antigravity"];
106
107fn default_data_dir() -> Result<String, String> {
108    Ok(crate::core::data_dir::lean_ctx_data_dir()?
109        .to_string_lossy()
110        .to_string())
111}
112
113fn write_mcp_json(
114    target: &EditorTarget,
115    binary: &str,
116    opts: WriteOptions,
117) -> Result<WriteResult, String> {
118    let data_dir = default_data_dir()?;
119    let include_aa = !NO_AUTO_APPROVE_EDITORS.contains(&target.name);
120    let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
121
122    // Claude Code manages ~/.claude.json and may overwrite it on first start.
123    // Prefer the official CLI integration when available.
124    if target.agent_key == "claude" || target.name == "Claude Code" {
125        if let Ok(result) = try_claude_mcp_add(&desired) {
126            return Ok(result);
127        }
128    }
129
130    if target.config_path.exists() {
131        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
132        let mut json = match serde_json::from_str::<Value>(&content) {
133            Ok(v) => v,
134            Err(e) => {
135                if !opts.overwrite_invalid {
136                    return Err(e.to_string());
137                }
138                backup_invalid_file(&target.config_path)?;
139                return write_mcp_json_fresh(
140                    &target.config_path,
141                    desired,
142                    Some("overwrote invalid JSON".to_string()),
143                );
144            }
145        };
146        let obj = json
147            .as_object_mut()
148            .ok_or_else(|| "root JSON must be an object".to_string())?;
149
150        let servers = obj
151            .entry("mcpServers")
152            .or_insert_with(|| serde_json::json!({}));
153        let servers_obj = servers
154            .as_object_mut()
155            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
156
157        let existing = servers_obj.get("lean-ctx").cloned();
158        if existing.as_ref() == Some(&desired) {
159            return Ok(WriteResult {
160                action: WriteAction::Already,
161                note: None,
162            });
163        }
164        servers_obj.insert("lean-ctx".to_string(), desired);
165
166        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
167        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
168        return Ok(WriteResult {
169            action: WriteAction::Updated,
170            note: None,
171        });
172    }
173
174    write_mcp_json_fresh(&target.config_path, desired, None)
175}
176
177fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
178    use std::io::Write;
179    use std::process::{Command, Stdio};
180
181    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
182
183    let mut child = Command::new("claude")
184        .args(["mcp", "add-json", "--scope", "user", "lean-ctx"])
185        .stdin(Stdio::piped())
186        .stdout(Stdio::null())
187        .stderr(Stdio::null())
188        .spawn()
189        .map_err(|e| e.to_string())?;
190
191    if let Some(stdin) = child.stdin.as_mut() {
192        stdin
193            .write_all(server_json.as_bytes())
194            .map_err(|e| e.to_string())?;
195    }
196    let status = child.wait().map_err(|e| e.to_string())?;
197
198    if status.success() {
199        Ok(WriteResult {
200            action: WriteAction::Updated,
201            note: Some("via claude mcp add-json".to_string()),
202        })
203    } else {
204        Err("claude mcp add-json failed".to_string())
205    }
206}
207
208fn write_mcp_json_fresh(
209    path: &std::path::Path,
210    desired: Value,
211    note: Option<String>,
212) -> Result<WriteResult, String> {
213    let content = serde_json::to_string_pretty(&serde_json::json!({
214        "mcpServers": { "lean-ctx": desired }
215    }))
216    .map_err(|e| e.to_string())?;
217    crate::config_io::write_atomic_with_backup(path, &content)?;
218    Ok(WriteResult {
219        action: if note.is_some() {
220            WriteAction::Updated
221        } else {
222            WriteAction::Created
223        },
224        note,
225    })
226}
227
228fn write_zed_config(
229    target: &EditorTarget,
230    binary: &str,
231    opts: WriteOptions,
232) -> Result<WriteResult, String> {
233    let desired = serde_json::json!({
234        "source": "custom",
235        "command": binary,
236        "args": [],
237        "env": {}
238    });
239
240    if target.config_path.exists() {
241        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
242        let mut json = match serde_json::from_str::<Value>(&content) {
243            Ok(v) => v,
244            Err(e) => {
245                if !opts.overwrite_invalid {
246                    return Err(e.to_string());
247                }
248                backup_invalid_file(&target.config_path)?;
249                return write_zed_config_fresh(
250                    &target.config_path,
251                    desired,
252                    Some("overwrote invalid JSON".to_string()),
253                );
254            }
255        };
256        let obj = json
257            .as_object_mut()
258            .ok_or_else(|| "root JSON must be an object".to_string())?;
259
260        let servers = obj
261            .entry("context_servers")
262            .or_insert_with(|| serde_json::json!({}));
263        let servers_obj = servers
264            .as_object_mut()
265            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
266
267        let existing = servers_obj.get("lean-ctx").cloned();
268        if existing.as_ref() == Some(&desired) {
269            return Ok(WriteResult {
270                action: WriteAction::Already,
271                note: None,
272            });
273        }
274        servers_obj.insert("lean-ctx".to_string(), desired);
275
276        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
277        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
278        return Ok(WriteResult {
279            action: WriteAction::Updated,
280            note: None,
281        });
282    }
283
284    write_zed_config_fresh(&target.config_path, desired, None)
285}
286
287fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
288    if target.config_path.exists() {
289        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
290        let updated = upsert_codex_toml(&content, binary);
291        if updated == content {
292            return Ok(WriteResult {
293                action: WriteAction::Already,
294                note: None,
295            });
296        }
297        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
298        return Ok(WriteResult {
299            action: WriteAction::Updated,
300            note: None,
301        });
302    }
303
304    let content = format!(
305        "[mcp_servers.lean-ctx]\ncommand = \"{}\"\nargs = []\n",
306        binary
307    );
308    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
309    Ok(WriteResult {
310        action: WriteAction::Created,
311        note: None,
312    })
313}
314
315fn write_zed_config_fresh(
316    path: &std::path::Path,
317    desired: Value,
318    note: Option<String>,
319) -> Result<WriteResult, String> {
320    let content = serde_json::to_string_pretty(&serde_json::json!({
321        "context_servers": { "lean-ctx": desired }
322    }))
323    .map_err(|e| e.to_string())?;
324    crate::config_io::write_atomic_with_backup(path, &content)?;
325    Ok(WriteResult {
326        action: if note.is_some() {
327            WriteAction::Updated
328        } else {
329            WriteAction::Created
330        },
331        note,
332    })
333}
334
335fn write_vscode_mcp(
336    target: &EditorTarget,
337    binary: &str,
338    opts: WriteOptions,
339) -> Result<WriteResult, String> {
340    let desired = serde_json::json!({ "command": binary, "args": [] });
341
342    if target.config_path.exists() {
343        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
344        let mut json = match serde_json::from_str::<Value>(&content) {
345            Ok(v) => v,
346            Err(e) => {
347                if !opts.overwrite_invalid {
348                    return Err(e.to_string());
349                }
350                backup_invalid_file(&target.config_path)?;
351                return write_vscode_mcp_fresh(
352                    &target.config_path,
353                    binary,
354                    Some("overwrote invalid JSON".to_string()),
355                );
356            }
357        };
358        let obj = json
359            .as_object_mut()
360            .ok_or_else(|| "root JSON must be an object".to_string())?;
361
362        let servers = obj
363            .entry("servers")
364            .or_insert_with(|| serde_json::json!({}));
365        let servers_obj = servers
366            .as_object_mut()
367            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
368
369        let existing = servers_obj.get("lean-ctx").cloned();
370        if existing.as_ref() == Some(&desired) {
371            return Ok(WriteResult {
372                action: WriteAction::Already,
373                note: None,
374            });
375        }
376        servers_obj.insert("lean-ctx".to_string(), desired);
377
378        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
379        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
380        return Ok(WriteResult {
381            action: WriteAction::Updated,
382            note: None,
383        });
384    }
385
386    write_vscode_mcp_fresh(&target.config_path, binary, None)
387}
388
389fn write_vscode_mcp_fresh(
390    path: &std::path::Path,
391    binary: &str,
392    note: Option<String>,
393) -> Result<WriteResult, String> {
394    let content = serde_json::to_string_pretty(&serde_json::json!({
395        "servers": { "lean-ctx": { "command": binary, "args": [] } }
396    }))
397    .map_err(|e| e.to_string())?;
398    crate::config_io::write_atomic_with_backup(path, &content)?;
399    Ok(WriteResult {
400        action: if note.is_some() {
401            WriteAction::Updated
402        } else {
403            WriteAction::Created
404        },
405        note,
406    })
407}
408
409fn write_opencode_config(
410    target: &EditorTarget,
411    binary: &str,
412    opts: WriteOptions,
413) -> Result<WriteResult, String> {
414    let desired = serde_json::json!({
415        "type": "local",
416        "command": [binary],
417        "enabled": true
418    });
419
420    if target.config_path.exists() {
421        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
422        let mut json = match serde_json::from_str::<Value>(&content) {
423            Ok(v) => v,
424            Err(e) => {
425                if !opts.overwrite_invalid {
426                    return Err(e.to_string());
427                }
428                backup_invalid_file(&target.config_path)?;
429                return write_opencode_fresh(
430                    &target.config_path,
431                    binary,
432                    Some("overwrote invalid JSON".to_string()),
433                );
434            }
435        };
436        let obj = json
437            .as_object_mut()
438            .ok_or_else(|| "root JSON must be an object".to_string())?;
439        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
440        let mcp_obj = mcp
441            .as_object_mut()
442            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
443
444        let existing = mcp_obj.get("lean-ctx").cloned();
445        if existing.as_ref() == Some(&desired) {
446            return Ok(WriteResult {
447                action: WriteAction::Already,
448                note: None,
449            });
450        }
451        mcp_obj.insert("lean-ctx".to_string(), desired);
452
453        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
454        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
455        return Ok(WriteResult {
456            action: WriteAction::Updated,
457            note: None,
458        });
459    }
460
461    write_opencode_fresh(&target.config_path, binary, None)
462}
463
464fn write_opencode_fresh(
465    path: &std::path::Path,
466    binary: &str,
467    note: Option<String>,
468) -> Result<WriteResult, String> {
469    let content = serde_json::to_string_pretty(&serde_json::json!({
470        "$schema": "https://opencode.ai/config.json",
471        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true } }
472    }))
473    .map_err(|e| e.to_string())?;
474    crate::config_io::write_atomic_with_backup(path, &content)?;
475    Ok(WriteResult {
476        action: if note.is_some() {
477            WriteAction::Updated
478        } else {
479            WriteAction::Created
480        },
481        note,
482    })
483}
484
485fn write_crush_config(
486    target: &EditorTarget,
487    binary: &str,
488    opts: WriteOptions,
489) -> Result<WriteResult, String> {
490    let desired = serde_json::json!({ "type": "stdio", "command": binary });
491
492    if target.config_path.exists() {
493        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
494        let mut json = match serde_json::from_str::<Value>(&content) {
495            Ok(v) => v,
496            Err(e) => {
497                if !opts.overwrite_invalid {
498                    return Err(e.to_string());
499                }
500                backup_invalid_file(&target.config_path)?;
501                return write_crush_fresh(
502                    &target.config_path,
503                    desired,
504                    Some("overwrote invalid JSON".to_string()),
505                );
506            }
507        };
508        let obj = json
509            .as_object_mut()
510            .ok_or_else(|| "root JSON must be an object".to_string())?;
511        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
512        let mcp_obj = mcp
513            .as_object_mut()
514            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
515
516        let existing = mcp_obj.get("lean-ctx").cloned();
517        if existing.as_ref() == Some(&desired) {
518            return Ok(WriteResult {
519                action: WriteAction::Already,
520                note: None,
521            });
522        }
523        mcp_obj.insert("lean-ctx".to_string(), desired);
524
525        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
526        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
527        return Ok(WriteResult {
528            action: WriteAction::Updated,
529            note: None,
530        });
531    }
532
533    write_crush_fresh(&target.config_path, desired, None)
534}
535
536fn write_crush_fresh(
537    path: &std::path::Path,
538    desired: Value,
539    note: Option<String>,
540) -> Result<WriteResult, String> {
541    let content = serde_json::to_string_pretty(&serde_json::json!({
542        "mcp": { "lean-ctx": desired }
543    }))
544    .map_err(|e| e.to_string())?;
545    crate::config_io::write_atomic_with_backup(path, &content)?;
546    Ok(WriteResult {
547        action: if note.is_some() {
548            WriteAction::Updated
549        } else {
550            WriteAction::Created
551        },
552        note,
553    })
554}
555
556fn upsert_codex_toml(existing: &str, binary: &str) -> String {
557    let mut out = String::with_capacity(existing.len() + 128);
558    let mut in_section = false;
559    let mut saw_section = false;
560    let mut wrote_command = false;
561    let mut wrote_args = false;
562
563    for line in existing.lines() {
564        let trimmed = line.trim();
565        if trimmed.starts_with('[') && trimmed.ends_with(']') {
566            if in_section && !wrote_command {
567                out.push_str(&format!("command = \"{}\"\n", binary));
568                wrote_command = true;
569            }
570            if in_section && !wrote_args {
571                out.push_str("args = []\n");
572                wrote_args = true;
573            }
574            in_section = trimmed == "[mcp_servers.lean-ctx]";
575            if in_section {
576                saw_section = true;
577            }
578            out.push_str(line);
579            out.push('\n');
580            continue;
581        }
582
583        if in_section {
584            if trimmed.starts_with("command") && trimmed.contains('=') {
585                out.push_str(&format!("command = \"{}\"\n", binary));
586                wrote_command = true;
587                continue;
588            }
589            if trimmed.starts_with("args") && trimmed.contains('=') {
590                out.push_str("args = []\n");
591                wrote_args = true;
592                continue;
593            }
594        }
595
596        out.push_str(line);
597        out.push('\n');
598    }
599
600    if saw_section {
601        if in_section && !wrote_command {
602            out.push_str(&format!("command = \"{}\"\n", binary));
603        }
604        if in_section && !wrote_args {
605            out.push_str("args = []\n");
606        }
607        return out;
608    }
609
610    if !out.ends_with('\n') {
611        out.push('\n');
612    }
613    out.push_str("\n[mcp_servers.lean-ctx]\n");
614    out.push_str(&format!("command = \"{}\"\n", binary));
615    out.push_str("args = []\n");
616    out
617}
618
619fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
620    if !path.exists() {
621        return Ok(());
622    }
623    let parent = path
624        .parent()
625        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
626    let filename = path
627        .file_name()
628        .ok_or_else(|| "invalid path (no filename)".to_string())?
629        .to_string_lossy();
630    let pid = std::process::id();
631    let nanos = std::time::SystemTime::now()
632        .duration_since(std::time::UNIX_EPOCH)
633        .map(|d| d.as_nanos())
634        .unwrap_or(0);
635    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
636    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
637    Ok(())
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use std::path::PathBuf;
644
645    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
646        EditorTarget {
647            name: "test",
648            agent_key: "test".to_string(),
649            config_path: path,
650            detect_path: PathBuf::from("/nonexistent"),
651            config_type: ty,
652        }
653    }
654
655    #[test]
656    fn mcp_json_upserts_and_preserves_other_servers() {
657        let dir = tempfile::tempdir().unwrap();
658        let path = dir.path().join("mcp.json");
659        std::fs::write(
660            &path,
661            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
662        )
663        .unwrap();
664
665        let t = target(path.clone(), ConfigType::McpJson);
666        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
667        assert_eq!(res.action, WriteAction::Updated);
668
669        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
670        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
671        assert_eq!(
672            json["mcpServers"]["lean-ctx"]["command"],
673            "/new/path/lean-ctx"
674        );
675        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
676        assert!(
677            json["mcpServers"]["lean-ctx"]["autoApprove"]
678                .as_array()
679                .unwrap()
680                .len()
681                > 5
682        );
683    }
684
685    #[test]
686    fn crush_config_writes_mcp_root() {
687        let dir = tempfile::tempdir().unwrap();
688        let path = dir.path().join("crush.json");
689        std::fs::write(
690            &path,
691            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
692        )
693        .unwrap();
694
695        let t = target(path.clone(), ConfigType::Crush);
696        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
697        assert_eq!(res.action, WriteAction::Updated);
698
699        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
700        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
701        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
702    }
703
704    #[test]
705    fn codex_toml_upserts_existing_section() {
706        let dir = tempfile::tempdir().unwrap();
707        let path = dir.path().join("config.toml");
708        std::fs::write(
709            &path,
710            r#"[mcp_servers.lean-ctx]
711command = "old"
712args = ["x"]
713"#,
714        )
715        .unwrap();
716
717        let t = target(path.clone(), ConfigType::Codex);
718        let res = write_codex_config(&t, "new").unwrap();
719        assert_eq!(res.action, WriteAction::Updated);
720
721        let content = std::fs::read_to_string(&path).unwrap();
722        assert!(content.contains(r#"command = "new""#));
723        assert!(content.contains("args = []"));
724    }
725
726    #[test]
727    fn upsert_codex_toml_inserts_new_section_when_missing() {
728        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
729        assert!(updated.contains("[mcp_servers.lean-ctx]"));
730        assert!(updated.contains("command = \"lean-ctx\""));
731        assert!(updated.contains("args = []"));
732    }
733
734    #[test]
735    fn auto_approve_contains_core_tools() {
736        let tools = auto_approve_tools();
737        assert!(tools.contains(&"ctx_read"));
738        assert!(tools.contains(&"ctx_shell"));
739        assert!(tools.contains(&"ctx_search"));
740        assert!(tools.contains(&"ctx_workflow"));
741        assert!(tools.contains(&"ctx_cost"));
742    }
743
744    #[test]
745    fn antigravity_config_omits_auto_approve() {
746        let dir = tempfile::tempdir().unwrap();
747        let path = dir.path().join("mcp_config.json");
748
749        let t = EditorTarget {
750            name: "Antigravity",
751            agent_key: "gemini".to_string(),
752            config_path: path.clone(),
753            detect_path: PathBuf::from("/nonexistent"),
754            config_type: ConfigType::McpJson,
755        };
756        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
757        assert_eq!(res.action, WriteAction::Created);
758
759        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
760        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
761        assert_eq!(
762            json["mcpServers"]["lean-ctx"]["command"],
763            "/usr/local/bin/lean-ctx"
764        );
765    }
766}