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    let entry = serde_json::json!({
548        "name": "lean-ctx",
549        "command": binary,
550        "args": [],
551        "env": { "LEAN_CTX_DATA_DIR": data_dir }
552    });
553
554    if target.config_path.exists() {
555        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
556        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
557            Ok(v) => v,
558            Err(e) => {
559                if !opts.overwrite_invalid {
560                    return Err(e.to_string());
561                }
562                backup_invalid_file(&target.config_path)?;
563                let fresh = serde_json::json!({ "servers": [entry] });
564                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
565                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
566                return Ok(WriteResult {
567                    action: WriteAction::Updated,
568                    note: Some("overwrote invalid JSON".to_string()),
569                });
570            }
571        };
572        let obj = json
573            .as_object_mut()
574            .ok_or_else(|| "root JSON must be an object".to_string())?;
575        let servers = obj
576            .entry("servers")
577            .or_insert_with(|| serde_json::json!([]));
578        if let Some(arr) = servers.as_array_mut() {
579            let already = arr
580                .iter()
581                .any(|s| s.get("name").and_then(|n| n.as_str()) == Some("lean-ctx"));
582            if already {
583                return Ok(WriteResult {
584                    action: WriteAction::Already,
585                    note: None,
586                });
587            }
588            arr.push(entry);
589        }
590        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
591        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
592        return Ok(WriteResult {
593            action: WriteAction::Updated,
594            note: None,
595        });
596    }
597
598    let config = serde_json::json!({ "servers": [entry] });
599    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
600    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
601    Ok(WriteResult {
602        action: WriteAction::Created,
603        note: None,
604    })
605}
606
607fn write_amp_config(
608    target: &EditorTarget,
609    binary: &str,
610    opts: WriteOptions,
611) -> Result<WriteResult, String> {
612    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
613        .map(|d| d.to_string_lossy().to_string())
614        .unwrap_or_default();
615    let entry = serde_json::json!({
616        "command": binary,
617        "env": { "LEAN_CTX_DATA_DIR": data_dir }
618    });
619
620    if target.config_path.exists() {
621        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
622        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
623            Ok(v) => v,
624            Err(e) => {
625                if !opts.overwrite_invalid {
626                    return Err(e.to_string());
627                }
628                backup_invalid_file(&target.config_path)?;
629                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
630                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
631                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
632                return Ok(WriteResult {
633                    action: WriteAction::Updated,
634                    note: Some("overwrote invalid JSON".to_string()),
635                });
636            }
637        };
638        let obj = json
639            .as_object_mut()
640            .ok_or_else(|| "root JSON must be an object".to_string())?;
641        let servers = obj
642            .entry("amp.mcpServers")
643            .or_insert_with(|| serde_json::json!({}));
644        let servers_obj = servers
645            .as_object_mut()
646            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
647
648        let existing = servers_obj.get("lean-ctx").cloned();
649        if existing.as_ref() == Some(&entry) {
650            return Ok(WriteResult {
651                action: WriteAction::Already,
652                note: None,
653            });
654        }
655        servers_obj.insert("lean-ctx".to_string(), entry);
656
657        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
658        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
659        return Ok(WriteResult {
660            action: WriteAction::Updated,
661            note: None,
662        });
663    }
664
665    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
666    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
667    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
668    Ok(WriteResult {
669        action: WriteAction::Created,
670        note: None,
671    })
672}
673
674fn write_crush_config(
675    target: &EditorTarget,
676    binary: &str,
677    opts: WriteOptions,
678) -> Result<WriteResult, String> {
679    let desired = serde_json::json!({ "type": "stdio", "command": binary });
680
681    if target.config_path.exists() {
682        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
683        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
684            Ok(v) => v,
685            Err(e) => {
686                if !opts.overwrite_invalid {
687                    return Err(e.to_string());
688                }
689                backup_invalid_file(&target.config_path)?;
690                return write_crush_fresh(
691                    &target.config_path,
692                    &desired,
693                    Some("overwrote invalid JSON".to_string()),
694                );
695            }
696        };
697        let obj = json
698            .as_object_mut()
699            .ok_or_else(|| "root JSON must be an object".to_string())?;
700        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
701        let mcp_obj = mcp
702            .as_object_mut()
703            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
704
705        let existing = mcp_obj.get("lean-ctx").cloned();
706        if existing.as_ref() == Some(&desired) {
707            return Ok(WriteResult {
708                action: WriteAction::Already,
709                note: None,
710            });
711        }
712        mcp_obj.insert("lean-ctx".to_string(), desired);
713
714        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
715        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
716        return Ok(WriteResult {
717            action: WriteAction::Updated,
718            note: None,
719        });
720    }
721
722    write_crush_fresh(&target.config_path, &desired, None)
723}
724
725fn write_crush_fresh(
726    path: &std::path::Path,
727    desired: &Value,
728    note: Option<String>,
729) -> Result<WriteResult, String> {
730    let content = serde_json::to_string_pretty(&serde_json::json!({
731        "mcp": { "lean-ctx": desired }
732    }))
733    .map_err(|e| e.to_string())?;
734    crate::config_io::write_atomic_with_backup(path, &content)?;
735    Ok(WriteResult {
736        action: if note.is_some() {
737            WriteAction::Updated
738        } else {
739            WriteAction::Created
740        },
741        note,
742    })
743}
744
745fn upsert_codex_toml(existing: &str, binary: &str) -> String {
746    let mut out = String::with_capacity(existing.len() + 128);
747    let mut in_section = false;
748    let mut saw_section = false;
749    let mut wrote_command = false;
750    let mut wrote_args = false;
751
752    for line in existing.lines() {
753        let trimmed = line.trim();
754        if trimmed == "[]" {
755            continue;
756        }
757        if trimmed.starts_with('[') && trimmed.ends_with(']') {
758            if in_section && !wrote_command {
759                out.push_str(&format!("command = {}\n", toml_quote(binary)));
760                wrote_command = true;
761            }
762            if in_section && !wrote_args {
763                out.push_str("args = []\n");
764                wrote_args = true;
765            }
766            in_section = trimmed == "[mcp_servers.lean-ctx]";
767            if in_section {
768                saw_section = true;
769            }
770            out.push_str(line);
771            out.push('\n');
772            continue;
773        }
774
775        if in_section {
776            if trimmed.starts_with("command") && trimmed.contains('=') {
777                out.push_str(&format!("command = {}\n", toml_quote(binary)));
778                wrote_command = true;
779                continue;
780            }
781            if trimmed.starts_with("args") && trimmed.contains('=') {
782                out.push_str("args = []\n");
783                wrote_args = true;
784                continue;
785            }
786        }
787
788        out.push_str(line);
789        out.push('\n');
790    }
791
792    if saw_section {
793        if in_section && !wrote_command {
794            out.push_str(&format!("command = {}\n", toml_quote(binary)));
795        }
796        if in_section && !wrote_args {
797            out.push_str("args = []\n");
798        }
799        return out;
800    }
801
802    if !out.ends_with('\n') {
803        out.push('\n');
804    }
805    out.push_str("\n[mcp_servers.lean-ctx]\n");
806    out.push_str(&format!("command = {}\n", toml_quote(binary)));
807    out.push_str("args = []\n");
808    out
809}
810
811fn write_gemini_settings(
812    target: &EditorTarget,
813    binary: &str,
814    opts: WriteOptions,
815) -> Result<WriteResult, String> {
816    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
817        .map(|d| d.to_string_lossy().to_string())
818        .unwrap_or_default();
819    let entry = serde_json::json!({
820        "command": binary,
821        "env": { "LEAN_CTX_DATA_DIR": data_dir },
822        "trust": true,
823    });
824
825    if target.config_path.exists() {
826        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
827        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
828            Ok(v) => v,
829            Err(e) => {
830                if !opts.overwrite_invalid {
831                    return Err(e.to_string());
832                }
833                backup_invalid_file(&target.config_path)?;
834                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
835                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
836                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
837                return Ok(WriteResult {
838                    action: WriteAction::Updated,
839                    note: Some("overwrote invalid JSON".to_string()),
840                });
841            }
842        };
843        let obj = json
844            .as_object_mut()
845            .ok_or_else(|| "root JSON must be an object".to_string())?;
846        let servers = obj
847            .entry("mcpServers")
848            .or_insert_with(|| serde_json::json!({}));
849        let servers_obj = servers
850            .as_object_mut()
851            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
852
853        let existing = servers_obj.get("lean-ctx").cloned();
854        if existing.as_ref() == Some(&entry) {
855            return Ok(WriteResult {
856                action: WriteAction::Already,
857                note: None,
858            });
859        }
860        servers_obj.insert("lean-ctx".to_string(), entry);
861
862        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
863        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
864        return Ok(WriteResult {
865            action: WriteAction::Updated,
866            note: None,
867        });
868    }
869
870    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
871    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
872    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
873    Ok(WriteResult {
874        action: WriteAction::Created,
875        note: None,
876    })
877}
878
879fn write_hermes_yaml(
880    target: &EditorTarget,
881    binary: &str,
882    _opts: WriteOptions,
883) -> Result<WriteResult, String> {
884    let data_dir = default_data_dir()?;
885
886    let lean_ctx_block = format!(
887        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
888    );
889
890    if target.config_path.exists() {
891        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
892
893        if content.contains("lean-ctx") {
894            return Ok(WriteResult {
895                action: WriteAction::Already,
896                note: None,
897            });
898        }
899
900        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
901        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
902        return Ok(WriteResult {
903            action: WriteAction::Updated,
904            note: None,
905        });
906    }
907
908    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
909    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
910    Ok(WriteResult {
911        action: WriteAction::Created,
912        note: None,
913    })
914}
915
916fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
917    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
918    let mut in_mcp_section = false;
919    let mut saw_mcp_child = false;
920    let mut inserted = false;
921    let lines: Vec<&str> = existing.lines().collect();
922
923    for line in &lines {
924        if !inserted && line.trim_end() == "mcp_servers:" {
925            in_mcp_section = true;
926            out.push_str(line);
927            out.push('\n');
928            continue;
929        }
930
931        if in_mcp_section && !inserted {
932            let is_child = line.starts_with("  ") && !line.trim().is_empty();
933            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
934
935            if is_child {
936                saw_mcp_child = true;
937                out.push_str(line);
938                out.push('\n');
939                continue;
940            }
941
942            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
943                out.push_str(lean_ctx_block);
944                out.push('\n');
945                inserted = true;
946                in_mcp_section = false;
947            }
948        }
949
950        out.push_str(line);
951        out.push('\n');
952    }
953
954    if in_mcp_section && !inserted {
955        out.push_str(lean_ctx_block);
956        out.push('\n');
957        inserted = true;
958    }
959
960    if !inserted {
961        if !out.ends_with('\n') {
962            out.push('\n');
963        }
964        out.push_str("\nmcp_servers:\n");
965        out.push_str(lean_ctx_block);
966        out.push('\n');
967    }
968
969    out
970}
971
972fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
973    if !path.exists() {
974        return Ok(());
975    }
976    let parent = path
977        .parent()
978        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
979    let filename = path
980        .file_name()
981        .ok_or_else(|| "invalid path (no filename)".to_string())?
982        .to_string_lossy();
983    let pid = std::process::id();
984    let nanos = std::time::SystemTime::now()
985        .duration_since(std::time::UNIX_EPOCH)
986        .map_or(0, |d| d.as_nanos());
987    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
988    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
989    Ok(())
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995    use std::path::PathBuf;
996
997    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
998        EditorTarget {
999            name: "test",
1000            agent_key: "test".to_string(),
1001            config_path: path,
1002            detect_path: PathBuf::from("/nonexistent"),
1003            config_type: ty,
1004        }
1005    }
1006
1007    #[test]
1008    fn mcp_json_upserts_and_preserves_other_servers() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let path = dir.path().join("mcp.json");
1011        std::fs::write(
1012            &path,
1013            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1014        )
1015        .unwrap();
1016
1017        let t = target(path.clone(), ConfigType::McpJson);
1018        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1019        assert_eq!(res.action, WriteAction::Updated);
1020
1021        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1022        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1023        assert_eq!(
1024            json["mcpServers"]["lean-ctx"]["command"],
1025            "/new/path/lean-ctx"
1026        );
1027        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1028        assert!(
1029            json["mcpServers"]["lean-ctx"]["autoApprove"]
1030                .as_array()
1031                .unwrap()
1032                .len()
1033                > 5
1034        );
1035    }
1036
1037    #[test]
1038    fn crush_config_writes_mcp_root() {
1039        let dir = tempfile::tempdir().unwrap();
1040        let path = dir.path().join("crush.json");
1041        std::fs::write(
1042            &path,
1043            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1044        )
1045        .unwrap();
1046
1047        let t = target(path.clone(), ConfigType::Crush);
1048        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1049        assert_eq!(res.action, WriteAction::Updated);
1050
1051        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1052        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1053        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1054    }
1055
1056    #[test]
1057    fn codex_toml_upserts_existing_section() {
1058        let dir = tempfile::tempdir().unwrap();
1059        let path = dir.path().join("config.toml");
1060        std::fs::write(
1061            &path,
1062            r#"[mcp_servers.lean-ctx]
1063command = "old"
1064args = ["x"]
1065"#,
1066        )
1067        .unwrap();
1068
1069        let t = target(path.clone(), ConfigType::Codex);
1070        let res = write_codex_config(&t, "new").unwrap();
1071        assert_eq!(res.action, WriteAction::Updated);
1072
1073        let content = std::fs::read_to_string(&path).unwrap();
1074        assert!(content.contains(r#"command = "new""#));
1075        assert!(content.contains("args = []"));
1076    }
1077
1078    #[test]
1079    fn upsert_codex_toml_inserts_new_section_when_missing() {
1080        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1081        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1082        assert!(updated.contains("command = \"lean-ctx\""));
1083        assert!(updated.contains("args = []"));
1084    }
1085
1086    #[test]
1087    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1088        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1089        let updated = upsert_codex_toml("", win_path);
1090        assert!(
1091            updated.contains(&format!("command = '{win_path}'")),
1092            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1093        );
1094    }
1095
1096    #[test]
1097    fn codex_toml_uses_double_quotes_for_unix_paths() {
1098        let unix_path = "/usr/local/bin/lean-ctx";
1099        let updated = upsert_codex_toml("", unix_path);
1100        assert!(
1101            updated.contains(&format!("command = \"{unix_path}\"")),
1102            "Unix paths should use double quotes: {updated}"
1103        );
1104    }
1105
1106    #[test]
1107    fn auto_approve_contains_core_tools() {
1108        let tools = auto_approve_tools();
1109        assert!(tools.contains(&"ctx_read"));
1110        assert!(tools.contains(&"ctx_shell"));
1111        assert!(tools.contains(&"ctx_search"));
1112        assert!(tools.contains(&"ctx_workflow"));
1113        assert!(tools.contains(&"ctx_cost"));
1114    }
1115
1116    #[test]
1117    fn antigravity_config_omits_auto_approve() {
1118        let dir = tempfile::tempdir().unwrap();
1119        let path = dir.path().join("mcp_config.json");
1120
1121        let t = EditorTarget {
1122            name: "Antigravity",
1123            agent_key: "gemini".to_string(),
1124            config_path: path.clone(),
1125            detect_path: PathBuf::from("/nonexistent"),
1126            config_type: ConfigType::McpJson,
1127        };
1128        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1129        assert_eq!(res.action, WriteAction::Created);
1130
1131        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1132        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1133        assert_eq!(
1134            json["mcpServers"]["lean-ctx"]["command"],
1135            "/usr/local/bin/lean-ctx"
1136        );
1137    }
1138
1139    #[test]
1140    fn hermes_yaml_inserts_into_existing_mcp_servers() {
1141        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";
1142        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1143        let result = upsert_hermes_yaml_mcp(existing, block);
1144        assert!(result.contains("lean-ctx"));
1145        assert!(result.contains("model: anthropic/claude-sonnet-4"));
1146        assert!(result.contains("tool_allowlist:"));
1147        assert!(result.contains("github:"));
1148    }
1149
1150    #[test]
1151    fn hermes_yaml_creates_mcp_servers_section() {
1152        let existing = "model: openai/gpt-4o\n";
1153        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
1154        let result = upsert_hermes_yaml_mcp(existing, block);
1155        assert!(result.contains("mcp_servers:"));
1156        assert!(result.contains("lean-ctx"));
1157        assert!(result.contains("model: openai/gpt-4o"));
1158    }
1159
1160    #[test]
1161    fn hermes_yaml_skips_if_already_present() {
1162        let dir = tempfile::tempdir().unwrap();
1163        let path = dir.path().join("config.yaml");
1164        std::fs::write(
1165            &path,
1166            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
1167        )
1168        .unwrap();
1169        let t = target(path.clone(), ConfigType::HermesYaml);
1170        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1171        assert_eq!(res.action, WriteAction::Already);
1172    }
1173}