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    }
56}
57
58pub fn auto_approve_tools() -> Vec<&'static str> {
59    vec![
60        "ctx_read",
61        "ctx_shell",
62        "ctx_search",
63        "ctx_tree",
64        "ctx_overview",
65        "ctx_preload",
66        "ctx_compress",
67        "ctx_metrics",
68        "ctx_session",
69        "ctx_knowledge",
70        "ctx_agent",
71        "ctx_share",
72        "ctx_analyze",
73        "ctx_benchmark",
74        "ctx_cache",
75        "ctx_discover",
76        "ctx_smart_read",
77        "ctx_delta",
78        "ctx_edit",
79        "ctx_dedup",
80        "ctx_fill",
81        "ctx_intent",
82        "ctx_response",
83        "ctx_context",
84        "ctx_graph",
85        "ctx_wrapped",
86        "ctx_multi_read",
87        "ctx_semantic_search",
88        "ctx_symbol",
89        "ctx_outline",
90        "ctx_callers",
91        "ctx_callees",
92        "ctx_callgraph",
93        "ctx_routes",
94        "ctx_graph_diagram",
95        "ctx_cost",
96        "ctx_heatmap",
97        "ctx_task",
98        "ctx_impact",
99        "ctx_architecture",
100        "ctx_workflow",
101        "ctx",
102    ]
103}
104
105fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
106    let mut entry = serde_json::json!({
107        "command": binary,
108        "env": {
109            "LEAN_CTX_DATA_DIR": data_dir
110        }
111    });
112    if include_auto_approve {
113        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
114    }
115    entry
116}
117
118const NO_AUTO_APPROVE_EDITORS: &[&str] = &["Antigravity"];
119
120fn default_data_dir() -> Result<String, String> {
121    Ok(crate::core::data_dir::lean_ctx_data_dir()?
122        .to_string_lossy()
123        .to_string())
124}
125
126fn write_mcp_json(
127    target: &EditorTarget,
128    binary: &str,
129    opts: WriteOptions,
130) -> Result<WriteResult, String> {
131    let data_dir = default_data_dir()?;
132    let include_aa = !NO_AUTO_APPROVE_EDITORS.contains(&target.name);
133    let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
134
135    // Claude Code manages ~/.claude.json and may overwrite it on first start.
136    // Prefer the official CLI integration when available.
137    if target.agent_key == "claude" || target.name == "Claude Code" {
138        if let Ok(result) = try_claude_mcp_add(&desired) {
139            return Ok(result);
140        }
141    }
142
143    if target.config_path.exists() {
144        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
145        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
146            Ok(v) => v,
147            Err(e) => {
148                if !opts.overwrite_invalid {
149                    return Err(e.to_string());
150                }
151                backup_invalid_file(&target.config_path)?;
152                return write_mcp_json_fresh(
153                    &target.config_path,
154                    &desired,
155                    Some("overwrote invalid JSON".to_string()),
156                );
157            }
158        };
159        let obj = json
160            .as_object_mut()
161            .ok_or_else(|| "root JSON must be an object".to_string())?;
162
163        let servers = obj
164            .entry("mcpServers")
165            .or_insert_with(|| serde_json::json!({}));
166        let servers_obj = servers
167            .as_object_mut()
168            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
169
170        let existing = servers_obj.get("lean-ctx").cloned();
171        if existing.as_ref() == Some(&desired) {
172            return Ok(WriteResult {
173                action: WriteAction::Already,
174                note: None,
175            });
176        }
177        servers_obj.insert("lean-ctx".to_string(), desired);
178
179        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
180        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
181        return Ok(WriteResult {
182            action: WriteAction::Updated,
183            note: None,
184        });
185    }
186
187    write_mcp_json_fresh(&target.config_path, &desired, None)
188}
189
190fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
191    use std::io::Write;
192    use std::process::{Command, Stdio};
193    use std::time::{Duration, Instant};
194
195    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
196
197    // On Windows, `claude` may be a `.cmd` shim and cannot be executed directly
198    // via CreateProcess; route through `cmd /C` for reliable invocation.
199    let mut cmd = if cfg!(windows) {
200        let mut c = Command::new("cmd");
201        c.args([
202            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
203        ]);
204        c
205    } else {
206        let mut c = Command::new("claude");
207        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
208        c
209    };
210
211    let mut child = cmd
212        .stdin(Stdio::piped())
213        .stdout(Stdio::null())
214        .stderr(Stdio::null())
215        .spawn()
216        .map_err(|e| e.to_string())?;
217
218    if let Some(mut stdin) = child.stdin.take() {
219        let _ = stdin.write_all(server_json.as_bytes());
220    }
221
222    let deadline = Duration::from_secs(3);
223    let start = Instant::now();
224    loop {
225        match child.try_wait() {
226            Ok(Some(status)) => {
227                return if status.success() {
228                    Ok(WriteResult {
229                        action: WriteAction::Updated,
230                        note: Some("via claude mcp add-json".to_string()),
231                    })
232                } else {
233                    Err("claude mcp add-json failed".to_string())
234                };
235            }
236            Ok(None) => {
237                if start.elapsed() > deadline {
238                    let _ = child.kill();
239                    let _ = child.wait();
240                    return Err("claude mcp add-json timed out".to_string());
241                }
242                std::thread::sleep(Duration::from_millis(20));
243            }
244            Err(e) => return Err(e.to_string()),
245        }
246    }
247}
248
249fn write_mcp_json_fresh(
250    path: &std::path::Path,
251    desired: &Value,
252    note: Option<String>,
253) -> Result<WriteResult, String> {
254    let content = serde_json::to_string_pretty(&serde_json::json!({
255        "mcpServers": { "lean-ctx": desired }
256    }))
257    .map_err(|e| e.to_string())?;
258    crate::config_io::write_atomic_with_backup(path, &content)?;
259    Ok(WriteResult {
260        action: if note.is_some() {
261            WriteAction::Updated
262        } else {
263            WriteAction::Created
264        },
265        note,
266    })
267}
268
269fn write_zed_config(
270    target: &EditorTarget,
271    binary: &str,
272    opts: WriteOptions,
273) -> Result<WriteResult, String> {
274    let desired = serde_json::json!({
275        "source": "custom",
276        "command": binary,
277        "args": [],
278        "env": {}
279    });
280
281    if target.config_path.exists() {
282        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
283        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
284            Ok(v) => v,
285            Err(e) => {
286                if !opts.overwrite_invalid {
287                    return Err(e.to_string());
288                }
289                backup_invalid_file(&target.config_path)?;
290                return write_zed_config_fresh(
291                    &target.config_path,
292                    &desired,
293                    Some("overwrote invalid JSON".to_string()),
294                );
295            }
296        };
297        let obj = json
298            .as_object_mut()
299            .ok_or_else(|| "root JSON must be an object".to_string())?;
300
301        let servers = obj
302            .entry("context_servers")
303            .or_insert_with(|| serde_json::json!({}));
304        let servers_obj = servers
305            .as_object_mut()
306            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
307
308        let existing = servers_obj.get("lean-ctx").cloned();
309        if existing.as_ref() == Some(&desired) {
310            return Ok(WriteResult {
311                action: WriteAction::Already,
312                note: None,
313            });
314        }
315        servers_obj.insert("lean-ctx".to_string(), desired);
316
317        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
318        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
319        return Ok(WriteResult {
320            action: WriteAction::Updated,
321            note: None,
322        });
323    }
324
325    write_zed_config_fresh(&target.config_path, &desired, None)
326}
327
328fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
329    if target.config_path.exists() {
330        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
331        let updated = upsert_codex_toml(&content, binary);
332        if updated == content {
333            return Ok(WriteResult {
334                action: WriteAction::Already,
335                note: None,
336            });
337        }
338        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
339        return Ok(WriteResult {
340            action: WriteAction::Updated,
341            note: None,
342        });
343    }
344
345    let content = format!(
346        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
347        toml_quote(binary)
348    );
349    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
350    Ok(WriteResult {
351        action: WriteAction::Created,
352        note: None,
353    })
354}
355
356fn write_zed_config_fresh(
357    path: &std::path::Path,
358    desired: &Value,
359    note: Option<String>,
360) -> Result<WriteResult, String> {
361    let content = serde_json::to_string_pretty(&serde_json::json!({
362        "context_servers": { "lean-ctx": desired }
363    }))
364    .map_err(|e| e.to_string())?;
365    crate::config_io::write_atomic_with_backup(path, &content)?;
366    Ok(WriteResult {
367        action: if note.is_some() {
368            WriteAction::Updated
369        } else {
370            WriteAction::Created
371        },
372        note,
373    })
374}
375
376fn write_vscode_mcp(
377    target: &EditorTarget,
378    binary: &str,
379    opts: WriteOptions,
380) -> Result<WriteResult, String> {
381    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
382        .map(|d| d.to_string_lossy().to_string())
383        .unwrap_or_default();
384    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
385
386    if target.config_path.exists() {
387        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
388        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
389            Ok(v) => v,
390            Err(e) => {
391                if !opts.overwrite_invalid {
392                    return Err(e.to_string());
393                }
394                backup_invalid_file(&target.config_path)?;
395                return write_vscode_mcp_fresh(
396                    &target.config_path,
397                    binary,
398                    Some("overwrote invalid JSON".to_string()),
399                );
400            }
401        };
402        let obj = json
403            .as_object_mut()
404            .ok_or_else(|| "root JSON must be an object".to_string())?;
405
406        let servers = obj
407            .entry("servers")
408            .or_insert_with(|| serde_json::json!({}));
409        let servers_obj = servers
410            .as_object_mut()
411            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
412
413        let existing = servers_obj.get("lean-ctx").cloned();
414        if existing.as_ref() == Some(&desired) {
415            return Ok(WriteResult {
416                action: WriteAction::Already,
417                note: None,
418            });
419        }
420        servers_obj.insert("lean-ctx".to_string(), desired);
421
422        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
423        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
424        return Ok(WriteResult {
425            action: WriteAction::Updated,
426            note: None,
427        });
428    }
429
430    write_vscode_mcp_fresh(&target.config_path, binary, None)
431}
432
433fn write_vscode_mcp_fresh(
434    path: &std::path::Path,
435    binary: &str,
436    note: Option<String>,
437) -> Result<WriteResult, String> {
438    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
439        .map(|d| d.to_string_lossy().to_string())
440        .unwrap_or_default();
441    let content = serde_json::to_string_pretty(&serde_json::json!({
442        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
443    }))
444    .map_err(|e| e.to_string())?;
445    crate::config_io::write_atomic_with_backup(path, &content)?;
446    Ok(WriteResult {
447        action: if note.is_some() {
448            WriteAction::Updated
449        } else {
450            WriteAction::Created
451        },
452        note,
453    })
454}
455
456fn write_opencode_config(
457    target: &EditorTarget,
458    binary: &str,
459    opts: WriteOptions,
460) -> Result<WriteResult, String> {
461    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
462        .map(|d| d.to_string_lossy().to_string())
463        .unwrap_or_default();
464    let desired = serde_json::json!({
465        "type": "local",
466        "command": [binary],
467        "enabled": true,
468        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
469    });
470
471    if target.config_path.exists() {
472        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
473        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
474            Ok(v) => v,
475            Err(e) => {
476                if !opts.overwrite_invalid {
477                    return Err(e.to_string());
478                }
479                backup_invalid_file(&target.config_path)?;
480                return write_opencode_fresh(
481                    &target.config_path,
482                    binary,
483                    Some("overwrote invalid JSON".to_string()),
484                );
485            }
486        };
487        let obj = json
488            .as_object_mut()
489            .ok_or_else(|| "root JSON must be an object".to_string())?;
490        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
491        let mcp_obj = mcp
492            .as_object_mut()
493            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
494
495        let existing = mcp_obj.get("lean-ctx").cloned();
496        if existing.as_ref() == Some(&desired) {
497            return Ok(WriteResult {
498                action: WriteAction::Already,
499                note: None,
500            });
501        }
502        mcp_obj.insert("lean-ctx".to_string(), desired);
503
504        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
505        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
506        return Ok(WriteResult {
507            action: WriteAction::Updated,
508            note: None,
509        });
510    }
511
512    write_opencode_fresh(&target.config_path, binary, None)
513}
514
515fn write_opencode_fresh(
516    path: &std::path::Path,
517    binary: &str,
518    note: Option<String>,
519) -> Result<WriteResult, String> {
520    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
521        .map(|d| d.to_string_lossy().to_string())
522        .unwrap_or_default();
523    let content = serde_json::to_string_pretty(&serde_json::json!({
524        "$schema": "https://opencode.ai/config.json",
525        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
526    }))
527    .map_err(|e| e.to_string())?;
528    crate::config_io::write_atomic_with_backup(path, &content)?;
529    Ok(WriteResult {
530        action: if note.is_some() {
531            WriteAction::Updated
532        } else {
533            WriteAction::Created
534        },
535        note,
536    })
537}
538
539fn write_jetbrains_config(
540    target: &EditorTarget,
541    binary: &str,
542    opts: WriteOptions,
543) -> Result<WriteResult, String> {
544    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
545        .map(|d| d.to_string_lossy().to_string())
546        .unwrap_or_default();
547    // JetBrains AI Assistant expects an "mcpServers" mapping in the JSON snippet
548    // you paste into Settings | Tools | AI Assistant | Model Context Protocol (MCP).
549    // We write that snippet to a file for easy copy/paste.
550    let desired = serde_json::json!({
551        "command": binary,
552        "args": [],
553        "env": { "LEAN_CTX_DATA_DIR": data_dir }
554    });
555
556    if target.config_path.exists() {
557        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
558        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
559            Ok(v) => v,
560            Err(e) => {
561                if !opts.overwrite_invalid {
562                    return Err(e.to_string());
563                }
564                backup_invalid_file(&target.config_path)?;
565                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
566                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
567                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
568                return Ok(WriteResult {
569                    action: WriteAction::Updated,
570                    note: Some(
571                        "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
572                            .to_string(),
573                    ),
574                });
575            }
576        };
577        let obj = json
578            .as_object_mut()
579            .ok_or_else(|| "root JSON must be an object".to_string())?;
580
581        let servers = obj
582            .entry("mcpServers")
583            .or_insert_with(|| serde_json::json!({}));
584        let servers_obj = servers
585            .as_object_mut()
586            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
587
588        let existing = servers_obj.get("lean-ctx").cloned();
589        if existing.as_ref() == Some(&desired) {
590            return Ok(WriteResult {
591                action: WriteAction::Already,
592                note: Some("paste this snippet into JetBrains MCP settings".to_string()),
593            });
594        }
595        servers_obj.insert("lean-ctx".to_string(), desired);
596
597        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
598        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
599        return Ok(WriteResult {
600            action: WriteAction::Updated,
601            note: Some("paste this snippet into JetBrains MCP settings".to_string()),
602        });
603    }
604
605    let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
606    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
607    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
608    Ok(WriteResult {
609        action: WriteAction::Created,
610        note: Some("paste this snippet into JetBrains MCP settings".to_string()),
611    })
612}
613
614fn write_amp_config(
615    target: &EditorTarget,
616    binary: &str,
617    opts: WriteOptions,
618) -> Result<WriteResult, String> {
619    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
620        .map(|d| d.to_string_lossy().to_string())
621        .unwrap_or_default();
622    let entry = serde_json::json!({
623        "command": binary,
624        "env": { "LEAN_CTX_DATA_DIR": data_dir }
625    });
626
627    if target.config_path.exists() {
628        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
629        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
630            Ok(v) => v,
631            Err(e) => {
632                if !opts.overwrite_invalid {
633                    return Err(e.to_string());
634                }
635                backup_invalid_file(&target.config_path)?;
636                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
637                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
638                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
639                return Ok(WriteResult {
640                    action: WriteAction::Updated,
641                    note: Some("overwrote invalid JSON".to_string()),
642                });
643            }
644        };
645        let obj = json
646            .as_object_mut()
647            .ok_or_else(|| "root JSON must be an object".to_string())?;
648        let servers = obj
649            .entry("amp.mcpServers")
650            .or_insert_with(|| serde_json::json!({}));
651        let servers_obj = servers
652            .as_object_mut()
653            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
654
655        let existing = servers_obj.get("lean-ctx").cloned();
656        if existing.as_ref() == Some(&entry) {
657            return Ok(WriteResult {
658                action: WriteAction::Already,
659                note: None,
660            });
661        }
662        servers_obj.insert("lean-ctx".to_string(), entry);
663
664        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
665        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
666        return Ok(WriteResult {
667            action: WriteAction::Updated,
668            note: None,
669        });
670    }
671
672    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
673    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
674    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
675    Ok(WriteResult {
676        action: WriteAction::Created,
677        note: None,
678    })
679}
680
681fn write_crush_config(
682    target: &EditorTarget,
683    binary: &str,
684    opts: WriteOptions,
685) -> Result<WriteResult, String> {
686    let desired = serde_json::json!({ "type": "stdio", "command": binary });
687
688    if target.config_path.exists() {
689        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
690        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
691            Ok(v) => v,
692            Err(e) => {
693                if !opts.overwrite_invalid {
694                    return Err(e.to_string());
695                }
696                backup_invalid_file(&target.config_path)?;
697                return write_crush_fresh(
698                    &target.config_path,
699                    &desired,
700                    Some("overwrote invalid JSON".to_string()),
701                );
702            }
703        };
704        let obj = json
705            .as_object_mut()
706            .ok_or_else(|| "root JSON must be an object".to_string())?;
707        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
708        let mcp_obj = mcp
709            .as_object_mut()
710            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
711
712        let existing = mcp_obj.get("lean-ctx").cloned();
713        if existing.as_ref() == Some(&desired) {
714            return Ok(WriteResult {
715                action: WriteAction::Already,
716                note: None,
717            });
718        }
719        mcp_obj.insert("lean-ctx".to_string(), desired);
720
721        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
722        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
723        return Ok(WriteResult {
724            action: WriteAction::Updated,
725            note: None,
726        });
727    }
728
729    write_crush_fresh(&target.config_path, &desired, None)
730}
731
732fn write_crush_fresh(
733    path: &std::path::Path,
734    desired: &Value,
735    note: Option<String>,
736) -> Result<WriteResult, String> {
737    let content = serde_json::to_string_pretty(&serde_json::json!({
738        "mcp": { "lean-ctx": desired }
739    }))
740    .map_err(|e| e.to_string())?;
741    crate::config_io::write_atomic_with_backup(path, &content)?;
742    Ok(WriteResult {
743        action: if note.is_some() {
744            WriteAction::Updated
745        } else {
746            WriteAction::Created
747        },
748        note,
749    })
750}
751
752fn upsert_codex_toml(existing: &str, binary: &str) -> String {
753    let mut out = String::with_capacity(existing.len() + 128);
754    let mut in_section = false;
755    let mut saw_section = false;
756    let mut wrote_command = false;
757    let mut wrote_args = false;
758
759    for line in existing.lines() {
760        let trimmed = line.trim();
761        if trimmed == "[]" {
762            continue;
763        }
764        if trimmed.starts_with('[') && trimmed.ends_with(']') {
765            if in_section && !wrote_command {
766                out.push_str(&format!("command = {}\n", toml_quote(binary)));
767                wrote_command = true;
768            }
769            if in_section && !wrote_args {
770                out.push_str("args = []\n");
771                wrote_args = true;
772            }
773            in_section = trimmed == "[mcp_servers.lean-ctx]";
774            if in_section {
775                saw_section = true;
776            }
777            out.push_str(line);
778            out.push('\n');
779            continue;
780        }
781
782        if in_section {
783            if trimmed.starts_with("command") && trimmed.contains('=') {
784                out.push_str(&format!("command = {}\n", toml_quote(binary)));
785                wrote_command = true;
786                continue;
787            }
788            if trimmed.starts_with("args") && trimmed.contains('=') {
789                out.push_str("args = []\n");
790                wrote_args = true;
791                continue;
792            }
793        }
794
795        out.push_str(line);
796        out.push('\n');
797    }
798
799    if saw_section {
800        if in_section && !wrote_command {
801            out.push_str(&format!("command = {}\n", toml_quote(binary)));
802        }
803        if in_section && !wrote_args {
804            out.push_str("args = []\n");
805        }
806        return out;
807    }
808
809    if !out.ends_with('\n') {
810        out.push('\n');
811    }
812    out.push_str("\n[mcp_servers.lean-ctx]\n");
813    out.push_str(&format!("command = {}\n", toml_quote(binary)));
814    out.push_str("args = []\n");
815    out
816}
817
818fn write_gemini_settings(
819    target: &EditorTarget,
820    binary: &str,
821    opts: WriteOptions,
822) -> Result<WriteResult, String> {
823    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
824        .map(|d| d.to_string_lossy().to_string())
825        .unwrap_or_default();
826    let entry = serde_json::json!({
827        "command": binary,
828        "env": { "LEAN_CTX_DATA_DIR": data_dir },
829        "trust": true,
830    });
831
832    if target.config_path.exists() {
833        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
834        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
835            Ok(v) => v,
836            Err(e) => {
837                if !opts.overwrite_invalid {
838                    return Err(e.to_string());
839                }
840                backup_invalid_file(&target.config_path)?;
841                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
842                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
843                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
844                return Ok(WriteResult {
845                    action: WriteAction::Updated,
846                    note: Some("overwrote invalid JSON".to_string()),
847                });
848            }
849        };
850        let obj = json
851            .as_object_mut()
852            .ok_or_else(|| "root JSON must be an object".to_string())?;
853        let servers = obj
854            .entry("mcpServers")
855            .or_insert_with(|| serde_json::json!({}));
856        let servers_obj = servers
857            .as_object_mut()
858            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
859
860        let existing = servers_obj.get("lean-ctx").cloned();
861        if existing.as_ref() == Some(&entry) {
862            return Ok(WriteResult {
863                action: WriteAction::Already,
864                note: None,
865            });
866        }
867        servers_obj.insert("lean-ctx".to_string(), entry);
868
869        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
870        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
871        return Ok(WriteResult {
872            action: WriteAction::Updated,
873            note: None,
874        });
875    }
876
877    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
878    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
879    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
880    Ok(WriteResult {
881        action: WriteAction::Created,
882        note: None,
883    })
884}
885
886fn write_hermes_yaml(
887    target: &EditorTarget,
888    binary: &str,
889    _opts: WriteOptions,
890) -> Result<WriteResult, String> {
891    let data_dir = default_data_dir()?;
892
893    let lean_ctx_block = format!(
894        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
895    );
896
897    if target.config_path.exists() {
898        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
899
900        if content.contains("lean-ctx") {
901            return Ok(WriteResult {
902                action: WriteAction::Already,
903                note: None,
904            });
905        }
906
907        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
908        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
909        return Ok(WriteResult {
910            action: WriteAction::Updated,
911            note: None,
912        });
913    }
914
915    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
916    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
917    Ok(WriteResult {
918        action: WriteAction::Created,
919        note: None,
920    })
921}
922
923fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
924    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
925    let mut in_mcp_section = false;
926    let mut saw_mcp_child = false;
927    let mut inserted = false;
928    let lines: Vec<&str> = existing.lines().collect();
929
930    for line in &lines {
931        if !inserted && line.trim_end() == "mcp_servers:" {
932            in_mcp_section = true;
933            out.push_str(line);
934            out.push('\n');
935            continue;
936        }
937
938        if in_mcp_section && !inserted {
939            let is_child = line.starts_with("  ") && !line.trim().is_empty();
940            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
941
942            if is_child {
943                saw_mcp_child = true;
944                out.push_str(line);
945                out.push('\n');
946                continue;
947            }
948
949            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
950                out.push_str(lean_ctx_block);
951                out.push('\n');
952                inserted = true;
953                in_mcp_section = false;
954            }
955        }
956
957        out.push_str(line);
958        out.push('\n');
959    }
960
961    if in_mcp_section && !inserted {
962        out.push_str(lean_ctx_block);
963        out.push('\n');
964        inserted = true;
965    }
966
967    if !inserted {
968        if !out.ends_with('\n') {
969            out.push('\n');
970        }
971        out.push_str("\nmcp_servers:\n");
972        out.push_str(lean_ctx_block);
973        out.push('\n');
974    }
975
976    out
977}
978
979fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
980    if !path.exists() {
981        return Ok(());
982    }
983    let parent = path
984        .parent()
985        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
986    let filename = path
987        .file_name()
988        .ok_or_else(|| "invalid path (no filename)".to_string())?
989        .to_string_lossy();
990    let pid = std::process::id();
991    let nanos = std::time::SystemTime::now()
992        .duration_since(std::time::UNIX_EPOCH)
993        .map_or(0, |d| d.as_nanos());
994    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
995    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
996    Ok(())
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use std::path::PathBuf;
1003
1004    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
1005        EditorTarget {
1006            name: "test",
1007            agent_key: "test".to_string(),
1008            config_path: path,
1009            detect_path: PathBuf::from("/nonexistent"),
1010            config_type: ty,
1011        }
1012    }
1013
1014    #[test]
1015    fn mcp_json_upserts_and_preserves_other_servers() {
1016        let dir = tempfile::tempdir().unwrap();
1017        let path = dir.path().join("mcp.json");
1018        std::fs::write(
1019            &path,
1020            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1021        )
1022        .unwrap();
1023
1024        let t = target(path.clone(), ConfigType::McpJson);
1025        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1026        assert_eq!(res.action, WriteAction::Updated);
1027
1028        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1029        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1030        assert_eq!(
1031            json["mcpServers"]["lean-ctx"]["command"],
1032            "/new/path/lean-ctx"
1033        );
1034        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1035        assert!(
1036            json["mcpServers"]["lean-ctx"]["autoApprove"]
1037                .as_array()
1038                .unwrap()
1039                .len()
1040                > 5
1041        );
1042    }
1043
1044    #[test]
1045    fn crush_config_writes_mcp_root() {
1046        let dir = tempfile::tempdir().unwrap();
1047        let path = dir.path().join("crush.json");
1048        std::fs::write(
1049            &path,
1050            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1051        )
1052        .unwrap();
1053
1054        let t = target(path.clone(), ConfigType::Crush);
1055        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1056        assert_eq!(res.action, WriteAction::Updated);
1057
1058        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1059        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1060        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1061    }
1062
1063    #[test]
1064    fn codex_toml_upserts_existing_section() {
1065        let dir = tempfile::tempdir().unwrap();
1066        let path = dir.path().join("config.toml");
1067        std::fs::write(
1068            &path,
1069            r#"[mcp_servers.lean-ctx]
1070command = "old"
1071args = ["x"]
1072"#,
1073        )
1074        .unwrap();
1075
1076        let t = target(path.clone(), ConfigType::Codex);
1077        let res = write_codex_config(&t, "new").unwrap();
1078        assert_eq!(res.action, WriteAction::Updated);
1079
1080        let content = std::fs::read_to_string(&path).unwrap();
1081        assert!(content.contains(r#"command = "new""#));
1082        assert!(content.contains("args = []"));
1083    }
1084
1085    #[test]
1086    fn upsert_codex_toml_inserts_new_section_when_missing() {
1087        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1088        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1089        assert!(updated.contains("command = \"lean-ctx\""));
1090        assert!(updated.contains("args = []"));
1091    }
1092
1093    #[test]
1094    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1095        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1096        let updated = upsert_codex_toml("", win_path);
1097        assert!(
1098            updated.contains(&format!("command = '{win_path}'")),
1099            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1100        );
1101    }
1102
1103    #[test]
1104    fn codex_toml_uses_double_quotes_for_unix_paths() {
1105        let unix_path = "/usr/local/bin/lean-ctx";
1106        let updated = upsert_codex_toml("", unix_path);
1107        assert!(
1108            updated.contains(&format!("command = \"{unix_path}\"")),
1109            "Unix paths should use double quotes: {updated}"
1110        );
1111    }
1112
1113    #[test]
1114    fn auto_approve_contains_core_tools() {
1115        let tools = auto_approve_tools();
1116        assert!(tools.contains(&"ctx_read"));
1117        assert!(tools.contains(&"ctx_shell"));
1118        assert!(tools.contains(&"ctx_search"));
1119        assert!(tools.contains(&"ctx_workflow"));
1120        assert!(tools.contains(&"ctx_cost"));
1121    }
1122
1123    #[test]
1124    fn antigravity_config_omits_auto_approve() {
1125        let dir = tempfile::tempdir().unwrap();
1126        let path = dir.path().join("mcp_config.json");
1127
1128        let t = EditorTarget {
1129            name: "Antigravity",
1130            agent_key: "gemini".to_string(),
1131            config_path: path.clone(),
1132            detect_path: PathBuf::from("/nonexistent"),
1133            config_type: ConfigType::McpJson,
1134        };
1135        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1136        assert_eq!(res.action, WriteAction::Created);
1137
1138        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1139        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1140        assert_eq!(
1141            json["mcpServers"]["lean-ctx"]["command"],
1142            "/usr/local/bin/lean-ctx"
1143        );
1144    }
1145
1146    #[test]
1147    fn hermes_yaml_inserts_into_existing_mcp_servers() {
1148        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";
1149        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1150        let result = upsert_hermes_yaml_mcp(existing, block);
1151        assert!(result.contains("lean-ctx"));
1152        assert!(result.contains("model: anthropic/claude-sonnet-4"));
1153        assert!(result.contains("tool_allowlist:"));
1154        assert!(result.contains("github:"));
1155    }
1156
1157    #[test]
1158    fn hermes_yaml_creates_mcp_servers_section() {
1159        let existing = "model: openai/gpt-4o\n";
1160        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
1161        let result = upsert_hermes_yaml_mcp(existing, block);
1162        assert!(result.contains("mcp_servers:"));
1163        assert!(result.contains("lean-ctx"));
1164        assert!(result.contains("model: openai/gpt-4o"));
1165    }
1166
1167    #[test]
1168    fn hermes_yaml_skips_if_already_present() {
1169        let dir = tempfile::tempdir().unwrap();
1170        let path = dir.path().join("config.yaml");
1171        std::fs::write(
1172            &path,
1173            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
1174        )
1175        .unwrap();
1176        let t = target(path.clone(), ConfigType::HermesYaml);
1177        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1178        assert_eq!(res.action, WriteAction::Already);
1179    }
1180}